bookly/static/chat.js
Cody Borders 30cdea2aac Build Bookly customer support agent
A FastAPI + vanilla JS chat app fronting an Anthropic Claude agent for
order status, returns, and policy questions.

Architecture:
- agent.py: system prompt, runtime reminder injection, output validation,
  agentic tool-use loop with prompt caching on the system prompt block
- tools.py: four tools (lookup_order, check_return_eligibility,
  initiate_return, lookup_policy) with per-session SessionGuardState
  enforcing protocol ordering on the tool side
- mock_data.py: orders, return policy, and FAQ entries used as the single
  source of truth by both the prompt and the tools
- server.py: FastAPI app exposing /api/chat, /health, and the static UI
- static/: vanilla HTML/CSS/JS chat UI, no build step
- tests/: 30 tests covering tool-side enforcement, the privacy boundary,
  output validation, and the agent loop with a mocked Anthropic client
- deploy/: systemd unit and nginx site config for production
2026-04-14 22:17:59 -07:00

83 lines
2.4 KiB
JavaScript

(function () {
"use strict";
const messagesEl = document.getElementById("messages");
const formEl = document.getElementById("composer");
const inputEl = document.getElementById("input");
const sendEl = document.getElementById("send");
const SESSION_KEY = "bookly_session_id";
let sessionId = sessionStorage.getItem(SESSION_KEY);
if (!sessionId) {
sessionId = crypto.randomUUID();
sessionStorage.setItem(SESSION_KEY, sessionId);
}
const GREETING =
"Hi! I'm the Bookly support assistant. I can help you check on an order, start a return, or answer questions about shipping, returns, or password reset. How can I help today?";
function appendMessage(role, text) {
const el = document.createElement("div");
el.className = "message message--" + role;
el.textContent = text;
messagesEl.appendChild(el);
messagesEl.scrollTop = messagesEl.scrollHeight;
return el;
}
function appendTypingIndicator() {
const el = document.createElement("div");
el.className = "message message--assistant message--typing";
el.setAttribute("aria-label", "Assistant is typing");
el.innerHTML = "<span></span><span></span><span></span>";
messagesEl.appendChild(el);
messagesEl.scrollTop = messagesEl.scrollHeight;
return el;
}
async function sendMessage(text) {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, message: text }),
});
if (!response.ok) {
throw new Error("Server returned " + response.status);
}
const data = await response.json();
return data.reply;
}
formEl.addEventListener("submit", async function (event) {
event.preventDefault();
const text = inputEl.value.trim();
if (!text) return;
appendMessage("user", text);
inputEl.value = "";
inputEl.disabled = true;
sendEl.disabled = true;
const typing = appendTypingIndicator();
try {
const reply = await sendMessage(text);
typing.remove();
appendMessage("assistant", reply);
} catch (err) {
typing.remove();
appendMessage(
"assistant",
"Sorry, I couldn't reach the server. Please try again in a moment."
);
console.error(err);
} finally {
inputEl.disabled = false;
sendEl.disabled = false;
inputEl.focus();
}
});
appendMessage("assistant", GREETING);
inputEl.focus();
})();