Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
<!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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>知识库管理后台</h1>
|
||||
<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>
|
||||
<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>
|
||||
<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");
|
||||
}
|
||||
|
||||
/* 统一 API 封装:code !== 0 抛错,网络错误同样捕获 */
|
||||
function api(path, options) {
|
||||
return fetch(path, options).then(function (resp) {
|
||||
return resp.json().catch(function () {
|
||||
throw { code: "HTTP " + resp.status, message: "响应解析失败" };
|
||||
});
|
||||
}).then(function (body) {
|
||||
if (body.code !== 0) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- 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
|
||||
};
|
||||
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 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"));
|
||||
}
|
||||
|
||||
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); });
|
||||
}
|
||||
|
||||
/* ---------- 初始化 ---------- */
|
||||
|
||||
activateSection("section-overview");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user