Download app/templates/index.html from KBaba7/DocsBot: direct link, hf CLI and curl.
- Browser
- Download file 26 kB
-
https://huggingface.co/spaces/KBaba7/DocsBot/resolve/main/app/templates/index.html
- Command line
-
hf download hf://spaces/KBaba7/DocsBot/app/templates/index.html
-
curl -L -o index.html https://huggingface.co/spaces/KBaba7/DocsBot/resolve/main/app/templates/index.html
26 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>DocsQA Assignment</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" | |
| rel="stylesheet" | |
| /> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <link rel="stylesheet" href="/static/style.css" /> | |
| </head> | |
| <body> | |
| <div class="bg-orb orb-one"></div> | |
| <div class="bg-orb orb-two"></div> | |
| <main class="shell"> | |
| {% if not user %} | |
| <section class="hero card"> | |
| <div class="hero-topline"> | |
| <p class="eyebrow">LangGraph Assignment</p> | |
| <span class="badge">FastAPI + Supabase + PGVector</span> | |
| </div> | |
| <h1>DocsQA Workspace</h1> | |
| <p class="lede"> | |
| Upload PDFs, avoid duplicate reprocessing by file hash, and ask an agent that uses user-scoped document retrieval with optional web search. | |
| </p> | |
| <p class="developer-credit">Developed by Baba Kattubadi</p> | |
| {% if db_unavailable %} | |
| <p class="db-warning"> | |
| Database connection is temporarily unavailable. This is usually a transient DNS/network issue with the Supabase host. Please retry shortly. | |
| </p> | |
| {% endif %} | |
| </section> | |
| {% else %} | |
| {% endif %} | |
| {% if not user %} | |
| <section class="grid two auth-grid"> | |
| <form class="card panel" id="register-form" method="post" action="/register"> | |
| <div class="panel-head"> | |
| <h2>Create account</h2> | |
| <p>Start by creating your personal docs workspace.</p> | |
| </div> | |
| <label>Email <input type="email" name="email" required /></label> | |
| <label>Password <input type="password" name="password" required /></label> | |
| <button type="submit">Register</button> | |
| <pre class="result ok" id="register-result"></pre> | |
| </form> | |
| <form class="card panel" id="login-form" method="post" action="/login"> | |
| <div class="panel-head"> | |
| <h2>Sign in</h2> | |
| <p>Continue with your existing account.</p> | |
| </div> | |
| <label>Email <input type="email" name="email" required /></label> | |
| <label>Password <input type="password" name="password" required /></label> | |
| <button type="submit">Login</button> | |
| <pre class="result ok" id="login-result"></pre> | |
| </form> | |
| </section> | |
| {% else %} | |
| <section class="app-layout"> | |
| <aside class="card panel sidebar-panel"> | |
| <div class="panel-head"> | |
| <p class="eyebrow">LangGraph Assignment</p> | |
| <div class="sidebar-title-row"> | |
| <div> | |
| <h1 class="sidebar-title">DocsQA Workspace</h1> | |
| <p class="muted">Private document chat with structured sources.</p> | |
| <p class="developer-credit">Developed by Baba Kattubadi</p> | |
| </div> | |
| <span class="badge">FastAPI + Supabase + PGVector</span> | |
| </div> | |
| </div> | |
| <div class="panel-head account-head"> | |
| <h2 class="user-email">{{ user.email }}</h2> | |
| <p class="muted">Your uploaded docs are private to this account.</p> | |
| </div> | |
| <form id="upload-form" class="panel"> | |
| <label class="muted">Upload PDFs (max 5 files, 10 pages each)</label> | |
| <input type="file" id="file" name="file" accept="application/pdf" multiple required /> | |
| <button type="submit">Upload</button> | |
| </form> | |
| <pre class="result" id="upload-result"></pre> | |
| <div class="panel-head panel-head-inline"> | |
| <h3>Your documents</h3> | |
| <span class="badge">{{ documents|length }}</span> | |
| </div> | |
| <div class="docs sidebar-docs"> | |
| {% for document in documents %} | |
| <article class="doc"> | |
| <header class="doc-head"> | |
| <h3>{{ document.filename }}</h3> | |
| <span class="doc-pages">{{ document.page_count }} pages</span> | |
| </header> | |
| <div class="doc-actions"> | |
| <button | |
| type="button" | |
| class="danger doc-delete-btn" | |
| data-document-id="{{ document.id }}" | |
| data-document-name="{{ document.filename }}" | |
| > | |
| Delete | |
| </button> | |
| </div> | |
| </article> | |
| {% else %} | |
| <p class="muted">No documents uploaded yet.</p> | |
| {% endfor %} | |
| </div> | |
| <form method="post" action="/logout" id="logout-form"> | |
| <button type="submit" class="secondary">Sign out</button> | |
| </form> | |
| </aside> | |
| <section class="card panel chat-shell chat-panel"> | |
| <div class="panel-head panel-head-inline"> | |
| <h2>DocsQA Chat</h2> | |
| <div style="display: flex; gap: 8px; align-items: center;"> | |
| <button type="button" id="new-chat-btn" class="secondary" style="font-size: 0.875rem; padding: 6px 12px;">New Chat</button> | |
| <span class="badge">Markdown enabled</span> | |
| </div> | |
| </div> | |
| <div id="chat-thread" class="chat-thread"> | |
| <article class="chat-msg assistant"> | |
| <div class="chat-bubble chat-bubble-assistant chat-markdown"> | |
| <p>Ask anything about your uploaded PDFs and I will answer with citations from retrieved chunks.</p> | |
| </div> | |
| </article> | |
| </div> | |
| <form id="ask-form" class="chat-composer"> | |
| <textarea | |
| id="query" | |
| rows="3" | |
| placeholder="Message DocsQA..." | |
| required | |
| ></textarea> | |
| <button type="submit">Send</button> | |
| </form> | |
| </section> | |
| </section> | |
| {% endif %} | |
| </main> | |
| <script> | |
| // Session management | |
| let currentSessionId = sessionStorage.getItem("chat_session_id") || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| sessionStorage.setItem("chat_session_id", currentSessionId); | |
| const chatStorageKey = () => `chat_thread_${currentSessionId}`; | |
| const registerForm = document.getElementById("register-form"); | |
| const loginForm = document.getElementById("login-form"); | |
| const logoutForm = document.getElementById("logout-form"); | |
| const registerResult = document.getElementById("register-result"); | |
| const loginResult = document.getElementById("login-result"); | |
| const uploadForm = document.getElementById("upload-form"); | |
| const askForm = document.getElementById("ask-form"); | |
| const uploadResult = document.getElementById("upload-result"); | |
| const chatThread = document.getElementById("chat-thread"); | |
| const queryInput = document.getElementById("query"); | |
| const docDeleteButtons = document.querySelectorAll(".doc-delete-btn"); | |
| const newChatBtn = document.getElementById("new-chat-btn"); | |
| const saveChatThread = () => { | |
| if (!chatThread) return; | |
| sessionStorage.setItem(chatStorageKey(), chatThread.innerHTML); | |
| }; | |
| const restoreChatThread = () => { | |
| if (!chatThread) return; | |
| const savedThread = sessionStorage.getItem(chatStorageKey()); | |
| if (savedThread) { | |
| chatThread.innerHTML = savedThread; | |
| } | |
| }; | |
| const resetChatThread = () => { | |
| if (!chatThread) return; | |
| chatThread.innerHTML = ` | |
| <article class="chat-msg assistant"> | |
| <div class="chat-bubble chat-bubble-assistant chat-markdown"> | |
| <p>Ask anything about your uploaded PDFs and I will answer with citations from retrieved chunks.</p> | |
| </div> | |
| </article> | |
| `; | |
| saveChatThread(); | |
| }; | |
| restoreChatThread(); | |
| // New Chat button handler | |
| newChatBtn?.addEventListener("click", () => { | |
| currentSessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| sessionStorage.setItem("chat_session_id", currentSessionId); | |
| resetChatThread(); | |
| }); | |
| const safeJson = async (response) => { | |
| try { | |
| return await response.json(); | |
| } catch { | |
| return { detail: "Unexpected non-JSON response" }; | |
| } | |
| }; | |
| const prettyError = (body) => { | |
| if (!body) return "Request failed."; | |
| if (typeof body.detail === "string") return body.detail; | |
| if (typeof body.message === "string") return body.message; | |
| return "Request failed."; | |
| }; | |
| const setBusy = (form, busy) => { | |
| if (!form) return; | |
| const button = form.querySelector("button[type='submit']"); | |
| if (!button) return; | |
| button.disabled = busy; | |
| if (busy) { | |
| button.dataset.originalText = button.textContent; | |
| button.textContent = "Please wait..."; | |
| } else if (button.dataset.originalText) { | |
| button.textContent = button.dataset.originalText; | |
| } | |
| }; | |
| const escapeHtml = (value) => | |
| value | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">"); | |
| const normalizeTabularAnswer = (text) => { | |
| const lines = text.split("\n"); | |
| const out = []; | |
| let i = 0; | |
| while (i < lines.length) { | |
| const line = lines[i]; | |
| if (!line.includes("\t")) { | |
| out.push(line); | |
| i += 1; | |
| continue; | |
| } | |
| const rows = []; | |
| while (i < lines.length && lines[i].includes("\t")) { | |
| rows.push(lines[i].split("\t").map((cell) => cell.trim())); | |
| i += 1; | |
| } | |
| if (rows.length < 2) { | |
| out.push(...rows.map((row) => row.join(" "))); | |
| continue; | |
| } | |
| const columnCount = Math.max(...rows.map((row) => row.length)); | |
| const paddedRows = rows.map((row) => { | |
| const copy = [...row]; | |
| while (copy.length < columnCount) copy.push(""); | |
| return copy.map((cell) => cell.replaceAll("|", "\\|").replaceAll("\n", "<br>")); | |
| }); | |
| out.push(`| ${paddedRows[0].join(" | ")} |`); | |
| out.push(`| ${Array(columnCount).fill("---").join(" | ")} |`); | |
| for (const row of paddedRows.slice(1)) { | |
| out.push(`| ${row.join(" | ")} |`); | |
| } | |
| } | |
| return out.join("\n"); | |
| }; | |
| const sanitizeHtml = (html) => { | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(html, "text/html"); | |
| doc.querySelectorAll("script,style,iframe,object,embed").forEach((node) => node.remove()); | |
| for (const el of doc.querySelectorAll("*")) { | |
| for (const attr of [...el.attributes]) { | |
| const name = attr.name.toLowerCase(); | |
| const value = attr.value.toLowerCase(); | |
| if (name.startsWith("on")) { | |
| el.removeAttribute(attr.name); | |
| } | |
| if ((name === "href" || name === "src") && value.startsWith("javascript:")) { | |
| el.removeAttribute(attr.name); | |
| } | |
| } | |
| } | |
| return doc.body.innerHTML; | |
| }; | |
| const renderMarkdown = (text) => { | |
| const normalized = normalizeTabularAnswer(text || ""); | |
| if (window.marked?.parse) { | |
| const html = window.marked.parse(normalized, { gfm: true, breaks: true }); | |
| return sanitizeHtml(html); | |
| } | |
| return `<pre>${escapeHtml(normalized)}</pre>`; | |
| }; | |
| const stripSourceStatusLines = (text) => { | |
| if (!text) return ""; | |
| const withoutSourcesSection = text.replace(/\n+\s*(?:#+\s*)?sources\s*:?\s*\n[\s\S]*$/i, "").trim(); | |
| return withoutSourcesSection | |
| .split("\n") | |
| .filter((line) => !/^\s*no sources were used for this response\.?\s*$/i.test(line.trim())) | |
| .filter((line) => !/^\s*no citations available for this turn\.?\s*$/i.test(line.trim())) | |
| .join("\n") | |
| .trim(); | |
| }; | |
| const buildSourcesMarkdown = (sources) => { | |
| const vectorSources = Array.isArray(sources?.vector) ? sources.vector : []; | |
| const webSources = Array.isArray(sources?.web) ? sources.web : []; | |
| const lines = []; | |
| if (vectorSources.length) { | |
| lines.push("### Document Sources"); | |
| for (const src of vectorSources) { | |
| const doc = src.document || "Unknown document"; | |
| const page = src.page || "unknown"; | |
| const excerpt = src.excerpt || ""; | |
| lines.push(`- **${doc}**, page **${page}**`); | |
| if (excerpt) { | |
| lines.push(` - "${excerpt}"`); | |
| } | |
| } | |
| } | |
| if (webSources.length) { | |
| if (lines.length) lines.push(""); | |
| lines.push("### Web Sources"); | |
| for (const src of webSources) { | |
| const title = src.title || "Untitled source"; | |
| const url = src.url || ""; | |
| if (url && url !== "N/A") { | |
| lines.push(`- [${title}](${url})`); | |
| } else { | |
| lines.push(`- ${title}`); | |
| } | |
| } | |
| } | |
| if (lines.length) return lines.join("\n"); | |
| return "_No citations available for this turn._"; | |
| }; | |
| const renderSourcesHtml = (sources, queryText = "") => { | |
| const vectorSources = Array.isArray(sources?.vector) ? sources.vector : []; | |
| const webSources = Array.isArray(sources?.web) ? sources.web : []; | |
| const sections = []; | |
| if (vectorSources.length) { | |
| const vectorCards = vectorSources | |
| .map((src) => { | |
| const documentId = (src.document_id || "").toString().trim(); | |
| const doc = escapeHtml(src.document || "Unknown document"); | |
| const page = escapeHtml(src.page || "unknown"); | |
| const excerptHtml = escapeHtml(src.excerpt || ""); | |
| const pageNumber = Number.parseInt(src.page || "", 10); | |
| const pageAnchor = Number.isFinite(pageNumber) && pageNumber > 0 ? `#page=${pageNumber}` : ""; | |
| const pdfUrl = documentId ? `/documents/${encodeURIComponent(documentId)}/pdf${pageAnchor}` : ""; | |
| return ` | |
| <article class="source-card"> | |
| <div class="source-meta"> | |
| <span class="source-doc">${doc}</span> | |
| <div class="source-meta-right"> | |
| <span class="source-page">Page ${page}</span> | |
| </div> | |
| </div> | |
| <p class="source-excerpt">${excerptHtml || "No excerpt available."}</p> | |
| ${ | |
| pdfUrl | |
| ? `<div class="source-actions"><a class="source-link" href="${pdfUrl}" target="_blank" rel="noopener noreferrer">Open PDF</a></div>` | |
| : "" | |
| } | |
| </article> | |
| `; | |
| }) | |
| .join(""); | |
| sections.push(`<section><h4>Document Sources</h4><div class="source-list">${vectorCards}</div></section>`); | |
| } | |
| if (webSources.length) { | |
| const webItems = webSources | |
| .map((src) => { | |
| const title = escapeHtml(src.title || "Untitled source"); | |
| const url = src.url || ""; | |
| if (url && url !== "N/A") { | |
| return `<li><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${title}</a></li>`; | |
| } | |
| return `<li>${title}</li>`; | |
| }) | |
| .join(""); | |
| sections.push(`<section><h4>Web Sources</h4><ul class="source-web-list">${webItems}</ul></section>`); | |
| } | |
| if (!sections.length) { | |
| return `<p class="muted">No citations available for this turn.</p>`; | |
| } | |
| return sections.join(""); | |
| }; | |
| const renderAssistantResponse = (container, fullAnswerText, sourceDict = null, queryText = "") => { | |
| if (!container) return; | |
| const answerContent = stripSourceStatusLines(fullAnswerText) || "Response received."; | |
| const sourcesContent = renderSourcesHtml(sourceDict, queryText); | |
| container.innerHTML = ""; | |
| const answerPanel = document.createElement("div"); | |
| answerPanel.className = "message-panel"; | |
| answerPanel.innerHTML = renderMarkdown(answerContent); | |
| container.appendChild(answerPanel); | |
| const details = document.createElement("details"); | |
| details.className = "source-dropdown"; | |
| const summary = document.createElement("summary"); | |
| summary.textContent = "Sources"; | |
| const body = document.createElement("div"); | |
| body.className = "sources-panel"; | |
| body.innerHTML = sourcesContent; | |
| details.appendChild(summary); | |
| details.appendChild(body); | |
| container.appendChild(details); | |
| }; | |
| const readStreamingAnswer = async ({ query, target }) => { | |
| const response = await fetch("/ask/stream", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "X-Session-Id": currentSessionId | |
| }, | |
| body: JSON.stringify({ query }), | |
| }); | |
| if (!response.ok || !response.body) { | |
| const body = await safeJson(response); | |
| throw new Error(prettyError(body)); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| let answerText = ""; | |
| let sources = null; | |
| const processEvent = (rawEvent) => { | |
| const lines = rawEvent.split("\n"); | |
| let eventName = "message"; | |
| const dataLines = []; | |
| for (const line of lines) { | |
| if (line.startsWith("event:")) { | |
| eventName = line.slice(6).trim(); | |
| } else if (line.startsWith("data:")) { | |
| dataLines.push(line.slice(5).trim()); | |
| } | |
| } | |
| if (!dataLines.length) return; | |
| const payload = JSON.parse(dataLines.join("\n")); | |
| if (eventName === "token") { | |
| answerText += payload.content || ""; | |
| target.classList.remove("chat-pending"); | |
| target.classList.add("chat-markdown"); | |
| target.innerHTML = renderMarkdown(answerText || "Thinking..."); | |
| chatThread.scrollTop = chatThread.scrollHeight; | |
| return; | |
| } | |
| if (eventName === "sources") { | |
| sources = payload.sources || null; | |
| return; | |
| } | |
| if (eventName === "done") { | |
| answerText = payload.answer || answerText || "Response received."; | |
| target.classList.remove("chat-pending"); | |
| target.classList.add("chat-markdown"); | |
| renderAssistantResponse(target, answerText, sources, query); | |
| chatThread.scrollTop = chatThread.scrollHeight; | |
| return; | |
| } | |
| if (eventName === "error") { | |
| throw new Error(payload.detail || "Streaming failed."); | |
| } | |
| }; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const events = buffer.split("\n\n"); | |
| buffer = events.pop() || ""; | |
| for (const rawEvent of events) { | |
| if (rawEvent.trim()) processEvent(rawEvent); | |
| } | |
| } | |
| buffer += decoder.decode(); | |
| if (buffer.trim()) processEvent(buffer); | |
| return { answer: answerText, sources }; | |
| }; | |
| const appendMessage = ({ role, text, markdown = false, pending = false, isError = false }) => { | |
| if (!chatThread) return null; | |
| const row = document.createElement("article"); | |
| row.className = `chat-msg ${role}`; | |
| const bubble = document.createElement("div"); | |
| bubble.className = `chat-bubble ${role === "user" ? "chat-bubble-user" : "chat-bubble-assistant"}`; | |
| if (markdown) bubble.classList.add("chat-markdown"); | |
| if (pending) bubble.classList.add("chat-pending"); | |
| if (isError) bubble.classList.add("chat-error"); | |
| if (markdown) { | |
| bubble.innerHTML = renderMarkdown(text); | |
| } else { | |
| bubble.textContent = text; | |
| } | |
| row.appendChild(bubble); | |
| chatThread.appendChild(row); | |
| chatThread.scrollTop = chatThread.scrollHeight; | |
| saveChatThread(); | |
| return bubble; | |
| }; | |
| registerForm?.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| setBusy(registerForm, true); | |
| const formData = new FormData(registerForm); | |
| const response = await fetch("/register", { method: "POST", body: formData }); | |
| const body = await safeJson(response); | |
| registerResult.textContent = response.ok | |
| ? "Registration successful. Redirecting to your workspace..." | |
| : prettyError(body); | |
| registerResult.classList.toggle("error", !response.ok); | |
| setBusy(registerForm, false); | |
| if (response.ok) { | |
| window.location.reload(); | |
| } | |
| }); | |
| loginForm?.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| setBusy(loginForm, true); | |
| const formData = new FormData(loginForm); | |
| const response = await fetch("/login", { method: "POST", body: formData }); | |
| const body = await safeJson(response); | |
| loginResult.textContent = response.ok | |
| ? "Login successful. Redirecting..." | |
| : prettyError(body); | |
| loginResult.classList.toggle("error", !response.ok); | |
| setBusy(loginForm, false); | |
| if (response.ok) { | |
| window.location.reload(); | |
| } | |
| }); | |
| logoutForm?.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| const response = await fetch("/logout", { method: "POST" }); | |
| if (response.ok) { | |
| sessionStorage.removeItem(chatStorageKey()); | |
| window.location.reload(); | |
| } | |
| }); | |
| uploadForm?.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| setBusy(uploadForm, true); | |
| const formData = new FormData(); | |
| const fileInput = document.getElementById("file"); | |
| const files = Array.from(fileInput?.files || []); | |
| if (!files.length) { | |
| uploadResult.textContent = "Please choose at least one PDF."; | |
| uploadResult.classList.add("error"); | |
| setBusy(uploadForm, false); | |
| return; | |
| } | |
| for (const file of files) { | |
| formData.append("file", file); | |
| } | |
| const response = await fetch("/upload", { method: "POST", body: formData }); | |
| const body = await safeJson(response); | |
| if (response.ok) { | |
| const createdCount = (body.documents || []).filter((doc) => doc.created).length; | |
| const reusedCount = (body.documents || []).length - createdCount; | |
| uploadResult.textContent = | |
| `Uploaded ${body.count} file(s). ${createdCount} indexed, ${reusedCount} reused.\n` + | |
| (body.documents || []) | |
| .map((doc) => `- ${doc.filename} (${doc.page_count} pages)`) | |
| .join("\n"); | |
| } else { | |
| uploadResult.textContent = prettyError(body); | |
| } | |
| uploadResult.classList.toggle("error", !response.ok); | |
| setBusy(uploadForm, false); | |
| if (response.ok) { | |
| saveChatThread(); | |
| window.location.reload(); | |
| } | |
| }); | |
| docDeleteButtons.forEach((button) => { | |
| button.addEventListener("click", async () => { | |
| const documentId = button.dataset.documentId; | |
| const documentName = button.dataset.documentName || "this document"; | |
| if (!documentId) return; | |
| const confirmed = window.confirm(`Delete ${documentName} from your documents?`); | |
| if (!confirmed) return; | |
| button.disabled = true; | |
| const response = await fetch(`/documents/${documentId}`, { method: "DELETE" }); | |
| const body = await safeJson(response); | |
| if (!response.ok) { | |
| button.disabled = false; | |
| uploadResult.textContent = prettyError(body); | |
| uploadResult.classList.add("error"); | |
| return; | |
| } | |
| window.location.reload(); | |
| }); | |
| }); | |
| askForm?.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| const query = queryInput?.value?.trim() || ""; | |
| if (!query) return; | |
| appendMessage({ role: "user", text: query }); | |
| if (queryInput) queryInput.value = ""; | |
| setBusy(askForm, true); | |
| const pendingBubble = appendMessage({ role: "assistant", text: "Thinking...", markdown: false, pending: true }); | |
| const target = pendingBubble || appendMessage({ role: "assistant", text: "", markdown: false }); | |
| if (!target) { | |
| setBusy(askForm, false); | |
| return; | |
| } | |
| try { | |
| await readStreamingAnswer({ query, target }); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : "Request failed."; | |
| target.classList.remove("chat-pending"); | |
| target.classList.add("chat-error"); | |
| target.textContent = message; | |
| } finally { | |
| chatThread.scrollTop = chatThread.scrollHeight; | |
| saveChatThread(); | |
| setBusy(askForm, false); | |
| } | |
| }); | |
| queryInput?.addEventListener("keydown", (event) => { | |
| if (event.key === "Enter" && !event.shiftKey) { | |
| event.preventDefault(); | |
| askForm?.requestSubmit(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |