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 { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI';
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, Badge } from '../components/UI';
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 = () => {
const [view, setView] = useState<'list' | 'detail'>('list');
@@ -11,11 +16,18 @@ export const KnowledgeBasePage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isCreateKbOpen, setIsCreateKbOpen] = useState(false);
const [newKbName, setNewKbName] = useState('');
const [isKbModalOpen, setIsKbModalOpen] = useState(false);
const [editingKb, setEditingKb] = useState<KnowledgeBase | null>(null);
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 () => {
setIsLoading(true);
@@ -26,9 +38,9 @@ export const KnowledgeBasePage: React.FC = () => {
const nextSelected = list.find((item) => item.id === selectedKb.id) || null;
setSelectedKb(nextSelected);
}
} catch (error) {
} catch (error: any) {
console.error(error);
alert('加载知识库失败,请检查后端服务。');
alert(error?.message || '加载知识库失败,请检查后端服务。');
} finally {
setIsLoading(false);
}
@@ -43,20 +55,85 @@ export const KnowledgeBasePage: React.FC = () => {
setView('detail');
};
const handleImportClick = () => {
setIsUploadOpen(true);
const openCreateKb = () => {
setEditingKb(null);
setKbName('');
setKbDescription('');
setKbEmbeddingModel('text-embedding-3-small');
setKbChunkSize(500);
setKbChunkOverlap(50);
setIsKbModalOpen(true);
};
const handleCreateKb = async () => {
if (!newKbName.trim()) return;
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 {
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();
setIsCreateKbOpen(false);
setNewKbName('');
} catch (error) {
} catch (error: any) {
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
kb={selectedKb}
onBack={() => setView('list')}
onImport={handleImportClick}
onImport={() => setIsUploadOpen(true)}
onEdit={() => openEditKb(selectedKb)}
onDelete={() => handleDeleteKb(selectedKb)}
onDeleteDocument={async (docId) => {
try {
await deleteKnowledgeDocument(selectedKb.id, docId);
await refreshKnowledgeBases();
} catch (error) {
} catch (error: any) {
console.error(error);
alert('删除文档失败。');
alert(error?.message || '删除文档失败。');
}
}}
/>
@@ -83,6 +162,24 @@ export const KnowledgeBasePage: React.FC = () => {
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}
/>
</div>
);
}
@@ -93,7 +190,6 @@ export const KnowledgeBasePage: React.FC = () => {
<h1 className="text-2xl font-bold tracking-tight text-white"></h1>
</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="relative w-full">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -107,35 +203,61 @@ export const KnowledgeBasePage: React.FC = () => {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredKbs.map(kb => (
{filteredKbs.map((kb) => (
<Card
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 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>
<Badge variant="outline">{kb.embeddingModel || 'embedding'}</Badge>
</div>
<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">
<p>: {kb.documents.length}</p>
<p>: {kb.creator}</p>
<p>: {kb.chunkSize ?? 500}/{kb.chunkOverlap ?? 50}</p>
<p>: {kb.createdAt}</p>
</div>
</div>
</Card>
))}
{/* Add New Placeholder */}
<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]"
>
<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>
)}
@@ -144,15 +266,68 @@ export const KnowledgeBasePage: React.FC = () => {
)}
</div>
{/* New Knowledge Base Dialog */}
<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>
);
};
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={isCreateKbOpen}
onClose={() => setIsCreateKbOpen(false)}
title="新建知识库"
isOpen={isOpen}
onClose={onClose}
title={editingKb ? '编辑知识库' : '新建知识库'}
footer={
<>
<Button variant="ghost" onClick={() => setIsCreateKbOpen(false)}></Button>
<Button onClick={handleCreateKb} disabled={!newKbName.trim()}></Button>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={onSave} disabled={saving || !kbName.trim()}>{saving ? '保存中...' : (editingKb ? '保存修改' : '确认创建')}</Button>
</>
}
>
@@ -160,30 +335,64 @@ export const KnowledgeBasePage: React.FC = () => {
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input
value={newKbName}
onChange={(e) => setNewKbName(e.target.value)}
value={kbName}
onChange={(e) => setKbName(e.target.value)}
placeholder="请输入知识库名称..."
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateKb()}
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">
AI
使 ChromaDB Embedding Model
</p>
</div>
</Dialog>
</div>
);
};
);
const KnowledgeBaseDetail: React.FC<{
kb: KnowledgeBase;
onBack: () => void;
onImport: () => void;
onEdit: () => void;
onDelete: () => void;
onDeleteDocument: (docId: string) => void;
}> = ({ kb, onBack, onImport, onDeleteDocument }) => {
}> = ({ kb, onBack, onImport, onEdit, onDelete, onDeleteDocument }) => {
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 (
<div className="space-y-6 animate-in slide-in-from-right-4">
@@ -194,12 +403,14 @@ const KnowledgeBaseDetail: React.FC<{
</Button>
<div>
<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>
<Button onClick={onImport}>
<Upload className="mr-2 h-4 w-4" /> ()
</Button>
<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">
@@ -224,10 +435,10 @@ const KnowledgeBaseDetail: React.FC<{
</TableRow>
</TableHeader>
<tbody>
{filteredDocs.length > 0 ? filteredDocs.map(doc => (
{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}
<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>
@@ -262,9 +473,9 @@ const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === "dragleave") {
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
@@ -274,19 +485,19 @@ const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void
e.stopPropagation();
setDragActive(false);
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>) => {
e.preventDefault();
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) => {
setFiles(prev => prev.filter((_, i) => i !== idx));
setFiles((prev) => prev.filter((_, i) => i !== idx));
};
const handleUpload = async () => {
@@ -296,9 +507,9 @@ const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void
await onUploaded();
onClose();
setFiles([]);
} catch (error) {
} catch (error: any) {
console.error(error);
alert('上传失败,请稍后重试。');
alert(error?.message || '上传失败,请稍后重试。');
}
};
@@ -315,7 +526,7 @@ const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void
}
>
<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}
onDragLeave={handleDrag}
onDragOver={handleDrag}

View File

@@ -143,6 +143,11 @@ const mapKnowledgeBase = (raw: AnyRecord): KnowledgeBase => ({
name: readField(raw, ['name'], ''),
creator: 'Admin',
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)),
});
@@ -522,12 +527,37 @@ export const fetchKnowledgeBases = async (): Promise<KnowledgeBase[]> => {
return list.map((item) => mapKnowledgeBase(item));
};
export const createKnowledgeBase = async (name: string): Promise<KnowledgeBase> => {
const payload = { name, description: '', embeddingModel: 'text-embedding-3-small', chunkSize: 500, chunkOverlap: 50 };
export const createKnowledgeBase = async (data: {
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 });
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> => {
await apiRequest(`/knowledge/bases/${kbId}`, { method: 'DELETE' });
};

View File

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