(() => { const API_BASE = ""; // same-origin when served by FastAPI const messagesEl = document.getElementById("messages"); const messagesInner = document.getElementById("messages-inner"); const form = document.getElementById("chat-form"); const input = document.getElementById("chat-input"); const sendBtn = document.getElementById("send-btn"); const sendLabel = document.getElementById("send-label"); const sendSpinner = document.getElementById("send-spinner"); const statusDot = document.getElementById("status-dot"); const suggestions = document.getElementById("suggestions"); const settingsBtn = document.getElementById("settings-btn"); const settingsModal = document.getElementById("settings-modal"); const closeSettings = document.getElementById("close-settings"); const uploadForm = document.getElementById("upload-form"); const fileInput = document.getElementById("file-input"); const reindexBtn = document.getElementById("reindex-btn"); const uploadStatus = document.getElementById("upload-status"); const AGENT_CLASS = { product_agent: "badge-product", policy_agent: "badge-policy", tech_agent: "badge-tech", general_agent: "badge-general", }; const AGENT_ICON = { product_agent: "📦", policy_agent: "📜", tech_agent: "🛠️", general_agent: "💬", }; function scrollToBottom(smooth = true) { requestAnimationFrame(() => { messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: smooth ? "smooth" : "auto", }); }); } function autoresizeTextarea() { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, window.innerHeight * 0.4) + "px"; } input.addEventListener("input", autoresizeTextarea); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } }); function renderMarkdown(text) { try { const html = window.marked ? window.marked.parse(text || "", { breaks: true, gfm: true }) : (text || "").replace(/"); return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; } catch { return (text || "").replace(/"); } } function appendUserMessage(text) { const tpl = document.getElementById("tpl-user-msg"); const node = tpl.content.firstElementChild.cloneNode(true); node.querySelector("div").textContent = text; messagesInner.appendChild(node); scrollToBottom(); } function appendTyping() { const tpl = document.getElementById("tpl-typing"); const node = tpl.content.firstElementChild.cloneNode(true); node.dataset.typing = "1"; messagesInner.appendChild(node); scrollToBottom(); return node; } function appendBotMessage({ response, selected_agents, agent_labels, agent_outputs, debug_log }) { const tpl = document.getElementById("tpl-bot-msg"); const node = tpl.content.firstElementChild.cloneNode(true); const badges = node.querySelector(".agent-badges"); (selected_agents || []).forEach((a) => { const badge = document.createElement("span"); badge.className = `agent-badge ${AGENT_CLASS[a] || "badge-general"}`; const label = (agent_labels && agent_labels[a]) || a; badge.textContent = `${AGENT_ICON[a] || "🤖"} ${label}`; badges.appendChild(badge); }); if (!badges.children.length) badges.remove(); node.querySelector(".answer").innerHTML = renderMarkdown(response || ""); const trace = node.querySelector(".trace"); trace.textContent = debug_log || "(no trace)"; const specialists = node.querySelector(".specialists"); const entries = Object.entries(agent_outputs || {}); if (entries.length === 0) { specialists.parentElement.remove(); } else { entries.forEach(([name, out]) => { const card = document.createElement("div"); card.className = "specialist-card"; const label = (agent_labels && agent_labels[name]) || name; card.innerHTML = `

${AGENT_ICON[name] || "🤖"} ${label}

`; card.querySelector(".body").textContent = out; specialists.appendChild(card); }); } messagesInner.appendChild(node); scrollToBottom(); } function setLoading(loading) { sendBtn.disabled = loading; input.disabled = loading; sendLabel.classList.toggle("hidden", loading); sendSpinner.classList.toggle("hidden", !loading); } async function sendMessage(text) { appendUserMessage(text); const typingNode = appendTyping(); setLoading(true); try { const res = await fetch(`${API_BASE}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }), }); const data = await res.json().catch(() => ({})); typingNode.remove(); if (!res.ok) { appendBotMessage({ response: `**Error:** ${data.detail || res.statusText}`, selected_agents: [], agent_labels: {}, agent_outputs: {}, debug_log: "", }); } else { appendBotMessage(data); } } catch (err) { typingNode.remove(); appendBotMessage({ response: `**Network error:** ${err.message}`, selected_agents: [], agent_labels: {}, agent_outputs: {}, debug_log: "", }); } finally { setLoading(false); input.focus(); } } form.addEventListener("submit", (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; input.value = ""; autoresizeTextarea(); sendMessage(text); }); suggestions?.addEventListener("click", (e) => { const btn = e.target.closest(".suggestion"); if (!btn) return; input.value = btn.textContent.trim(); autoresizeTextarea(); input.focus(); }); function openSettings() { settingsModal.classList.remove("hidden"); settingsModal.classList.add("flex"); settingsModal.setAttribute("aria-hidden", "false"); } function closeSettingsModal() { settingsModal.classList.add("hidden"); settingsModal.classList.remove("flex"); settingsModal.setAttribute("aria-hidden", "true"); } settingsBtn.addEventListener("click", openSettings); closeSettings.addEventListener("click", closeSettingsModal); settingsModal.addEventListener("click", (e) => { if (e.target === settingsModal) closeSettingsModal(); }); function summarizeIngest(summary) { return Object.entries(summary || {}) .map(([k, v]) => `${k}: ${v}`) .join(" · "); } uploadForm.addEventListener("submit", async (e) => { e.preventDefault(); const files = fileInput.files; if (!files || !files.length) { uploadStatus.textContent = "Pick one or more files first."; return; } const fd = new FormData(); Array.from(files).forEach((f) => fd.append("files", f)); uploadStatus.textContent = "Uploading and indexing…"; try { const res = await fetch(`${API_BASE}/api/upload`, { method: "POST", body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || res.statusText); uploadStatus.textContent = `Done. ${summarizeIngest(data.chunks_per_collection)}`; fileInput.value = ""; } catch (err) { uploadStatus.textContent = `Error: ${err.message}`; } }); reindexBtn.addEventListener("click", async () => { uploadStatus.textContent = "Re-indexing…"; try { const res = await fetch(`${API_BASE}/api/ingest?reset=true`, { method: "POST" }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || res.statusText); uploadStatus.textContent = `Re-indexed. ${summarizeIngest(data.chunks_per_collection)}`; } catch (err) { uploadStatus.textContent = `Error: ${err.message}`; } }); async function pollHealth() { try { const res = await fetch(`${API_BASE}/api/health`); const data = await res.json(); const ok = data.status === "ok" && data.has_api_key === "yes"; statusDot.classList.remove("bg-slate-400", "bg-emerald-500", "bg-amber-500", "bg-rose-500"); statusDot.classList.add(ok ? "bg-emerald-500" : "bg-amber-500"); statusDot.title = ok ? `Online · ${data.model}` : `API reachable, but GROQ_API_KEY is missing`; } catch { statusDot.classList.remove("bg-slate-400", "bg-emerald-500", "bg-amber-500"); statusDot.classList.add("bg-rose-500"); statusDot.title = "Backend unreachable"; } } pollHealth(); setInterval(pollHealth, 30000); autoresizeTextarea(); setTimeout(() => input.focus(), 50); })();