Enhance knowledge base functionality and integrate S3 storage support

- Add new models for `KnowledgeDocument` and `KnowledgeChunk` to manage document ingestion and chunking.
- Implement S3-compatible storage integration for knowledge documents, allowing for file uploads and retrieval.
- Introduce API endpoints for managing knowledge bases and documents, including creation, deletion, and searching.
- Update frontend components to support knowledge base configuration and document management, improving user interaction.
- Enhance backend services for knowledge processing and retrieval, ensuring robust handling of document statuses and errors.
This commit is contained in:
Xin Wang
2026-07-12 13:58:47 +08:00
parent 01c563a3e7
commit de58f30014
21 changed files with 995 additions and 34 deletions

View File

@@ -664,7 +664,7 @@ export function AssistantPage(props: AssistantPageProps) {
...(form.voice ? { TTS: form.voice } : {}),
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
},
knowledgeBaseId: form.knowledgeBase || null,
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
toolIds: form.toolIds,
prompt: form.prompt,
}),
@@ -1900,18 +1900,20 @@ export function AssistantPage(props: AssistantPageProps) {
/>
</SectionCard>
<SectionCard
icon={<Database size={18} />}
title="知识库配置"
description="选择助手回答时可检索的业务知识来源"
>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/>
</SectionCard>
{form.runtimeMode === "pipeline" && (
<SectionCard
icon={<Database size={18} />}
title="知识库配置"
description="选择助手回答时可检索的业务知识来源"
>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/>
</SectionCard>
)}
<SectionCard
icon={<Wrench size={18} />}

View File

@@ -1,10 +1,221 @@
import { PlaceholderPage } from "./PlaceholderPage";
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Database, Eye, FileText, Loader2, Pencil, Plus, RefreshCw,
Search, Trash2, Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
knowledgeBasesApi, modelResourcesApi, type KnowledgeBase,
type KnowledgeChunk, type KnowledgeDocument, type KnowledgeSearchResult,
type ModelResource,
} from "@/lib/api";
type BaseDialogMode = "create" | "edit" | null;
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function statusLabel(status: string) {
return ({ pending: "等待处理", processing: "处理中", ready: "已完成", failed: "失败" } as Record<string, string>)[status] ?? status;
}
function statusClass(status: string) {
if (status === "ready") return "bg-emerald-500/10 text-emerald-600";
if (status === "failed") return "bg-destructive/10 text-destructive";
return "bg-amber-500/10 text-amber-600";
}
export function ComponentsKnowledgePage() {
const [bases, setBases] = useState<KnowledgeBase[]>([]);
const [models, setModels] = useState<ModelResource[]>([]);
const [selectedId, setSelectedId] = useState("");
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [baseDialog, setBaseDialog] = useState<BaseDialogMode>(null);
const [textOpen, setTextOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewTitle, setPreviewTitle] = useState("");
const [chunks, setChunks] = useState<KnowledgeChunk[]>([]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [embeddingId, setEmbeddingId] = useState("");
const [textName, setTextName] = useState("");
const [content, setContent] = useState("");
const [documentQuery, setDocumentQuery] = useState("");
const [testQuery, setTestQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<KnowledgeSearchResult[]>([]);
const selected = bases.find((item) => item.id === selectedId);
const filteredDocuments = useMemo(() => {
const query = documentQuery.trim().toLowerCase();
return query ? documents.filter((item) => item.name.toLowerCase().includes(query)) : documents;
}, [documentQuery, documents]);
const loadBases = useCallback(async () => {
const [nextBases, resources] = await Promise.all([
knowledgeBasesApi.list(), modelResourcesApi.list(),
]);
setBases(nextBases);
setModels(resources.filter((item) => item.capability === "Embedding" && item.enabled));
setSelectedId((current) => nextBases.some((item) => item.id === current) ? current : nextBases[0]?.id || "");
}, []);
const loadDocuments = useCallback(async (kbId: string) => {
setDocuments(await knowledgeBasesApi.documents(kbId));
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial remote data load
void loadBases().catch((cause) => setError(cause instanceof Error ? cause.message : "加载失败"));
}, [loadBases]);
useEffect(() => {
if (!selectedId) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- selection-driven remote data load
void loadDocuments(selectedId).catch((cause) => setError(cause.message));
}, [loadDocuments, selectedId]);
useEffect(() => {
if (!selectedId || !documents.some((item) => ["pending", "processing"].includes(item.status))) return;
const timer = window.setInterval(() => {
void loadDocuments(selectedId).catch(() => undefined);
}, 2000);
return () => window.clearInterval(timer);
}, [documents, loadDocuments, selectedId]);
function openCreate() {
setName(""); setDescription(""); setEmbeddingId(""); setBaseDialog("create");
}
function openEdit() {
if (!selected) return;
setName(selected.name); setDescription(selected.description);
setEmbeddingId(selected.embeddingModelResourceId || ""); setBaseDialog("edit");
}
async function saveBase() {
setBusy(true); setError("");
try {
const body = { name: name.trim(), description, embeddingModelResourceId: embeddingId || null };
const saved = baseDialog === "edit" && selected
? await knowledgeBasesApi.update(selected.id, body)
: await knowledgeBasesApi.create(body);
setBaseDialog(null); await loadBases(); setSelectedId(saved.id);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "保存失败");
} finally { setBusy(false); }
}
async function removeBase() {
if (!selected || !window.confirm(`确定删除知识库“${selected.name}”及其全部文档吗?`)) return;
setBusy(true); setError("");
try { await knowledgeBasesApi.remove(selected.id); setDocuments([]); await loadBases(); }
catch (cause) { setError(cause instanceof Error ? cause.message : "删除失败"); }
finally { setBusy(false); }
}
async function addText() {
if (!selectedId) return;
setBusy(true); setError("");
try {
await knowledgeBasesApi.addText(selectedId, { name: textName.trim(), content });
setTextOpen(false); setTextName(""); setContent(""); await loadDocuments(selectedId);
} catch (cause) { setError(cause instanceof Error ? cause.message : "入库失败"); }
finally { setBusy(false); }
}
async function addFile(file?: File) {
if (!file || !selectedId) return;
setBusy(true); setError("");
try { await knowledgeBasesApi.addFile(selectedId, file); await loadDocuments(selectedId); }
catch (cause) { setError(cause instanceof Error ? cause.message : "上传失败"); }
finally { setBusy(false); }
}
async function retryDocument(documentId: string) {
setError("");
try { await knowledgeBasesApi.retryDocument(selectedId, documentId); await loadDocuments(selectedId); }
catch (cause) { setError(cause instanceof Error ? cause.message : "重试失败"); }
}
async function removeDocument(document: KnowledgeDocument) {
if (!window.confirm(`确定删除“${document.name}”吗?`)) return;
try { await knowledgeBasesApi.removeDocument(selectedId, document.id); await loadDocuments(selectedId); }
catch (cause) { setError(cause instanceof Error ? cause.message : "删除失败"); }
}
async function previewDocument(document: KnowledgeDocument) {
setPreviewTitle(document.name); setChunks([]); setPreviewOpen(true);
try { setChunks(await knowledgeBasesApi.chunks(selectedId, document.id)); }
catch (cause) { setError(cause instanceof Error ? cause.message : "读取分块失败"); }
}
async function testSearch() {
if (!selectedId || !testQuery.trim()) return;
setSearching(true); setError("");
try { setSearchResults(await knowledgeBasesApi.search(selectedId, testQuery.trim())); }
catch (cause) { setError(cause instanceof Error ? cause.message : "检索失败"); }
finally { setSearching(false); }
}
return (
<PlaceholderPage
title="知识库"
description="统一管理大语言模型、语音识别、声音资源、知识库与工具插件。"
/>
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div><h1 className="text-2xl font-semibold"></h1><p className="mt-1 text-sm text-muted-foreground"> pipeline </p></div>
<Button className="gap-2" onClick={openCreate}><Plus size={16} /></Button>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div>}
<div className="grid gap-5 lg:grid-cols-[280px_1fr]">
<Card><CardHeader><CardTitle className="text-base"></CardTitle></CardHeader><CardContent className="space-y-2">
{bases.length === 0 && <p className="text-sm text-muted-foreground"></p>}
{bases.map((base) => (
<button key={base.id} onClick={() => { setSelectedId(base.id); setSearchResults([]); }} className={`w-full rounded-lg border p-3 text-left text-sm transition-colors ${selectedId === base.id ? "border-primary bg-primary/5" : "border-hairline hover:bg-surface-strong"}`}>
<div className="flex items-center gap-2 font-medium"><Database size={15}/>{base.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{base.description || base.id}</div>
</button>
))}
</CardContent></Card>
<div className="space-y-5">
<Card><CardHeader><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle className="text-base">{selected?.name || "请选择知识库"}</CardTitle>{selected?.description && <p className="mt-1 text-xs text-muted-foreground">{selected.description}</p>}</div>{selected && <div className="flex flex-wrap gap-2"><Button variant="ghost" size="sm" onClick={openEdit}><Pencil size={14}/></Button><Button variant="ghost" size="sm" onClick={() => void removeBase()}><Trash2 size={14}/></Button><Button variant="outline" size="sm" onClick={() => setTextOpen(true)}><FileText size={15}/></Button><label className="inline-flex cursor-pointer items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground"><Upload size={15}/>{busy ? "上传中" : "上传文件"}<input hidden type="file" accept=".pdf,.docx,.txt,.md,.csv,.json,.html,.htm" disabled={busy} onChange={(event) => { void addFile(event.target.files?.[0]); event.target.value = ""; }}/></label></div>}</div></CardHeader>
<CardContent>
{selected && <div className="relative mb-4"><Search className="absolute left-3 top-2.5 text-muted-foreground" size={15}/><Input className="pl-9" placeholder="搜索文档名称" value={documentQuery} onChange={(event) => setDocumentQuery(event.target.value)}/></div>}
<div className="space-y-2">
{selected && filteredDocuments.length === 0 && <p className="py-6 text-center text-sm text-muted-foreground"></p>}
{filteredDocuments.map((document) => (
<div key={document.id} className="rounded-lg border border-hairline p-3">
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="truncate text-sm font-medium">{document.name}</span><span className={`rounded-full px-2 py-0.5 text-xs ${statusClass(document.status)}`}>{statusLabel(document.status)}</span></div><div className="mt-1 text-xs text-muted-foreground">{document.sourceType === "file" ? "文件" : "文字"} · {formatBytes(document.sizeBytes)} · {document.chunkCount} </div></div><div className="flex shrink-0 gap-1">{document.status === "failed" && <Button variant="ghost" size="icon" title="重新处理" onClick={() => void retryDocument(document.id)}><RefreshCw size={15}/></Button>}{document.status === "ready" && <Button variant="ghost" size="icon" title="查看分块" onClick={() => void previewDocument(document)}><Eye size={15}/></Button>}<Button variant="ghost" size="icon" title="删除" onClick={() => void removeDocument(document)}><Trash2 size={15}/></Button></div></div>
{document.status === "processing" && <div className="mt-2 flex items-center gap-2 text-xs text-amber-600"><Loader2 className="animate-spin" size={13}/></div>}
{document.errorMessage && <p className="mt-2 rounded bg-destructive/5 p-2 text-xs text-destructive">{document.errorMessage}</p>}
</div>
))}
</div>
</CardContent>
</Card>
{selected && <Card><CardHeader><CardTitle className="text-base"></CardTitle><p className="text-xs text-muted-foreground"></p></CardHeader><CardContent><div className="flex gap-2"><Input placeholder="输入问题,例如:退款规则是什么?" value={testQuery} onChange={(event) => setTestQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void testSearch(); }}/><Button disabled={searching || !testQuery.trim()} onClick={() => void testSearch()}>{searching ? <Loader2 className="animate-spin" size={15}/> : <Search size={15}/>}</Button></div><div className="mt-4 space-y-3">{searchResults.map((result, index) => <div key={`${result.document}-${index}`} className="rounded-lg border border-hairline p-3"><div className="mb-2 flex justify-between text-xs text-muted-foreground"><span>{result.document}</span><span> {result.score}</span></div><p className="whitespace-pre-wrap text-sm leading-6">{result.content}</p></div>)}</div></CardContent></Card>}
</div>
</div>
<Dialog open={baseDialog !== null} onOpenChange={(open) => !open && setBaseDialog(null)}><DialogContent><DialogHeader><DialogTitle>{baseDialog === "edit" ? "编辑知识库" : "新建知识库"}</DialogTitle></DialogHeader><div className="space-y-4"><Input placeholder="知识库名称" value={name} onChange={(event) => setName(event.target.value)}/><Textarea placeholder="用途说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)}/><Select value={embeddingId} onValueChange={setEmbeddingId}><SelectTrigger><SelectValue placeholder="选择 Embedding 模型"/></SelectTrigger><SelectContent>{models.map((model) => <SelectItem key={model.id} value={model.id}>{model.name}</SelectItem>)}</SelectContent></Select>{baseDialog === "edit" && documents.length > 0 && <p className="text-xs text-muted-foreground"> Embedding </p>}</div><DialogFooter><Button disabled={busy || !name.trim() || !embeddingId} onClick={() => void saveBase()}>{busy ? <Loader2 className="animate-spin" size={15}/> : null}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={textOpen} onOpenChange={setTextOpen}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><div className="space-y-4"><Input placeholder="内容名称" value={textName} onChange={(event) => setTextName(event.target.value)}/><Textarea rows={10} placeholder="粘贴需要入库的内容" value={content} onChange={(event) => setContent(event.target.value)}/></div><DialogFooter><Button disabled={busy || !textName.trim() || !content.trim()} onClick={() => void addText()}></Button></DialogFooter></DialogContent></Dialog>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}><DialogContent className="sm:max-w-3xl"><DialogHeader><DialogTitle>{previewTitle} · </DialogTitle></DialogHeader><div className="max-h-[60vh] space-y-3 overflow-y-auto">{chunks.length === 0 ? <p className="text-sm text-muted-foreground"></p> : chunks.map((chunk) => <div key={chunk.id} className="rounded-lg border border-hairline p-3"><div className="mb-2 text-xs text-muted-foreground"> #{chunk.chunkIndex + 1}</div><p className="whitespace-pre-wrap text-sm leading-6">{chunk.content}</p></div>)}</div></DialogContent></Dialog>
</div>
);
}