import React, { useState, useEffect, useRef, useCallback } from 'react'; import { knowledgeAPI } from '@/api'; import './KnowledgePage.css'; // ===== SVG Icons ===== const UploadIcon = () => ( ); const ChevronDownIcon = () => ( ); const FileIcon = () => ( ); const FolderIcon = () => ( ); const GraphIcon = () => ( ); const ProcessingIcon = () => ( ); const CheckIcon = () => ( ); const CloseIcon = () => ( ); const AddIcon = () => ( ); const TrashIcon = () => ( ); const AlertTriangleIcon = () => ( ); // ===== Helpers ===== function formatFileSize(bytes: number): string { if (!bytes || bytes === 0) return '-'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } function formatDate(dateStr: string): string { if (!dateStr) return '-'; const d = new Date(dateStr); return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } function getTypeLabel(fileType: string): string { const types: Record = { pdf: 'PDF', docx: 'Word', doc: 'Word', xlsx: 'Excel', xls: 'Excel', csv: 'CSV', md: 'Markdown', txt: '文本', pptx: 'PPT', ppt: 'PPT', }; return types[fileType] || fileType.toUpperCase(); } // ===== Types ===== interface GraphNode { id: string; label: string; type: string; size: number; group: string; } interface GraphEdge { source: string; target: string; label?: string; weight: number; } interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; stats: { total_nodes: number; total_edges: number; document_count: number; entity_types?: Record; }; } interface KnowledgeBase { id: string; name: string; description: string; document_count: number; knowledge_graph_count?: number; created_at: string; updated_at: string; } interface KnowledgeDoc { id: string; base_id?: string; knowledge_base_id?: string; filename: string; file_type: string; file_size: number; status: string; chunk_count: number; created_at: string; updated_at: string; } // ===== Color helpers for graph nodes ===== const GROUP_COLORS: Record = { document: '#4A90D9', concept: '#7B68EE', entity: '#50C878', attribute: '#FF6347', relation: '#FFD700', }; // ===== Knowledge Graph Visualization ===== const KnowledgeGraphView: React.FC<{ graphData: GraphData; onClose: () => void }> = ({ graphData, onClose }) => { const canvasRef = useRef(null); const animationRef = useRef(0); interface LayoutNode extends GraphNode { x: number; y: number; vx: number; vy: number; } const layoutNodesRef = useRef([]); const [selectedNodeId, setSelectedNodeId] = useState(null); const [draggedNodeId, setDraggedNodeId] = useState(null); const dragStartRef = useRef<{ x: number; y: number; nodeX: number; nodeY: number } | null>(null); const hoveredNodeRef = useRef(null); const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string; type: string; group: string } | null>(null); const initLayout = useCallback(() => { const { nodes, edges } = graphData; const centerX = 400; const centerY = 300; const layoutNodes: LayoutNode[] = nodes.map((node, i) => { if (node.type === 'document') { const angle = (2 * Math.PI * i) / Math.max(nodes.filter(n => n.type === 'document').length, 1); const radius = 120; return { ...node, x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), vx: 0, vy: 0 }; } return { ...node, x: centerX + (Math.random() - 0.5) * 400, y: centerY + (Math.random() - 0.5) * 400, vx: 0, vy: 0 }; }); // Force simulation const nodeMap = new Map(); layoutNodes.forEach(n => nodeMap.set(n.id, n)); const simulationSteps = 100; for (let step = 0; step < simulationSteps; step++) { const alpha = 1 - step / simulationSteps; const repulsionStrength = 500 * alpha; const attractionStrength = 0.01 * alpha; const centeringStrength = 0.005 * alpha; for (let i = 0; i < layoutNodes.length; i++) { for (let j = i + 1; j < layoutNodes.length; j++) { const dx = layoutNodes[j].x - layoutNodes[i].x; const dy = layoutNodes[j].y - layoutNodes[i].y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = repulsionStrength / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; layoutNodes[i].vx -= fx; layoutNodes[i].vy -= fy; layoutNodes[j].vx += fx; layoutNodes[j].vy += fy; } } edges.forEach(edge => { const src = nodeMap.get(edge.source); const tgt = nodeMap.get(edge.target); if (src && tgt) { const dx = tgt.x - src.x; const dy = tgt.y - src.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = dist * attractionStrength; const fx = (dx / dist) * force; const fy = (dy / dist) * force; src.vx += fx; src.vy += fy; tgt.vx -= fx; tgt.vy -= fy; } }); layoutNodes.forEach(n => { n.vx += (centerX - n.x) * centeringStrength; n.vy += (centerY - n.y) * centeringStrength; }); layoutNodes.forEach(n => { n.vx *= 0.6; n.vy *= 0.6; n.x += n.vx; n.y += n.vy; n.vx = 0; n.vy = 0; }); } layoutNodesRef.current = layoutNodes; }, [graphData]); const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, rect.width, rect.height); const { edges } = graphData; const nodes = layoutNodesRef.current; const nodeMap = new Map(); nodes.forEach(n => nodeMap.set(n.id, n)); edges.forEach(edge => { const src = nodeMap.get(edge.source); const tgt = nodeMap.get(edge.target); if (!src || !tgt) return; ctx.beginPath(); ctx.moveTo(src.x, src.y); ctx.lineTo(tgt.x, tgt.y); ctx.strokeStyle = 'rgba(148, 163, 184, 0.5)'; ctx.lineWidth = Math.max(0.5, edge.weight * 2); ctx.stroke(); if (edge.label && edge.label.length < 20) { const mx = (src.x + tgt.x) / 2; const my = (src.y + tgt.y) / 2; ctx.fillStyle = '#94a3b8'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(edge.label, mx, my - 4); } }); // 预计算选中节点的邻居集合 const highlightedNeighbors = new Set(); if (selectedNodeId) { highlightedNeighbors.add(selectedNodeId); edges.forEach(edge => { if (edge.source === selectedNodeId) highlightedNeighbors.add(edge.target); if (edge.target === selectedNodeId) highlightedNeighbors.add(edge.source); }); } nodes.forEach(node => { const r = Math.max(6, node.size * 1.2); const color = GROUP_COLORS[node.group] || '#A9A9A9'; const isSelected = node.id === selectedNodeId; const isNeighbor = selectedNodeId && highlightedNeighbors.has(node.id) && !isSelected; const isDimmed = selectedNodeId && !highlightedNeighbors.has(node.id); const isHovered = hoveredNodeRef.current === node.id; // 高亮光晕 if (isSelected || isHovered) { ctx.beginPath(); ctx.arc(node.x, node.y, r + 10, 0, 2 * Math.PI); ctx.fillStyle = isSelected ? color + '50' : color + '25'; ctx.fill(); } if (isNeighbor) { ctx.beginPath(); ctx.arc(node.x, node.y, r + 5, 0, 2 * Math.PI); ctx.fillStyle = color + '20'; ctx.fill(); } // 节点本体 const alpha = isDimmed ? '40' : ''; ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 2 * Math.PI); ctx.fillStyle = isSelected ? color : (isDimmed ? color + '30' : color); ctx.fill(); if (isSelected) { ctx.strokeStyle = '#FFD700'; ctx.lineWidth = 3; } else if (isHovered) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; } else { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; } ctx.stroke(); const label = node.label.length > 8 ? node.label.slice(0, 8) + '..' : node.label; ctx.fillStyle = isDimmed ? 'rgba(148,163,184,0.3)' : '#e2e8f0'; ctx.font = `${isSelected ? 'bold ' : ''}${Math.max(10, node.size * 0.8)}px sans-serif`; ctx.textAlign = 'center'; ctx.fillText(label, node.x, node.y + r + 12); }); const legendX = 16; let legendY = 16; ctx.fillStyle = 'rgba(15, 23, 42, 0.85)'; ctx.fillRect(legendX - 8, legendY - 8, 110, 160); ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#e2e8f0'; ctx.fillText('节点类型', legendX + 4, legendY + 8); legendY += 24; Object.entries(GROUP_COLORS).forEach(([group, color]) => { ctx.beginPath(); ctx.arc(legendX + 4, legendY, 6, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); ctx.fillStyle = '#cbd5e1'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(group === 'document' ? '文档' : group === 'concept' ? '概念' : group === 'entity' ? '实体' : group === 'attribute' ? '属性' : '关系', legendX + 16, legendY + 4); legendY += 22; }); }, [graphData, selectedNodeId]); // 鼠标事件:拖拽节点 + 点击高亮 const getCanvasPos = useCallback((e: React.MouseEvent): { x: number; y: number } => { const canvas = canvasRef.current; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }, []); const findNodeAt = useCallback((canvasX: number, canvasY: number): LayoutNode | null => { const nodes = layoutNodesRef.current; for (let i = nodes.length - 1; i >= 0; i--) { const node = nodes[i]; const r = Math.max(6, node.size * 1.2) + 6; const dx = canvasX - node.x; const dy = canvasY - node.y; if (dx * dx + dy * dy <= r * r) return node; } return null; }, []); const handleMouseDown = useCallback((e: React.MouseEvent) => { const pos = getCanvasPos(e); const node = findNodeAt(pos.x, pos.y); if (node) { setDraggedNodeId(node.id); dragStartRef.current = { x: pos.x, y: pos.y, nodeX: node.x, nodeY: node.y }; } }, [getCanvasPos, findNodeAt]); const handleMouseMove = useCallback((e: React.MouseEvent) => { const pos = getCanvasPos(e); if (draggedNodeId && dragStartRef.current) { const dx = pos.x - dragStartRef.current.x; const dy = pos.y - dragStartRef.current.y; const nodes = layoutNodesRef.current; const node = nodes.find(n => n.id === draggedNodeId); if (node) { node.x = dragStartRef.current.nodeX + dx; node.y = dragStartRef.current.nodeY + dy; } setTooltip(null); return; } const hoveredNode = findNodeAt(pos.x, pos.y); hoveredNodeRef.current = hoveredNode?.id || null; if (hoveredNode) { setTooltip({ x: pos.x, y: pos.y, label: hoveredNode.label, type: hoveredNode.type, group: hoveredNode.group }); } else { setTooltip(null); } }, [getCanvasPos, draggedNodeId, findNodeAt]); const handleMouseUp = useCallback((e: React.MouseEvent) => { if (draggedNodeId && dragStartRef.current) { const pos = getCanvasPos(e); const dx = pos.x - dragStartRef.current.x; const dy = pos.y - dragStartRef.current.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 4) { // 几乎没移动 = 点击 setSelectedNodeId(prev => prev === draggedNodeId ? null : draggedNodeId); } } setDraggedNodeId(null); dragStartRef.current = null; }, [draggedNodeId, getCanvasPos]); const handleCanvasClick = useCallback((e: React.MouseEvent) => { const pos = getCanvasPos(e); const node = findNodeAt(pos.x, pos.y); if (!node) { setSelectedNodeId(null); } }, [getCanvasPos, findNodeAt]); useEffect(() => { if (layoutNodesRef.current.length === 0) { initLayout(); } const animate = () => { draw(); animationRef.current = requestAnimationFrame(animate); }; animate(); return () => cancelAnimationFrame(animationRef.current); }, [initLayout, draw]); return (
e.stopPropagation()}>

知识图谱可视化

{graphData.stats.total_nodes} 节点 {graphData.stats.total_edges} 边 {graphData.stats.document_count} 文档
{tooltip && !draggedNodeId && (
{tooltip.label}
类型: {tooltip.group === 'document' ? '文档' : tooltip.group === 'concept' ? '概念' : tooltip.group === 'entity' ? '实体' : tooltip.group === 'attribute' ? '属性' : tooltip.group === 'relation' ? '关系' : tooltip.group}
)} {selectedNodeId && (() => { const selNode = layoutNodesRef.current.find(n => n.id === selectedNodeId); if (!selNode) return null; const neighbors = graphData.edges .filter(e => e.source === selectedNodeId || e.target === selectedNodeId) .map(e => { const neighborId = e.source === selectedNodeId ? e.target : e.source; const node = layoutNodesRef.current.find(n => n.id === neighborId); return { label: e.label || '', nodeName: node?.label || neighborId }; }); return (
{selNode.label}
类型: {selNode.group === 'document' ? '文档' : selNode.group === 'concept' ? '概念' : selNode.group === 'entity' ? '实体' : selNode.group === 'attribute' ? '属性' : selNode.group === 'relation' ? '关系' : selNode.group}
{neighbors.length > 0 && (
关联节点 ({neighbors.length})
{neighbors.map((n, i) => (
{n.label ? `[${n.label}]` : ''} {n.nodeName}
))}
)}
); })()}
); }; // ===== Modal for creating knowledge base ===== const CreateKBModal: React.FC<{ onClose: () => void; onCreate: (name: string, desc: string) => Promise }> = ({ onClose, onCreate }) => { const [name, setName] = useState(''); const [desc, setDesc] = useState(''); const [loading, setLoading] = useState(false); const handleSubmit = async () => { if (!name.trim()) return; setLoading(true); try { await onCreate(name.trim(), desc.trim()); onClose(); } catch (e) { alert('创建失败:' + (e as Error).message); } finally { setLoading(false); } }; return (
e.stopPropagation()}>

创建知识库

setName(e.target.value)} autoFocus />