Files
AI-VideoAssistant/web/pages/KnowledgeBase.tsx
2026-02-09 08:14:17 +08:00

633 lines
23 KiB
TypeScript
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.
import React, { useEffect, useState, useRef } from 'react';
import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X, Pencil, Trash2, Settings2, MoreHorizontal } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog, Badge } from '../components/UI';
import { KnowledgeBase } from '../types';
import { createKnowledgeBase, deleteKnowledgeBase, deleteKnowledgeDocument, fetchKnowledgeBaseById, fetchKnowledgeBases, fetchLLMModels, updateKnowledgeBase, uploadKnowledgeDocument } from '../services/backendApi';
const EMBEDDING_OPTIONS = [
'text-embedding-3-small',
'text-embedding-3-large',
'bge-small-zh',
];
export const KnowledgeBasePage: React.FC = () => {
const [view, setView] = useState<'list' | 'detail'>('list');
const [selectedKb, setSelectedKb] = useState<KnowledgeBase | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isKbModalOpen, setIsKbModalOpen] = useState(false);
const [editingKb, setEditingKb] = useState<KnowledgeBase | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [embeddingOptions, setEmbeddingOptions] = useState<string[]>(EMBEDDING_OPTIONS);
const [hasDbEmbeddingModels, setHasDbEmbeddingModels] = useState(false);
const [openMenuKbId, setOpenMenuKbId] = useState<string | null>(null);
const [kbName, setKbName] = useState('');
const [kbDescription, setKbDescription] = useState('');
const [kbEmbeddingModel, setKbEmbeddingModel] = useState('text-embedding-3-small');
const [kbChunkSize, setKbChunkSize] = useState(500);
const [kbChunkOverlap, setKbChunkOverlap] = useState(50);
const [isSavingKb, setIsSavingKb] = useState(false);
const filteredKbs = kbs.filter((kb) => kb.name.toLowerCase().includes(searchTerm.toLowerCase()));
const refreshKnowledgeBases = async () => {
setIsLoading(true);
try {
const list = await fetchKnowledgeBases();
setKbs(list);
if (selectedKb) {
const nextSelected = list.find((item) => item.id === selectedKb.id) || null;
setSelectedKb(nextSelected);
}
} catch (error: any) {
console.error(error);
alert(error?.message || '加载知识库失败,请检查后端服务。');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
refreshKnowledgeBases();
const loadEmbeddingModels = async () => {
try {
const models = await fetchLLMModels();
const fromDb = Array.from(new Set(models
.filter((m) => m.type === 'embedding')
.map((m) => (m.modelName || m.name || '').trim())
.filter(Boolean)));
setHasDbEmbeddingModels(fromDb.length > 0);
// Prefer DB embedding models first; keep defaults as fallback at the end.
const defaultsTail = EMBEDDING_OPTIONS.filter((item) => !fromDb.includes(item));
const ordered = [...fromDb, ...defaultsTail];
setEmbeddingOptions(ordered.length > 0 ? ordered : EMBEDDING_OPTIONS);
} catch {
setEmbeddingOptions(EMBEDDING_OPTIONS);
setHasDbEmbeddingModels(false);
}
};
loadEmbeddingModels();
}, []);
useEffect(() => {
const onDocClick = () => setOpenMenuKbId(null);
document.addEventListener('click', onDocClick);
return () => document.removeEventListener('click', onDocClick);
}, []);
const handleSelect = async (kb: KnowledgeBase) => {
setSelectedKb(kb);
setView('detail');
setIsDetailLoading(true);
try {
const fullKb = await fetchKnowledgeBaseById(kb.id);
setSelectedKb(fullKb);
} catch (error: any) {
console.error(error);
alert(error?.message || '加载知识库详情失败。');
} finally {
setIsDetailLoading(false);
}
};
const openCreateKb = () => {
setEditingKb(null);
setKbName('');
setKbDescription('');
setKbEmbeddingModel('text-embedding-3-small');
setKbChunkSize(500);
setKbChunkOverlap(50);
setIsKbModalOpen(true);
};
const openEditKb = (kb: KnowledgeBase) => {
setEditingKb(kb);
setKbName(kb.name || '');
setKbDescription(kb.description || '');
setKbEmbeddingModel(kb.embeddingModel || 'text-embedding-3-small');
setKbChunkSize(kb.chunkSize ?? 500);
setKbChunkOverlap(kb.chunkOverlap ?? 50);
setIsKbModalOpen(true);
};
const handleSaveKb = async () => {
if (!kbName.trim()) {
alert('请输入知识库名称');
return;
}
if (kbChunkSize <= 0) {
alert('Chunk Size 必须大于 0');
return;
}
if (kbChunkOverlap < 0) {
alert('Chunk Overlap 不能小于 0');
return;
}
if (kbChunkOverlap >= kbChunkSize) {
alert('Chunk Overlap 必须小于 Chunk Size');
return;
}
try {
setIsSavingKb(true);
if (editingKb) {
await updateKnowledgeBase(editingKb.id, {
name: kbName.trim(),
description: kbDescription,
embeddingModel: kbEmbeddingModel,
chunkSize: kbChunkSize,
chunkOverlap: kbChunkOverlap,
});
} else {
await createKnowledgeBase({
name: kbName.trim(),
description: kbDescription,
embeddingModel: kbEmbeddingModel,
chunkSize: kbChunkSize,
chunkOverlap: kbChunkOverlap,
});
}
setIsKbModalOpen(false);
await refreshKnowledgeBases();
} catch (error: any) {
console.error(error);
alert(error?.message || (editingKb ? '更新知识库失败。' : '新建知识库失败。'));
} finally {
setIsSavingKb(false);
}
};
const handleDeleteKb = async (kb: KnowledgeBase) => {
if (!confirm(`确认删除知识库「${kb.name}」吗?此操作会删除其所有文档和向量数据。`)) return;
try {
await deleteKnowledgeBase(kb.id);
if (selectedKb?.id === kb.id) {
setSelectedKb(null);
setView('list');
}
await refreshKnowledgeBases();
} catch (error: any) {
console.error(error);
alert(error?.message || '删除知识库失败。');
}
};
if (view === 'detail' && selectedKb) {
return (
<div className="py-4 pb-10">
{isDetailLoading ? (
<div className="rounded-lg border border-white/10 bg-card/40 p-8 text-center text-muted-foreground">...</div>
) : (
<KnowledgeBaseDetail
kb={selectedKb}
onBack={() => setView('list')}
onImport={() => setIsUploadOpen(true)}
onEdit={() => openEditKb(selectedKb)}
onDelete={() => handleDeleteKb(selectedKb)}
onDeleteDocument={async (docId) => {
try {
await deleteKnowledgeDocument(selectedKb.id, docId);
await refreshKnowledgeBases();
} catch (error: any) {
console.error(error);
alert(error?.message || '删除文档失败。');
}
}}
/>
)}
<UploadModal
kbId={selectedKb.id}
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
onUploaded={refreshKnowledgeBases}
/>
<KnowledgeBaseModal
isOpen={isKbModalOpen}
onClose={() => setIsKbModalOpen(false)}
onSave={handleSaveKb}
saving={isSavingKb}
editingKb={editingKb}
kbName={kbName}
setKbName={setKbName}
kbDescription={kbDescription}
setKbDescription={setKbDescription}
kbEmbeddingModel={kbEmbeddingModel}
setKbEmbeddingModel={setKbEmbeddingModel}
kbChunkSize={kbChunkSize}
setKbChunkSize={setKbChunkSize}
kbChunkOverlap={kbChunkOverlap}
setKbChunkOverlap={setKbChunkOverlap}
embeddingOptions={embeddingOptions}
hasDbEmbeddingModels={hasDbEmbeddingModels}
/>
</div>
);
}
return (
<div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight text-white"></h1>
</div>
<div className="bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative w-full">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索知识库名称..."
className="pl-9 border-0 bg-white/5 w-full"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredKbs.map((kb) => (
<Card
key={kb.id}
className="p-6 hover:border-primary/50 transition-colors cursor-pointer group relative"
onClick={() => handleSelect(kb)}
>
<div className="absolute top-3 right-3 z-20">
<Button
variant="ghost"
size="icon"
title="更多操作"
onClick={(e) => {
e.stopPropagation();
setOpenMenuKbId((prev) => (prev === kb.id ? null : kb.id));
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
{openMenuKbId === kb.id && (
<div
className="absolute right-0 mt-1 w-36 rounded-md border border-white/10 bg-card/95 backdrop-blur-md shadow-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 text-destructive"
onClick={() => {
setOpenMenuKbId(null);
handleDeleteKb(kb);
}}
>
</button>
</div>
)}
</div>
<div className="flex items-start justify-between mb-4">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<FileText className="h-6 w-6" />
</div>
</div>
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors text-white">{kb.name}</h3>
<div className="mt-2">
<Badge variant="outline">{kb.embeddingModel || 'embedding'}</Badge>
</div>
<div className="mt-4 space-y-1 text-sm text-muted-foreground">
<p>: {kb.documents.length}</p>
<p>: {kb.chunkSize ?? 500}/{kb.chunkOverlap ?? 50}</p>
<p>: {kb.createdAt}</p>
</div>
</Card>
))}
<div
onClick={openCreateKb}
className="border border-dashed border-white/10 rounded-xl p-6 flex flex-col items-center justify-center text-muted-foreground hover:bg-white/5 hover:border-primary/30 transition-all cursor-pointer min-h-[200px]"
>
<Plus className="h-8 w-8 mb-2 opacity-50" />
<span></span>
</div>
{!isLoading && filteredKbs.length === 0 && (
<div className="col-span-full text-center text-muted-foreground py-8"></div>
)}
{isLoading && (
<div className="col-span-full text-center text-muted-foreground py-8">...</div>
)}
</div>
<KnowledgeBaseModal
isOpen={isKbModalOpen}
onClose={() => setIsKbModalOpen(false)}
onSave={handleSaveKb}
saving={isSavingKb}
editingKb={editingKb}
kbName={kbName}
setKbName={setKbName}
kbDescription={kbDescription}
setKbDescription={setKbDescription}
kbEmbeddingModel={kbEmbeddingModel}
setKbEmbeddingModel={setKbEmbeddingModel}
kbChunkSize={kbChunkSize}
setKbChunkSize={setKbChunkSize}
kbChunkOverlap={kbChunkOverlap}
setKbChunkOverlap={setKbChunkOverlap}
embeddingOptions={embeddingOptions}
hasDbEmbeddingModels={hasDbEmbeddingModels}
/>
</div>
);
};
const KnowledgeBaseModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSave: () => Promise<void>;
saving: boolean;
editingKb: KnowledgeBase | null;
kbName: string;
setKbName: (v: string) => void;
kbDescription: string;
setKbDescription: (v: string) => void;
kbEmbeddingModel: string;
setKbEmbeddingModel: (v: string) => void;
kbChunkSize: number;
setKbChunkSize: (v: number) => void;
kbChunkOverlap: number;
setKbChunkOverlap: (v: number) => void;
embeddingOptions: string[];
hasDbEmbeddingModels: boolean;
}> = ({
isOpen,
onClose,
onSave,
saving,
editingKb,
kbName,
setKbName,
kbDescription,
setKbDescription,
kbEmbeddingModel,
setKbEmbeddingModel,
kbChunkSize,
setKbChunkSize,
kbChunkOverlap,
setKbChunkOverlap,
embeddingOptions,
hasDbEmbeddingModels,
}) => (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={editingKb ? '编辑知识库' : '新建知识库'}
footer={
<>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={onSave} disabled={saving || !kbName.trim()}>{saving ? '保存中...' : (editingKb ? '保存修改' : '确认创建')}</Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input
value={kbName}
onChange={(e) => setKbName(e.target.value)}
placeholder="请输入知识库名称..."
autoFocus
onKeyDown={(e) => e.key === 'Enter' && onSave()}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
value={kbDescription}
onChange={(e) => setKbDescription(e.target.value)}
placeholder="描述知识库用途与内容范围..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Embedding Model</label>
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
value={kbEmbeddingModel}
onChange={(e) => setKbEmbeddingModel(e.target.value)}
>
{embeddingOptions.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Chunk Size</label>
<Input type="number" min={1} value={kbChunkSize} onChange={(e) => setKbChunkSize(parseInt(e.target.value || '0', 10))} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Chunk Overlap</label>
<Input type="number" min={0} value={kbChunkOverlap} onChange={(e) => setKbChunkOverlap(parseInt(e.target.value || '0', 10))} />
</div>
</div>
{!hasDbEmbeddingModels && (
<p className="text-xs text-yellow-400/90">
LLM Library `embedding` 使 embedding
</p>
)}
<p className="text-xs text-muted-foreground">
使 ChromaDB Embedding Model
</p>
</div>
</Dialog>
);
const KnowledgeBaseDetail: React.FC<{
kb: KnowledgeBase;
onBack: () => void;
onImport: () => void;
onEdit: () => void;
onDelete: () => void;
onDeleteDocument: (docId: string) => void;
}> = ({ kb, onBack, onImport, onEdit, onDelete, onDeleteDocument }) => {
const [docSearch, setDocSearch] = useState('');
const filteredDocs = kb.documents.filter((d) => d.name.toLowerCase().includes(docSearch.toLowerCase()));
return (
<div className="space-y-6 animate-in slide-in-from-right-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold text-white">{kb.name}</h1>
<p className="text-sm text-muted-foreground"> {kb.createdAt} · {kb.embeddingModel} · by {kb.creator}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onEdit}><Settings2 className="mr-2 h-4 w-4" /> </Button>
<Button onClick={onImport}><Upload className="mr-2 h-4 w-4" /> ()</Button>
<Button variant="ghost" className="text-destructive" onClick={onDelete}><Trash2 className="mr-2 h-4 w-4" /> </Button>
</div>
</div>
<Card className="overflow-hidden border-white/5">
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
<h3 className="font-medium text-white"></h3>
<div className="w-64">
<Input
placeholder="搜索文档..."
value={docSearch}
onChange={(e) => setDocSearch(e.target.value)}
className="bg-black/20 border-transparent focus:bg-black/40"
/>
</div>
</div>
<table className="w-full text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<tbody>
{filteredDocs.length > 0 ? filteredDocs.map((doc) => (
<TableRow key={doc.id}>
<TableCell className="font-medium flex items-center text-white">
<FileText className="h-4 w-4 mr-2 text-primary" /> {doc.name}
</TableCell>
<TableCell className="text-muted-foreground">{doc.size}</TableCell>
<TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive/80"
onClick={() => onDeleteDocument(doc.id)}
>
</Button>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground"></TableCell>
</TableRow>
)}
</tbody>
</table>
</Card>
</div>
);
};
const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void; onUploaded: () => Promise<void> }> = ({ kbId, isOpen, onClose, onUploaded }) => {
const [dragActive, setDragActive] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
setFiles((prev) => [...prev, ...Array.from(e.dataTransfer.files)]);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (e.target.files && e.target.files[0]) {
setFiles((prev) => [...prev, ...Array.from(e.target.files || [])]);
}
};
const removeFile = (idx: number) => {
setFiles((prev) => prev.filter((_, i) => i !== idx));
};
const handleUpload = async () => {
if (files.length === 0) return;
try {
await Promise.all(files.map((file) => uploadKnowledgeDocument(kbId, file)));
await onUploaded();
onClose();
setFiles([]);
} catch (error: any) {
console.error(error);
alert(error?.message || '上传失败,请稍后重试。');
}
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title="上传知识文档"
footer={
<>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={handleUpload}></Button>
</>
}
>
<div
className={`relative flex flex-col items-center justify-center w-full h-48 rounded-lg border-2 border-dashed transition-colors ${dragActive ? 'border-primary bg-primary/10' : 'border-white/10 bg-white/5 hover:bg-white/10'}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleChange}
accept=".pdf,.doc,.docx,.txt,.md"
/>
<CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground text-center">
<span className="font-semibold text-primary"></span>
</p>
<p className="text-xs text-muted-foreground mt-1 text-white/50"> PDF, DOCX, TXT (Max 10MB)</p>
</div>
{files.length > 0 && (
<div className="mt-4 space-y-2 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
{files.map((file, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded-md bg-white/5 border border-white/5">
<div className="flex items-center space-x-2 overflow-hidden">
<FileIcon className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm truncate max-w-[200px] text-white">{file.name}</span>
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
</div>
<button onClick={() => removeFile(idx)} className="text-muted-foreground hover:text-destructive transition-colors">
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</Dialog>
);
};