Update knowledge frontend
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user