Update knowledge frontend

This commit is contained in:
Xin Wang
2026-02-09 07:36:47 +08:00
parent 7206c313d2
commit bdd5a7a274
3 changed files with 445 additions and 199 deletions

View File

@@ -1,9 +1,14 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react'; import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X, Pencil, Trash2, Settings2 } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog, Badge } from '../components/UI';
import { KnowledgeBase } from '../types'; import { KnowledgeBase } from '../types';
import { createKnowledgeBase, deleteKnowledgeDocument, fetchKnowledgeBases, uploadKnowledgeDocument } from '../services/backendApi'; import { createKnowledgeBase, deleteKnowledgeBase, deleteKnowledgeDocument, fetchKnowledgeBases, updateKnowledgeBase, uploadKnowledgeDocument } from '../services/backendApi';
const EMBEDDING_OPTIONS = [
'text-embedding-3-small',
'text-embedding-3-large',
'bge-small-zh',
];
export const KnowledgeBasePage: React.FC = () => { export const KnowledgeBasePage: React.FC = () => {
const [view, setView] = useState<'list' | 'detail'>('list'); const [view, setView] = useState<'list' | 'detail'>('list');
@@ -11,11 +16,18 @@ export const KnowledgeBasePage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [kbs, setKbs] = useState<KnowledgeBase[]>([]); const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isCreateKbOpen, setIsCreateKbOpen] = useState(false); const [isKbModalOpen, setIsKbModalOpen] = useState(false);
const [newKbName, setNewKbName] = useState(''); const [editingKb, setEditingKb] = useState<KnowledgeBase | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase())); 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 () => { const refreshKnowledgeBases = async () => {
setIsLoading(true); setIsLoading(true);
@@ -26,9 +38,9 @@ export const KnowledgeBasePage: React.FC = () => {
const nextSelected = list.find((item) => item.id === selectedKb.id) || null; const nextSelected = list.find((item) => item.id === selectedKb.id) || null;
setSelectedKb(nextSelected); setSelectedKb(nextSelected);
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
alert('加载知识库失败,请检查后端服务。'); alert(error?.message || '加载知识库失败,请检查后端服务。');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -43,20 +55,85 @@ export const KnowledgeBasePage: React.FC = () => {
setView('detail'); setView('detail');
}; };
const handleImportClick = () => { const openCreateKb = () => {
setIsUploadOpen(true); setEditingKb(null);
setKbName('');
setKbDescription('');
setKbEmbeddingModel('text-embedding-3-small');
setKbChunkSize(500);
setKbChunkOverlap(50);
setIsKbModalOpen(true);
}; };
const handleCreateKb = async () => { const openEditKb = (kb: KnowledgeBase) => {
if (!newKbName.trim()) return; 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 { try {
await createKnowledgeBase(newKbName.trim()); 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(); await refreshKnowledgeBases();
setIsCreateKbOpen(false); } catch (error: any) {
setNewKbName('');
} catch (error) {
console.error(error); console.error(error);
alert('新建知识库失败。'); 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 || '删除知识库失败。');
} }
}; };
@@ -66,14 +143,16 @@ export const KnowledgeBasePage: React.FC = () => {
<KnowledgeBaseDetail <KnowledgeBaseDetail
kb={selectedKb} kb={selectedKb}
onBack={() => setView('list')} onBack={() => setView('list')}
onImport={handleImportClick} onImport={() => setIsUploadOpen(true)}
onEdit={() => openEditKb(selectedKb)}
onDelete={() => handleDeleteKb(selectedKb)}
onDeleteDocument={async (docId) => { onDeleteDocument={async (docId) => {
try { try {
await deleteKnowledgeDocument(selectedKb.id, docId); await deleteKnowledgeDocument(selectedKb.id, docId);
await refreshKnowledgeBases(); await refreshKnowledgeBases();
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
alert('删除文档失败。'); alert(error?.message || '删除文档失败。');
} }
}} }}
/> />
@@ -83,6 +162,24 @@ export const KnowledgeBasePage: React.FC = () => {
onClose={() => setIsUploadOpen(false)} onClose={() => setIsUploadOpen(false)}
onUploaded={refreshKnowledgeBases} 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}
/>
</div> </div>
); );
} }
@@ -93,49 +190,74 @@ export const KnowledgeBasePage: React.FC = () => {
<h1 className="text-2xl font-bold tracking-tight text-white"></h1> <h1 className="text-2xl font-bold tracking-tight text-white"></h1>
</div> </div>
{/* Search Bar - Layout aligned with History Page and width filled */}
<div className="bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm"> <div className="bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative w-full"> <div className="relative w-full">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="搜索知识库名称..." placeholder="搜索知识库名称..."
className="pl-9 border-0 bg-white/5 w-full" className="pl-9 border-0 bg-white/5 w-full"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredKbs.map(kb => ( {filteredKbs.map((kb) => (
<Card <Card
key={kb.id} key={kb.id}
className="p-6 hover:border-primary/50 transition-colors cursor-pointer group" className="p-6 hover:border-primary/50 transition-colors cursor-pointer group relative"
> >
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
openEditKb(kb);
}}
title="编辑知识库"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteKb(kb);
}}
title="删除知识库"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div onClick={() => handleSelect(kb)}> <div onClick={() => handleSelect(kb)}>
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="p-2 bg-primary/10 rounded-lg text-primary"> <div className="p-2 bg-primary/10 rounded-lg text-primary">
<FileText className="h-6 w-6" /> <FileText className="h-6 w-6" />
</div> </div>
<Badge variant="outline">{kb.embeddingModel || 'embedding'}</Badge>
</div> </div>
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors text-white">{kb.name}</h3> <h3 className="text-lg font-semibold group-hover:text-primary transition-colors text-white">{kb.name}</h3>
<div className="mt-4 space-y-1 text-sm text-muted-foreground"> <div className="mt-4 space-y-1 text-sm text-muted-foreground">
<p>: {kb.documents.length}</p> <p>: {kb.documents.length}</p>
<p>: {kb.creator}</p> <p>: {kb.chunkSize ?? 500}/{kb.chunkOverlap ?? 50}</p>
<p>: {kb.createdAt}</p> <p>: {kb.createdAt}</p>
</div> </div>
</div> </div>
</Card> </Card>
))} ))}
{/* Add New Placeholder */}
<div <div
onClick={() => setIsCreateKbOpen(true)} 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]" 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" /> <Plus className="h-8 w-8 mb-2 opacity-50" />
<span></span> <span></span>
</div> </div>
{!isLoading && filteredKbs.length === 0 && ( {!isLoading && filteredKbs.length === 0 && (
<div className="col-span-full text-center text-muted-foreground py-8"></div> <div className="col-span-full text-center text-muted-foreground py-8"></div>
)} )}
@@ -144,46 +266,133 @@ export const KnowledgeBasePage: React.FC = () => {
)} )}
</div> </div>
{/* New Knowledge Base Dialog */} <KnowledgeBaseModal
<Dialog isOpen={isKbModalOpen}
isOpen={isCreateKbOpen} onClose={() => setIsKbModalOpen(false)}
onClose={() => setIsCreateKbOpen(false)} onSave={handleSaveKb}
title="新建知识库" saving={isSavingKb}
footer={ editingKb={editingKb}
<> kbName={kbName}
<Button variant="ghost" onClick={() => setIsCreateKbOpen(false)}></Button> setKbName={setKbName}
<Button onClick={handleCreateKb} disabled={!newKbName.trim()}></Button> kbDescription={kbDescription}
</> setKbDescription={setKbDescription}
} kbEmbeddingModel={kbEmbeddingModel}
> setKbEmbeddingModel={setKbEmbeddingModel}
<div className="space-y-4"> kbChunkSize={kbChunkSize}
<div className="space-y-1.5"> setKbChunkSize={setKbChunkSize}
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label> kbChunkOverlap={kbChunkOverlap}
<Input setKbChunkOverlap={setKbChunkOverlap}
value={newKbName} />
onChange={(e) => setNewKbName(e.target.value)}
placeholder="请输入知识库名称..."
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateKb()}
/>
</div>
<p className="text-xs text-muted-foreground">
AI
</p>
</div>
</Dialog>
</div> </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;
}> = ({
isOpen,
onClose,
onSave,
saving,
editingKb,
kbName,
setKbName,
kbDescription,
setKbDescription,
kbEmbeddingModel,
setKbEmbeddingModel,
kbChunkSize,
setKbChunkSize,
kbChunkOverlap,
setKbChunkOverlap,
}) => (
<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)}
>
{EMBEDDING_OPTIONS.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>
<p className="text-xs text-muted-foreground">
使 ChromaDB Embedding Model
</p>
</div>
</Dialog>
);
const KnowledgeBaseDetail: React.FC<{ const KnowledgeBaseDetail: React.FC<{
kb: KnowledgeBase; kb: KnowledgeBase;
onBack: () => void; onBack: () => void;
onImport: () => void; onImport: () => void;
onEdit: () => void;
onDelete: () => void;
onDeleteDocument: (docId: string) => void; onDeleteDocument: (docId: string) => void;
}> = ({ kb, onBack, onImport, onDeleteDocument }) => { }> = ({ kb, onBack, onImport, onEdit, onDelete, onDeleteDocument }) => {
const [docSearch, setDocSearch] = useState(''); const [docSearch, setDocSearch] = useState('');
const filteredDocs = kb.documents.filter(d => d.name.toLowerCase().includes(docSearch.toLowerCase())); const filteredDocs = kb.documents.filter((d) => d.name.toLowerCase().includes(docSearch.toLowerCase()));
return ( return (
<div className="space-y-6 animate-in slide-in-from-right-4"> <div className="space-y-6 animate-in slide-in-from-right-4">
@@ -194,25 +403,27 @@ const KnowledgeBaseDetail: React.FC<{
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold text-white">{kb.name}</h1> <h1 className="text-2xl font-bold text-white">{kb.name}</h1>
<p className="text-sm text-muted-foreground"> {kb.createdAt} · by {kb.creator}</p> <p className="text-sm text-muted-foreground"> {kb.createdAt} · {kb.embeddingModel} · by {kb.creator}</p>
</div> </div>
</div> </div>
<Button onClick={onImport}> <div className="flex items-center gap-2">
<Upload className="mr-2 h-4 w-4" /> () <Button variant="outline" onClick={onEdit}><Settings2 className="mr-2 h-4 w-4" /> </Button>
</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> </div>
<Card className="overflow-hidden border-white/5"> <Card className="overflow-hidden border-white/5">
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-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> <h3 className="font-medium text-white"></h3>
<div className="w-64"> <div className="w-64">
<Input <Input
placeholder="搜索文档..." placeholder="搜索文档..."
value={docSearch} value={docSearch}
onChange={(e) => setDocSearch(e.target.value)} onChange={(e) => setDocSearch(e.target.value)}
className="bg-black/20 border-transparent focus:bg-black/40" className="bg-black/20 border-transparent focus:bg-black/40"
/> />
</div> </div>
</div> </div>
<table className="w-full text-sm"> <table className="w-full text-sm">
<TableHeader> <TableHeader>
@@ -224,28 +435,28 @@ const KnowledgeBaseDetail: React.FC<{
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<tbody> <tbody>
{filteredDocs.length > 0 ? filteredDocs.map(doc => ( {filteredDocs.length > 0 ? filteredDocs.map((doc) => (
<TableRow key={doc.id}> <TableRow key={doc.id}>
<TableCell className="font-medium flex items-center text-white"> <TableCell className="font-medium flex items-center text-white">
<FileText className="h-4 w-4 mr-2 text-primary"/> {doc.name} <FileText className="h-4 w-4 mr-2 text-primary" /> {doc.name}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{doc.size}</TableCell> <TableCell className="text-muted-foreground">{doc.size}</TableCell>
<TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell> <TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-destructive hover:text-destructive/80" className="text-destructive hover:text-destructive/80"
onClick={() => onDeleteDocument(doc.id)} onClick={() => onDeleteDocument(doc.id)}
> >
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) : ( )) : (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground"></TableCell> <TableCell colSpan={4} className="text-center py-8 text-muted-foreground"></TableCell>
</TableRow> </TableRow>
)} )}
</tbody> </tbody>
</table> </table>
@@ -255,104 +466,104 @@ const KnowledgeBaseDetail: React.FC<{
}; };
const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void; onUploaded: () => Promise<void> }> = ({ kbId, isOpen, onClose, onUploaded }) => { const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void; onUploaded: () => Promise<void> }> = ({ kbId, isOpen, onClose, onUploaded }) => {
const [dragActive, setDragActive] = useState(false); const [dragActive, setDragActive] = useState(false);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const handleDrag = (e: React.DragEvent) => { const handleDrag = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") { if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true); setDragActive(true);
} else if (e.type === "dragleave") { } else if (e.type === 'dragleave') {
setDragActive(false); setDragActive(false);
} }
}; };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragActive(false); setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) { if (e.dataTransfer.files && e.dataTransfer.files[0]) {
setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); setFiles((prev) => [...prev, ...Array.from(e.dataTransfer.files)]);
} }
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault(); e.preventDefault();
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
setFiles(prev => [...prev, ...Array.from(e.target.files || [])]); setFiles((prev) => [...prev, ...Array.from(e.target.files || [])]);
} }
}; };
const removeFile = (idx: number) => { const removeFile = (idx: number) => {
setFiles(prev => prev.filter((_, i) => i !== idx)); setFiles((prev) => prev.filter((_, i) => i !== idx));
}; };
const handleUpload = async () => { const handleUpload = async () => {
if (files.length === 0) return; if (files.length === 0) return;
try { try {
await Promise.all(files.map((file) => uploadKnowledgeDocument(kbId, file))); await Promise.all(files.map((file) => uploadKnowledgeDocument(kbId, file)));
await onUploaded(); await onUploaded();
onClose(); onClose();
setFiles([]); setFiles([]);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
alert('上传失败,请稍后重试。'); alert(error?.message || '上传失败,请稍后重试。');
} }
}; };
return ( return (
<Dialog <Dialog
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title="上传知识文档" title="上传知识文档"
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}></Button> <Button variant="ghost" onClick={onClose}></Button>
<Button onClick={handleUpload}></Button> <Button onClick={handleUpload}></Button>
</> </>
} }
> >
<div <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"}`} 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} onDragEnter={handleDrag}
onDragLeave={handleDrag} onDragLeave={handleDrag}
onDragOver={handleDrag} onDragOver={handleDrag}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
multiple multiple
className="hidden" className="hidden"
onChange={handleChange} onChange={handleChange}
accept=".pdf,.doc,.docx,.txt,.md" accept=".pdf,.doc,.docx,.txt,.md"
/> />
<CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} /> <CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
<span className="font-semibold text-primary"></span> <span className="font-semibold text-primary"></span>
</p> </p>
<p className="text-xs text-muted-foreground mt-1 text-white/50"> PDF, DOCX, TXT (Max 10MB)</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>
))}
{files.length > 0 && ( </div>
<div className="mt-4 space-y-2 max-h-40 overflow-y-auto pr-1 custom-scrollbar"> )}
{files.map((file, idx) => ( </Dialog>
<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>
);
}; };

View File

@@ -143,6 +143,11 @@ const mapKnowledgeBase = (raw: AnyRecord): KnowledgeBase => ({
name: readField(raw, ['name'], ''), name: readField(raw, ['name'], ''),
creator: 'Admin', creator: 'Admin',
createdAt: normalizeDateLabel(readField(raw, ['createdAt', 'created_at'], '')), createdAt: normalizeDateLabel(readField(raw, ['createdAt', 'created_at'], '')),
description: readField(raw, ['description'], ''),
embeddingModel: readField(raw, ['embeddingModel', 'embedding_model'], ''),
chunkSize: Number(readField(raw, ['chunkSize', 'chunk_size'], 500)),
chunkOverlap: Number(readField(raw, ['chunkOverlap', 'chunk_overlap'], 50)),
status: readField(raw, ['status'], 'active'),
documents: readField(raw, ['documents'], []).map((doc: AnyRecord) => mapKnowledgeDocument(doc)), documents: readField(raw, ['documents'], []).map((doc: AnyRecord) => mapKnowledgeDocument(doc)),
}); });
@@ -522,12 +527,37 @@ export const fetchKnowledgeBases = async (): Promise<KnowledgeBase[]> => {
return list.map((item) => mapKnowledgeBase(item)); return list.map((item) => mapKnowledgeBase(item));
}; };
export const createKnowledgeBase = async (name: string): Promise<KnowledgeBase> => { export const createKnowledgeBase = async (data: {
const payload = { name, description: '', embeddingModel: 'text-embedding-3-small', chunkSize: 500, chunkOverlap: 50 }; name: string;
description?: string;
embeddingModel?: string;
chunkSize?: number;
chunkOverlap?: number;
}): Promise<KnowledgeBase> => {
const payload = {
name: data.name,
description: data.description || '',
embeddingModel: data.embeddingModel || 'text-embedding-3-small',
chunkSize: data.chunkSize ?? 500,
chunkOverlap: data.chunkOverlap ?? 50,
};
const response = await apiRequest<AnyRecord>('/knowledge/bases', { method: 'POST', body: payload }); const response = await apiRequest<AnyRecord>('/knowledge/bases', { method: 'POST', body: payload });
return mapKnowledgeBase(response); return mapKnowledgeBase(response);
}; };
export const updateKnowledgeBase = async (kbId: string, data: Partial<KnowledgeBase>): Promise<KnowledgeBase> => {
const payload = {
name: data.name,
description: data.description,
embeddingModel: data.embeddingModel,
chunkSize: data.chunkSize,
chunkOverlap: data.chunkOverlap,
status: data.status,
};
const response = await apiRequest<AnyRecord>(`/knowledge/bases/${kbId}`, { method: 'PUT', body: payload });
return mapKnowledgeBase(response);
};
export const deleteKnowledgeBase = async (kbId: string): Promise<void> => { export const deleteKnowledgeBase = async (kbId: string): Promise<void> => {
await apiRequest(`/knowledge/bases/${kbId}`, { method: 'DELETE' }); await apiRequest(`/knowledge/bases/${kbId}`, { method: 'DELETE' });
}; };

View File

@@ -44,6 +44,11 @@ export interface KnowledgeBase {
name: string; name: string;
creator: string; creator: string;
createdAt: string; createdAt: string;
description?: string;
embeddingModel?: string;
chunkSize?: number;
chunkOverlap?: number;
status?: string;
documents: KnowledgeDocument[]; documents: KnowledgeDocument[];
} }