zcbot/web/static/js/kb.js

271 lines
12 KiB
JavaScript

// 个人知识库 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 = '<div class="sk-empty">← 选一个库查看,或「+ 新建库」</div>';
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 = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
const kbs = data.results || [];
let html = '<div class="sk-group-title">我的库</div>';
html += kbs.length
? kbs.map((k) => `<div class="sk-item${k.name === _current ? " active" : ""}" data-kb="${escapeHtml(k.name)}">
<div class="sk-name">${escapeHtml(k.name)}${k.pending_count ? ` <span class="sk-badge">待入库 ${k.pending_count}</span>` : ""}</div>
<div class="sk-desc">${k.doc_count} 篇文档</div>
</div>`).join("")
: '<div class="muted" style="padding:4px 8px;font-size:12px;">还没有库。建一个,把常用资料(规范 / 报告 / 标准…)传进来,以后对话我能直接查。</div>';
html += '<div style="padding:8px;"><button id="kb-new" class="small" style="width:100%;">+ 新建库</button></div>';
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 = '<div class="muted" style="padding:8px;">加载中…</div>';
}
let d;
try {
d = await api("GET", "/v1/kb/" + encodeURIComponent(name));
} catch (e) {
if (!isPoll) $("kb-detail").innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
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 = `<div class="sk-d-head">
<span class="sk-d-name">${escapeHtml(d.name)}</span>
<span class="sk-badge">${entries.length} 篇</span>
<span class="spacer"></span>
<button id="kb-upload-btn" class="small primary">上传文件</button>
<button id="kb-del" class="small danger" data-kb="${escapeHtml(d.name)}">删除库</button>
</div>`;
if (ing.running) {
html += `<div class="kb-ingest-bar">入库中… ${ing.done}/${ing.total}${ing.current ? " · " + escapeHtml(ing.current) : ""}</div>`;
}
if (ing.errors && ing.errors.length) {
html += `<div class="kb-ingest-err">${ing.errors.map((x) =>
`<div>⚠ ${escapeHtml(x.source)}:${escapeHtml(x.error)}</div>`).join("")}</div>`;
}
if (pending.length) {
html += `<div class="sk-group-title" style="margin-top:10px;">待入库 (${pending.length})</div>`;
html += pending.map((n) => `<div class="kb-pending">${escapeHtml(n)}
<button class="small kb-src-view" data-src="${escapeHtml(n)}" title="查看原件">查看</button>
<button class="small kb-src-dl" data-src="${escapeHtml(n)}" title="下载原件">下载</button></div>`).join("");
if (!ing.running) {
html += `<div style="padding:6px 0;"><button id="kb-reingest" class="small">重试入库</button></div>`;
}
}
html += '<div class="sk-group-title" style="margin-top:10px;">已入库</div>';
_docSources = {};
html += entries.length
? entries.map((e) => {
const doc = e.doc.replace(/^docs\//, "");
const src = (e.source || "").replace(/^sources\//, "");
if (src) _docSources[doc] = src;
// 行内只留标题 + 单行摘要(CSS 省略);完整摘要/关键词进 title 悬停提示,不占行高
const tip = e.summary + (e.keywords ? "\n关键词: " + e.keywords : "");
return `<div class="kb-entry">
<div class="kb-entry-main" data-doc="${escapeHtml(doc)}" title="${escapeHtml(tip)}">
<div class="sk-name">${escapeHtml(e.title)}</div>
<div class="sk-desc">${escapeHtml(e.summary)}</div>
</div>
${src ? `<button class="small kb-src-view" data-src="${escapeHtml(src)}" title="查看原件">👁</button>
<button class="small kb-src-dl" data-src="${escapeHtml(src)}" title="下载原件">⬇</button>` : ""}
<button class="small danger kb-doc-del" data-doc="${escapeHtml(doc)}" title="删除该篇(含原件)">✕</button>
</div>`;
}).join("")
: '<div class="muted" style="padding:4px 0;font-size:12px;">空库。点「上传文件」把资料传进来,自动转 markdown 并写摘要。</div>';
$("kb-detail").innerHTML = html;
}
// ───── 单篇查看 ─────
async function showDoc(filename) {
const detail = $("kb-detail");
detail.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/kb/" + encodeURIComponent(_current) + "/docs/" + encodeURIComponent(filename));
} catch (e) {
detail.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
const src = _docSources[filename];
detail.innerHTML =
`<div class="sk-d-head"><button id="kb-back" class="small">← 返回</button>
<span class="sk-d-name">${escapeHtml(filename)}</span>
<span class="spacer"></span>
${src ? `<button id="kb-doc-src-view" class="small" data-src="${escapeHtml(src)}">查看原件</button>
<button id="kb-doc-src-dl" class="small" data-src="${escapeHtml(src)}">下载原件</button>` : ""}
<button id="kb-doc-md-dl" class="small" data-doc="${escapeHtml(filename)}">下载 Markdown</button></div>` +
`<div class="sk-detail-md">${renderMd(data.content)}</div>`;
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"));
});