Files
2026-10-12 11:28:37 +08:00

401 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
const where = data.llm_provider === "ollama" ? "局域网 Ollama" : "生成模型";
llm.textContent = `${where} · ${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();