const EXAMPLES = [ "周末几点开门,几点关门?", "柏卡会员年费多少,能借几本书?", "买的书几天内可以退?", "阅读区可以带外面的食物吗?", "十月读书会的主题和票价是什么?", ]; const state = { ready: false, failed: false, sending: false, documents: [], turns: [], }; const messages = document.querySelector("#messages"); const questionInput = document.querySelector("#question"); const sendButton = document.querySelector("#send"); const topK = document.querySelector("#top-k"); const docForm = document.querySelector("#doc-form"); const docTitle = document.querySelector("#doc-title"); const docContent = document.querySelector("#doc-content"); const saveDoc = document.querySelector("#save-doc"); const pickFile = document.querySelector("#pick-file"); const fileInput = document.querySelector("#file"); const docList = document.querySelector("#doc-list"); const pills = document.querySelector("#pills"); const facts = document.querySelector("#facts"); const libraryMeta = document.querySelector("#library-meta"); messages.addEventListener("click", (event) => { const chip = event.target.closest("[data-question]"); if (chip) { ask(chip.dataset.question); return; } const cite = event.target.closest(".cite"); if (!cite) return; const turn = cite.closest(".turn"); turn.querySelectorAll(".cite").forEach((node) => node.classList.remove("is-on")); turn.querySelectorAll(".slice").forEach((node) => node.classList.remove("is-active")); cite.classList.add("is-on"); const slice = turn.querySelector(`.slice[data-n="${cite.dataset.n}"]`); if (slice) { slice.classList.add("is-active"); slice.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }); docList.addEventListener("click", (event) => { const button = event.target.closest("[data-delete]"); if (!button) return; removeDocument(button.dataset.delete, button.dataset.title); }); document.querySelector("#composer").addEventListener("submit", (event) => { event.preventDefault(); ask(questionInput.value); }); questionInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); document.querySelector("#composer").requestSubmit(); } }); docForm.addEventListener("submit", async (event) => { event.preventDefault(); await createDocument(docTitle.value, docContent.value); }); pickFile.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", async () => { const file = fileInput.files && fileInput.files[0]; fileInput.value = ""; if (!file) return; const body = new FormData(); body.append("file", file); try { const response = await fetch("/api/documents/upload", { method: "POST", body }); if (!response.ok) throw new Error(await readError(response)); const doc = await response.json(); toast(`已把《${doc.title}》切成 ${doc.chunk_count} 片并写入向量库`); await refreshDocs(); await refreshStatus(); } catch (error) { toast(error.message); } }); function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; } function toast(message) { const node = document.querySelector("#toast"); node.textContent = message; node.classList.add("is-on"); clearTimeout(toast.timer); toast.timer = setTimeout(() => node.classList.remove("is-on"), 3400); } async function readError(response) { try { const data = await response.json(); if (typeof data.detail === "string") return data.detail; } catch { /* 响应体不是 JSON */ } return `请求失败(${response.status})`; } function setBusy(busy) { state.sending = busy; const locked = busy || !state.ready; questionInput.disabled = locked; sendButton.disabled = locked; topK.disabled = locked; docTitle.disabled = !state.ready; docContent.disabled = !state.ready; saveDoc.disabled = !state.ready; pickFile.disabled = !state.ready; sendButton.textContent = busy ? "检索中" : "提问"; } function renderStatus(data) { pills.replaceChildren(); const model = el("span", "pill"); if (data.error) { model.classList.add("is-bad"); model.textContent = "模型准备失败"; } else if (data.ready) { model.classList.add("is-ok"); model.textContent = data.embedding_model; } else { model.classList.add("is-wait"); model.textContent = "正在下载并加载嵌入模型…"; } const store = el("span", "pill"); store.append(`${data.vector_db} · `); const strong = el("strong", null, `${data.documents} 篇 / ${data.chunks} 片`); store.append(strong); const llm = el("span", "pill"); if (data.llm_ready) { llm.classList.add("is-ok"); llm.textContent = `生成模型 ${data.llm_model}`; } else { llm.textContent = "未配置大模型,先摘录原文"; } pills.append(model, store, llm); if (data.error) { const retry = el("button", "chip", "重试"); retry.type = "button"; retry.addEventListener("click", async () => { state.failed = false; await fetch("/api/boot", { method: "POST" }); poll(); }); pills.append(retry); } facts.replaceChildren(); const rows = [ ["嵌入", data.embedding_model], ["向量库", data.vector_db], ["切片", `${data.chunk_size} 字,重叠 ${data.chunk_overlap} 字`], ]; for (const [key, value] of rows) { facts.append(el("dt", null, key), el("dd", null, value)); } libraryMeta.textContent = data.ready ? "示例资料是虚构的云栖书店。可以继续追加你自己的文本,提问时会一起检索。" : "第一次启动会下载大约一百兆的中文嵌入模型,页面可以先开着。"; } function renderDocs() { docList.replaceChildren(); if (!state.documents.length) { docList.append(el("p", "empty-docs quiet", "知识库还是空的。")); return; } for (const doc of state.documents) { const card = el("article", "doc"); const title = el("h3", "doc-title", doc.title); const button = el("button", "icon-btn", "删"); button.type = "button"; button.dataset.delete = doc.id; button.dataset.title = doc.title; button.setAttribute("aria-label", `删除《${doc.title}》`); const preview = el("p", "doc-preview", `${doc.chunk_count} 个切片 · ${doc.preview}`); card.append(title, button, preview); docList.append(card); } } function appendRichText(container, text) { const pattern = /〔来源(\d+)〕/g; let cursor = 0; for (const match of String(text || "").matchAll(pattern)) { const index = match.index ?? 0; if (index > cursor) container.append(text.slice(cursor, index)); const cite = el("button", "cite", `来源 ${match[1]}`); cite.type = "button"; cite.dataset.n = match[1]; cite.title = `定位到第 ${match[1]} 条切片`; container.append(cite); cursor = index + match[0].length; } if (cursor < text.length) container.append(text.slice(cursor)); } function renderSlice(chunk) { const item = el("li", "slice"); item.dataset.n = String(chunk.n); const top = el("div", "slice-top"); top.append(el("span", "slice-no", `来源 ${chunk.n}`)); const bar = el("div", "bar"); const fill = document.createElement("span"); fill.style.width = `${Math.max(6, Math.round(chunk.score * 100))}%`; bar.append(fill); top.append(bar, el("span", "slice-no", `${Math.round(chunk.score * 100)}%`)); const title = el("p", "slice-title", `《${chunk.title}》 · 第 ${chunk.chunk_index}/${chunk.chunk_total} 片`); const body = el("p", "slice-text", chunk.text); item.append(top, title, body); return item; } function renderTurn(turn) { const article = el("article", "turn"); const question = el("div", "question"); question.append(el("span", "label", "提问"), el("p", null, turn.question)); const row = el("div", "answer-row"); const answer = el("div", "answer"); answer.append(el("span", "label", "回答")); if (turn.pending) { answer.append(el("div", "pending"), el("div", "pending short")); } else if (turn.error) { answer.append(el("p", "warn", turn.error)); } else { const body = el("div", "answer-body"); appendRichText(body, turn.answer); answer.append(body); const foot = el("div", "answer-foot"); const mode = el("span", turn.mode === "llm" ? "tag" : "tag is-extract", turn.mode === "llm" ? "模型生成" : "原文摘录"); foot.append(mode, el("span", null, `${turn.chunks.length} 条切片 · ${formatElapsed(turn.elapsed_ms)}`)); answer.append(foot); if (turn.notice) answer.append(el("p", "quiet", turn.notice)); } const slices = el("aside", "slices"); const head = el("div", "slices-head"); head.append(el("h3", null, "检索到的知识切片"), el("span", "quiet", turn.pending ? "检索中" : `${(turn.chunks || []).length} 条`)); slices.append(head); if (turn.pending) { slices.append(el("div", "pending"), el("div", "pending")); } else if (turn.chunks && turn.chunks.length) { if (turn.low_confidence) slices.append(el("p", "warn", "最高相关度偏低,请核对原文后再采信。")); const list = el("ol", "slice-list"); turn.chunks.forEach((chunk) => list.append(renderSlice(chunk))); slices.append(list); } else if (!turn.error) { slices.append(el("p", "quiet", "这次没有检索到切片。")); } row.append(answer, slices); article.append(question, row); return article; } function renderThread() { messages.replaceChildren(); if (!state.turns.length) { const empty = el("div", "empty"); empty.append( el("h2", null, "先问一句,看证据从哪来"), el("p", null, "预置了一家虚构书店的规章。回答出现在左侧,右侧是向量库检索到的原文切片。"), ); const chips = el("div", "chips"); for (const sample of EXAMPLES) { const button = el("button", "chip", sample); button.type = "button"; button.dataset.question = sample; button.disabled = !state.ready || state.sending; chips.append(button); } empty.append(chips); messages.append(empty); return; } state.turns.forEach((turn) => messages.append(renderTurn(turn))); messages.scrollTop = messages.scrollHeight; } function formatElapsed(ms) { if (ms < 1000) return `${ms} ms`; return `${(ms / 1000).toFixed(1)} s`; } async function refreshStatus() { const response = await fetch("/api/status"); if (!response.ok) throw new Error(await readError(response)); const data = await response.json(); state.ready = Boolean(data.ready); state.failed = Boolean(data.error); renderStatus(data); setBusy(state.sending); return data; } async function refreshDocs() { const response = await fetch("/api/documents"); if (!response.ok) throw new Error(await readError(response)); const data = await response.json(); state.documents = data.documents; renderDocs(); } async function poll() { try { const data = await refreshStatus(); if (data.ready) { await refreshDocs(); renderThread(); questionInput.focus(); return; } if (!data.error) setTimeout(poll, 1000); else renderThread(); } catch { pills.replaceChildren(el("span", "pill is-bad", "暂时连不上问答服务")); setTimeout(poll, 1500); } } async function ask(raw) { const question = (raw || "").trim(); if (!question || state.sending || !state.ready) return; questionInput.value = ""; const turn = { id: crypto.randomUUID(), question, pending: true, chunks: [] }; state.turns.push(turn); renderThread(); setBusy(true); try { const response = await fetch("/api/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question, top_k: Number(topK.value) }), }); if (!response.ok) throw new Error(await readError(response)); Object.assign(turn, await response.json(), { pending: false, error: null }); } catch (error) { turn.pending = false; turn.error = error.message; turn.chunks = []; } finally { setBusy(false); renderThread(); } } async function createDocument(title, content) { if (!state.ready) return; try { const response = await fetch("/api/documents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: title.trim(), content }), }); if (!response.ok) throw new Error(await readError(response)); const doc = await response.json(); docTitle.value = ""; docContent.value = ""; toast(`已把《${doc.title}》切成 ${doc.chunk_count} 片并写入向量库`); await refreshDocs(); await refreshStatus(); } catch (error) { toast(error.message); } } async function removeDocument(id, title) { if (!confirm(`从向量库删除《${title}》?`)) return; try { const response = await fetch(`/api/documents/${id}`, { method: "DELETE" }); if (!response.ok) throw new Error(await readError(response)); toast(`已删除《${title}》`); await refreshDocs(); await refreshStatus(); } catch (error) { toast(error.message); } } renderThread(); poll();