'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import { GraphCanvas, GraphCanvasRef, type Theme, type GraphNode as ReagraphNode, type GraphEdge as ReagraphEdge, type InternalGraphNode } from 'reagraph' import { useMissionControl } from '@/store' // --- Data interfaces (match API response) --- interface AgentFileInfo { path: string chunks: number textSize: number } interface AgentGraphData { name: string dbSize: number totalChunks: number totalFiles: number files: AgentFileInfo[] } // --- Obsidian-inspired palette (muted purples, warm grays) --- const AGENT_COLORS = [ '#b4befe', // lavender '#cba6f7', // mauve '#f5c2e7', // pink '#89b4fa', // blue '#74c7ec', // sapphire '#89dceb', // sky '#94e2d5', // teal '#a6e3a1', // green '#f9e2af', // yellow '#fab387', // peach '#eba0ac', // maroon '#f38ba8', // red '#cdd6f4', // text '#bac2de', // subtext1 '#a6adc8', // subtext0 '#b4befe', // lavender2 '#cba6f7', // mauve2 ] function getFileColor(filePath: string): string { if (filePath.startsWith('sessions/') || filePath.includes('/sessions/')) return '#89dceb' if (filePath.startsWith('memory/') || filePath.includes('/memory/')) return '#94e2d5' if (filePath.startsWith('knowledge') || filePath.includes('/knowledge')) return '#b4befe' if (filePath.endsWith('.md')) return '#f9e2af' if (filePath.endsWith('.json') || filePath.endsWith('.jsonl')) return '#cba6f7' return '#89b4fa' } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] } // --- Obsidian graph theme --- const obsidianTheme: Theme = { canvas: { background: '#11111b', fog: '#11111b', }, node: { fill: '#6c7086', activeFill: '#cba6f7', opacity: 1, selectedOpacity: 1, inactiveOpacity: 0.1, label: { color: '#cdd6f4', stroke: '#11111b', activeColor: '#f5f5f7', }, }, ring: { fill: '#6c7086', activeFill: '#cba6f7', }, edge: { fill: '#45475a', activeFill: '#cba6f7', opacity: 0.15, selectedOpacity: 0.5, inactiveOpacity: 0.03, label: { color: '#6c7086', activeColor: '#cdd6f4', }, }, arrow: { fill: '#45475a', activeFill: '#cba6f7', }, lasso: { background: 'rgba(203, 166, 247, 0.08)', border: 'rgba(203, 166, 247, 0.25)', }, } // --- Component --- export function MemoryGraph() { const t = useTranslations('memoryGraph') const { memoryGraphAgents, setMemoryGraphAgents } = useMissionControl() const agents = memoryGraphAgents || [] const [selectedAgent, setSelectedAgent] = useState('all') const [isLoading, setIsLoading] = useState(memoryGraphAgents === null) const [error, setError] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [selectedFile, setSelectedFile] = useState(null) const [actives, setActives] = useState([]) const [hoveredNode, setHoveredNode] = useState<{ label: string; sub?: string } | null>(null) const graphRef = useRef(null) // Fetch data const fetchData = useCallback(async () => { setIsLoading(true) setError(null) try { const res = await fetch('/api/memory/graph?agent=all') if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || `HTTP ${res.status}`) } const data = await res.json() setMemoryGraphAgents(data.agents || []) } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Failed to load') } finally { setIsLoading(false) } }, [setMemoryGraphAgents]) useEffect(() => { if (memoryGraphAgents !== null) return fetchData() }, [fetchData, memoryGraphAgents]) // Stats const stats = useMemo(() => { const totalAgents = agents.length const totalFiles = agents.reduce((s, a) => s + a.totalFiles, 0) const totalChunks = agents.reduce((s, a) => s + a.totalChunks, 0) const totalSize = agents.reduce((s, a) => s + a.dbSize, 0) return { totalAgents, totalFiles, totalChunks, totalSize } }, [agents]) // Build reagraph nodes/edges from API data const { graphNodes, graphEdges } = useMemo(() => { if (!agents.length) return { graphNodes: [], graphEdges: [] } const nodes: ReagraphNode[] = [] const edges: ReagraphEdge[] = [] if (selectedAgent === 'all') { agents.forEach((agent, i) => { const color = AGENT_COLORS[i % AGENT_COLORS.length] const hubSize = Math.max(5, Math.min(15, 4 + Math.sqrt(agent.totalChunks) * 0.8)) nodes.push({ id: `hub-${agent.name}`, label: agent.name, fill: color, size: hubSize, }) const maxFiles = 25 const files = agent.files.slice(0, maxFiles) files.forEach((file, fi) => { const fileSize = Math.max(1.5, Math.min(5, 1 + Math.sqrt(file.chunks) * 0.6)) const fileColor = getFileColor(file.path) const nodeId = `file-${agent.name}-${fi}` nodes.push({ id: nodeId, label: '', fill: fileColor, size: fileSize, data: { filePath: file.path, chunks: file.chunks, textSize: file.textSize, agentName: agent.name }, }) edges.push({ id: `edge-hub-${agent.name}-${nodeId}`, source: `hub-${agent.name}`, target: nodeId, fill: color, }) }) }) } else { const agent = agents.find((a) => a.name === selectedAgent) if (!agent) return { graphNodes: [], graphEdges: [] } const agentIdx = agents.indexOf(agent) const color = AGENT_COLORS[agentIdx % AGENT_COLORS.length] const hubSize = Math.max(6, Math.min(18, 5 + Math.sqrt(agent.totalChunks) * 0.8)) nodes.push({ id: `hub-${agent.name}`, label: agent.name, fill: color, size: hubSize, }) let files = agent.files if (searchQuery) { const q = searchQuery.toLowerCase() files = files.filter((f) => f.path.toLowerCase().includes(q)) } const maxFiles = 120 const displayFiles = files.slice(0, maxFiles) displayFiles.forEach((file, fi) => { const fileSize = Math.max(2, Math.min(8, 2 + Math.sqrt(file.chunks) * 0.8)) const fileColor = getFileColor(file.path) const nodeId = `file-${agent.name}-${fi}` nodes.push({ id: nodeId, label: file.path.split('/').pop() || file.path, fill: fileColor, size: fileSize, data: { filePath: file.path, chunks: file.chunks, textSize: file.textSize, agentName: agent.name }, }) edges.push({ id: `edge-hub-${agent.name}-${nodeId}`, source: `hub-${agent.name}`, target: nodeId, fill: color, }) }) // Weak inter-file edges for same-directory clustering const dirMap = new Map() displayFiles.forEach((file, fi) => { const dir = file.path.split('/').slice(0, -1).join('/') if (!dir) return const nodeId = `file-${agent.name}-${fi}` if (!dirMap.has(dir)) dirMap.set(dir, []) dirMap.get(dir)!.push(nodeId) }) for (const ids of dirMap.values()) { for (let i = 0; i < ids.length - 1 && i < 5; i++) { edges.push({ id: `edge-dir-${ids[i]}-${ids[i + 1]}`, source: ids[i], target: ids[i + 1], }) } } } return { graphNodes: nodes, graphEdges: edges } }, [agents, selectedAgent, searchQuery]) // Auto-fit the graph after layout settles (nodes change) useEffect(() => { if (!graphNodes.length) return // reagraph force layout needs time to settle before fitNodesInView works const t1 = setTimeout(() => graphRef.current?.fitNodesInView(undefined, { animated: false }), 800) const t2 = setTimeout(() => graphRef.current?.fitNodesInView(undefined, { animated: false }), 2500) const t3 = setTimeout(() => graphRef.current?.fitNodesInView(undefined, { animated: false }), 5000) const t4 = setTimeout(() => graphRef.current?.fitNodesInView(undefined, { animated: false }), 8000) return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4) } }, [graphNodes.length, selectedAgent]) // Navigation helpers const goBack = useCallback(() => { setSelectedAgent('all') setSelectedFile(null) setSearchQuery('') setActives([]) setHoveredNode(null) }, []) const drillInto = useCallback((agentName: string) => { setSelectedAgent(agentName) setSelectedFile(null) setSearchQuery('') setActives([]) setHoveredNode(null) }, []) // Interaction handlers const handleNodeClick = useCallback((node: InternalGraphNode) => { const id = node.id if (id.startsWith('hub-') && selectedAgent === 'all') { drillInto(id.replace('hub-', '')) } else if (id.startsWith('hub-') && selectedAgent !== 'all') { // clicking the hub in drilled-in view goes back goBack() } else if (id.startsWith('file-') && node.data) { const { filePath, chunks, textSize } = node.data as { filePath: string; chunks: number; textSize: number } setSelectedFile({ path: filePath, chunks, textSize }) } }, [selectedAgent, drillInto, goBack]) const handleNodeHover = useCallback((node: InternalGraphNode) => { setActives([node.id]) if (node.data) { const d = node.data as { filePath: string; chunks: number; textSize: number; agentName: string } setHoveredNode({ label: d.filePath, sub: `${d.chunks} chunks / ${formatBytes(d.textSize)}` }) } else if (node.id.startsWith('hub-')) { const name = node.id.replace('hub-', '') const agent = agents.find(a => a.name === name) if (agent) { setHoveredNode({ label: agent.name, sub: `${agent.totalChunks} chunks / ${agent.totalFiles} files / ${formatBytes(agent.dbSize)}` }) } } }, [agents]) const handleNodeUnhover = useCallback(() => { setActives([]) setHoveredNode(null) }, []) const handleCanvasClick = useCallback(() => { setActives([]) setSelectedFile(null) setHoveredNode(null) }, []) // --- Render --- if (isLoading) { return (
{t('loading')}
) } if (error) { return (
{error}
) } if (!agents.length) { return (
{t('noMemoryDatabases')} {t('noMemoryDatabasesHint')}
) } const activeAgent = selectedAgent !== 'all' ? agents.find(a => a.name === selectedAgent) : null return (
{/* Full-bleed graph canvas */} {/* Floating breadcrumb / navigation bar (top-left) */}
{activeAgent && ( <> / {activeAgent.name} )}
{/* Floating stats (top-right) */}
{selectedAgent !== 'all' && ( setSearchQuery(e.target.value)} placeholder={t('filterFiles')} className="px-2.5 py-1 text-[11px] font-mono rounded-md bg-[#1e1e2e]/80 backdrop-blur-xl border border-[#45475a]/50 text-[#cdd6f4] placeholder-[#45475a] focus:outline-none focus:border-[#cba6f7]/40 w-36 transition-colors" /> )}
{/* Hover tooltip (bottom-center) */} {hoveredNode && (
{hoveredNode.label}
{hoveredNode.sub && (
{hoveredNode.sub}
)}
)} {/* Selected file detail panel (bottom-left) */} {selectedFile && (

{selectedFile.path}

{selectedFile.chunks} {t('chunks')} {formatBytes(selectedFile.textSize)} {t('text')}
)} {/* Color legend (bottom-right) */}
{t('legendSessions')} {t('legendMemory')} {t('legendKnowledge')} .md .json
{/* Keyboard hint */}
{t('keyboardHint')}
) } function StatChip({ label, value }: { label: string; value: number | string }) { const display = typeof value === 'number' ? value.toLocaleString() : value return ( {display} {label} ) } function Sep() { return | }