zcbot/web/static/js/kb.js

269 lines
12 KiB
JavaScript
Raw 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.

// 个人知识库 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;
return `<div class="kb-entry">
<div class="kb-entry-main" data-doc="${escapeHtml(doc)}">
<div class="sk-name">${escapeHtml(e.title)}</div>
<div class="sk-desc">${escapeHtml(e.summary)}${e.keywords ? " " + escapeHtml(e.keywords) : ""}</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"));
});