feat(web): 知识库 V2 管理页面 — 列表/CRUD/上传/向量搜索测试

- knowledgeV2.ts API client: 知识库/文档/搜索完整接口
- KnowledgeV2Page: 知识库列表 + 创建/编辑/删除
- 文档列表 Drawer: 按知识库查看文档(状态/切片进度)
- 上传 Modal: Multipart 文件上传(PDF/TXT/MD/DOCX/XLSX)
- 向量搜索测试 Drawer: 输入查询 → 余弦相似度结果展示

路由: /health/ai-knowledge-v2
Phase 3 Task 16-19
This commit is contained in:
iven
2026-05-27 00:38:11 +08:00
parent 823d69a3c3
commit 2324d770bc
3 changed files with 756 additions and 0 deletions

View File

@@ -47,6 +47,7 @@ const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage'));
const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
@@ -331,6 +332,7 @@ export default function App() {
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/ai-config" element={<AiConfigPage />} />
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} />
<Route path="/health/ai-knowledge-v2" element={<KnowledgeV2Page />} />
<Route path="/ai/chat" element={<AiChatPage />} />
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />

View File

@@ -0,0 +1,188 @@
import client from '../client';
// === Types ===
export interface KnowledgeBase {
id: string;
tenant_id: string;
name: string;
kb_type: string;
description: string | null;
icon: string | null;
chunk_strategy: Record<string, unknown>;
intent_keywords: Record<string, unknown>;
embedding_model: string | null;
is_enabled: boolean;
document_count: number;
chunk_count: number;
created_at: string;
updated_at: string;
}
export interface KnowledgeDocument {
id: string;
tenant_id: string;
knowledge_base_id: string;
title: string;
doc_type: string;
source_type: string;
source_url: string | null;
file_name: string | null;
file_size: number | null;
file_mime_type: string | null;
content: string | null;
status: string;
chunk_count: number;
embedded_count: number;
error_message: string | null;
processing_started_at: string | null;
processing_completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface SearchHit {
chunk_id: string;
document_id: string;
chunk_index: number;
content: string;
doc_title: string;
similarity: number;
metadata: Record<string, unknown>;
}
export interface CreateKnowledgeBaseReq {
name: string;
kb_type: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface UpdateKnowledgeBaseReq {
name?: string;
kb_type?: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface CreateDocumentReq {
kb_id: string;
title: string;
doc_type?: string;
source_type?: string;
source_url?: string;
content?: string;
}
// === API ===
export const knowledgeV2Api = {
// Knowledge Bases
listKnowledgeBases: async (params?: {
kb_type?: string;
is_enabled?: boolean;
page?: number;
page_size?: number;
}) => {
const resp = await client.get('/ai/knowledge-bases', { params });
return resp.data.data as {
data: KnowledgeBase[];
total: number;
page: number;
page_size: number;
};
},
getKnowledgeBase: async (id: string) => {
const resp = await client.get(`/ai/knowledge-bases/${id}`);
return resp.data.data as KnowledgeBase;
},
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
const resp = await client.post('/ai/knowledge-bases', data);
return resp.data.data as { id: string };
},
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
return resp.data.data as { id: string };
},
deleteKnowledgeBase: async (id: string) => {
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
return resp.data.data as { id: string };
},
// Documents
listDocuments: async (
kbId: string,
params?: { status?: string; page?: number; page_size?: number },
) => {
const resp = await client.get(
`/ai/knowledge-bases/${kbId}/documents`,
{ params },
);
return resp.data.data as {
data: KnowledgeDocument[];
total: number;
page: number;
page_size: number;
};
},
getDocument: async (id: string) => {
const resp = await client.get(`/ai/documents/${id}`);
return resp.data.data as KnowledgeDocument;
},
createManualDocument: async (data: CreateDocumentReq) => {
const resp = await client.post('/ai/documents/manual', data);
return resp.data.data as { id: string };
},
uploadDocument: async (
kbId: string,
file: File,
title?: string,
) => {
const formData = new FormData();
formData.append('kb_id', kbId);
formData.append('file', file);
if (title) {
formData.append('title', title);
}
const resp = await client.post('/ai/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return resp.data.data as { id: string };
},
deleteDocument: async (kbId: string, id: string) => {
const resp = await client.delete(
`/ai/knowledge-bases/${kbId}/documents/${id}`,
);
return resp.data.data as { id: string };
},
// Hit Test
hitTest: async (kbId: string, query: string, topK?: number) => {
const resp = await client.post('/ai/documents/hit-test', {
kb_id: kbId,
query,
top_k: topK,
});
return resp.data.data as {
query: string;
total: number;
hits: SearchHit[];
};
},
};

View File

@@ -0,0 +1,566 @@
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tag,
Upload,
Progress,
Drawer,
List,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
UploadOutlined,
SearchOutlined,
FileTextOutlined,
DatabaseOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import {
knowledgeV2Api,
type KnowledgeBase,
type KnowledgeDocument,
type SearchHit,
type CreateKnowledgeBaseReq,
} from '../../api/ai/knowledgeV2';
const KB_TYPES = [
{ label: '临床指南', value: 'clinical_guide' },
{ label: '操作规程', value: 'sop' },
{ label: 'FAQ', value: 'faq' },
{ label: '产品知识', value: 'product' },
{ label: '通用', value: 'general' },
];
export default function KnowledgeV2Page() {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editKb, setEditKb] = useState<KnowledgeBase | null>(null);
const [form] = Form.useForm();
// Document drawer state
const [docDrawerKb, setDocDrawerKb] = useState<KnowledgeBase | null>(null);
const [docs, setDocs] = useState<KnowledgeDocument[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadKbId, setUploadKbId] = useState<string>('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
// Hit test state
const [hitTestKb, setHitTestKb] = useState<KnowledgeBase | null>(null);
const [hitTestQuery, setHitTestQuery] = useState('');
const [hitResults, setHitResults] = useState<SearchHit[]>([]);
const [hitTestLoading, setHitTestLoading] = useState(false);
const loadKbs = useCallback(async () => {
setLoading(true);
try {
const res = await knowledgeV2Api.listKnowledgeBases({
page,
page_size: 20,
});
setKbs(res.data);
setTotal(res.total);
} catch {
message.error('加载知识库列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
loadKbs();
}, [loadKbs]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
const req: CreateKnowledgeBaseReq = {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled ?? true,
};
await knowledgeV2Api.createKnowledgeBase(req);
message.success('知识库创建成功');
setCreateModalOpen(false);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleUpdate = async () => {
if (!editKb) return;
try {
const values = await form.validateFields();
await knowledgeV2Api.updateKnowledgeBase(editKb.id, {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled,
});
message.success('知识库更新成功');
setEditKb(null);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeV2Api.deleteKnowledgeBase(id);
message.success('知识库已删除');
loadKbs();
} catch {
message.error('删除失败');
}
};
const loadDocuments = async (kb: KnowledgeBase) => {
setDocDrawerKb(kb);
setDocsLoading(true);
try {
const res = await knowledgeV2Api.listDocuments(kb.id, {
page: 1,
page_size: 50,
});
setDocs(res.data);
} catch {
message.error('加载文档列表失败');
} finally {
setDocsLoading(false);
}
};
const handleUpload = async () => {
if (!uploadKbId || fileList.length === 0) return;
try {
const file = fileList[0].originFileObj;
if (!file) return;
await knowledgeV2Api.uploadDocument(uploadKbId, file);
message.success('文档上传成功,正在处理...');
setUploadModalOpen(false);
setFileList([]);
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('上传失败');
}
};
const handleDeleteDoc = async (kbId: string, docId: string) => {
try {
await knowledgeV2Api.deleteDocument(kbId, docId);
message.success('文档已删除');
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('删除失败');
}
};
const handleHitTest = async () => {
if (!hitTestKb || !hitTestQuery.trim()) return;
setHitTestLoading(true);
try {
const res = await knowledgeV2Api.hitTest(hitTestKb.id, hitTestQuery, 5);
setHitResults(res.hits);
} catch {
message.error('搜索失败');
setHitResults([]);
} finally {
setHitTestLoading(false);
}
};
const statusTag = (status: string) => {
const map: Record<string, { color: string; label: string }> = {
pending: { color: 'default', label: '待处理' },
processing: { color: 'processing', label: '处理中' },
completed: { color: 'success', label: '已完成' },
failed: { color: 'error', label: '失败' },
};
const info = map[status] || { color: 'default', label: status };
return <Tag color={info.color}>{info.label}</Tag>;
};
const kbColumns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: KnowledgeBase) => (
<Button type="link" onClick={() => loadDocuments(record)}>
{name}
</Button>
),
},
{
title: '类型',
dataIndex: 'kb_type',
key: 'kb_type',
render: (type: string) => {
const found = KB_TYPES.find((t) => t.value === type);
return found?.label || type;
},
},
{
title: '文档数',
dataIndex: 'document_count',
key: 'document_count',
width: 90,
},
{
title: '切片数',
dataIndex: 'chunk_count',
key: 'chunk_count',
width: 90,
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 240,
render: (_: unknown, record: KnowledgeBase) => (
<Space size="small">
<Button
size="small"
icon={<UploadOutlined />}
onClick={() => {
setUploadKbId(record.id);
setUploadModalOpen(true);
}}
>
</Button>
<Button
size="small"
icon={<SearchOutlined />}
onClick={() => {
setHitTestKb(record);
setHitResults([]);
setHitTestQuery('');
}}
>
</Button>
<Button
size="small"
onClick={() => {
setEditKb(record);
form.setFieldsValue({
name: record.name,
kb_type: record.kb_type,
description: record.description,
is_enabled: record.is_enabled,
});
}}
>
</Button>
<Popconfirm
title="确定删除此知识库?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const kbFormContent = (
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="知识库名称"
rules={[{ required: true, message: '请输入知识库名称' }]}
>
<Input placeholder="例:高血压临床指南" />
</Form.Item>
<Form.Item
name="kb_type"
label="知识库类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select options={KB_TYPES} placeholder="选择类型" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="知识库描述(可选)" />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
</Form>
);
const docColumns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: '类型',
dataIndex: 'doc_type',
key: 'doc_type',
width: 80,
},
{
title: '来源',
dataIndex: 'source_type',
key: 'source_type',
width: 80,
render: (v: string) => <Tag>{v === 'upload' ? '上传' : '手动'}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: statusTag,
},
{
title: '切片/嵌入',
key: 'progress',
width: 130,
render: (_: unknown, record: KnowledgeDocument) => {
if (record.chunk_count === 0) return '-';
const pct = Math.round(
(record.embedded_count / record.chunk_count) * 100,
);
return <Progress percent={pct} size="small" />;
},
},
{
title: '操作',
key: 'actions',
width: 70,
render: (_: unknown, record: KnowledgeDocument) => (
<Popconfirm
title="确定删除此文档?"
onConfirm={() =>
handleDeleteDoc(record.knowledge_base_id, record.id)
}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<DatabaseOutlined />
V2
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
setCreateModalOpen(true);
}}
>
</Button>
}
>
<Table
rowKey="id"
columns={kbColumns}
dataSource={kbs}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
/>
</Card>
{/* 创建知识库 Modal */}
<Modal
title="新建知识库"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => {
setCreateModalOpen(false);
form.resetFields();
}}
okText="创建"
>
{kbFormContent}
</Modal>
{/* 编辑知识库 Modal */}
<Modal
title="编辑知识库"
open={!!editKb}
onOk={handleUpdate}
onCancel={() => {
setEditKb(null);
form.resetFields();
}}
okText="保存"
>
{kbFormContent}
</Modal>
{/* 文档列表 Drawer */}
<Drawer
title={
docDrawerKb
? `${docDrawerKb.name} — 文档列表`
: '文档列表'
}
open={!!docDrawerKb}
onClose={() => setDocDrawerKb(null)}
width={720}
>
<Table
rowKey="id"
columns={docColumns}
dataSource={docs}
loading={docsLoading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="暂无文档" /> }}
/>
</Drawer>
{/* 上传文档 Modal */}
<Modal
title="上传文档"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => {
setUploadModalOpen(false);
setFileList([]);
}}
okText="上传"
>
<Upload
beforeUpload={() => false}
maxCount={1}
fileList={fileList}
onChange={({ fileList: fl }) => setFileList(fl)}
accept=".pdf,.txt,.md,.docx,.xlsx"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
PDFTXTMarkdownDOCXXLSX 20MB
</div>
</Modal>
{/* Hit Test Drawer */}
<Drawer
title={
hitTestKb ? `${hitTestKb.name} — 向量搜索测试` : '搜索测试'
}
open={!!hitTestKb}
onClose={() => {
setHitTestKb(null);
setHitResults([]);
}}
width={600}
>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Input
placeholder="输入搜索文本..."
value={hitTestQuery}
onChange={(e) => setHitTestQuery(e.target.value)}
onPressEnter={handleHitTest}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={hitTestLoading}
onClick={handleHitTest}
>
</Button>
</Space.Compact>
<List
dataSource={hitResults}
locale={{ emptyText: <Empty description="输入查询后点击搜索" /> }}
renderItem={(item) => (
<List.Item key={item.chunk_id}>
<List.Item.Meta
avatar={
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
}
title={
<Space>
<span>{item.doc_title}</span>
<Tag color="blue"> #{item.chunk_index}</Tag>
<Tag color="green">
{(item.similarity * 100).toFixed(1)}%
</Tag>
</Space>
}
description={
<div
style={{
maxHeight: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
color: '#666',
}}
>
{item.content}
</div>
}
/>
</List.Item>
)}
/>
</Drawer>
</div>
);
}