@andespindola/brainlink 0.1.0-beta.2 → 0.1.0-beta.20
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/AGENTS.md +5 -5
- package/CHANGELOG.md +50 -2
- package/CONTRIBUTING.md +2 -2
- package/README.md +157 -20
- package/SECURITY.md +1 -1
- package/dist/application/add-note.js +62 -13
- package/dist/application/analyze-vault.js +95 -8
- package/dist/application/frontend/client-css.js +190 -99
- package/dist/application/frontend/client-html.js +57 -45
- package/dist/application/frontend/client-js.js +416 -85
- package/dist/application/get-graph-layout.js +22 -7
- package/dist/application/get-graph-node.js +12 -0
- package/dist/application/get-graph-summary.js +12 -0
- package/dist/application/get-graph.js +3 -3
- package/dist/application/index-vault.js +11 -4
- package/dist/application/list-agents.js +3 -3
- package/dist/application/list-links.js +5 -5
- package/dist/application/migrate-vault.js +91 -0
- package/dist/application/search-graph-node-ids.js +12 -0
- package/dist/application/search-knowledge.js +75 -5
- package/dist/application/server/routes.js +27 -1
- package/dist/benchmarks/large-vault.js +1 -1
- package/dist/cli/commands/agent-commands.js +412 -0
- package/dist/cli/commands/config-commands.js +167 -0
- package/dist/cli/commands/read-commands.js +25 -8
- package/dist/cli/commands/write-commands.js +173 -4
- package/dist/cli/main.js +4 -0
- package/dist/cli/runtime.js +5 -2
- package/dist/domain/context.js +53 -11
- package/dist/domain/embeddings.js +2 -1
- package/dist/domain/graph-layout.js +20 -14
- package/dist/domain/markdown.js +36 -4
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +94 -8
- package/dist/infrastructure/file-index.js +294 -0
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/paths.js +9 -1
- package/dist/infrastructure/private-pack-codec.js +73 -0
- package/dist/infrastructure/search-packs.js +348 -0
- package/dist/infrastructure/session-state.js +172 -0
- package/dist/mcp/main.js +11 -3
- package/dist/mcp/server.js +17 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +571 -19
- package/docs/AGENT_USAGE.md +99 -15
- package/docs/ARCHITECTURE.md +37 -26
- package/docs/QUICKSTART.md +104 -0
- package/docs/RELEASE.md +3 -3
- package/package.json +1 -3
- package/dist/infrastructure/sqlite/document-writer.js +0 -51
- package/dist/infrastructure/sqlite/graph-reader.js +0 -120
- package/dist/infrastructure/sqlite/schema.js +0 -111
- package/dist/infrastructure/sqlite/search-reader.js +0 -156
- package/dist/infrastructure/sqlite/types.js +0 -1
- package/dist/infrastructure/sqlite-index.js +0 -25
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
|
+
const largeGraphNodeThreshold = 4000
|
|
4
|
+
const largeGraphEdgeRenderLimit = 16000
|
|
5
|
+
const renderNodeBudget = 1800
|
|
6
|
+
const minNodePixelRadius = 1.8
|
|
7
|
+
const viewportPaddingPx = 280
|
|
3
8
|
const state = {
|
|
4
9
|
graph: { nodes: [], edges: [] },
|
|
5
10
|
nodes: [],
|
|
6
11
|
edges: [],
|
|
12
|
+
visibleNodes: [],
|
|
13
|
+
visibleEdges: [],
|
|
14
|
+
renderNodes: [],
|
|
15
|
+
renderEdges: [],
|
|
16
|
+
nodeDegrees: new Map(),
|
|
7
17
|
selected: null,
|
|
8
18
|
hovered: null,
|
|
9
19
|
query: '',
|
|
20
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
21
|
agentId: '',
|
|
11
22
|
agentsSignature: '',
|
|
23
|
+
nodeDetails: new Map(),
|
|
12
24
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
25
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
14
26
|
graphSignature: '',
|
|
27
|
+
graphStatus: '',
|
|
15
28
|
last: performance.now()
|
|
16
29
|
}
|
|
17
30
|
|
|
@@ -23,26 +36,40 @@ const escapeHtml = value => String(value)
|
|
|
23
36
|
.replaceAll('"', '"')
|
|
24
37
|
.replaceAll("'", ''')
|
|
25
38
|
const elements = {
|
|
26
|
-
stats: byId('stats'),
|
|
27
39
|
search: byId('search'),
|
|
28
40
|
agent: byId('agent'),
|
|
29
|
-
title: byId('title'),
|
|
30
|
-
path: byId('path'),
|
|
31
|
-
tags: byId('tags'),
|
|
32
|
-
notes: byId('notes'),
|
|
33
|
-
content: byId('content'),
|
|
34
|
-
outgoing: byId('outgoing'),
|
|
35
|
-
incoming: byId('incoming'),
|
|
36
41
|
nodeCount: byId('nodeCount'),
|
|
37
42
|
edgeCount: byId('edgeCount'),
|
|
38
43
|
tagCount: byId('tagCount'),
|
|
39
44
|
zoomIn: byId('zoomIn'),
|
|
40
45
|
zoomOut: byId('zoomOut'),
|
|
41
|
-
|
|
46
|
+
fit: byId('fit'),
|
|
47
|
+
reset: byId('reset'),
|
|
48
|
+
contentDialog: byId('contentDialog'),
|
|
49
|
+
contentTitle: byId('contentTitle'),
|
|
50
|
+
contentPath: byId('contentPath'),
|
|
51
|
+
contentTags: byId('contentTags'),
|
|
52
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
53
|
+
contentIncoming: byId('contentIncoming'),
|
|
54
|
+
contentBody: byId('contentBody'),
|
|
55
|
+
contentClose: byId('contentClose')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const zoomRange = {
|
|
59
|
+
min: 0.05,
|
|
60
|
+
max: 4.5
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
|
|
45
64
|
|
|
65
|
+
const setGraphStatus = text => {
|
|
66
|
+
state.graphStatus = text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const handleGraphRefreshError = error => {
|
|
70
|
+
console.error(error)
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
const graphTheme = {
|
|
47
74
|
node: '#aeb8c5',
|
|
48
75
|
nodeSelected: '#f3f7fb',
|
|
@@ -66,30 +93,110 @@ const resize = () => {
|
|
|
66
93
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
67
94
|
}
|
|
68
95
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
96
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
97
|
+
|
|
98
|
+
const localFilteredNodes = query =>
|
|
99
|
+
state.nodes.filter(node =>
|
|
73
100
|
node.title.toLowerCase().includes(query) ||
|
|
74
101
|
node.path.toLowerCase().includes(query) ||
|
|
75
102
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
76
103
|
)
|
|
77
|
-
}
|
|
78
104
|
|
|
79
|
-
const
|
|
105
|
+
const filteredNodes = () => {
|
|
106
|
+
const query = normalizeQuery(state.query)
|
|
107
|
+
if (!query) return state.nodes
|
|
108
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
109
|
+
return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
110
|
+
}
|
|
80
111
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
return localFilteredNodes(query)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const recomputeVisibility = () => {
|
|
116
|
+
const nodes = filteredNodes()
|
|
117
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
118
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
119
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
120
|
+
? [...edges]
|
|
121
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
122
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
123
|
+
: edges
|
|
124
|
+
|
|
125
|
+
state.visibleNodes = nodes
|
|
126
|
+
state.visibleEdges = limitedEdges
|
|
84
127
|
}
|
|
85
128
|
|
|
86
129
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
87
130
|
|
|
88
|
-
const
|
|
131
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
132
|
+
|
|
133
|
+
const graphBounds = nodes => {
|
|
134
|
+
if (nodes.length === 0) return null
|
|
135
|
+
let minX = Number.POSITIVE_INFINITY
|
|
136
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
137
|
+
let minY = Number.POSITIVE_INFINITY
|
|
138
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
139
|
+
|
|
140
|
+
nodes.forEach(node => {
|
|
141
|
+
const radius = baseNodeRadius(node)
|
|
142
|
+
minX = Math.min(minX, node.x - radius)
|
|
143
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
144
|
+
minY = Math.min(minY, node.y - radius)
|
|
145
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
minX,
|
|
150
|
+
maxX,
|
|
151
|
+
minY,
|
|
152
|
+
maxY,
|
|
153
|
+
width: Math.max(maxX - minX, 1),
|
|
154
|
+
height: Math.max(maxY - minY, 1)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
159
|
+
if (nodeCount <= 6) return 2.4
|
|
160
|
+
if (nodeCount <= 20) return 1.9
|
|
161
|
+
if (nodeCount <= 60) return 1.5
|
|
162
|
+
if (nodeCount <= 180) return 1.25
|
|
163
|
+
if (nodeCount <= 600) return 1.05
|
|
164
|
+
if (nodeCount <= 2000) return 0.9
|
|
165
|
+
if (nodeCount <= 6000) return 0.72
|
|
166
|
+
return 0.62
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const fitView = (options = { useFiltered: true }) => {
|
|
89
170
|
const rect = canvas.getBoundingClientRect()
|
|
90
|
-
|
|
171
|
+
const width = Math.max(rect.width, 320)
|
|
172
|
+
const height = Math.max(rect.height, 320)
|
|
173
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
174
|
+
const bounds = graphBounds(nodes)
|
|
175
|
+
|
|
176
|
+
if (!bounds) {
|
|
177
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const padding = 100
|
|
182
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
183
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
184
|
+
const fitScale = clampScale(Math.min(scaleX, scaleY))
|
|
185
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
186
|
+
const minimumLargeGraphScale = nodes.length > largeGraphNodeThreshold ? 0.13 : zoomRange.min
|
|
187
|
+
const scale = Math.max(biasedScale, minimumLargeGraphScale)
|
|
188
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
189
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
190
|
+
|
|
191
|
+
state.transform = {
|
|
192
|
+
x: width / 2 - centerX * scale,
|
|
193
|
+
y: height / 2 - centerY * scale,
|
|
194
|
+
scale
|
|
195
|
+
}
|
|
91
196
|
}
|
|
92
197
|
|
|
198
|
+
const resetView = () => fitView({ useFiltered: false })
|
|
199
|
+
|
|
93
200
|
const createLayout = graph => {
|
|
94
201
|
const nodes = graph.nodes.map(node => ({
|
|
95
202
|
...node,
|
|
@@ -111,18 +218,79 @@ const encodeEntityTag = (value) => {
|
|
|
111
218
|
binary += String.fromCharCode(utf8[index])
|
|
112
219
|
}
|
|
113
220
|
|
|
114
|
-
return btoa(binary).
|
|
221
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
115
222
|
}
|
|
116
223
|
|
|
117
224
|
const graphSignature = graph => JSON.stringify({
|
|
118
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
225
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
119
226
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
120
227
|
})
|
|
121
228
|
|
|
229
|
+
const resetContentFilter = () => {
|
|
230
|
+
if (state.contentFilter.timer) {
|
|
231
|
+
clearTimeout(state.contentFilter.timer)
|
|
232
|
+
}
|
|
233
|
+
state.contentFilter = {
|
|
234
|
+
query: '',
|
|
235
|
+
ids: null,
|
|
236
|
+
token: state.contentFilter.token + 1,
|
|
237
|
+
timer: null
|
|
238
|
+
}
|
|
239
|
+
recomputeVisibility()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const syncContentFilter = async (query, token) => {
|
|
243
|
+
const response = await fetch(
|
|
244
|
+
'/api/graph-filter?q=' +
|
|
245
|
+
encodeURIComponent(query) +
|
|
246
|
+
'&limit=' +
|
|
247
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
248
|
+
agentQuery()
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const payload = await response.json()
|
|
256
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
257
|
+
if (token !== state.contentFilter.token) {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
state.contentFilter.query = query
|
|
262
|
+
state.contentFilter.ids = new Set(nodeIds)
|
|
263
|
+
recomputeVisibility()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const scheduleContentFilterSync = () => {
|
|
267
|
+
const query = normalizeQuery(state.query)
|
|
268
|
+
if (!query) {
|
|
269
|
+
resetContentFilter()
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (state.contentFilter.timer) {
|
|
274
|
+
clearTimeout(state.contentFilter.timer)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const token = state.contentFilter.token + 1
|
|
278
|
+
state.contentFilter = {
|
|
279
|
+
query: state.contentFilter.query,
|
|
280
|
+
ids: state.contentFilter.ids,
|
|
281
|
+
token,
|
|
282
|
+
timer: setTimeout(() => {
|
|
283
|
+
syncContentFilter(query, token).catch(() => {})
|
|
284
|
+
}, 180)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
122
288
|
const tick = delta => {
|
|
123
|
-
const nodes =
|
|
124
|
-
const
|
|
125
|
-
|
|
289
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
290
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
291
|
+
if (nodes.length > 1200) {
|
|
292
|
+
return
|
|
293
|
+
}
|
|
126
294
|
const strength = Math.min(delta / 16, 2)
|
|
127
295
|
|
|
128
296
|
edges.forEach(edge => {
|
|
@@ -181,7 +349,11 @@ const worldPoint = event => {
|
|
|
181
349
|
}
|
|
182
350
|
|
|
183
351
|
const hitNode = point => {
|
|
184
|
-
|
|
352
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
|
|
353
|
+
return null
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const nodes = state.renderNodes
|
|
185
357
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
186
358
|
const node = nodes[index]
|
|
187
359
|
const radius = nodeRadius(node)
|
|
@@ -190,14 +362,92 @@ const hitNode = point => {
|
|
|
190
362
|
return null
|
|
191
363
|
}
|
|
192
364
|
|
|
193
|
-
const
|
|
194
|
-
const degree = state.
|
|
365
|
+
const baseNodeRadius = node => {
|
|
366
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
195
367
|
return 9 + Math.min(degree, 8) * 1.6
|
|
196
368
|
}
|
|
197
369
|
|
|
370
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
371
|
+
|
|
372
|
+
const worldViewportBounds = () => {
|
|
373
|
+
const rect = canvas.getBoundingClientRect()
|
|
374
|
+
const width = Math.max(rect.width, 320)
|
|
375
|
+
const height = Math.max(rect.height, 320)
|
|
376
|
+
const padding = viewportPaddingPx
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
380
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
381
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
382
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const isNodeInViewport = (node, viewport) =>
|
|
387
|
+
node.x >= viewport.minX &&
|
|
388
|
+
node.x <= viewport.maxX &&
|
|
389
|
+
node.y >= viewport.minY &&
|
|
390
|
+
node.y <= viewport.maxY
|
|
391
|
+
|
|
392
|
+
const viewportNodeStride = () => {
|
|
393
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
394
|
+
return 1
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (state.transform.scale >= 0.95) {
|
|
398
|
+
return 1
|
|
399
|
+
}
|
|
400
|
+
if (state.transform.scale >= 0.7) {
|
|
401
|
+
return 2
|
|
402
|
+
}
|
|
403
|
+
if (state.transform.scale >= 0.48) {
|
|
404
|
+
return 3
|
|
405
|
+
}
|
|
406
|
+
if (state.transform.scale >= 0.28) {
|
|
407
|
+
return 5
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return 8
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const computeRenderVisibility = () => {
|
|
414
|
+
const viewport = worldViewportBounds()
|
|
415
|
+
const stride = viewportNodeStride()
|
|
416
|
+
const picked = []
|
|
417
|
+
|
|
418
|
+
for (let index = 0; index < state.visibleNodes.length; index += 1) {
|
|
419
|
+
const node = state.visibleNodes[index]
|
|
420
|
+
if (!isNodeInViewport(node, viewport)) {
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const isPriority =
|
|
425
|
+
node.id === state.selected?.id ||
|
|
426
|
+
node.id === state.hovered?.id ||
|
|
427
|
+
node.id === state.pointer.dragNode?.id
|
|
428
|
+
if (isPriority || index % stride === 0) {
|
|
429
|
+
picked.push(node)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const nodes = picked.length > renderNodeBudget
|
|
434
|
+
? picked.slice(0, renderNodeBudget)
|
|
435
|
+
: picked
|
|
436
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
437
|
+
const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
|
|
438
|
+
|
|
439
|
+
state.renderNodes = nodes
|
|
440
|
+
state.renderEdges = edges
|
|
441
|
+
}
|
|
442
|
+
|
|
198
443
|
const render = now => {
|
|
199
444
|
const delta = now - state.last
|
|
200
445
|
state.last = now
|
|
446
|
+
const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
|
|
447
|
+
if (delta < minFrameIntervalMs) {
|
|
448
|
+
requestAnimationFrame(render)
|
|
449
|
+
return
|
|
450
|
+
}
|
|
201
451
|
const rect = canvas.getBoundingClientRect()
|
|
202
452
|
const width = Math.max(rect.width, 320)
|
|
203
453
|
const height = Math.max(rect.height, 320)
|
|
@@ -214,7 +464,11 @@ const render = now => {
|
|
|
214
464
|
ctx.translate(state.transform.x, state.transform.y)
|
|
215
465
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
216
466
|
|
|
217
|
-
|
|
467
|
+
computeRenderVisibility()
|
|
468
|
+
tick(delta)
|
|
469
|
+
const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
470
|
+
if (drawEdges) {
|
|
471
|
+
state.renderEdges.forEach(edge => {
|
|
218
472
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
219
473
|
ctx.beginPath()
|
|
220
474
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
@@ -222,9 +476,10 @@ const render = now => {
|
|
|
222
476
|
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
223
477
|
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
224
478
|
ctx.stroke()
|
|
225
|
-
|
|
479
|
+
})
|
|
480
|
+
}
|
|
226
481
|
|
|
227
|
-
|
|
482
|
+
state.renderNodes.forEach(node => {
|
|
228
483
|
const radius = nodeRadius(node)
|
|
229
484
|
const isSelected = state.selected?.id === node.id
|
|
230
485
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -240,7 +495,11 @@ const render = now => {
|
|
|
240
495
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
241
496
|
ctx.stroke()
|
|
242
497
|
|
|
243
|
-
|
|
498
|
+
const shouldDrawLabels =
|
|
499
|
+
isSelected ||
|
|
500
|
+
isHovered ||
|
|
501
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
502
|
+
if (shouldDrawLabels) {
|
|
244
503
|
ctx.fillStyle = graphTheme.label
|
|
245
504
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
246
505
|
ctx.textAlign = 'center'
|
|
@@ -250,6 +509,12 @@ const render = now => {
|
|
|
250
509
|
})
|
|
251
510
|
|
|
252
511
|
ctx.restore()
|
|
512
|
+
if (state.renderNodes.length === 0) {
|
|
513
|
+
ctx.fillStyle = '#99a5b5'
|
|
514
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
515
|
+
ctx.textAlign = 'center'
|
|
516
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
517
|
+
}
|
|
253
518
|
requestAnimationFrame(render)
|
|
254
519
|
}
|
|
255
520
|
|
|
@@ -257,22 +522,7 @@ const list = items => items.length
|
|
|
257
522
|
? items.map(item => '<li>' + (item.id ? '<button type="button" data-node-id="' + escapeHtml(item.id) + '">' + escapeHtml(item.title) + '</button>' : escapeHtml(item.title)) + '<small>' + escapeHtml(item.path) + (item.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : '') + '</small></li>').join('')
|
|
258
523
|
: '<li><small>No links found.</small></li>'
|
|
259
524
|
|
|
260
|
-
const
|
|
261
|
-
? state.nodes.map(node => '<li><button type="button" data-node-id="' + escapeHtml(node.id) + '">' + escapeHtml(node.title) + '</button><small>' + escapeHtml(node.path) + '</small></li>').join('')
|
|
262
|
-
: '<li><small>No notes indexed.</small></li>'
|
|
263
|
-
|
|
264
|
-
const selectNode = node => {
|
|
265
|
-
state.selected = node
|
|
266
|
-
if (!node) {
|
|
267
|
-
elements.title.textContent = 'Graph Overview'
|
|
268
|
-
elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
|
|
269
|
-
elements.tags.innerHTML = ''
|
|
270
|
-
elements.notes.innerHTML = allNotesList()
|
|
271
|
-
elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
|
|
272
|
-
elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
|
|
273
|
-
elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
|
|
274
|
-
return
|
|
275
|
-
}
|
|
525
|
+
const linkedNodes = node => {
|
|
276
526
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
277
527
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
278
528
|
...linkedNode,
|
|
@@ -288,56 +538,129 @@ const selectNode = node => {
|
|
|
288
538
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
289
539
|
.filter(Boolean)
|
|
290
540
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
541
|
+
return { outgoing, incoming }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const fetchNodeDetails = async node => {
|
|
545
|
+
const cached = state.nodeDetails.get(node.id)
|
|
546
|
+
if (cached) {
|
|
547
|
+
return cached
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
throw new Error('Failed to load graph node details')
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const payload = await response.json()
|
|
556
|
+
const detail = payload?.node
|
|
557
|
+
if (!detail || !detail.id) {
|
|
558
|
+
throw new Error('Invalid graph node payload')
|
|
559
|
+
}
|
|
560
|
+
state.nodeDetails.set(detail.id, detail)
|
|
561
|
+
return detail
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const openContentDialog = async node => {
|
|
565
|
+
if (!node) return
|
|
566
|
+
const { outgoing, incoming } = linkedNodes(node)
|
|
567
|
+
elements.contentTitle.textContent = node.title
|
|
568
|
+
elements.contentPath.textContent = node.path
|
|
569
|
+
elements.contentTags.innerHTML = node.tags.length
|
|
294
570
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
295
571
|
: '<span>No tags</span>'
|
|
296
|
-
elements.
|
|
297
|
-
elements.
|
|
298
|
-
elements.
|
|
299
|
-
elements.
|
|
572
|
+
elements.contentOutgoing.innerHTML = list(outgoing)
|
|
573
|
+
elements.contentIncoming.innerHTML = list(incoming)
|
|
574
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
575
|
+
if (!elements.contentDialog.open) {
|
|
576
|
+
elements.contentDialog.showModal()
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
581
|
+
if (state.selected?.id !== node.id) {
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
elements.contentBody.textContent = detailedNode.content
|
|
585
|
+
} catch {
|
|
586
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
591
|
+
state.selected = node
|
|
592
|
+
if (node && options.openContent) {
|
|
593
|
+
openContentDialog(node).catch(() => {
|
|
594
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
595
|
+
})
|
|
596
|
+
}
|
|
300
597
|
}
|
|
301
598
|
|
|
302
599
|
const selectNodeById = id => {
|
|
303
600
|
const node = state.nodes.find(item => item.id === id)
|
|
304
|
-
if (node) selectNode(node)
|
|
601
|
+
if (node) selectNode(node, { openContent: true })
|
|
305
602
|
}
|
|
306
603
|
|
|
307
|
-
const
|
|
308
|
-
|
|
604
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
605
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
606
|
+
if (nextScale === state.transform.scale) return
|
|
607
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
608
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
609
|
+
state.transform.scale = nextScale
|
|
610
|
+
state.transform.x = screenX - worldX * nextScale
|
|
611
|
+
state.transform.y = screenY - worldY * nextScale
|
|
309
612
|
}
|
|
310
613
|
|
|
311
614
|
const bindEvents = () => {
|
|
312
615
|
window.addEventListener('resize', resize)
|
|
313
616
|
elements.search.addEventListener('input', event => {
|
|
314
617
|
state.query = event.target.value
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
618
|
+
recomputeVisibility()
|
|
619
|
+
scheduleContentFilterSync()
|
|
318
620
|
})
|
|
319
621
|
elements.agent.addEventListener('change', event => {
|
|
320
622
|
state.agentId = event.target.value
|
|
321
623
|
state.selected = null
|
|
624
|
+
state.nodeDetails = new Map()
|
|
625
|
+
resetContentFilter()
|
|
626
|
+
recomputeVisibility()
|
|
627
|
+
scheduleContentFilterSync()
|
|
322
628
|
loadGraph({ reset: true }).catch(error => {
|
|
323
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
324
629
|
console.error(error)
|
|
325
630
|
})
|
|
326
631
|
})
|
|
327
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
632
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
633
|
+
const rect = canvas.getBoundingClientRect()
|
|
634
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.18)
|
|
635
|
+
})
|
|
636
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
637
|
+
const rect = canvas.getBoundingClientRect()
|
|
638
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
|
|
639
|
+
})
|
|
640
|
+
if (elements.fit) {
|
|
641
|
+
elements.fit.addEventListener('click', () => {
|
|
642
|
+
fitView({ useFiltered: true })
|
|
336
643
|
})
|
|
644
|
+
}
|
|
645
|
+
elements.reset.addEventListener('click', () => {
|
|
646
|
+
resetView()
|
|
647
|
+
})
|
|
648
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
649
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
650
|
+
const target = event.target
|
|
651
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
652
|
+
selectNodeById(target.dataset.nodeId)
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
337
656
|
})
|
|
338
657
|
canvas.addEventListener('wheel', event => {
|
|
339
658
|
event.preventDefault()
|
|
340
|
-
|
|
659
|
+
const rect = canvas.getBoundingClientRect()
|
|
660
|
+
const cursorX = event.clientX - rect.left
|
|
661
|
+
const cursorY = event.clientY - rect.top
|
|
662
|
+
const factor = event.deltaY < 0 ? 1.08 : 0.92
|
|
663
|
+
zoomAtPoint(cursorX, cursorY, factor)
|
|
341
664
|
}, { passive: false })
|
|
342
665
|
canvas.addEventListener('pointerdown', event => {
|
|
343
666
|
const point = worldPoint(event)
|
|
@@ -367,8 +690,8 @@ const bindEvents = () => {
|
|
|
367
690
|
state.transform.y += dy
|
|
368
691
|
})
|
|
369
692
|
canvas.addEventListener('pointerup', event => {
|
|
370
|
-
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
|
|
371
|
-
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
|
|
693
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
694
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
372
695
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
373
696
|
canvas.releasePointerCapture(event.pointerId)
|
|
374
697
|
})
|
|
@@ -417,14 +740,29 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
417
740
|
state.graph = graph
|
|
418
741
|
state.nodes = layout.nodes
|
|
419
742
|
state.edges = layout.edges
|
|
743
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
744
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
745
|
+
if (edge.target) {
|
|
746
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
747
|
+
}
|
|
748
|
+
return degrees
|
|
749
|
+
}, new Map())
|
|
750
|
+
state.nodeDetails = new Map()
|
|
751
|
+
resetContentFilter()
|
|
752
|
+
recomputeVisibility()
|
|
753
|
+
scheduleContentFilterSync()
|
|
420
754
|
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
421
|
-
|
|
755
|
+
setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
|
|
422
756
|
elements.nodeCount.textContent = graph.nodes.length
|
|
423
757
|
elements.edgeCount.textContent = graph.edges.length
|
|
424
758
|
elements.tagCount.textContent = tags.size
|
|
425
759
|
resize()
|
|
426
760
|
if (options.reset) resetView()
|
|
427
|
-
|
|
761
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
762
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
763
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
764
|
+
elements.contentDialog.close()
|
|
765
|
+
}
|
|
428
766
|
}
|
|
429
767
|
|
|
430
768
|
bindEvents()
|
|
@@ -441,10 +779,7 @@ const refreshGraphLoop = () => {
|
|
|
441
779
|
return
|
|
442
780
|
}
|
|
443
781
|
|
|
444
|
-
loadGraph().catch(
|
|
445
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
446
|
-
console.error(error)
|
|
447
|
-
})
|
|
782
|
+
loadGraph().catch(handleGraphRefreshError)
|
|
448
783
|
|
|
449
784
|
tickCounter += 1
|
|
450
785
|
if (tickCounter % 3 === 0) {
|
|
@@ -461,7 +796,6 @@ loadAgents()
|
|
|
461
796
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
462
797
|
})
|
|
463
798
|
.catch(error => {
|
|
464
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
465
799
|
console.error(error)
|
|
466
800
|
})
|
|
467
801
|
|
|
@@ -470,9 +804,6 @@ document.addEventListener('visibilitychange', () => {
|
|
|
470
804
|
return
|
|
471
805
|
}
|
|
472
806
|
|
|
473
|
-
loadGraph({ reset: true }).catch(
|
|
474
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
475
|
-
console.error(error)
|
|
476
|
-
})
|
|
807
|
+
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
477
808
|
})
|
|
478
809
|
`;
|