Files
AI-VideoAssistant/web/pages/KnowledgeBase.tsx
2026-02-10 10:42:40 +08:00

821 lines
31 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, KnowledgeDocument } from '../types';
import { createKnowledgeBase, deleteKnowledgeBase, deleteKnowledgeDocument, fetchKnowledgeBaseById, fetchKnowledgeBases, fetchLLMModels, indexKnowledgeDocument, searchKnowledgeBase, updateKnowledgeBase, uploadKnowledgeDocument, type KnowledgeSearchResultItem } 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)}
onIndexDocument={async (docId, content) => {
try {
await indexKnowledgeDocument(selectedKb.id, docId, content);
await refreshKnowledgeBases();
} catch (error: any) {
console.error(error);
throw new Error(error?.message || '索引失败。');
}
}}
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;
onIndexDocument: (docId: string, content: string) => Promise<void>;
onDeleteDocument: (docId: string) => void;
}> = ({ kb, onBack, onImport, onEdit, onDelete, onIndexDocument, onDeleteDocument }) => {
const [docSearch, setDocSearch] = useState('');
const [indexingDoc, setIndexingDoc] = useState<KnowledgeDocument | null>(null);
const [indexContent, setIndexContent] = useState('');
const [indexing, setIndexing] = useState(false);
const [debugQuery, setDebugQuery] = useState('');
const [debugTopK, setDebugTopK] = useState(5);
const [debugLoading, setDebugLoading] = useState(false);
const [debugResults, setDebugResults] = useState<KnowledgeSearchResultItem[]>([]);
const [debugError, setDebugError] = useState('');
const filteredDocs = kb.documents.filter((d) => d.name.toLowerCase().includes(docSearch.toLowerCase()));
const docNameById = new Map(kb.documents.map((doc) => [doc.id, doc.name]));
const runDebugSearch = async () => {
const query = debugQuery.trim();
if (!query) {
alert('请输入要检索的文本');
return;
}
try {
setDebugLoading(true);
setDebugError('');
const response = await searchKnowledgeBase(kb.id, query, Math.min(Math.max(debugTopK, 1), 20));
setDebugResults(response.results || []);
} catch (error: any) {
console.error(error);
setDebugResults([]);
setDebugError(error?.message || '检索失败,请稍后重试。');
} finally {
setDebugLoading(false);
}
};
const renderMatchPercent = (distance?: number) => {
if (typeof distance !== 'number' || Number.isNaN(distance)) return '-';
const score = 1 / (1 + Math.max(distance, 0));
return `${(score * 100).toFixed(2)}%`;
};
const handleStartIndex = async () => {
if (!indexingDoc) return;
const content = indexContent.trim();
if (!content) {
alert('请输入文档内容后再开始索引');
return;
}
try {
setIndexing(true);
await onIndexDocument(indexingDoc.id, content);
setIndexingDoc(null);
setIndexContent('');
} catch (error: any) {
alert(error?.message || '索引失败');
} finally {
setIndexing(false);
}
};
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></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>
<Badge variant="outline">
{doc.status === 'completed' ? '已索引' : doc.status === 'failed' ? '失败' : doc.status === 'processing' ? '处理中' : '待处理'}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{doc.chunkCount ?? 0}</TableCell>
<TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary/80 mr-1"
onClick={() => {
setIndexingDoc(doc);
setIndexContent('');
}}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive/80"
onClick={() => onDeleteDocument(doc.id)}
>
</Button>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground"></TableCell>
</TableRow>
)}
</tbody>
</table>
</Card>
<Card className="overflow-hidden border-white/5">
<div className="p-4 border-b border-white/5 bg-white/5">
<h3 className="font-medium text-white"></h3>
<p className="text-xs text-muted-foreground mt-1">
使 embedding
</p>
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-[1fr_120px_120px] gap-3">
<Input
placeholder="输入要测试的检索文本..."
value={debugQuery}
onChange={(e) => setDebugQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !debugLoading) void runDebugSearch();
}}
className="bg-black/20 border-transparent focus:bg-black/40"
/>
<Input
type="number"
min={1}
max={20}
value={debugTopK}
onChange={(e) => setDebugTopK(parseInt(e.target.value || '1', 10))}
className="bg-black/20 border-transparent focus:bg-black/40"
/>
<Button onClick={() => void runDebugSearch()} disabled={debugLoading}>
{debugLoading ? '检索中...' : '开始检索'}
</Button>
</div>
{!!debugError && (
<div className="text-sm text-destructive">{debugError}</div>
)}
{!debugLoading && debugResults.length === 0 && !debugError && (
<div className="text-sm text-muted-foreground"></div>
)}
{debugResults.length > 0 && (
<div className="space-y-3">
{debugResults.map((item, idx) => {
const docId = String(item.metadata?.document_id || '');
const chunkIndex = item.metadata?.chunk_index;
const docName = docNameById.get(docId);
return (
<div key={`${docId}-${chunkIndex}-${idx}`} className="rounded-lg border border-white/10 bg-white/5 p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">#{idx + 1}</Badge>
<Badge variant="outline">{docName || docId || 'unknown_doc'}</Badge>
<Badge variant="outline">chunk {chunkIndex ?? '-'}</Badge>
<Badge variant="outline">distance: {typeof item.distance === 'number' ? item.distance.toFixed(6) : '-'}</Badge>
<Badge variant="outline">: {renderMatchPercent(item.distance)}</Badge>
</div>
<p className="text-sm text-white/90 whitespace-pre-wrap leading-relaxed">
{item.content || '(空内容)'}
</p>
</div>
);
})}
</div>
)}
</div>
</Card>
<Dialog
isOpen={!!indexingDoc}
onClose={() => {
if (indexing) return;
setIndexingDoc(null);
setIndexContent('');
}}
title={`开始索引: ${indexingDoc?.name || ''}`}
footer={
<>
<Button
variant="ghost"
onClick={() => {
if (indexing) return;
setIndexingDoc(null);
setIndexContent('');
}}
>
</Button>
<Button onClick={handleStartIndex} disabled={indexing}>
{indexing ? '索引中...' : '开始索引'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
</p>
<textarea
value={indexContent}
onChange={(e) => setIndexContent(e.target.value)}
className="flex min-h-[220px] 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"
placeholder="粘贴文档内容用于索引..."
/>
</div>
</Dialog>
</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>
);
};