Files
QMDSearch/app/static/admin.html
T
kplam dce9e31bde feat: 新增多格式文件上传入库与认证体系
- 新增 JWT 认证模块,支持登录/注册/用户管理
- 新增文件上传接口,支持 .txt/.md/.html/.pdf/.docx 等格式解析入库
- 新增检索结果 AI 总结功能
- 新增文本去重缓存机制
- 新增全局认证夹具简化测试
- 新增配置项与环境变量支持
- 完善文档与测试覆盖
2026-07-30 10:30:15 +08:00

889 lines
32 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>知识库管理后台</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f5f6f8;
color: #24292f;
}
header {
background: #1f2937;
color: #fff;
padding: 12px 24px;
}
header h1 { font-size: 18px; margin: 0 0 10px; }
nav button {
background: transparent;
border: 1px solid #4b5563;
color: #d1d5db;
padding: 6px 14px;
margin-right: 8px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
nav button.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
main { padding: 20px 24px; max-width: 1080px; margin: 0 auto; }
section { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px 20px; }
section h2 { font-size: 16px; margin: 0 0 14px; border-bottom: 1px solid #e5e7eb; padding-bottom: 8px; }
.hidden { display: none; }
.error-bar {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #b91c1c;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 12px;
font-size: 13px;
white-space: pre-wrap;
}
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
.card {
flex: 1 1 140px;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 12px;
background: #fafafa;
}
.card .num { font-size: 24px; font-weight: 600; }
.card .label { font-size: 12px; color: #6b7280; margin-top: 4px; }
.bar-row { display: flex; align-items: center; margin-bottom: 6px; font-size: 13px; }
.bar-row .name { width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-row .bar-track { flex: 1; background: #f3f4f6; border-radius: 3px; height: 16px; margin: 0 8px; }
.bar-row .bar-fill { background: #3b82f6; height: 100%; border-radius: 3px; min-width: 2px; }
.bar-row .count { width: 48px; text-align: right; color: #374151; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #e5e7eb; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f9fafb; color: #374151; }
button.action {
border: 1px solid #d1d5db;
background: #fff;
border-radius: 4px;
padding: 4px 10px;
margin-right: 6px;
cursor: pointer;
font-size: 12px;
}
button.action:hover { background: #f3f4f6; }
button.danger { color: #b91c1c; border-color: #fca5a5; }
button.primary {
background: #3b82f6; color: #fff; border: none;
border-radius: 4px; padding: 8px 18px; cursor: pointer; font-size: 14px;
}
button.primary:disabled { background: #9ca3af; cursor: not-allowed; }
form .field { margin-bottom: 12px; }
form label { display: block; font-size: 13px; margin-bottom: 4px; color: #374151; }
input[type="text"], input[type="number"], textarea {
width: 100%; border: 1px solid #d1d5db; border-radius: 4px;
padding: 8px; font-size: 13px; font-family: inherit;
}
textarea { min-height: 160px; resize: vertical; }
.result-box {
margin-top: 14px; border: 1px solid #e5e7eb; border-radius: 6px;
background: #fafafa; padding: 12px; font-size: 13px;
}
.result-box .kv { margin-bottom: 6px; }
.result-box .k { color: #6b7280; display: inline-block; min-width: 110px; }
.detail-panel {
margin-top: 14px; border: 1px solid #d1d5db; border-radius: 6px; padding: 12px;
}
.detail-panel h3 { margin: 0 0 8px; font-size: 14px; }
.detail-panel pre {
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 4px;
padding: 8px; white-space: pre-wrap; word-break: break-word;
font-size: 12px; max-height: 260px; overflow: auto;
}
.node-item { border-left: 3px solid #93c5fd; padding: 4px 8px; margin-bottom: 6px; background: #f8fafc; }
.node-item .path { font-size: 12px; color: #6b7280; }
.hit-item { border: 1px solid #e5e7eb; border-radius: 6px; padding: 10px; margin-bottom: 10px; }
.hit-item .meta { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
.hit-item .snippet { font-size: 13px; white-space: pre-wrap; word-break: break-word; }
.tag { display: inline-block; background: #eff6ff; color: #1d4ed8; border-radius: 3px; padding: 1px 6px; font-size: 12px; margin-right: 4px; }
.fallback-flag { color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 6px 10px; font-size: 13px; margin-bottom: 10px; }
.toolbar { margin: 12px 0; }
.muted { color: #6b7280; font-size: 12px; }
.status-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px;
font-size: 12px; margin-left: 6px; border: 1px solid transparent;
}
.status-running { background: #eff6ff; color: #1d4ed8; border-color: #93c5fd; }
.status-done { background: #f0fdf4; color: #15803d; border-color: #86efac; }
.status-failed { background: #fef2f2; color: #b91c1c; border-color: #fca5a5; }
header { position: relative; }
.user-area { position: absolute; top: 14px; right: 24px; display: flex; align-items: center; gap: 8px; }
.user-area .action { padding: 4px 10px; }
.login-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.login-box {
background: #fff; border-radius: 8px; padding: 24px 28px; width: 320px;
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.login-box h2 { font-size: 16px; margin: 0 0 16px; }
.extracted-info {
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
}
.extracted-info .ei-title { font-weight: 600; margin-bottom: 6px; color: #0369a1; }
.extracted-info .ei-row { margin-bottom: 3px; }
.extracted-info .ei-k { color: #6b7280; margin-right: 4px; }
.summary-box {
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px;
padding: 12px 14px; margin-bottom: 12px; font-size: 13px; white-space: pre-wrap;
}
.summary-box .sum-title { font-weight: 600; margin-bottom: 6px; color: #15803d; }
</style>
</head>
<body>
<div id="login-overlay" class="login-overlay">
<div class="login-box">
<h2>登录知识库后台</h2>
<div class="error-bar hidden" id="error-login"></div>
<form id="login-form">
<div class="field">
<label for="login-username">用户名</label>
<input type="text" id="login-username" name="username" required autocomplete="username">
</div>
<div class="field">
<label for="login-password">密码</label>
<input type="password" id="login-password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="primary">登录</button>
</form>
</div>
</div>
<header>
<h1>知识库管理后台</h1>
<div id="user-area" class="user-area hidden">
<span class="muted" id="user-name"></span>
<button type="button" class="action" id="btn-logout">登出</button>
</div>
<nav id="nav">
<button type="button" data-target="section-overview" class="active">概览</button>
<button type="button" data-target="section-docs">文档管理</button>
<button type="button" data-target="section-ingest">文档入库</button>
<button type="button" data-target="section-search">检索测试台</button>
<button type="button" data-target="section-categories">类目列表</button>
</nav>
</header>
<main>
<section id="section-overview">
<h2>概览</h2>
<div class="error-bar hidden" id="error-overview"></div>
<div class="toolbar">
<button type="button" class="action" id="btn-refresh-overview">刷新</button>
</div>
<div class="cards" id="overview-cards"></div>
<h3 style="font-size:14px;">类目分布</h3>
<div id="overview-categories"></div>
</section>
<section id="section-docs" class="hidden">
<h2>文档管理</h2>
<div class="error-bar hidden" id="error-docs"></div>
<table>
<thead>
<tr><th>标题</th><th>类目</th><th>标签</th><th>L1 摘要</th><th>操作</th></tr>
</thead>
<tbody id="docs-tbody"></tbody>
</table>
<div class="toolbar">
<button type="button" class="action" id="btn-load-more">加载更多</button>
<span class="muted" id="docs-status"></span>
</div>
<div class="detail-panel hidden" id="doc-detail"></div>
</section>
<section id="section-ingest" class="hidden">
<h2>文档入库</h2>
<div class="error-bar hidden" id="error-ingest"></div>
<form id="ingest-form">
<div class="field">
<label for="ingest-title">标题</label>
<input type="text" id="ingest-title" name="title" required>
</div>
<div class="field">
<label for="ingest-source">来源</label>
<input type="text" id="ingest-source" name="source" placeholder="例如:manual / web / file">
</div>
<div class="field">
<label for="ingest-text">正文</label>
<textarea id="ingest-text" name="text" required></textarea>
</div>
<button type="submit" class="primary" id="btn-ingest-submit">提交入库</button>
</form>
<h3 style="font-size:14px; margin-top:24px;">或上传文件</h3>
<form id="upload-form">
<div class="field">
<label for="upload-file">选择文件(支持 .txt/.md/.html/.htm/.pdf/.docx</label>
<input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" required>
</div>
<div class="field">
<label for="upload-title">标题(可选,默认取文件名)</label>
<input type="text" id="upload-title" name="title" placeholder="留空则使用文件名去扩展">
</div>
<div class="field">
<label for="upload-source">来源(可选,默认 file:原文件名)</label>
<input type="text" id="upload-source" name="source" placeholder="例如:manual / web">
</div>
<button type="submit" class="primary" id="btn-upload-submit">上传入库</button>
</form>
<div class="result-box hidden" id="ingest-result"></div>
</section>
<section id="section-search" class="hidden">
<h2>检索测试台</h2>
<div class="error-bar hidden" id="error-search"></div>
<form id="search-form">
<div class="field">
<label for="search-query">查询语句</label>
<input type="text" id="search-query" name="query" required>
</div>
<div class="field">
<label for="search-topk">top_k</label>
<input type="number" id="search-topk" name="top_k" value="5" min="1" max="50">
</div>
<div class="field">
<label><input type="checkbox" id="search-summarize" name="summarize"> 对结果生成 AI 总结</label>
</div>
<button type="submit" class="primary" id="btn-search-submit">检索</button>
</form>
<div class="result-box hidden" id="search-result"></div>
</section>
<section id="section-categories" class="hidden">
<h2>类目列表</h2>
<div class="error-bar hidden" id="error-categories"></div>
<table>
<thead>
<tr><th>名称</th><th>描述</th></tr>
</thead>
<tbody id="categories-tbody"></tbody>
</table>
</section>
</main>
<script>
"use strict";
/* ---------- 通用工具 ---------- */
function el(tag, text, className) {
var node = document.createElement(tag);
if (className) { node.className = className; }
if (text !== undefined && text !== null) { node.textContent = String(text); }
return node;
}
function clearChildren(node) {
while (node.firstChild) { node.removeChild(node.firstChild); }
}
function showError(boxId, err) {
var box = document.getElementById(boxId);
clearChildren(box);
var code = (err && err.code !== undefined) ? err.code : "网络错误";
var message = (err && err.message) ? err.message : String(err);
box.textContent = "错误 [" + code + "] " + message;
box.classList.remove("hidden");
}
function hideError(boxId) {
document.getElementById(boxId).classList.add("hidden");
}
/* ---------- 认证 token 管理 ---------- */
function getToken() { return localStorage.getItem("qmd_token") || ""; }
function setToken(t) { localStorage.setItem("qmd_token", t); }
function clearToken() { localStorage.removeItem("qmd_token"); }
function showLogin() {
document.getElementById("login-overlay").classList.remove("hidden");
document.getElementById("user-area").classList.add("hidden");
loadedOnce = {};
}
function afterLogin(user) {
document.getElementById("login-overlay").classList.add("hidden");
document.getElementById("user-area").classList.remove("hidden");
document.getElementById("user-name").textContent = user.username + " (" + user.role + ")";
loadedOnce = {};
activateSection("section-overview");
}
document.getElementById("login-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-login");
var payload = {
username: document.getElementById("login-username").value,
password: document.getElementById("login-password").value
};
fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (resp) { return resp.json(); }).then(function (body) {
if (body.code !== 0) {
showError("error-login", { code: body.code, message: body.message });
return;
}
setToken(body.data.access_token);
afterLogin(body.data.user);
}).catch(function (err) {
showError("error-login", { code: "NETWORK", message: "登录请求失败: " + (err && err.message ? err.message : err) });
});
});
document.getElementById("btn-logout").addEventListener("click", function () {
clearToken();
showLogin();
});
/* 统一 API 封装:自动注入 token,code !== 0 抛错,未认证跳登录 */
function api(path, options) {
options = options || {};
options.headers = Object.assign({}, options.headers || {});
var token = getToken();
if (token && !options.headers["Authorization"]) {
options.headers["Authorization"] = "Bearer " + token;
}
return fetch(path, options).then(function (resp) {
if (resp.status === 401) {
clearToken();
showLogin();
throw { code: 1003, message: "未认证或登录已过期,请重新登录" };
}
return resp.json().catch(function () {
throw { code: "HTTP " + resp.status, message: "响应解析失败" };
});
}).then(function (body) {
if (body.code !== 0) {
if (body.code === 1003 || body.code === 1005) {
clearToken();
showLogin();
}
throw { code: body.code, message: body.message };
}
return body.data;
}).catch(function (err) {
if (err && err.code !== undefined) { throw err; }
throw { code: "NETWORK", message: "网络请求失败: " + (err && err.message ? err.message : err) };
});
}
function truncate(text, maxLen) {
if (!text) { return ""; }
var s = String(text);
return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
}
/* ---------- 导航切换 ---------- */
var loadedOnce = {};
function activateSection(targetId) {
var sections = document.querySelectorAll("main section");
sections.forEach(function (sec) {
sec.classList.toggle("hidden", sec.id !== targetId);
});
var buttons = document.querySelectorAll("#nav button");
buttons.forEach(function (btn) {
btn.classList.toggle("active", btn.getAttribute("data-target") === targetId);
});
if (!loadedOnce[targetId]) {
loadedOnce[targetId] = true;
if (targetId === "section-overview") { loadOverview(); }
if (targetId === "section-docs") { loadDocuments(true); }
if (targetId === "section-categories") { loadCategories(); }
}
}
document.getElementById("nav").addEventListener("click", function (event) {
var btn = event.target.closest("button[data-target]");
if (btn) { activateSection(btn.getAttribute("data-target")); }
});
/* ---------- 1. 概览 ---------- */
function loadOverview() {
hideError("error-overview");
api("/api/v1/knowledge/stats").then(function (data) {
renderOverviewCards(data);
renderOverviewCategories(data.categories || {});
}).catch(function (err) { showError("error-overview", err); });
}
function renderOverviewCards(data) {
var container = document.getElementById("overview-cards");
clearChildren(container);
var collections = data.collections || {};
var cards = [
["L1 文档总结", collections.doc_l1],
["L2 大纲节点", collections.doc_l2],
["L3 内容大纲", collections.doc_l3],
["Chunks", collections.chunks],
["文档总数", data.documents_total],
["未分类文档", data.uncategorized_count]
];
cards.forEach(function (pair) {
var card = el("div", null, "card");
card.appendChild(el("div", pair[1] === undefined ? 0 : pair[1], "num"));
card.appendChild(el("div", pair[0], "label"));
container.appendChild(card);
});
}
function renderOverviewCategories(categories) {
var container = document.getElementById("overview-categories");
clearChildren(container);
var names = Object.keys(categories);
if (names.length === 0) {
container.appendChild(el("div", "暂无数据", "muted"));
return;
}
var max = 1;
names.forEach(function (name) { max = Math.max(max, categories[name]); });
names.sort(function (a, b) { return categories[b] - categories[a]; });
names.forEach(function (name) {
var count = categories[name];
var row = el("div", null, "bar-row");
row.appendChild(el("span", name, "name"));
var track = el("div", null, "bar-track");
var fill = el("div", null, "bar-fill");
fill.style.width = Math.round((count / max) * 100) + "%";
track.appendChild(fill);
row.appendChild(track);
row.appendChild(el("span", count, "count"));
container.appendChild(row);
});
}
document.getElementById("btn-refresh-overview").addEventListener("click", loadOverview);
/* ---------- 2. 文档管理 ---------- */
var docsOffset = null;
function loadDocuments(reset) {
hideError("error-docs");
if (reset) {
docsOffset = null;
clearChildren(document.getElementById("docs-tbody"));
}
var path = "/api/v1/documents?limit=20";
if (docsOffset !== null) { path += "&offset=" + encodeURIComponent(docsOffset); }
api(path).then(function (data) {
renderDocuments(data.items || []);
docsOffset = data.next_offset;
document.getElementById("btn-load-more").disabled = docsOffset === null;
document.getElementById("docs-status").textContent =
docsOffset === null ? "已加载全部" : "还有更多";
}).catch(function (err) { showError("error-docs", err); });
}
function renderDocuments(items) {
var tbody = document.getElementById("docs-tbody");
items.forEach(function (item) {
var tr = el("tr");
tr.appendChild(el("td", item.title || "(无标题)"));
tr.appendChild(el("td", item.category || ""));
var tagsTd = el("td");
(item.tags || []).forEach(function (tag) { tagsTd.appendChild(el("span", tag, "tag")); });
tr.appendChild(tagsTd);
tr.appendChild(el("td", truncate(item.summary, 80)));
var opsTd = el("td");
var detailBtn = el("button", "详情", "action");
detailBtn.type = "button";
detailBtn.addEventListener("click", function () { loadDocDetail(item.doc_id); });
var deleteBtn = el("button", "删除", "action danger");
deleteBtn.type = "button";
deleteBtn.addEventListener("click", function () { deleteDocument(item.doc_id, item.title); });
opsTd.appendChild(detailBtn);
opsTd.appendChild(deleteBtn);
tr.appendChild(opsTd);
tbody.appendChild(tr);
});
}
document.getElementById("btn-load-more").addEventListener("click", function () {
loadDocuments(false);
});
function loadDocDetail(docId) {
hideError("error-docs");
api("/api/v1/documents/" + encodeURIComponent(docId)).then(function (data) {
renderDocDetail(data);
}).catch(function (err) { showError("error-docs", err); });
}
function renderDocDetail(data) {
var panel = document.getElementById("doc-detail");
clearChildren(panel);
panel.classList.remove("hidden");
var l1 = data.l1 || {};
panel.appendChild(el("h3", "文档详情:" + (l1.title || data.l1 && l1.doc_id || "")));
var meta = el("div", null, "kv");
meta.appendChild(el("span", "doc_id", "k"));
meta.appendChild(el("span", l1.doc_id || ""));
panel.appendChild(meta);
var meta2 = el("div", null, "kv");
meta2.appendChild(el("span", "类目 / 标签", "k"));
meta2.appendChild(el("span", (l1.category || "") + " / " + (l1.tags || []).join(", ")));
panel.appendChild(meta2);
var meta3 = el("div", null, "kv");
meta3.appendChild(el("span", "chunks_count", "k"));
meta3.appendChild(el("span", data.chunks_count));
panel.appendChild(meta3);
panel.appendChild(el("h3", "L1 全文"));
panel.appendChild(el("pre", l1.text || ""));
panel.appendChild(el("h3", "L2 节点(" + (data.l2_nodes || []).length + ""));
renderNodes(panel, data.l2_nodes || []);
panel.appendChild(el("h3", "L3 节点(" + (data.l3_nodes || []).length + ""));
renderNodes(panel, data.l3_nodes || []);
}
function renderNodes(panel, nodes) {
if (nodes.length === 0) {
panel.appendChild(el("div", "无", "muted"));
return;
}
nodes.forEach(function (node) {
var item = el("div", null, "node-item");
item.appendChild(el("div", node.section_path || "", "path"));
item.appendChild(el("div", node.text || ""));
panel.appendChild(item);
});
}
function deleteDocument(docId, title) {
if (!confirm("确定删除文档「" + (title || docId) + "」(" + docId + ") 吗?该操作将删除四层集合中的全部数据,不可恢复。")) {
return;
}
hideError("error-docs");
api("/api/v1/documents/" + encodeURIComponent(docId), { method: "DELETE" }).then(function (data) {
document.getElementById("doc-detail").classList.add("hidden");
loadDocuments(true);
loadOverview();
var status = document.getElementById("docs-status");
status.textContent = "已删除 " + (data.deleted_total || 0) + " 条数据";
}).catch(function (err) { showError("error-docs", err); });
}
/* ---------- 3. 文档入库(异步任务轮询) ---------- */
var INGEST_STATUS_TEXT = {
pending: "排队中",
summarizing: "总结中",
classifying: "分类中",
embedding: "向量化中",
writing: "写入中",
done: "完成",
failed: "失败"
};
var INGEST_POLL_INTERVAL_MS = 2000;
var INGEST_POLL_MAX_ATTEMPTS = 150; /* 150 次 × 2s = 5 分钟超时 */
var ingestPollTimer = null;
function stopIngestPolling() {
if (ingestPollTimer !== null) {
clearInterval(ingestPollTimer);
ingestPollTimer = null;
}
}
function restoreIngestButton() {
var submitBtn = document.getElementById("btn-ingest-submit");
submitBtn.disabled = false;
submitBtn.textContent = "提交入库";
}
function makeStatusBadge(status) {
var cls = "status-running";
if (status === "done") { cls = "status-done"; }
if (status === "failed") { cls = "status-failed"; }
var badge = el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
badge.id = "ingest-status-badge";
return badge;
}
function updateIngestStatus(status) {
var old = document.getElementById("ingest-status-badge");
if (old && old.parentNode) {
old.parentNode.replaceChild(makeStatusBadge(status), old);
}
}
function ingestKv(box, label, value) {
var row = el("div", null, "kv");
row.appendChild(el("span", label, "k"));
row.appendChild(el("span", value));
box.appendChild(row);
}
function renderIngestHeader(taskId, status) {
var box = document.getElementById("ingest-result");
clearChildren(box);
box.classList.remove("hidden");
var row = el("div", null, "kv");
row.appendChild(el("span", "任务已提交:" + taskId));
row.appendChild(makeStatusBadge(status));
box.appendChild(row);
return box;
}
function renderIngestDone(task) {
var box = renderIngestHeader(task.task_id, "done");
var result = task.result || {};
var summary = result.summary || {};
ingestKv(box, "document_id", result.document_id);
ingestKv(box, "类目", (result.category || "") + "(置信度 " + result.category_confidence + "");
ingestKv(box, "标签", (result.tags || []).join(", "));
ingestKv(box, "总结层级 level", summary.level);
ingestKv(box, "写入集合", result.collection);
ingestKv(box, "chunks_count", result.chunks_count);
box.appendChild(el("h3", "L1 总结"));
box.appendChild(el("pre", summary.l1_summary || ""));
}
function renderIngestFailed(task) {
var box = renderIngestHeader(task.task_id, "failed");
var error = task.error || {};
ingestKv(box, "失败阶段", error.stage || "");
ingestKv(box, "错误信息", error.message || "");
var partial = error.partial_summary;
if (partial && partial.l1_summary) {
box.appendChild(el("div", "已产出总结保留:任务失败前已生成 L1 摘要", "fallback-flag"));
box.appendChild(el("h3", "L1 总结"));
box.appendChild(el("pre", partial.l1_summary));
}
}
function renderIngestTimeout(taskId) {
var box = document.getElementById("ingest-result");
box.appendChild(el("div", "任务仍在进行,可稍后凭 task_id 查询:" + taskId, "fallback-flag"));
}
function startIngestPolling(taskId) {
var attempts = 0;
document.getElementById("btn-ingest-submit").textContent = "入库中…";
ingestPollTimer = setInterval(function () {
attempts += 1;
if (attempts > INGEST_POLL_MAX_ATTEMPTS) {
stopIngestPolling();
renderIngestTimeout(taskId);
restoreIngestButton();
return;
}
api("/api/v1/documents/tasks/" + encodeURIComponent(taskId)).then(function (task) {
updateIngestStatus(task.status);
if (task.status === "done") {
stopIngestPolling();
renderIngestDone(task);
restoreIngestButton();
} else if (task.status === "failed") {
stopIngestPolling();
renderIngestFailed(task);
restoreIngestButton();
}
}).catch(function (err) {
stopIngestPolling();
showError("error-ingest", err);
restoreIngestButton();
});
}, INGEST_POLL_INTERVAL_MS);
}
document.getElementById("ingest-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-ingest");
stopIngestPolling();
var submitBtn = document.getElementById("btn-ingest-submit");
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
var payload = {
title: document.getElementById("ingest-title").value,
source: document.getElementById("ingest-source").value,
text: document.getElementById("ingest-text").value
};
api("/api/v1/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (data) {
renderIngestHeader(data.task_id, data.status || "pending");
startIngestPolling(data.task_id);
}).catch(function (err) {
showError("error-ingest", err);
restoreIngestButton();
});
});
document.getElementById("upload-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-ingest");
stopIngestPolling();
var fileInput = document.getElementById("upload-file");
if (!fileInput.files || fileInput.files.length === 0) {
showError("error-ingest", { code: "VALIDATION", message: "请选择文件" });
return;
}
var submitBtn = document.getElementById("btn-upload-submit");
submitBtn.disabled = true;
submitBtn.textContent = "上传中…";
var formData = new FormData();
formData.append("file", fileInput.files[0]);
var title = document.getElementById("upload-title").value;
var source = document.getElementById("upload-source").value;
if (title) { formData.append("title", title); }
if (source) { formData.append("source", source); }
api("/api/v1/documents/upload", {
method: "POST",
body: formData
}).then(function (data) {
renderIngestHeader(data.task_id, data.status || "pending");
startIngestPolling(data.task_id);
}).catch(function (err) {
showError("error-ingest", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = "上传入库";
});
});
/* ---------- 4. 检索测试台 ---------- */
document.getElementById("search-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-search");
var submitBtn = document.getElementById("btn-search-submit");
submitBtn.disabled = true;
var payload = {
query: document.getElementById("search-query").value,
top_k: parseInt(document.getElementById("search-topk").value, 10) || 5,
summarize: document.getElementById("search-summarize").checked
};
api("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (data) {
renderSearchResult(data);
}).catch(function (err) {
showError("error-search", err);
}).finally(function () {
submitBtn.disabled = false;
});
});
function eiRow(k, v) {
var row = el("div", null, "ei-row");
row.appendChild(el("span", k + ":", "ei-k"));
row.appendChild(el("span", v || "(无)"));
return row;
}
function renderSearchResult(data) {
var box = document.getElementById("search-result");
clearChildren(box);
box.classList.remove("hidden");
var routed = el("div", null, "kv");
routed.appendChild(el("span", "routed_categories", "k"));
routed.appendChild(el("span", (data.routed_categories || []).join(", ") || "(无)"));
box.appendChild(routed);
if (data.fallback) {
box.appendChild(el("div", "fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)", "fallback-flag"));
}
if (data.summary) {
var sumBox = el("div", null, "summary-box");
sumBox.appendChild(el("div", "AI 总结", "sum-title"));
sumBox.appendChild(el("div", data.summary));
box.appendChild(sumBox);
}
var ei = data.extracted_info;
if (ei) {
var eiBox = el("div", null, "extracted-info");
eiBox.appendChild(el("div", "AI 提取的关键信息", "ei-title"));
eiBox.appendChild(eiRow("改写", ei.rewrite));
eiBox.appendChild(eiRow("关键词", (ei.keywords || []).join(", ")));
eiBox.appendChild(eiRow("实体", (ei.entities || []).join(", ")));
eiBox.appendChild(eiRow("意图", ei.intent));
eiBox.appendChild(eiRow("时间范围", ei.time_range));
eiBox.appendChild(eiRow("命中类目", (ei.categories || []).join(", ")));
box.appendChild(eiBox);
}
var hits = data.hits || [];
box.appendChild(el("div", "命中 " + hits.length + " 条", "muted"));
hits.forEach(function (hit) {
var item = el("div", null, "hit-item");
var meta = el("div", null, "meta");
meta.textContent = "score=" + hit.score + " | doc_id=" + hit.doc_id +
" | 标题=" + (hit.title || "") + " | section=" + (hit.section_path || "");
item.appendChild(meta);
item.appendChild(el("div", hit.text || "", "snippet"));
if (hit.doc_summary) {
var summaryRow = el("div", null, "meta");
summaryRow.textContent = "文档摘要:" + hit.doc_summary;
item.appendChild(summaryRow);
}
box.appendChild(item);
});
}
/* ---------- 5. 类目列表 ---------- */
function loadCategories() {
hideError("error-categories");
api("/api/v1/knowledge/categories").then(function (data) {
var tbody = document.getElementById("categories-tbody");
clearChildren(tbody);
(data.categories || []).forEach(function (cat) {
var tr = el("tr");
tr.appendChild(el("td", cat.name));
tr.appendChild(el("td", cat.description || ""));
tbody.appendChild(tr);
});
}).catch(function (err) { showError("error-categories", err); });
}
/* ---------- 初始化 ---------- */
(function init() {
if (!getToken()) {
showLogin();
return;
}
api("/api/v1/auth/me").then(function (user) {
afterLogin(user);
}).catch(function () {
showLogin();
});
})();
</script>
</body>
</html>