claude-cortex 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/README.md +36 -0
- package/dashboard/components.json +22 -0
- package/dashboard/eslint.config.mjs +18 -0
- package/dashboard/next.config.ts +7 -0
- package/dashboard/package-lock.json +7784 -0
- package/dashboard/package.json +42 -0
- package/dashboard/postcss.config.mjs +7 -0
- package/dashboard/public/file.svg +1 -0
- package/dashboard/public/globe.svg +1 -0
- package/dashboard/public/next.svg +1 -0
- package/dashboard/public/vercel.svg +1 -0
- package/dashboard/public/window.svg +1 -0
- package/dashboard/src/app/favicon.ico +0 -0
- package/dashboard/src/app/globals.css +125 -0
- package/dashboard/src/app/layout.tsx +35 -0
- package/dashboard/src/app/page.tsx +338 -0
- package/dashboard/src/components/Providers.tsx +27 -0
- package/dashboard/src/components/brain/ActivityPulseSystem.tsx +229 -0
- package/dashboard/src/components/brain/BrainMesh.tsx +118 -0
- package/dashboard/src/components/brain/BrainRegions.tsx +254 -0
- package/dashboard/src/components/brain/BrainScene.tsx +255 -0
- package/dashboard/src/components/brain/CategoryLabels.tsx +103 -0
- package/dashboard/src/components/brain/CoreSphere.tsx +215 -0
- package/dashboard/src/components/brain/DataFlowParticles.tsx +123 -0
- package/dashboard/src/components/brain/DataStreamRings.tsx +161 -0
- package/dashboard/src/components/brain/ElectronFlow.tsx +323 -0
- package/dashboard/src/components/brain/HolographicGrid.tsx +235 -0
- package/dashboard/src/components/brain/MemoryLinks.tsx +271 -0
- package/dashboard/src/components/brain/MemoryNode.tsx +245 -0
- package/dashboard/src/components/brain/NeuralPathways.tsx +441 -0
- package/dashboard/src/components/brain/SynapseNodes.tsx +306 -0
- package/dashboard/src/components/brain/TimelineControls.tsx +205 -0
- package/dashboard/src/components/chip/ChipScene.tsx +497 -0
- package/dashboard/src/components/chip/ChipSubstrate.tsx +238 -0
- package/dashboard/src/components/chip/CortexCore.tsx +210 -0
- package/dashboard/src/components/chip/DataBus.tsx +416 -0
- package/dashboard/src/components/chip/MemoryCell.tsx +225 -0
- package/dashboard/src/components/chip/MemoryGrid.tsx +328 -0
- package/dashboard/src/components/chip/QuantumCell.tsx +316 -0
- package/dashboard/src/components/chip/SectionLabel.tsx +113 -0
- package/dashboard/src/components/chip/index.ts +14 -0
- package/dashboard/src/components/controls/ControlPanel.tsx +100 -0
- package/dashboard/src/components/dashboard/StatsPanel.tsx +164 -0
- package/dashboard/src/components/debug/ActivityLog.tsx +238 -0
- package/dashboard/src/components/debug/DebugPanel.tsx +101 -0
- package/dashboard/src/components/debug/QueryTester.tsx +192 -0
- package/dashboard/src/components/debug/RelationshipGraph.tsx +403 -0
- package/dashboard/src/components/debug/SqlConsole.tsx +313 -0
- package/dashboard/src/components/memory/MemoryDetail.tsx +325 -0
- package/dashboard/src/components/ui/button.tsx +62 -0
- package/dashboard/src/components/ui/card.tsx +92 -0
- package/dashboard/src/components/ui/input.tsx +21 -0
- package/dashboard/src/hooks/useDebouncedValue.ts +24 -0
- package/dashboard/src/hooks/useMemories.ts +276 -0
- package/dashboard/src/hooks/useSuggestions.ts +46 -0
- package/dashboard/src/lib/category-colors.ts +84 -0
- package/dashboard/src/lib/position-algorithm.ts +177 -0
- package/dashboard/src/lib/simplex-noise.ts +217 -0
- package/dashboard/src/lib/store.ts +88 -0
- package/dashboard/src/lib/utils.ts +6 -0
- package/dashboard/src/lib/websocket.ts +216 -0
- package/dashboard/src/types/memory.ts +73 -0
- package/dashboard/tsconfig.json +34 -0
- package/dist/api/control.d.ts +27 -0
- package/dist/api/control.d.ts.map +1 -0
- package/dist/api/control.js +60 -0
- package/dist/api/control.js.map +1 -0
- package/dist/api/visualization-server.d.ts.map +1 -1
- package/dist/api/visualization-server.js +109 -2
- package/dist/api/visualization-server.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +80 -4
- package/dist/index.js.map +1 -1
- package/dist/memory/store.d.ts +6 -0
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.js +14 -0
- package/dist/memory/store.js.map +1 -1
- package/package.json +7 -3
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Debug Panel Component
|
|
5
|
+
*
|
|
6
|
+
* Collapsible bottom panel with tabbed interface for debug tools:
|
|
7
|
+
* - Memory Detail (enhanced)
|
|
8
|
+
* - Query Tester
|
|
9
|
+
* - Activity Log
|
|
10
|
+
* - Relationship Graph
|
|
11
|
+
* - SQL Console
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
import { QueryTester } from './QueryTester';
|
|
16
|
+
import { ActivityLog } from './ActivityLog';
|
|
17
|
+
import { RelationshipGraph } from './RelationshipGraph';
|
|
18
|
+
import { SqlConsole } from './SqlConsole';
|
|
19
|
+
|
|
20
|
+
type TabId = 'detail' | 'query' | 'activity' | 'graph' | 'sql';
|
|
21
|
+
|
|
22
|
+
interface Tab {
|
|
23
|
+
id: TabId;
|
|
24
|
+
label: string;
|
|
25
|
+
icon: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TABS: Tab[] = [
|
|
29
|
+
{ id: 'query', label: 'Query', icon: '🔍' },
|
|
30
|
+
{ id: 'activity', label: 'Activity', icon: '📋' },
|
|
31
|
+
{ id: 'graph', label: 'Graph', icon: '🕸' },
|
|
32
|
+
{ id: 'sql', label: 'SQL', icon: '💾' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
interface DebugPanelProps {
|
|
36
|
+
onCollapse?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function DebugPanel({ onCollapse }: DebugPanelProps) {
|
|
40
|
+
const [activeTab, setActiveTab] = useState<TabId>('query');
|
|
41
|
+
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
42
|
+
|
|
43
|
+
const handleCollapse = () => {
|
|
44
|
+
setIsCollapsed(!isCollapsed);
|
|
45
|
+
onCollapse?.();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (isCollapsed) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="h-10 border-t border-slate-700 bg-slate-900/80 flex items-center px-4">
|
|
51
|
+
<button
|
|
52
|
+
onClick={handleCollapse}
|
|
53
|
+
className="flex items-center gap-2 text-sm text-slate-400 hover:text-white"
|
|
54
|
+
>
|
|
55
|
+
<span>▲</span>
|
|
56
|
+
<span>Debug Panel</span>
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="h-96 border-t border-slate-700 bg-slate-900/80 flex flex-col">
|
|
64
|
+
{/* Tab Bar */}
|
|
65
|
+
<div className="flex items-center border-b border-slate-700 px-2">
|
|
66
|
+
{TABS.map((tab) => (
|
|
67
|
+
<button
|
|
68
|
+
key={tab.id}
|
|
69
|
+
onClick={() => setActiveTab(tab.id)}
|
|
70
|
+
className={`px-4 py-2 text-sm flex items-center gap-1.5 border-b-2 transition-colors ${
|
|
71
|
+
activeTab === tab.id
|
|
72
|
+
? 'text-white border-blue-500'
|
|
73
|
+
: 'text-slate-400 border-transparent hover:text-white hover:border-slate-600'
|
|
74
|
+
}`}
|
|
75
|
+
>
|
|
76
|
+
<span>{tab.icon}</span>
|
|
77
|
+
<span>{tab.label}</span>
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
|
|
81
|
+
<div className="flex-1" />
|
|
82
|
+
|
|
83
|
+
<button
|
|
84
|
+
onClick={handleCollapse}
|
|
85
|
+
className="p-2 text-slate-400 hover:text-white"
|
|
86
|
+
title="Collapse panel"
|
|
87
|
+
>
|
|
88
|
+
▼
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Tab Content */}
|
|
93
|
+
<div className="flex-1 overflow-hidden min-h-0">
|
|
94
|
+
{activeTab === 'query' && <QueryTester />}
|
|
95
|
+
{activeTab === 'activity' && <ActivityLog />}
|
|
96
|
+
{activeTab === 'graph' && <RelationshipGraph />}
|
|
97
|
+
{activeTab === 'sql' && <SqlConsole />}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Query Tester Component
|
|
5
|
+
*
|
|
6
|
+
* Allows testing search queries against the memory system
|
|
7
|
+
* with detailed score breakdowns and explanations.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { Input } from '@/components/ui/input';
|
|
13
|
+
import { Memory } from '@/types/memory';
|
|
14
|
+
|
|
15
|
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|
16
|
+
|
|
17
|
+
interface SearchResult {
|
|
18
|
+
memory: Memory & { decayedScore: number };
|
|
19
|
+
relevanceScore: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type SearchMode = 'hybrid' | 'fts' | 'vector';
|
|
23
|
+
|
|
24
|
+
export function QueryTester() {
|
|
25
|
+
const [query, setQuery] = useState('');
|
|
26
|
+
const [mode, setMode] = useState<SearchMode>('hybrid');
|
|
27
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
28
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
const handleSearch = async () => {
|
|
32
|
+
if (!query.trim()) return;
|
|
33
|
+
|
|
34
|
+
setIsSearching(true);
|
|
35
|
+
setError(null);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const params = new URLSearchParams({
|
|
39
|
+
query: query.trim(),
|
|
40
|
+
mode: 'search',
|
|
41
|
+
limit: '20',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const response = await fetch(`${API_BASE}/api/memories?${params}`);
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`Search failed: ${response.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const data = await response.json();
|
|
50
|
+
// Transform API response to include relevance score
|
|
51
|
+
const resultsWithScore: SearchResult[] = data.memories.map((m: Memory & { decayedScore: number }) => ({
|
|
52
|
+
memory: m,
|
|
53
|
+
relevanceScore: m.decayedScore, // Using decayedScore as proxy for relevance
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
setResults(resultsWithScore);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError((err as Error).message);
|
|
59
|
+
setResults([]);
|
|
60
|
+
} finally {
|
|
61
|
+
setIsSearching(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
66
|
+
if (e.key === 'Enter') {
|
|
67
|
+
handleSearch();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="h-full flex flex-col">
|
|
73
|
+
{/* Search Controls */}
|
|
74
|
+
<div className="p-3 border-b border-slate-700 flex gap-2 items-center">
|
|
75
|
+
<Input
|
|
76
|
+
type="text"
|
|
77
|
+
placeholder="Enter search query..."
|
|
78
|
+
value={query}
|
|
79
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
80
|
+
onKeyDown={handleKeyDown}
|
|
81
|
+
className="flex-1 bg-slate-800 border-slate-600 text-white"
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{/* Mode Toggle */}
|
|
85
|
+
<div className="flex gap-1 bg-slate-800 rounded-lg p-1">
|
|
86
|
+
{(['hybrid', 'fts', 'vector'] as SearchMode[]).map((m) => (
|
|
87
|
+
<button
|
|
88
|
+
key={m}
|
|
89
|
+
onClick={() => setMode(m)}
|
|
90
|
+
className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
91
|
+
mode === m
|
|
92
|
+
? 'bg-blue-600 text-white'
|
|
93
|
+
: 'text-slate-400 hover:text-white'
|
|
94
|
+
}`}
|
|
95
|
+
>
|
|
96
|
+
{m.toUpperCase()}
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<Button
|
|
102
|
+
onClick={handleSearch}
|
|
103
|
+
disabled={isSearching || !query.trim()}
|
|
104
|
+
size="sm"
|
|
105
|
+
className="bg-blue-600 hover:bg-blue-700"
|
|
106
|
+
>
|
|
107
|
+
{isSearching ? 'Searching...' : 'Search'}
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Results */}
|
|
112
|
+
<div className="flex-1 overflow-auto p-3">
|
|
113
|
+
{error && (
|
|
114
|
+
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-300 text-sm mb-3">
|
|
115
|
+
{error}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{results.length === 0 && !error && (
|
|
120
|
+
<div className="text-slate-500 text-sm text-center py-8">
|
|
121
|
+
{query ? 'No results found' : 'Enter a query to search memories'}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{results.length > 0 && (
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<div className="text-xs text-slate-400 mb-2">
|
|
128
|
+
Found {results.length} results
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<table className="w-full text-sm">
|
|
132
|
+
<thead>
|
|
133
|
+
<tr className="text-left text-slate-400 border-b border-slate-700">
|
|
134
|
+
<th className="pb-2 pr-4">Title</th>
|
|
135
|
+
<th className="pb-2 pr-4 w-20">Score</th>
|
|
136
|
+
<th className="pb-2 pr-4 w-24">Type</th>
|
|
137
|
+
<th className="pb-2 w-24">Category</th>
|
|
138
|
+
</tr>
|
|
139
|
+
</thead>
|
|
140
|
+
<tbody>
|
|
141
|
+
{results.map((result, index) => (
|
|
142
|
+
<tr
|
|
143
|
+
key={result.memory.id}
|
|
144
|
+
className="border-b border-slate-800 hover:bg-slate-800/50"
|
|
145
|
+
>
|
|
146
|
+
<td className="py-2 pr-4">
|
|
147
|
+
<div className="flex items-center gap-2">
|
|
148
|
+
<span className="text-slate-500 text-xs w-5">
|
|
149
|
+
{index + 1}.
|
|
150
|
+
</span>
|
|
151
|
+
<span className="text-white truncate max-w-[300px]">
|
|
152
|
+
{result.memory.title}
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
</td>
|
|
156
|
+
<td className="py-2 pr-4">
|
|
157
|
+
<div className="flex items-center gap-1">
|
|
158
|
+
<div
|
|
159
|
+
className="h-1.5 rounded-full bg-gradient-to-r from-blue-600 to-purple-600"
|
|
160
|
+
style={{ width: `${result.relevanceScore * 100}%`, maxWidth: '60px' }}
|
|
161
|
+
/>
|
|
162
|
+
<span className="text-xs text-slate-400">
|
|
163
|
+
{(result.relevanceScore * 100).toFixed(0)}%
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
</td>
|
|
167
|
+
<td className="py-2 pr-4">
|
|
168
|
+
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
|
169
|
+
result.memory.type === 'long_term'
|
|
170
|
+
? 'bg-purple-600/30 text-purple-300'
|
|
171
|
+
: result.memory.type === 'short_term'
|
|
172
|
+
? 'bg-blue-600/30 text-blue-300'
|
|
173
|
+
: 'bg-green-600/30 text-green-300'
|
|
174
|
+
}`}>
|
|
175
|
+
{result.memory.type.replace('_', '-')}
|
|
176
|
+
</span>
|
|
177
|
+
</td>
|
|
178
|
+
<td className="py-2">
|
|
179
|
+
<span className="text-xs text-slate-400">
|
|
180
|
+
{result.memory.category}
|
|
181
|
+
</span>
|
|
182
|
+
</td>
|
|
183
|
+
</tr>
|
|
184
|
+
))}
|
|
185
|
+
</tbody>
|
|
186
|
+
</table>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Relationship Graph Component - Focus Mode
|
|
5
|
+
*
|
|
6
|
+
* Clean visualization: click a memory to see its direct connections.
|
|
7
|
+
* Unselected state shows all nodes dimmed, selected shows focus view.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
11
|
+
import { useMemoryLinks, useMemories } from '@/hooks/useMemories';
|
|
12
|
+
import { Memory, MemoryLink } from '@/types/memory';
|
|
13
|
+
|
|
14
|
+
interface Node {
|
|
15
|
+
id: number;
|
|
16
|
+
title: string;
|
|
17
|
+
category: string;
|
|
18
|
+
salience: number;
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Edge {
|
|
24
|
+
source: number;
|
|
25
|
+
target: number;
|
|
26
|
+
relationship: string;
|
|
27
|
+
strength: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RELATIONSHIP_COLORS: Record<string, string> = {
|
|
31
|
+
related: '#6366f1',
|
|
32
|
+
extends: '#22c55e',
|
|
33
|
+
references: '#3b82f6',
|
|
34
|
+
contradicts: '#ef4444',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const CATEGORY_COLORS: Record<string, string> = {
|
|
38
|
+
architecture: '#8b5cf6',
|
|
39
|
+
pattern: '#3b82f6',
|
|
40
|
+
error: '#ef4444',
|
|
41
|
+
learning: '#22c55e',
|
|
42
|
+
preference: '#f59e0b',
|
|
43
|
+
context: '#6366f1',
|
|
44
|
+
todo: '#ec4899',
|
|
45
|
+
note: '#64748b',
|
|
46
|
+
relationship: '#14b8a6',
|
|
47
|
+
custom: '#64748b',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function RelationshipGraph() {
|
|
51
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
52
|
+
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
|
53
|
+
const [hoveredNodeId, setHoveredNodeId] = useState<number | null>(null);
|
|
54
|
+
const [relationshipFilter, setRelationshipFilter] = useState<string | null>(null);
|
|
55
|
+
|
|
56
|
+
const { data: links = [] } = useMemoryLinks();
|
|
57
|
+
const { data: memories = [] } = useMemories({ limit: 200 });
|
|
58
|
+
|
|
59
|
+
// Build graph data with stable positions
|
|
60
|
+
const { nodes, edges, nodeMap } = useMemo(() => {
|
|
61
|
+
const linkedIds = new Set<number>();
|
|
62
|
+
for (const link of links) {
|
|
63
|
+
linkedIds.add(link.source_id);
|
|
64
|
+
linkedIds.add(link.target_id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const linkedMemories = memories.filter((m) => linkedIds.has(m.id));
|
|
68
|
+
|
|
69
|
+
// Create stable grid layout
|
|
70
|
+
const cols = Math.ceil(Math.sqrt(linkedMemories.length));
|
|
71
|
+
const cellWidth = 100;
|
|
72
|
+
const cellHeight = 80;
|
|
73
|
+
|
|
74
|
+
const nodes: Node[] = linkedMemories.map((m, i) => ({
|
|
75
|
+
id: m.id,
|
|
76
|
+
title: m.title,
|
|
77
|
+
category: m.category,
|
|
78
|
+
salience: m.salience,
|
|
79
|
+
x: (i % cols) * cellWidth + cellWidth / 2 + 50,
|
|
80
|
+
y: Math.floor(i / cols) * cellHeight + cellHeight / 2 + 50,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const edges: Edge[] = links.map((l) => ({
|
|
84
|
+
source: l.source_id,
|
|
85
|
+
target: l.target_id,
|
|
86
|
+
relationship: l.relationship,
|
|
87
|
+
strength: l.strength,
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
91
|
+
|
|
92
|
+
return { nodes, edges, nodeMap };
|
|
93
|
+
}, [memories, links]);
|
|
94
|
+
|
|
95
|
+
// Filter edges
|
|
96
|
+
const filteredEdges = relationshipFilter
|
|
97
|
+
? edges.filter((e) => e.relationship === relationshipFilter)
|
|
98
|
+
: edges;
|
|
99
|
+
|
|
100
|
+
// Get connections for selected node
|
|
101
|
+
const selectedConnections = useMemo(() => {
|
|
102
|
+
if (!selectedNodeId) return { connectedIds: new Set<number>(), connectedEdges: [] };
|
|
103
|
+
|
|
104
|
+
const connectedIds = new Set<number>();
|
|
105
|
+
const connectedEdges: Edge[] = [];
|
|
106
|
+
|
|
107
|
+
for (const edge of filteredEdges) {
|
|
108
|
+
if (edge.source === selectedNodeId) {
|
|
109
|
+
connectedIds.add(edge.target);
|
|
110
|
+
connectedEdges.push(edge);
|
|
111
|
+
} else if (edge.target === selectedNodeId) {
|
|
112
|
+
connectedIds.add(edge.source);
|
|
113
|
+
connectedEdges.push(edge);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { connectedIds, connectedEdges };
|
|
118
|
+
}, [selectedNodeId, filteredEdges]);
|
|
119
|
+
|
|
120
|
+
// Get unique relationship types
|
|
121
|
+
const relationshipTypes = [...new Set(edges.map((e) => e.relationship))];
|
|
122
|
+
|
|
123
|
+
// Get selected node details
|
|
124
|
+
const selectedNode = selectedNodeId ? nodeMap.get(selectedNodeId) : null;
|
|
125
|
+
|
|
126
|
+
// Canvas rendering
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const canvas = canvasRef.current;
|
|
129
|
+
if (!canvas) return;
|
|
130
|
+
|
|
131
|
+
const ctx = canvas.getContext('2d');
|
|
132
|
+
if (!ctx) return;
|
|
133
|
+
|
|
134
|
+
const rect = canvas.getBoundingClientRect();
|
|
135
|
+
canvas.width = rect.width * window.devicePixelRatio;
|
|
136
|
+
canvas.height = rect.height * window.devicePixelRatio;
|
|
137
|
+
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
|
138
|
+
|
|
139
|
+
const width = rect.width;
|
|
140
|
+
const height = rect.height;
|
|
141
|
+
|
|
142
|
+
// Clear
|
|
143
|
+
ctx.fillStyle = '#0f172a';
|
|
144
|
+
ctx.fillRect(0, 0, width, height);
|
|
145
|
+
|
|
146
|
+
if (nodes.length === 0) return;
|
|
147
|
+
|
|
148
|
+
// Calculate layout to fit in canvas
|
|
149
|
+
const padding = 60;
|
|
150
|
+
const cols = Math.ceil(Math.sqrt(nodes.length));
|
|
151
|
+
const rows = Math.ceil(nodes.length / cols);
|
|
152
|
+
const cellWidth = (width - padding * 2) / cols;
|
|
153
|
+
const cellHeight = (height - padding * 2) / rows;
|
|
154
|
+
|
|
155
|
+
// Update positions to fit canvas
|
|
156
|
+
nodes.forEach((node, i) => {
|
|
157
|
+
node.x = (i % cols) * cellWidth + cellWidth / 2 + padding;
|
|
158
|
+
node.y = Math.floor(i / cols) * cellHeight + cellHeight / 2 + padding;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const { connectedIds, connectedEdges } = selectedConnections;
|
|
162
|
+
|
|
163
|
+
// Draw edges (only for selected node, or all dimmed if none selected)
|
|
164
|
+
if (selectedNodeId) {
|
|
165
|
+
// Draw connected edges prominently
|
|
166
|
+
ctx.lineWidth = 2;
|
|
167
|
+
for (const edge of connectedEdges) {
|
|
168
|
+
const source = nodeMap.get(edge.source);
|
|
169
|
+
const target = nodeMap.get(edge.target);
|
|
170
|
+
if (!source || !target) continue;
|
|
171
|
+
|
|
172
|
+
ctx.strokeStyle = RELATIONSHIP_COLORS[edge.relationship] || '#475569';
|
|
173
|
+
ctx.globalAlpha = 0.8;
|
|
174
|
+
ctx.beginPath();
|
|
175
|
+
ctx.moveTo(source.x, source.y);
|
|
176
|
+
ctx.lineTo(target.x, target.y);
|
|
177
|
+
ctx.stroke();
|
|
178
|
+
|
|
179
|
+
// Draw relationship label at midpoint
|
|
180
|
+
const midX = (source.x + target.x) / 2;
|
|
181
|
+
const midY = (source.y + target.y) / 2;
|
|
182
|
+
ctx.font = '10px system-ui';
|
|
183
|
+
ctx.fillStyle = RELATIONSHIP_COLORS[edge.relationship] || '#94a3b8';
|
|
184
|
+
ctx.globalAlpha = 1;
|
|
185
|
+
ctx.textAlign = 'center';
|
|
186
|
+
ctx.fillText(edge.relationship, midX, midY - 4);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
// Show all edges very dimmed when nothing selected
|
|
190
|
+
ctx.lineWidth = 1;
|
|
191
|
+
ctx.globalAlpha = 0.15;
|
|
192
|
+
for (const edge of filteredEdges) {
|
|
193
|
+
const source = nodeMap.get(edge.source);
|
|
194
|
+
const target = nodeMap.get(edge.target);
|
|
195
|
+
if (!source || !target) continue;
|
|
196
|
+
|
|
197
|
+
ctx.strokeStyle = RELATIONSHIP_COLORS[edge.relationship] || '#475569';
|
|
198
|
+
ctx.beginPath();
|
|
199
|
+
ctx.moveTo(source.x, source.y);
|
|
200
|
+
ctx.lineTo(target.x, target.y);
|
|
201
|
+
ctx.stroke();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
ctx.globalAlpha = 1;
|
|
205
|
+
|
|
206
|
+
// Draw nodes
|
|
207
|
+
for (const node of nodes) {
|
|
208
|
+
const isSelected = node.id === selectedNodeId;
|
|
209
|
+
const isConnected = connectedIds.has(node.id);
|
|
210
|
+
const isHovered = node.id === hoveredNodeId;
|
|
211
|
+
const isHighlighted = isSelected || isConnected || !selectedNodeId;
|
|
212
|
+
|
|
213
|
+
const baseRadius = 6 + node.salience * 6;
|
|
214
|
+
const radius = isSelected ? baseRadius + 4 : isHovered ? baseRadius + 2 : baseRadius;
|
|
215
|
+
|
|
216
|
+
// Determine opacity
|
|
217
|
+
const opacity = isHighlighted ? 1 : 0.2;
|
|
218
|
+
|
|
219
|
+
// Draw node
|
|
220
|
+
ctx.beginPath();
|
|
221
|
+
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
|
|
222
|
+
ctx.fillStyle = CATEGORY_COLORS[node.category] || '#64748b';
|
|
223
|
+
ctx.globalAlpha = opacity;
|
|
224
|
+
ctx.fill();
|
|
225
|
+
|
|
226
|
+
// Selection/hover ring
|
|
227
|
+
if (isSelected || isHovered) {
|
|
228
|
+
ctx.strokeStyle = isSelected ? '#f472b6' : '#94a3b8';
|
|
229
|
+
ctx.lineWidth = isSelected ? 3 : 2;
|
|
230
|
+
ctx.globalAlpha = 1;
|
|
231
|
+
ctx.stroke();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Draw label for highlighted nodes
|
|
235
|
+
if (isHighlighted && (isSelected || isConnected || isHovered || node.salience > 0.5)) {
|
|
236
|
+
ctx.globalAlpha = opacity;
|
|
237
|
+
const label = node.title.length > 20 ? node.title.slice(0, 20) + '...' : node.title;
|
|
238
|
+
ctx.font = isSelected ? 'bold 11px system-ui' : '10px system-ui';
|
|
239
|
+
ctx.textAlign = 'center';
|
|
240
|
+
|
|
241
|
+
// Background
|
|
242
|
+
const textWidth = ctx.measureText(label).width;
|
|
243
|
+
ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
|
|
244
|
+
ctx.fillRect(node.x - textWidth / 2 - 4, node.y + radius + 4, textWidth + 8, 16);
|
|
245
|
+
|
|
246
|
+
// Text
|
|
247
|
+
ctx.fillStyle = isSelected ? '#f472b6' : isConnected ? '#e2e8f0' : '#94a3b8';
|
|
248
|
+
ctx.fillText(label, node.x, node.y + radius + 16);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
ctx.globalAlpha = 1;
|
|
253
|
+
}, [nodes, nodeMap, filteredEdges, selectedNodeId, hoveredNodeId, selectedConnections]);
|
|
254
|
+
|
|
255
|
+
// Mouse handling
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const canvas = canvasRef.current;
|
|
258
|
+
if (!canvas) return;
|
|
259
|
+
|
|
260
|
+
const getNodeAt = (x: number, y: number): Node | null => {
|
|
261
|
+
for (const node of nodes) {
|
|
262
|
+
const radius = 6 + node.salience * 6 + 4;
|
|
263
|
+
const dx = node.x - x;
|
|
264
|
+
const dy = node.y - y;
|
|
265
|
+
if (dx * dx + dy * dy < radius * radius) {
|
|
266
|
+
return node;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const handleClick = (e: MouseEvent) => {
|
|
273
|
+
const rect = canvas.getBoundingClientRect();
|
|
274
|
+
const x = e.clientX - rect.left;
|
|
275
|
+
const y = e.clientY - rect.top;
|
|
276
|
+
const node = getNodeAt(x, y);
|
|
277
|
+
|
|
278
|
+
if (node) {
|
|
279
|
+
setSelectedNodeId(node.id === selectedNodeId ? null : node.id);
|
|
280
|
+
} else {
|
|
281
|
+
setSelectedNodeId(null);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
286
|
+
const rect = canvas.getBoundingClientRect();
|
|
287
|
+
const x = e.clientX - rect.left;
|
|
288
|
+
const y = e.clientY - rect.top;
|
|
289
|
+
const node = getNodeAt(x, y);
|
|
290
|
+
setHoveredNodeId(node?.id || null);
|
|
291
|
+
canvas.style.cursor = node ? 'pointer' : 'default';
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
canvas.addEventListener('click', handleClick);
|
|
295
|
+
canvas.addEventListener('mousemove', handleMouseMove);
|
|
296
|
+
|
|
297
|
+
return () => {
|
|
298
|
+
canvas.removeEventListener('click', handleClick);
|
|
299
|
+
canvas.removeEventListener('mousemove', handleMouseMove);
|
|
300
|
+
};
|
|
301
|
+
}, [nodes, selectedNodeId]);
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className="h-full flex flex-col">
|
|
305
|
+
{/* Controls */}
|
|
306
|
+
<div className="p-2 border-b border-slate-700 flex items-center gap-3">
|
|
307
|
+
<span className="text-xs text-slate-400">Filter:</span>
|
|
308
|
+
<button
|
|
309
|
+
onClick={() => setRelationshipFilter(null)}
|
|
310
|
+
className={`px-2 py-0.5 text-xs rounded ${
|
|
311
|
+
relationshipFilter === null
|
|
312
|
+
? 'bg-slate-600 text-white'
|
|
313
|
+
: 'text-slate-400 hover:text-white'
|
|
314
|
+
}`}
|
|
315
|
+
>
|
|
316
|
+
All
|
|
317
|
+
</button>
|
|
318
|
+
{relationshipTypes.map((type) => (
|
|
319
|
+
<button
|
|
320
|
+
key={type}
|
|
321
|
+
onClick={() => setRelationshipFilter(type)}
|
|
322
|
+
className={`px-2 py-0.5 text-xs rounded ${
|
|
323
|
+
relationshipFilter === type ? 'text-white' : 'text-slate-400 hover:text-white'
|
|
324
|
+
}`}
|
|
325
|
+
style={{
|
|
326
|
+
backgroundColor: relationshipFilter === type ? RELATIONSHIP_COLORS[type] : 'transparent',
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
{type}
|
|
330
|
+
</button>
|
|
331
|
+
))}
|
|
332
|
+
|
|
333
|
+
<div className="flex-1" />
|
|
334
|
+
|
|
335
|
+
{/* Instructions */}
|
|
336
|
+
<span className="text-xs text-slate-500">
|
|
337
|
+
{selectedNodeId ? 'Click elsewhere to deselect' : 'Click a node to focus'}
|
|
338
|
+
</span>
|
|
339
|
+
|
|
340
|
+
{/* Legend */}
|
|
341
|
+
<div className="flex items-center gap-2 text-xs border-l border-slate-700 pl-3">
|
|
342
|
+
{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
|
|
343
|
+
<div key={type} className="flex items-center gap-1">
|
|
344
|
+
<div className="w-3 h-0.5" style={{ backgroundColor: color }} />
|
|
345
|
+
<span className="text-slate-500">{type}</span>
|
|
346
|
+
</div>
|
|
347
|
+
))}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Graph */}
|
|
352
|
+
<div className="flex-1 relative min-h-0">
|
|
353
|
+
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
|
354
|
+
|
|
355
|
+
{/* Selected Node Details */}
|
|
356
|
+
{selectedNode && (
|
|
357
|
+
<div className="absolute top-3 left-3 p-3 bg-slate-800/95 border border-slate-700 rounded-lg max-w-xs">
|
|
358
|
+
<div className="flex items-center gap-2 mb-2">
|
|
359
|
+
<div
|
|
360
|
+
className="w-3 h-3 rounded-full"
|
|
361
|
+
style={{ backgroundColor: CATEGORY_COLORS[selectedNode.category] }}
|
|
362
|
+
/>
|
|
363
|
+
<span className="text-white font-medium text-sm">{selectedNode.title}</span>
|
|
364
|
+
</div>
|
|
365
|
+
<div className="text-xs text-slate-400 space-y-1">
|
|
366
|
+
<div>Category: {selectedNode.category}</div>
|
|
367
|
+
<div>Salience: {(selectedNode.salience * 100).toFixed(0)}%</div>
|
|
368
|
+
<div>Connections: {selectedConnections.connectedIds.size}</div>
|
|
369
|
+
</div>
|
|
370
|
+
{selectedConnections.connectedEdges.length > 0 && (
|
|
371
|
+
<div className="mt-2 pt-2 border-t border-slate-700">
|
|
372
|
+
<div className="text-xs text-slate-500 mb-1">Connected to:</div>
|
|
373
|
+
<div className="space-y-1 max-h-24 overflow-y-auto">
|
|
374
|
+
{selectedConnections.connectedEdges.map((edge, i) => {
|
|
375
|
+
const otherId = edge.source === selectedNodeId ? edge.target : edge.source;
|
|
376
|
+
const other = nodeMap.get(otherId);
|
|
377
|
+
return (
|
|
378
|
+
<div key={i} className="text-xs flex items-center gap-1">
|
|
379
|
+
<span
|
|
380
|
+
className="w-2 h-2 rounded-full shrink-0"
|
|
381
|
+
style={{ backgroundColor: RELATIONSHIP_COLORS[edge.relationship] }}
|
|
382
|
+
/>
|
|
383
|
+
<span className="text-slate-400">{edge.relationship}:</span>
|
|
384
|
+
<span className="text-slate-300 truncate">{other?.title || 'Unknown'}</span>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
})}
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{/* Empty state */}
|
|
395
|
+
{nodes.length === 0 && (
|
|
396
|
+
<div className="absolute inset-0 flex items-center justify-center text-slate-500">
|
|
397
|
+
No relationships to display
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|