feat: 知识库页优化——文档列表与检索结果分页;目录来源/文档列表移到检索上方;目录扫描后台化并推送进度条与处理统计(处理/成功/失败/忽略/分块)

This commit is contained in:
xianrenge
2026-08-18 01:30:08 +08:00
parent 221a3f3e69
commit 405bdec2c6
6 changed files with 613 additions and 240 deletions
+37 -7
View File
@@ -261,9 +261,30 @@ export interface KbSourceInput {
recursive: boolean;
}
export interface KbSourceScanResult {
export interface KbDocumentPage {
items: KbDocument[];
total: number;
page: number;
page_size: number;
}
export interface KbSearchPage {
items: KbSearchHit[];
total: number;
page: number;
page_size: number;
}
export interface KbScanProgressEvent {
kb_id: string;
source_path: string;
status: string;
current: number;
total: number;
imported: number;
failed: number;
ignored: number;
chunks: number;
errors: string[];
}
@@ -491,8 +512,12 @@ export const api = {
invoke<void>("update_knowledge_base", { id, input }),
removeKnowledgeBase: (id: string) =>
invoke<void>("remove_knowledge_base", { id }),
listKbDocuments: (kbId: string) =>
invoke<KbDocument[]>("list_kb_documents", { kbId }),
listKbDocuments: (kbId: string, page?: number, pageSize?: number) =>
invoke<KbDocumentPage>("list_kb_documents", {
kbId,
page: page ?? 1,
pageSize: pageSize ?? 10,
}),
getKbDocument: (documentId: string) =>
invoke<KbDocumentDetail>("get_kb_document", { documentId }),
kbImportDocuments: (
@@ -503,13 +528,18 @@ export const api = {
invoke<void>("remove_kb_document", { documentId }),
kbRechunkDocument: (documentId: string) =>
invoke<number>("kb_rechunk_document", { documentId }),
kbSearch: (kbId: string | null, query: string, limit?: number) =>
invoke<KbSearchHit[]>("kb_search", { kbId, query, limit: limit ?? 10 }),
kbSearch: (kbId: string | null, query: string, page?: number, pageSize?: number) =>
invoke<KbSearchPage>("kb_search", {
kbId,
query,
page: page ?? 1,
pageSize: pageSize ?? 10,
}),
kbListSources: (kbId: string) => invoke<KbSource[]>("kb_list_sources", { kbId }),
kbAddSource: (input: KbSourceInput) =>
invoke<KbSourceScanResult>("kb_add_source", { input }),
invoke<void>("kb_add_source", { input }),
kbScanSources: (kbId: string) =>
invoke<KbSourceScanResult>("kb_scan_sources", { kbId }),
invoke<void>("kb_scan_sources", { kbId }),
kbRemoveSource: (sourceId: string) =>
invoke<number>("kb_remove_source", { sourceId }),
listModels: () => invoke<ModelInfo[]>("list_models"),
+236 -97
View File
@@ -4,14 +4,18 @@ import {
api,
KbDocument,
KbDocumentDetail,
KbScanProgressEvent,
KbSearchHit,
KbSource,
KbSourceScanResult,
KnowledgeBase,
KnowledgeBaseInput,
onEvent,
} from "../api";
import Icon from "../components/Icon";
const DOC_PAGE_SIZE = 10;
const SEARCH_PAGE_SIZE = 10;
function fmtSize(bytes: number) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -48,6 +52,8 @@ export default function KnowledgeBasePage() {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [docs, setDocs] = useState<KbDocument[]>([]);
const [docTotal, setDocTotal] = useState(0);
const [docPage, setDocPage] = useState(1);
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [kbModal, setKbModal] = useState<{
@@ -59,10 +65,16 @@ export default function KnowledgeBasePage() {
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<KbSearchHit[] | null>(null);
const [searchTotal, setSearchTotal] = useState(0);
const [searchPage, setSearchPage] = useState(1);
const [sources, setSources] = useState<KbSource[]>([]);
const [sourceModal, setSourceModal] = useState(false);
const [sourceMsg, setSourceMsg] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<KbScanProgressEvent | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const selectedIdRef = useRef<string | null>(null);
const docPageRef = useRef(1);
const selected = useMemo(
() => kbs.find((k) => k.id === selectedId) ?? null,
@@ -79,27 +91,73 @@ export default function KnowledgeBasePage() {
}
}
async function loadDocPage(kbId: string, page: number) {
const p = await api.listKbDocuments(kbId, page, DOC_PAGE_SIZE);
if (p.items.length === 0 && p.page > 1) {
const prev = await api.listKbDocuments(kbId, p.page - 1, DOC_PAGE_SIZE);
setDocs(prev.items);
setDocTotal(prev.total);
setDocPage(prev.page);
docPageRef.current = prev.page;
return;
}
setDocs(p.items);
setDocTotal(p.total);
setDocPage(p.page);
docPageRef.current = p.page;
}
useEffect(() => {
refreshKbs().catch((e) => setMsg(String(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
selectedIdRef.current = selectedId;
}, [selectedId]);
useEffect(() => {
docPageRef.current = docPage;
}, [docPage]);
useEffect(() => {
if (!selectedId) {
setDocs([]);
setDocTotal(0);
setDocPage(1);
docPageRef.current = 1;
setSources([]);
setSearchResults(null);
setSearchTotal(0);
return;
}
api
.listKbDocuments(selectedId)
.then(setDocs)
.catch((e) => setMsg(`加载文档失败:${String(e)}`));
api
.kbListSources(selectedId)
.then(setSources)
.catch((e) => setMsg(`加载目录来源失败:${String(e)}`));
loadDocPage(selectedId, 1).catch((e) => setMsg(`加载文档失败:${String(e)}`));
}, [selectedId]);
useEffect(() => {
const un = onEvent<KbScanProgressEvent>("kb://scan-progress", (e) => {
setScanProgress(e);
setScanning(e.status !== "done");
if (e.status === "done") {
refreshKbs().catch(() => {});
if (selectedIdRef.current === e.kb_id) {
api
.kbListSources(e.kb_id)
.then(setSources)
.catch(() => {});
loadDocPage(e.kb_id, docPageRef.current).catch(() => {});
}
}
});
return () => {
un.then((f) => f());
};
}, []);
async function handleSaveKb(input: KnowledgeBaseInput) {
setBusy(true);
try {
@@ -121,7 +179,11 @@ export default function KnowledgeBasePage() {
}
async function handleDeleteKb(kb: KnowledgeBase) {
if (!confirm(`确定删除知识库「${kb.name}」吗?将同时删除其中 ${kb.doc_count} 个文档与 ${kb.chunk_count} 个分块。`)) {
if (
!confirm(
`确定删除知识库「${kb.name}」吗?将同时删除其中 ${kb.doc_count} 个文档与 ${kb.chunk_count} 个分块。`,
)
) {
return;
}
try {
@@ -147,10 +209,14 @@ export default function KnowledgeBasePage() {
const okCount = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok);
setUploadMsg(
`导入完成:成功 ${okCount}${failed.length > 0 ? `,失败 ${failed.length} 个(${failed.map((f) => `${f.name}: ${f.error}`).join("")}` : ""}`,
`导入完成:成功 ${okCount}${
failed.length > 0
? `,失败 ${failed.length} 个(${failed.map((f) => `${f.name}: ${f.error}`).join("")}`
: ""
}`,
);
await refreshKbs();
setDocs(await api.listKbDocuments(selected.id));
await loadDocPage(selected.id, docPageRef.current);
} catch (e) {
setUploadMsg(`导入失败:${String(e)}`);
} finally {
@@ -165,7 +231,7 @@ export default function KnowledgeBasePage() {
const n = await api.kbRechunkDocument(doc.id);
setMsg(`已重新切分为 ${n} 个分块`);
if (selected) {
setDocs(await api.listKbDocuments(selected.id));
await loadDocPage(selected.id, docPageRef.current);
await refreshKbs();
}
} catch (e) {
@@ -179,7 +245,7 @@ export default function KnowledgeBasePage() {
await api.removeKbDocument(doc.id);
setMsg("文档已删除");
if (selected) {
setDocs(await api.listKbDocuments(selected.id));
await loadDocPage(selected.id, docPageRef.current);
await refreshKbs();
}
} catch (e) {
@@ -187,11 +253,14 @@ export default function KnowledgeBasePage() {
}
}
async function handleSearch() {
async function handleSearch(page = 1) {
if (!query.trim()) return;
setSearching(true);
try {
setSearchResults(await api.kbSearch(selectedId, query.trim(), 20));
const p = await api.kbSearch(selectedId, query.trim(), page, SEARCH_PAGE_SIZE);
setSearchResults(p.items);
setSearchTotal(p.total);
setSearchPage(p.page);
} catch (e) {
setMsg(`检索失败:${String(e)}`);
} finally {
@@ -199,42 +268,23 @@ export default function KnowledgeBasePage() {
}
}
async function refreshSourcesAndDocs() {
if (!selected) return;
const [d, s] = await Promise.all([
api.listKbDocuments(selected.id),
api.kbListSources(selected.id),
]);
setDocs(d);
setSources(s);
await refreshKbs();
}
function formatScanMsg(label: string, r: KbSourceScanResult) {
const errPart =
r.errors.length > 0
? `${r.errors.length} 个错误(${r.errors
.slice(0, 3)
.join("")}${r.errors.length > 3 ? "…" : ""}`
: "";
return `${label}完成:导入 ${r.imported} 个,忽略 ${r.ignored}${errPart}`;
}
async function handleAddSource(path: string, extensions: string, recursive: boolean) {
if (!selected) return;
setBusy(true);
setSourceMsg(null);
setScanProgress(null);
setScanning(true);
try {
const r = await api.kbAddSource({
await api.kbAddSource({
kb_id: selected.id,
path,
extensions,
recursive,
});
await refreshSourcesAndDocs();
setSourceMsg(formatScanMsg("添加目录", r));
setSourceMsg("开始扫描目录,请稍候…");
} catch (e) {
setSourceMsg(`添加目录失败:${String(e)}`);
setScanning(false);
} finally {
setBusy(false);
setSourceModal(false);
@@ -245,12 +295,13 @@ export default function KnowledgeBasePage() {
if (!selected) return;
setBusy(true);
setSourceMsg(null);
setScanProgress(null);
setScanning(true);
try {
const r = await api.kbScanSources(selected.id);
await refreshSourcesAndDocs();
setSourceMsg(formatScanMsg("重新扫描", r));
await api.kbScanSources(selected.id);
} catch (e) {
setSourceMsg(`重新扫描失败:${String(e)}`);
setScanning(false);
} finally {
setBusy(false);
}
@@ -262,7 +313,9 @@ export default function KnowledgeBasePage() {
const n = await api.kbRemoveSource(src.id);
setSourceMsg(`已删除目录来源及其 ${n} 个文档`);
if (selected) {
await refreshSourcesAndDocs();
setSources(await api.kbListSources(selected.id));
await loadDocPage(selected.id, docPageRef.current);
await refreshKbs();
}
} catch (e) {
setSourceMsg(`删除失败:${String(e)}`);
@@ -388,41 +441,6 @@ export default function KnowledgeBasePage() {
</div>
) : null}
{/* 检索 */}
<section className="mb-6 rounded-xl border border-border bg-panel p-4">
<div className="mb-2 text-xs uppercase text-slate-500"></div>
<div className="flex gap-2">
<input
className="input flex-1"
value={query}
placeholder="输入关键词,检索全部文档分块(支持中文)"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
<button
className="btn-primary shrink-0"
onClick={handleSearch}
disabled={searching || !query.trim()}
>
<Icon name="search" className="mr-1 inline h-3.5 w-3.5" />
{searching ? "检索中…" : "检索"}
</button>
</div>
{searchResults ? (
searchResults.length === 0 ? (
<div className="mt-3 text-xs text-slate-500"></div>
) : (
<div className="mt-3 space-y-2">
{searchResults.map((hit) => (
<SearchHitRow key={hit.chunk_id} hit={hit} />
))}
</div>
)
) : null}
</section>
{/* 目录来源 */}
<section className="mb-6 rounded-xl border border-border bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
@@ -433,7 +451,7 @@ export default function KnowledgeBasePage() {
<button
className="btn-secondary !px-2.5 !py-1 text-xs"
onClick={handleScanAll}
disabled={busy || sources.length === 0}
disabled={busy || scanning || sources.length === 0}
>
<Icon name="refresh" className="mr-1 inline h-3 w-3" />
@@ -441,6 +459,7 @@ export default function KnowledgeBasePage() {
<button
className="btn-primary !px-2.5 !py-1 text-xs"
onClick={() => setSourceModal(true)}
disabled={scanning}
>
+
</button>
@@ -454,6 +473,40 @@ export default function KnowledgeBasePage() {
{sourceMsg}
</div>
) : null}
{scanProgress ? (
<div className="mb-3 rounded-lg border border-border bg-panel-2/60 p-3">
<div className="flex items-center justify-between gap-2 text-xs text-slate-300">
<span className="min-w-0 flex-1 truncate">{scanProgress.source_path}</span>
<span className="shrink-0">
{scanning
? `处理中 ${scanProgress.current}/${scanProgress.total}`
: "扫描完成"}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel">
<div
className="h-full rounded-full bg-gradient-to-r from-accent to-accent-2 transition-all duration-200"
style={{
width: `${
scanProgress.total > 0
? (scanProgress.current / scanProgress.total) * 100
: 0
}%`,
}}
/>
</div>
<div className="mt-1.5 text-[11px] text-slate-400">
{scanProgress.current} / {scanProgress.total} · {" "}
{scanProgress.imported} · {scanProgress.failed} · {" "}
{scanProgress.ignored} · {scanProgress.chunks}
{scanProgress.errors.length > 0
? `${scanProgress.errors[0]}${
scanProgress.errors.length > 1 ? " 等" : ""
}`
: ""}
</div>
</div>
) : null}
{sources.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-panel-2/40 px-3 py-6 text-center text-xs text-slate-500">
@@ -499,30 +552,84 @@ export default function KnowledgeBasePage() {
</section>
{/* 文档列表 */}
<section>
<section className="mb-6">
<div className="mb-2 text-xs uppercase text-slate-500">
{docs.length}
{docTotal}
</div>
{docs.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-10 text-center text-sm text-slate-500">
txt / md / PDF
txt / md / PDF
</div>
) : (
<div className="space-y-2">
{docs.map((doc) => (
<DocRow
key={doc.id}
doc={doc}
onPreview={() => {
api.getKbDocument(doc.id).then(setPreview).catch((e) => setMsg(String(e)));
}}
onRechunk={() => handleRechunk(doc)}
onDelete={() => handleDeleteDoc(doc)}
/>
))}
</div>
<>
<div className="space-y-2">
{docs.map((doc) => (
<DocRow
key={doc.id}
doc={doc}
onPreview={() => {
api
.getKbDocument(doc.id)
.then(setPreview)
.catch((e) => setMsg(String(e)));
}}
onRechunk={() => handleRechunk(doc)}
onDelete={() => handleDeleteDoc(doc)}
/>
))}
</div>
<Pager
page={docPage}
total={docTotal}
pageSize={DOC_PAGE_SIZE}
onChange={(p) => selected && loadDocPage(selected.id, p)}
/>
</>
)}
</section>
{/* 检索 */}
<section className="rounded-xl border border-border bg-panel p-4">
<div className="mb-2 text-xs uppercase text-slate-500"></div>
<div className="flex gap-2">
<input
className="input flex-1"
value={query}
placeholder="输入关键词,检索全部文档分块(支持中文)"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch(1);
}}
/>
<button
className="btn-primary shrink-0"
onClick={() => handleSearch(1)}
disabled={searching || !query.trim()}
>
<Icon name="search" className="mr-1 inline h-3.5 w-3.5" />
{searching ? "检索中…" : "检索"}
</button>
</div>
{searchResults ? (
searchResults.length === 0 ? (
<div className="mt-3 text-xs text-slate-500"></div>
) : (
<div className="mt-3">
<div className="space-y-2">
{searchResults.map((hit) => (
<SearchHitRow key={hit.chunk_id} hit={hit} />
))}
</div>
<Pager
page={searchPage}
total={searchTotal}
pageSize={SEARCH_PAGE_SIZE}
onChange={(p) => handleSearch(p)}
/>
</div>
)
) : null}
</section>
</div>
)}
</main>
@@ -571,6 +678,41 @@ export default function KnowledgeBasePage() {
);
}
function Pager({
page,
total,
pageSize,
onChange,
}: {
page: number;
total: number;
pageSize: number;
onChange: (p: number) => void;
}) {
const pages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="mt-3 flex items-center justify-center gap-3 text-xs text-slate-400">
<button
className="rounded border border-border bg-panel-2 px-2.5 py-1 hover:text-slate-200 disabled:opacity-40"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
</button>
<span>
{page} / {pages} · {total}
</span>
<button
className="rounded border border-border bg-panel-2 px-2.5 py-1 hover:text-slate-200 disabled:opacity-40"
disabled={page >= pages}
onClick={() => onChange(page + 1)}
>
</button>
</div>
);
}
function SearchHitRow({ hit }: { hit: KbSearchHit }) {
const [copied, setCopied] = useState(false);
return (
@@ -845,10 +987,7 @@ function SourceModal({
/>
</div>
<div className="flex items-center gap-2">
<Toggle
checked={recursive}
onChange={(v) => setRecursive(v)}
/>
<Toggle checked={recursive} onChange={(v) => setRecursive(v)} />
<span className="text-xs text-slate-400">
{recursive ? "递归扫描所有子目录" : "仅扫描当前目录"}
</span>
@@ -866,7 +1005,7 @@ function SourceModal({
disabled={busy || !valid}
onClick={() => onSave(path.trim(), extensions.trim(), recursive)}
>
{busy ? "扫描中…" : "添加并扫描"}
{busy ? "添加中…" : "添加并扫描"}
</button>
</div>
</div>