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:
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user