memory-graph-view.tsx
1 'use client' 2 3 import { useEffect, useRef, useState, useCallback } from 'react' 4 import { api } from '@/lib/app/api-client' 5 import { useAppStore } from '@/stores/use-app-store' 6 7 interface Node { 8 id: string 9 title: string 10 category: string 11 agentId?: string | null 12 x: number 13 y: number 14 vx: number 15 vy: number 16 } 17 18 interface Link { 19 source: string 20 target: string 21 type: string 22 } 23 24 /** Kinetic energy threshold — stop simulation when total energy drops below this. */ 25 const SETTLE_THRESHOLD = 0.5 26 27 export function MemoryGraphView() { 28 const [initialData, setInitialData] = useState<{ nodes: Node[]; links: Link[] } | null>(null) 29 const [loading, setLoading] = useState(true) 30 const [hoveredNode, setHoveredNode] = useState<string | null>(null) 31 const containerRef = useRef<HTMLDivElement>(null) 32 const svgRef = useRef<SVGSVGElement>(null) 33 const requestRef = useRef<number>(null) 34 const nodesRef = useRef<Node[]>([]) 35 const linksRef = useRef<Link[]>([]) 36 37 const selectedMemoryId = useAppStore((s) => s.selectedMemoryId) 38 const setSelectedMemoryId = useAppStore((s) => s.setSelectedMemoryId) 39 const memoryAgentFilter = useAppStore((s) => s.memoryAgentFilter) 40 41 useEffect(() => { 42 async function load() { 43 setLoading(true) 44 try { 45 const url = `/memory/graph${memoryAgentFilter ? `?agentId=${memoryAgentFilter}` : ''}` 46 const res = await api<{ nodes: Node[]; links: Link[] }>('GET', url) 47 48 // Initialize positions 49 const nodes = res.nodes.map(n => ({ 50 ...n, 51 x: Math.random() * 800, 52 y: Math.random() * 600, 53 vx: 0, 54 vy: 0 55 })) 56 57 nodesRef.current = nodes 58 linksRef.current = res.links 59 setInitialData({ nodes, links: res.links }) 60 } catch (err) { 61 console.error('Failed to load memory graph', err) 62 } finally { 63 setLoading(false) 64 } 65 } 66 load() 67 }, [memoryAgentFilter]) 68 69 // Write positions directly to SVG DOM — no React state updates per frame 70 const updateDOM = useCallback(() => { 71 const svg = svgRef.current 72 if (!svg) return 73 const nodes = nodesRef.current 74 75 // Update link positions 76 const lineElements = svg.querySelectorAll<SVGLineElement>('[data-link]') 77 lineElements.forEach((el) => { 78 const srcId = el.getAttribute('data-src') 79 const tgtId = el.getAttribute('data-tgt') 80 const s = nodes.find(n => n.id === srcId) 81 const t = nodes.find(n => n.id === tgtId) 82 if (s && t) { 83 el.setAttribute('x1', String(s.x)) 84 el.setAttribute('y1', String(s.y)) 85 el.setAttribute('x2', String(t.x)) 86 el.setAttribute('y2', String(t.y)) 87 } 88 }) 89 90 // Update node positions 91 const gElements = svg.querySelectorAll<SVGGElement>('[data-node-id]') 92 gElements.forEach((el) => { 93 const id = el.getAttribute('data-node-id') 94 const node = nodes.find(n => n.id === id) 95 if (node) { 96 el.setAttribute('transform', `translate(${node.x},${node.y})`) 97 } 98 }) 99 }, []) 100 101 // Force-directed simulation running in refs, writing to DOM imperatively 102 useEffect(() => { 103 const nodes = nodesRef.current 104 const links = linksRef.current 105 if (nodes.length === 0) return 106 107 const animate = () => { 108 let totalEnergy = 0 109 110 // 1. Repulsion between all nodes 111 for (let i = 0; i < nodes.length; i++) { 112 for (let j = i + 1; j < nodes.length; j++) { 113 const dx = nodes[i].x - nodes[j].x 114 const dy = nodes[i].y - nodes[j].y 115 const distSq = dx * dx + dy * dy + 0.1 116 const force = 400 / distSq 117 const fx = dx * force 118 const fy = dy * force 119 nodes[i].vx += fx 120 nodes[i].vy += fy 121 nodes[j].vx -= fx 122 nodes[j].vy -= fy 123 } 124 } 125 126 // 2. Attraction along links 127 for (const link of links) { 128 const source = nodes.find(n => n.id === link.source) 129 const target = nodes.find(n => n.id === link.target) 130 if (source && target) { 131 const dx = target.x - source.x 132 const dy = target.y - source.y 133 const dist = Math.sqrt(dx * dx + dy * dy) + 0.1 134 const force = (dist - 100) * 0.02 135 const fx = (dx / dist) * force 136 const fy = (dy / dist) * force 137 source.vx += fx 138 source.vy += fy 139 target.vx -= fx 140 target.vy -= fy 141 } 142 } 143 144 // 3. Centering force 145 const cx = 400 146 const cy = 300 147 for (const node of nodes) { 148 node.vx += (cx - node.x) * 0.01 149 node.vy += (cy - node.y) * 0.01 150 } 151 152 // 4. Update positions with damping 153 for (const node of nodes) { 154 node.x += node.vx 155 node.y += node.vy 156 node.vx *= 0.8 157 node.vy *= 0.8 158 totalEnergy += node.vx * node.vx + node.vy * node.vy 159 } 160 161 // Write positions to DOM imperatively 162 updateDOM() 163 164 // Stop when settled 165 if (totalEnergy > SETTLE_THRESHOLD) { 166 requestRef.current = requestAnimationFrame(animate) 167 } 168 } 169 170 requestRef.current = requestAnimationFrame(animate) 171 return () => { 172 if (requestRef.current) cancelAnimationFrame(requestRef.current) 173 } 174 }, [initialData, updateDOM]) 175 176 if (loading) { 177 return ( 178 <div className="flex-1 flex items-center justify-center"> 179 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-bright"></div> 180 </div> 181 ) 182 } 183 184 const nodes = nodesRef.current 185 const links = linksRef.current 186 187 return ( 188 <div ref={containerRef} className="flex-1 relative overflow-hidden bg-black/20 rounded-[16px] border border-white/[0.06]"> 189 <svg ref={svgRef} width="100%" height="100%" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet"> 190 {/* Links */} 191 {links.map((link, i) => { 192 const s = nodes.find(n => n.id === link.source) 193 const t = nodes.find(n => n.id === link.target) 194 if (!s || !t) return null 195 return ( 196 <line 197 key={i} 198 data-link="" 199 data-src={link.source} 200 data-tgt={link.target} 201 x1={s.x} y1={s.y} 202 x2={t.x} y2={t.y} 203 stroke="white" 204 strokeOpacity="0.1" 205 strokeWidth="1" 206 /> 207 ) 208 })} 209 210 {/* Nodes */} 211 {nodes.map(node => ( 212 <g 213 key={node.id} 214 data-node-id={node.id} 215 transform={`translate(${node.x},${node.y})`} 216 onMouseEnter={() => setHoveredNode(node.id)} 217 onMouseLeave={() => setHoveredNode(null)} 218 onClick={() => setSelectedMemoryId(node.id)} 219 className="cursor-pointer" 220 > 221 <circle 222 r={selectedMemoryId === node.id ? 8 : 5} 223 fill={node.category === 'knowledge' ? '#10B981' : '#6366F1'} 224 stroke="white" 225 strokeWidth={selectedMemoryId === node.id ? 2 : 0} 226 className="transition-all" 227 /> 228 {(hoveredNode === node.id || selectedMemoryId === node.id) && ( 229 <text 230 y="-12" 231 textAnchor="middle" 232 className="text-[10px] fill-text font-600 pointer-events-none" 233 style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }} 234 > 235 {node.title} 236 </text> 237 )} 238 </g> 239 ))} 240 </svg> 241 242 {/* Legend */} 243 <div className="absolute bottom-4 left-4 p-3 bg-surface/80 backdrop-blur rounded-[12px] border border-white/[0.06] flex flex-col gap-2"> 244 <div className="flex items-center gap-2"> 245 <div className="w-3 h-3 rounded-full bg-[#10B981]" /> 246 <span className="text-[11px] text-text-3">Knowledge</span> 247 </div> 248 <div className="flex items-center gap-2"> 249 <div className="w-3 h-3 rounded-full bg-[#6366F1]" /> 250 <span className="text-[11px] text-text-3">Note / Working</span> 251 </div> 252 </div> 253 </div> 254 ) 255 }