import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Table, Badge, Button, Breadcrumb, Tooltip, App, Card, Input, Empty, Spin, } from "antd"; import { DeleteOutlined, EditOutlined, ReloadOutlined, } from "@ant-design/icons"; import { useNavigate, useParams, useSearchParams } from "react-router"; import DetailHeader from "@/components/DetailHeader"; import { SearchControls } from "@/components/SearchControls"; import { KBFile, KnowledgeBaseItem } from "../knowledge-base.model"; import { mapFileData, mapKnowledgeBase } from "../knowledge-base.const"; import { deleteKnowledgeBaseByIdUsingDelete, deleteKnowledgeBaseFileByIdUsingDelete, queryKnowledgeBaseByIdUsingGet, queryKnowledgeBaseFilesUsingGet, retrieveKnowledgeBaseContent, } from "../knowledge-base.api"; import AddDataDialog from "../components/AddDataDialog"; import CreateKnowledgeBase from "../components/CreateKnowledgeBase"; import { File, Folder } from "lucide-react"; interface StatisticItem { icon?: React.ReactNode; label: string; value: string | number; } interface RagChunk { id: string; text: string; metadata: string; } interface RecallResult { score: number; entity: RagChunk; id?: string | object; primaryKey?: string; } type KBFileRow = KBFile & { isDirectory?: boolean; displayName?: string; fullPath?: string; fileCount?: number; }; const PATH_SEPARATOR = "/"; const normalizePath = (value?: string) => (value ?? "").replace(/\\/g, PATH_SEPARATOR); const normalizePrefix = (value?: string) => { const trimmed = normalizePath(value).replace(/^\/+/, "").trim(); if (!trimmed) { return ""; } return trimmed.endsWith(PATH_SEPARATOR) ? trimmed : `${trimmed}${PATH_SEPARATOR}`; }; const splitRelativePath = (fullPath: string, prefix: string) => { if (prefix && !fullPath.startsWith(prefix)) { return []; } const remainder = fullPath.slice(prefix.length); return remainder.split(PATH_SEPARATOR).filter(Boolean); }; const resolveFileRelativePath = (file: KBFile) => { const rawPath = file.relativePath || file.fileName || file.name || ""; return normalizePath(rawPath).replace(/^\/+/, ""); }; const KnowledgeBaseDetailPage: React.FC = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { message } = App.useApp(); const { id } = useParams<{ id: string }>(); const [knowledgeBase, setKnowledgeBase] = useState(undefined); const [showEdit, setShowEdit] = useState(false); const [activeTab, setActiveTab] = useState<'fileList' | 'recallTest'>('fileList'); const [filePrefix, setFilePrefix] = useState(""); const [fileKeyword, setFileKeyword] = useState(""); const [filesLoading, setFilesLoading] = useState(false); const [allFiles, setAllFiles] = useState([]); const [filePagination, setFilePagination] = useState({ current: 1, pageSize: 10, }); const [recallLoading, setRecallLoading] = useState(false); const [recallResults, setRecallResults] = useState([]); const [recallQuery, setRecallQuery] = useState(""); const fetchKnowledgeBaseDetails = useCallback(async (baseId: string) => { const { data } = await queryKnowledgeBaseByIdUsingGet(baseId); setKnowledgeBase(mapKnowledgeBase(data)); }, []); const fetchFiles = useCallback(async () => { if (!id) { setAllFiles([]); return; } setFilesLoading(true); try { const pageSize = 200; let page = 0; let combined: KBFile[] = []; const currentPrefix = normalizePrefix(filePrefix); const keyword = fileKeyword.trim(); while (true) { const { data } = await queryKnowledgeBaseFilesUsingGet(id, { page, size: pageSize, ...(currentPrefix ? { relativePath: currentPrefix } : {}), ...(keyword ? { fileName: keyword } : {}), }); const content = Array.isArray(data?.content) ? data.content : []; combined = combined.concat(content.map(mapFileData)); if (content.length < pageSize) { break; } if (typeof data?.totalElements === "number" && combined.length >= data.totalElements) { break; } page += 1; } setAllFiles(combined); } catch (error) { console.error("Failed to fetch knowledge base files:", error); message.error("文件列表加载失败"); } finally { setFilesLoading(false); } }, [id, filePrefix, fileKeyword, message]); useEffect(() => { if (id) { fetchKnowledgeBaseDetails(id); } }, [id, fetchKnowledgeBaseDetails]); useEffect(() => { if (!id) { return; } const prefixParam = searchParams.get("prefix"); const fileNameParam = searchParams.get("fileName"); setFilePrefix(prefixParam ? normalizePrefix(prefixParam) : ""); setFileKeyword(fileNameParam ? fileNameParam : ""); }, [id, searchParams]); useEffect(() => { if (id) { fetchFiles(); } }, [id, fetchFiles]); // File table logic const handleDeleteFile = async (file: KBFileRow) => { try { await deleteKnowledgeBaseFileByIdUsingDelete(knowledgeBase!.id, { ids: [file.id] }); message.success("文件已删除"); fetchFiles(); } catch { message.error("文件删除失败"); } }; const handleDeleteKB = async (kb: KnowledgeBaseItem) => { await deleteKnowledgeBaseByIdUsingDelete(kb.id); message.success("知识库已删除"); navigate("/data/knowledge-base"); }; const handleRefreshPage = () => { if (knowledgeBase) { fetchKnowledgeBaseDetails(knowledgeBase.id); } fetchFiles(); setShowEdit(false); }; const handleRecallTest = async () => { if (!recallQuery || !knowledgeBase?.id) return; setRecallLoading(true); try { const result = await retrieveKnowledgeBaseContent({ query: recallQuery, topK: 10, threshold: 0.2, knowledgeBaseIds: [knowledgeBase.id], }); setRecallResults(result?.data || []); } catch { setRecallResults([]); } setRecallLoading(false); }; const handleOpenDirectory = (directoryName: string) => { const currentPrefix = normalizePrefix(filePrefix); const nextPrefix = normalizePrefix(`${currentPrefix}${directoryName}`); setFilePrefix(nextPrefix); }; const handleBackToParent = () => { const currentPrefix = normalizePrefix(filePrefix); if (!currentPrefix) { return; } const trimmed = currentPrefix.replace(/\/$/, ""); const parts = trimmed.split(PATH_SEPARATOR).filter(Boolean); parts.pop(); const parentPrefix = parts.length ? `${parts.join(PATH_SEPARATOR)}${PATH_SEPARATOR}` : ""; setFilePrefix(parentPrefix); }; const handleDeleteDirectory = async (directoryName: string) => { if (!knowledgeBase?.id) { return; } const currentPrefix = normalizePrefix(filePrefix); const directoryPrefix = normalizePrefix(`${currentPrefix}${directoryName}`); const targetIds = allFiles .filter((file) => { const fullPath = resolveFileRelativePath(file); return fullPath.startsWith(directoryPrefix); }) .map((file) => file.id); if (targetIds.length === 0) { message.info("该文件夹为空"); return; } try { await deleteKnowledgeBaseFileByIdUsingDelete(knowledgeBase.id, { ids: targetIds, }); message.success(`已删除 ${targetIds.length} 个文件`); fetchFiles(); } catch { message.error("文件夹删除失败"); } }; const handleKeywordChange = (keyword: string) => { setFileKeyword(keyword); }; useEffect(() => { setFilePagination((prev) => ({ ...prev, current: 1 })); }, [filePrefix, fileKeyword]); const normalizedPrefix = useMemo(() => normalizePrefix(filePrefix), [filePrefix]); const { rows: fileRows, total: fileTotal } = useMemo(() => { const folderMap = new Map(); const fileItems: KBFileRow[] = []; allFiles.forEach((file) => { const fullPath = resolveFileRelativePath(file); if (!fullPath) { return; } const segments = splitRelativePath(fullPath, normalizedPrefix); if (segments.length === 0) { return; } const leafName = segments[0]; if (segments.length > 1) { const folderName = leafName; const entry = folderMap.get(folderName) || { name: folderName, fileCount: 0, }; entry.fileCount += 1; folderMap.set(folderName, entry); return; } const normalizedFileName = normalizePath(file.fileName); const displayName = normalizedFileName.includes(PATH_SEPARATOR) ? leafName : file.fileName || leafName; fileItems.push({ ...file, name: displayName, displayName, fullPath, }); }); const folderItems: KBFileRow[] = Array.from(folderMap.values()).map( (entry) => ({ id: `directory-${normalizedPrefix}${entry.name}`, fileName: entry.name, name: entry.name, status: null, chunkCount: 0, createdAt: "", updatedAt: "", metadata: {}, knowledgeBaseId: knowledgeBase?.id || "", fileId: "", updatedBy: "", createdBy: "", isDirectory: true, displayName: entry.name, fullPath: `${normalizedPrefix}${entry.name}/`, fileCount: entry.fileCount, }) as KBFileRow ); const sortByName = (a: KBFileRow, b: KBFileRow) => (a.displayName || a.name || "").localeCompare( b.displayName || b.name || "", "zh-Hans-CN" ); folderItems.sort(sortByName); fileItems.sort(sortByName); const combined = [...folderItems, ...fileItems]; return { rows: combined, total: combined.length }; }, [allFiles, knowledgeBase?.id, normalizedPrefix]); const filePageCurrent = filePagination.current; const filePageSize = filePagination.pageSize; const pagedFileRows = useMemo(() => { const startIndex = (filePageCurrent - 1) * filePageSize; const endIndex = startIndex + filePageSize; return fileRows.slice(startIndex, endIndex); }, [filePageCurrent, filePageSize, fileRows]); useEffect(() => { const maxPage = Math.max(1, Math.ceil(fileTotal / filePageSize)); if (filePageCurrent > maxPage) { setFilePagination((prev) => ({ ...prev, current: maxPage })); } }, [filePageCurrent, filePageSize, fileTotal]); const operations = [ { key: "edit", label: "编辑知识库", icon: , onClick: () => { setShowEdit(true); }, }, { key: "refresh", label: "刷新知识库", icon: , onClick: () => { handleRefreshPage(); }, }, { key: "delete", label: "删除知识库", danger: true, confirm: { title: "确认删除该知识库吗?", description: "删除后将无法恢复,请谨慎操作。", cancelText: "取消", okText: "删除", okType: "danger", onConfirm: () => knowledgeBase && handleDeleteKB(knowledgeBase), }, icon: , }, ]; const fileOps = [ { key: "delete", label: "删除文件", icon: , danger: true, onClick: handleDeleteFile, }, ]; const fileColumns = [ { title: "文件名", dataIndex: "name", key: "name", width: 200, ellipsis: true, fixed: "left" as const, render: (name: string, record: KBFileRow) => { const displayName = record.displayName || name; if (record.isDirectory) { return ( ); } return (
{displayName}
); }, }, { title: "状态", dataIndex: "status", key: "vectorizationStatus", width: 120, render: (status: unknown, record: KBFileRow) => { if (record.isDirectory) { return ; } if (typeof status === "object" && status !== null) { const s = status as { color?: string; label?: string }; return ; } return ; }, }, { title: "分块数", dataIndex: "chunkCount", key: "chunkCount", width: 100, ellipsis: true, render: (value: number, record: KBFileRow) => record.isDirectory ? "-" : value ?? 0, }, { title: "创建时间", dataIndex: "createdAt", key: "createdAt", ellipsis: true, width: 180, render: (value: string, record: KBFileRow) => record.isDirectory ? "-" : value || "-", }, { title: "更新时间", dataIndex: "updatedAt", key: "updatedAt", ellipsis: true, width: 180, render: (value: string, record: KBFileRow) => record.isDirectory ? "-" : value || "-", }, { title: "操作", key: "actions", align: "right" as const, width: 100, render: (_: unknown, file: KBFileRow) => { if (file.isDirectory) { return ( {activeTab === 'fileList' && ( <>
{}} onClearFilters={() => setFileKeyword("")} showViewToggle={false} showReload={false} />
)} {activeTab === 'fileList' ? ( <>
{normalizedPrefix && ( )} {normalizedPrefix && ( 当前路径: {normalizedPrefix} )}
`共 ${total} 条`, onChange: (page, pageSize) => setFilePagination({ current: page, pageSize: pageSize || filePagination.pageSize, }), }} scroll={{ y: "calc(100vh - 30rem)" }} /> ) : (
基于语义文本检索和全文检索后的加权平均结果
setRecallQuery(e.target.value)} onSearch={handleRecallTest} placeholder="请输入召回测试问题" enterButton="检索" loading={recallLoading} style={{ width: "100%", fontSize: 18, height: 48 }} />
{recallLoading ? ( ) : recallResults.length === 0 ? ( ) : (
{recallResults.map((item, idx) => ( ID: {item.entity?.id ?? "-"}} style={{ wordBreak: "break-all" }} >
{item.entity?.text ?? ""}
metadata:
{item.entity?.metadata}
))}
)}
)} ); }; export default KnowledgeBaseDetailPage;