- knowledgeV2.ts API client: 知识库/文档/搜索完整接口 - KnowledgeV2Page: 知识库列表 + 创建/编辑/删除 - 文档列表 Drawer: 按知识库查看文档(状态/切片进度) - 上传 Modal: Multipart 文件上传(PDF/TXT/MD/DOCX/XLSX) - 向量搜索测试 Drawer: 输入查询 → 余弦相似度结果展示 路由: /health/ai-knowledge-v2 Phase 3 Task 16-19
567 lines
15 KiB
TypeScript
567 lines
15 KiB
TypeScript
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 }}>
|
||
支持 PDF、TXT、Markdown、DOCX、XLSX,最大 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>
|
||
);
|
||
}
|