272 lines
12 KiB
JavaScript
272 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 : "") + (src ? "\n原件: " + src : "");
|
|
return `<div class="kb-entry">
|
|
<div class="kb-entry-main" data-doc="${escapeHtml(doc)}" title="${escapeHtml(tip)}">
|
|
<div class="sk-name">${escapeHtml(e.title)}${src ? `<span class="kb-src-name">${escapeHtml(src)}</span>` : ""}</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>
|
|
${src ? `<span class="kb-src-name">原件: ${escapeHtml(src)}</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"));
|
|
});
|