From 2324d770bc575db70f3ce20078636394acb3865c Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 27 May 2026 00:38:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=9F=A5=E8=AF=86=E5=BA=93=20V2?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=20=E2=80=94=20=E5=88=97?= =?UTF-8?q?=E8=A1=A8/CRUD/=E4=B8=8A=E4=BC=A0/=E5=90=91=E9=87=8F=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - knowledgeV2.ts API client: 知识库/文档/搜索完整接口 - KnowledgeV2Page: 知识库列表 + 创建/编辑/删除 - 文档列表 Drawer: 按知识库查看文档(状态/切片进度) - 上传 Modal: Multipart 文件上传(PDF/TXT/MD/DOCX/XLSX) - 向量搜索测试 Drawer: 输入查询 → 余弦相似度结果展示 路由: /health/ai-knowledge-v2 Phase 3 Task 16-19 --- apps/web/src/App.tsx | 2 + apps/web/src/api/ai/knowledgeV2.ts | 188 +++++++ apps/web/src/pages/ai/KnowledgeV2Page.tsx | 566 ++++++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 apps/web/src/api/ai/knowledgeV2.ts create mode 100644 apps/web/src/pages/ai/KnowledgeV2Page.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8c8a21d..1433777 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/ai/knowledgeV2.ts b/apps/web/src/api/ai/knowledgeV2.ts new file mode 100644 index 0000000..3a1ba31 --- /dev/null +++ b/apps/web/src/api/ai/knowledgeV2.ts @@ -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; + intent_keywords: Record; + 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; +} + +export interface CreateKnowledgeBaseReq { + name: string; + kb_type: string; + description?: string; + icon?: string; + chunk_strategy?: Record; + intent_keywords?: Record; + embedding_model?: string; + is_enabled?: boolean; +} + +export interface UpdateKnowledgeBaseReq { + name?: string; + kb_type?: string; + description?: string; + icon?: string; + chunk_strategy?: Record; + intent_keywords?: Record; + 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[]; + }; + }, +}; diff --git a/apps/web/src/pages/ai/KnowledgeV2Page.tsx b/apps/web/src/pages/ai/KnowledgeV2Page.tsx new file mode 100644 index 0000000..df23f03 --- /dev/null +++ b/apps/web/src/pages/ai/KnowledgeV2Page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editKb, setEditKb] = useState(null); + const [form] = Form.useForm(); + + // Document drawer state + const [docDrawerKb, setDocDrawerKb] = useState(null); + const [docs, setDocs] = useState([]); + const [docsLoading, setDocsLoading] = useState(false); + const [uploadModalOpen, setUploadModalOpen] = useState(false); + const [uploadKbId, setUploadKbId] = useState(''); + const [fileList, setFileList] = useState([]); + + // Hit test state + const [hitTestKb, setHitTestKb] = useState(null); + const [hitTestQuery, setHitTestQuery] = useState(''); + const [hitResults, setHitResults] = useState([]); + 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 = { + 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 {info.label}; + }; + + const kbColumns = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + render: (name: string, record: KnowledgeBase) => ( + + ), + }, + { + 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) => ( + {v ? '启用' : '禁用'} + ), + }, + { + 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) => ( + + + + + handleDelete(record.id)} + > + + + + }} + renderItem={(item) => ( + + + } + title={ + + {item.doc_title} + 切片 #{item.chunk_index} + + 相似度 {(item.similarity * 100).toFixed(1)}% + + + } + description={ +
+ {item.content} +
+ } + /> +
+ )} + /> + + + ); +}