/** * MemoryGraph - 记忆图谱可视化组件 * * 使用 Canvas 实现力导向图布局,展示记忆之间的关联关系。 * * 功能: * - 力导向布局算法 * - 节点拖拽 * - 类型筛选 * - 搜索高亮 * - 导出图片 */ import { useCallback, useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { ZoomIn, ZoomOut, Maximize2, Download, Search, Filter, X, RefreshCw, Tag, Clock, Star, } from 'lucide-react'; import { Button, Badge } from './ui'; import { useMemoryGraphStore, type GraphNode, type GraphEdge, type MemoryType, } from '../store/memoryGraphStore'; import { useConversationStore } from '../store/chat/conversationStore'; import { cardHover, defaultTransition } from '../lib/animations'; // Mark as intentionally unused for future use void cardHover; void defaultTransition; // === Constants === const NODE_COLORS: Record = { fact: { fill: '#3b82f6', stroke: '#1d4ed8', text: '#ffffff' }, preference: { fill: '#f59e0b', stroke: '#d97706', text: '#ffffff' }, lesson: { fill: '#10b981', stroke: '#059669', text: '#ffffff' }, context: { fill: '#8b5cf6', stroke: '#7c3aed', text: '#ffffff' }, task: { fill: '#ef4444', stroke: '#dc2626', text: '#ffffff' }, }; const TYPE_LABELS: Record = { fact: '事实', preference: '偏好', lesson: '经验', context: '上下文', task: '任务', }; const NODE_RADIUS = 20; const REPULSION_STRENGTH = 5000; const ATTRACTION_STRENGTH = 0.01; const DAMPING = 0.9; const MIN_VELOCITY = 0.01; // === Force-Directed Layout === function simulateStep( nodes: GraphNode[], edges: GraphEdge[], width: number, height: number ): GraphNode[] { const updatedNodes = nodes.map(node => ({ ...node })); // 斥力 (节点间) for (let i = 0; i < updatedNodes.length; i++) { for (let j = i + 1; j < updatedNodes.length; j++) { const n1 = updatedNodes[i]; const n2 = updatedNodes[j]; const dx = n2.x - n1.x; const dy = n2.y - n1.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = REPULSION_STRENGTH / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; n1.vx -= fx; n1.vy -= fy; n2.vx += fx; n2.vy += fy; } } // 引力 (边) for (const edge of edges) { const source = updatedNodes.find(n => n.id === edge.source); const target = updatedNodes.find(n => n.id === edge.target); if (!source || !target) continue; const dx = target.x - source.x; const dy = target.y - source.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = dist * ATTRACTION_STRENGTH * edge.strength; const fx = (dx / dist) * force; const fy = (dy / dist) * force; source.vx += fx; source.vy += fy; target.vx -= fx; target.vy -= fy; } // 中心引力 const centerX = width / 2; const centerY = height / 2; const centerForce = 0.001; for (const node of updatedNodes) { node.vx += (centerX - node.x) * centerForce; node.vy += (centerY - node.y) * centerForce; } // 更新位置 for (const node of updatedNodes) { node.vx *= DAMPING; node.vy *= DAMPING; if (Math.abs(node.vx) < MIN_VELOCITY) node.vx = 0; if (Math.abs(node.vy) < MIN_VELOCITY) node.vy = 0; node.x += node.vx; node.y += node.vy; // 边界约束 node.x = Math.max(NODE_RADIUS, Math.min(width - NODE_RADIUS, node.x)); node.y = Math.max(NODE_RADIUS, Math.min(height - NODE_RADIUS, node.y)); } return updatedNodes; } // === Main Component === interface MemoryGraphProps { className?: string; } export function MemoryGraph({ className = '' }: MemoryGraphProps) { const canvasRef = useRef(null); const animationRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [dragNode, setDragNode] = useState(null); const [showFilters, setShowFilters] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const currentAgent = useConversationStore((s) => s.currentAgent); const agentId = currentAgent?.id || 'zclaw-main'; const { isLoading, error, filter, layout, selectedNodeId, showLabels, simulationRunning, loadGraph, setFilter, resetFilter, setLayout, selectNode, toggleLabels, startSimulation, stopSimulation, updateNodePositions, highlightSearch, getFilteredNodes, getFilteredEdges, } = useMemoryGraphStore(); const filteredNodes = getFilteredNodes(); const filteredEdges = getFilteredEdges(); // 加载图谱 useEffect(() => { loadGraph(agentId); }, [agentId, loadGraph]); // 力导向模拟 useEffect(() => { if (!simulationRunning || filteredNodes.length === 0) return; const canvas = canvasRef.current; if (!canvas) return; const simulate = () => { const updated = simulateStep(filteredNodes, filteredEdges, layout.width, layout.height); updateNodePositions(updated.map(n => ({ id: n.id, x: n.x, y: n.y }))); animationRef.current = requestAnimationFrame(simulate); }; animationRef.current = requestAnimationFrame(simulate); return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, [simulationRunning, filteredNodes.length, filteredEdges, layout.width, layout.height, updateNodePositions]); // Canvas 渲染 useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; canvas.width = layout.width * dpr; canvas.height = layout.height * dpr; ctx.scale(dpr, dpr); // 清空画布 - 使用浅色背景匹配系统主题 ctx.fillStyle = '#f9fafb'; // gray-50 ctx.fillRect(0, 0, layout.width, layout.height); // 应用变换 ctx.save(); ctx.translate(layout.offsetX, layout.offsetY); ctx.scale(layout.zoom, layout.zoom); // 绘制边 for (const edge of filteredEdges) { const source = filteredNodes.find(n => n.id === edge.source); const target = filteredNodes.find(n => n.id === edge.target); if (!source || !target) continue; ctx.beginPath(); ctx.moveTo(source.x, source.y); ctx.lineTo(target.x, target.y); ctx.strokeStyle = edge.type === 'reference' ? 'rgba(59, 130, 246, 0.5)' : edge.type === 'related' ? 'rgba(245, 158, 11, 0.3)' : 'rgba(139, 92, 246, 0.2)'; ctx.lineWidth = edge.strength * 3; ctx.stroke(); } // 绘制节点 for (const node of filteredNodes) { const colors = NODE_COLORS[node.type]; const isSelected = node.id === selectedNodeId; const radius = isSelected ? NODE_RADIUS * 1.3 : NODE_RADIUS; // 高亮效果 if (node.isHighlighted) { ctx.beginPath(); ctx.arc(node.x, node.y, radius + 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; ctx.fill(); } // 节点圆形 ctx.beginPath(); ctx.arc(node.x, node.y, radius, 0, Math.PI * 2); ctx.fillStyle = colors.fill; ctx.fill(); ctx.strokeStyle = colors.stroke; ctx.lineWidth = isSelected ? 3 : 1; ctx.stroke(); // 节点标签 if (showLabels) { ctx.font = '10px Inter, system-ui, sans-serif'; ctx.fillStyle = colors.text; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const text = node.label.slice(0, 10); ctx.fillText(text, node.x, node.y); } } ctx.restore(); // 图例 const legendY = 20; let legendX = 20; ctx.font = '12px Inter, system-ui, sans-serif'; for (const [type, label] of Object.entries(TYPE_LABELS)) { const colors = NODE_COLORS[type as MemoryType]; ctx.beginPath(); ctx.arc(legendX, legendY, 6, 0, Math.PI * 2); ctx.fillStyle = colors.fill; ctx.fill(); ctx.fillStyle = '#6b7280'; // gray-500 for better visibility on light background ctx.textAlign = 'left'; ctx.fillText(label, legendX + 12, legendY + 4); legendX += 70; } }, [filteredNodes, filteredEdges, layout, selectedNodeId, showLabels]); // 鼠标事件处理 const handleMouseDown = useCallback((e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left - layout.offsetX) / layout.zoom; const y = (e.clientY - rect.top - layout.offsetY) / layout.zoom; // 检查是否点击了节点 for (const node of filteredNodes) { const dx = x - node.x; const dy = y - node.y; if (dx * dx + dy * dy < NODE_RADIUS * NODE_RADIUS) { setDragNode(node.id); setIsDragging(true); selectNode(node.id); return; } } selectNode(null); }, [filteredNodes, layout, selectNode]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!isDragging || !dragNode) return; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left - layout.offsetX) / layout.zoom; const y = (e.clientY - rect.top - layout.offsetY) / layout.zoom; updateNodePositions([{ id: dragNode, x, y }]); }, [isDragging, dragNode, layout, updateNodePositions]); const handleMouseUp = useCallback(() => { setIsDragging(false); setDragNode(null); }, []); const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.max(0.2, Math.min(3, layout.zoom * delta)); setLayout({ zoom: newZoom }); }, [layout.zoom, setLayout]); const handleSearch = useCallback((query: string) => { setSearchQuery(query); highlightSearch(query); }, [highlightSearch]); const handleExport = useCallback(async () => { const canvas = canvasRef.current; if (!canvas) return; const dataUrl = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = dataUrl; a.download = `memory-graph-${new Date().toISOString().slice(0, 10)}.png`; a.click(); }, []); const selectedNode = filteredNodes.find(n => n.id === selectedNodeId); return (
{/* 工具栏 */}
{/* 搜索框 */}
handleSearch(e.target.value)} className="w-full pl-8 pr-2 py-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-orange-400 dark:focus:border-blue-500" />
{/* 筛选按钮 */} {/* 缩放控制 */}
{Math.round(layout.zoom * 100)}%
{/* 模拟控制 */}
{/* 导出 */} {/* 标签切换 */}
{/* 筛选面板 */} {showFilters && (
{/* 类型筛选 */}
类型: {(Object.keys(TYPE_LABELS) as MemoryType[]).map(type => ( ))}
{/* 重要性筛选 */}
重要性: setFilter({ minImportance: parseInt(e.target.value) })} className="w-20" /> {filter.minImportance}+
{/* 重置 */}
)}
{/* 图谱画布 */}
{isLoading && (
)} {error && (
{error}
)} {/* 节点详情面板 */} {selectedNode && (
{TYPE_LABELS[selectedNode.type]}

{selectedNode.label}

重要性: {selectedNode.importance}
访问次数: {selectedNode.accessCount}
创建: {new Date(selectedNode.createdAt).toLocaleDateString()}
{/* 关联边统计 */}
关联记忆:
{filteredEdges.filter( e => e.source === selectedNode.id || e.target === selectedNode.id ).length} 个
)}
{/* 空状态 */} {!isLoading && filteredNodes.length === 0 && (

暂无记忆数据

开始对话后将自动记录记忆

)}
{/* 状态栏 */}
节点: {filteredNodes.length} 关联: {filteredEdges.length}
{simulationRunning && ( 布局中... )}
); } export default MemoryGraph;