// 个人知识库 modal:左栏列库,右栏库详情(已入库条目 + 待入库 + 入库进度)/ 单篇正文。 // 照 memory.js master-detail 范式;删除走 dialogConfirm(crons.js 同款);上传用 XHR // FormData(files.js 同款,带 Bearer);入库进行中每 3s 轮询库详情(procs.js 自适应启停)。 // 后端:/v1/kb*(core/kb.py 纯文件,真实文件为准);检索侧 agent 走 fs 工具,前端只当"眼睛+搬运工"。 import { $ } from "./dom.js"; import { api } from "./api.js"; import { state } from "./state.js"; import { escapeHtml } from "./format.js"; import { renderMd, highlightIn } from "./markdown.js"; import { dialogConfirm, dialogPrompt, message } from "./dialog.js"; import { downloadFile } from "./media.js"; import { openFilePreview } from "./preview.js"; const PLACEHOLDER = '
← 选一个库查看,或「+ 新建库」
'; const POLL_MS = 3000; let _current = null; // 当前选中库名 let _pollTimer = null; let _docSources = {}; // doc 文件名 → source 原件名(renderDetail 时填,单篇页「下载原件」用) // .kb 就在 user_root 下,下载直接复用 /v1/files/download(media.js downloadFile) const kbRel = (sub) => ".kb/" + _current + "/" + sub; function openKbModal() { $("kb-modal").classList.add("show"); $("kb-detail").innerHTML = PLACEHOLDER; _current = null; renderList(); } export function closeKbModal() { $("kb-modal").classList.remove("show"); stopPolling(); } function startPolling() { if (!_pollTimer) _pollTimer = setInterval(() => { if (_current) showKb(_current, null, true); }, POLL_MS); } function stopPolling() { if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } } // ───── 左栏:库列表 ───── async function renderList() { const list = $("kb-list"); let data; try { data = await api("GET", "/v1/kb"); } catch (e) { list.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; return; } const kbs = data.results || []; let html = '
我的库
'; html += kbs.length ? kbs.map((k) => `
${escapeHtml(k.name)}${k.pending_count ? ` 待入库 ${k.pending_count}` : ""}
${k.doc_count} 篇文档
`).join("") : '
还没有库。建一个,把常用资料(规范 / 报告 / 标准…)传进来,以后对话我能直接查。
'; html += '
'; list.innerHTML = html; } async function createKb() { const name = await dialogPrompt({ title: "新建知识库", message: "库名(中文 / 字母 / 数字,如「行业标准」「项目资料」):", okText: "创建", }); if (!name || !name.trim()) return; try { await api("POST", "/v1/kb", { name: name.trim() }); } catch (e) { message("创建失败: " + e.message, "error"); return; } await renderList(); showKb(name.trim(), null); } // ───── 右栏:库详情 ───── function highlightSel(name) { $("kb-list").querySelectorAll(".sk-item").forEach((el) => { el.classList.toggle("active", el.getAttribute("data-kb") === name); }); } async function showKb(name, itemEl, isPoll = false) { _current = name; if (!isPoll) { highlightSel(name); $("kb-detail").innerHTML = '
加载中…
'; } let d; try { d = await api("GET", "/v1/kb/" + encodeURIComponent(name)); } catch (e) { if (!isPoll) $("kb-detail").innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; stopPolling(); return; } if (_current !== name) return; // 轮询回来时已切走 renderDetail(d); if (d.ingest && d.ingest.running) startPolling(); else stopPolling(); } function renderDetail(d) { const ing = d.ingest || {}; const entries = d.entries || []; const pending = d.pending || []; let html = `
${escapeHtml(d.name)} ${entries.length} 篇
`; if (ing.running) { html += `
入库中… ${ing.done}/${ing.total}${ing.current ? " · " + escapeHtml(ing.current) : ""}
`; } if (ing.errors && ing.errors.length) { html += `
${ing.errors.map((x) => `
⚠ ${escapeHtml(x.source)}:${escapeHtml(x.error)}
`).join("")}
`; } if (pending.length) { html += `
待入库 (${pending.length})
`; html += pending.map((n) => `
${escapeHtml(n)}
`).join(""); if (!ing.running) { html += `
`; } } html += '
已入库
'; _docSources = {}; html += entries.length ? entries.map((e) => { const doc = e.doc.replace(/^docs\//, ""); const src = (e.source || "").replace(/^sources\//, ""); if (src) _docSources[doc] = src; return `
${escapeHtml(e.title)}
${escapeHtml(e.summary)}${e.keywords ? " | " + escapeHtml(e.keywords) : ""}
${src ? ` ` : ""}
`; }).join("") : '
空库。点「上传文件」把资料传进来,自动转 markdown 并写摘要。
'; $("kb-detail").innerHTML = html; } // ───── 单篇查看 ───── async function showDoc(filename) { const detail = $("kb-detail"); detail.innerHTML = '
加载中…
'; let data; try { data = await api("GET", "/v1/kb/" + encodeURIComponent(_current) + "/docs/" + encodeURIComponent(filename)); } catch (e) { detail.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; return; } const src = _docSources[filename]; detail.innerHTML = `
${escapeHtml(filename)} ${src ? ` ` : ""}
` + `
${renderMd(data.content)}
`; highlightIn(detail); } // ───── 上传(files.js 同款 XHR,带进度)───── function uploadTo(name, fileList) { const files = Array.from(fileList || []); if (!files.length) return; const fd = new FormData(); for (const f of files) fd.append("files", f); const xhr = new XMLHttpRequest(); xhr.open("POST", "/v1/kb/" + encodeURIComponent(name) + "/upload"); xhr.setRequestHeader("Authorization", "Bearer " + state.token); xhr.onload = () => { let payload = null; try { payload = JSON.parse(xhr.responseText); } catch (e) {} if (xhr.status >= 200 && xhr.status < 300) { message(`已上传 ${payload && payload.count || files.length} 个文件,开始入库…`); showKb(name, null); // 立即刷详情;running 会自动开轮询 } else { const msg = (payload && payload.detail) || (xhr.status + " " + xhr.statusText); message("上传失败: " + msg, "error"); } }; xhr.onerror = () => message("上传失败: 网络错误", "error"); xhr.send(fd); message("上传中…"); } // ───── 顶层绑定 ───── $("hd-kb").onclick = openKbModal; $("kb-close").onclick = closeKbModal; $("kb-modal").addEventListener("click", (e) => { if (e.target.id === "kb-modal") closeKbModal(); // 点遮罩关闭 }); $("kb-list").addEventListener("click", (e) => { if (e.target.id === "kb-new" || e.target.closest("#kb-new")) { createKb(); return; } const item = e.target.closest(".sk-item"); if (item) showKb(item.getAttribute("data-kb"), item); }); $("kb-upload-input").addEventListener("change", () => { const inp = $("kb-upload-input"); if (_current) uploadTo(_current, inp.files); inp.value = ""; }); $("kb-detail").addEventListener("click", async (e) => { if (e.target.id === "kb-upload-btn") { $("kb-upload-input").click(); return; } if (e.target.id === "kb-back") { showKb(_current, null); return; } if (e.target.id === "kb-doc-md-dl") { downloadFile(kbRel("docs/" + e.target.getAttribute("data-doc"))); return; } if (e.target.id === "kb-doc-src-dl") { downloadFile(kbRel("sources/" + e.target.getAttribute("data-src"))); return; } // 查看原件:复用主预览弹框(preview.js,z 114 盖在 kb modal 之上),路径同下载走 .kb 相对路径 if (e.target.id === "kb-doc-src-view") { openFilePreview(kbRel("sources/" + e.target.getAttribute("data-src"))); return; } const srcView = e.target.closest(".kb-src-view"); if (srcView) { openFilePreview(kbRel("sources/" + srcView.getAttribute("data-src"))); return; } const srcDl = e.target.closest(".kb-src-dl"); if (srcDl) { downloadFile(kbRel("sources/" + srcDl.getAttribute("data-src"))); return; } if (e.target.id === "kb-reingest") { try { await api("POST", "/v1/kb/" + encodeURIComponent(_current) + "/ingest"); } catch (err) { message("触发失败: " + err.message, "error"); return; } showKb(_current, null); return; } const del = e.target.closest("#kb-del"); if (del) { const name = del.getAttribute("data-kb"); if (!await dialogConfirm({ title: "删除知识库", message: `删除库「${name}」?其中所有文档与原件将一并删除,不可恢复。`, okText: "删除", danger: true, })) return; try { await api("DELETE", "/v1/kb/" + encodeURIComponent(name)); } catch (err) { message("删除失败: " + err.message, "error"); return; } _current = null; stopPolling(); $("kb-detail").innerHTML = PLACEHOLDER; await renderList(); return; } const docDel = e.target.closest(".kb-doc-del"); if (docDel) { const doc = docDel.getAttribute("data-doc"); if (!await dialogConfirm({ title: "删除文档", message: `删除「${doc}」?对应原件与索引条目一并删除,不可恢复。`, okText: "删除", danger: true, })) return; try { await api("DELETE", "/v1/kb/" + encodeURIComponent(_current) + "/docs/" + encodeURIComponent(doc)); } catch (err) { message("删除失败: " + err.message, "error"); return; } showKb(_current, null); renderList(); // 左栏计数同步 return; } const main = e.target.closest(".kb-entry-main"); if (main) showDoc(main.getAttribute("data-doc")); });