@andespindola/brainlink 1.0.4 → 1.0.6
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/README.md +17 -9
- package/dist/application/add-note.js +2 -2
- package/dist/application/build-context.js +16 -10
- package/dist/application/canonical-context-links.js +44 -5
- package/dist/application/check-package-update.js +105 -0
- package/dist/application/frontend/client/chunk-fetch.js +236 -0
- package/dist/application/frontend/client/controls.js +178 -0
- package/dist/application/frontend/client/elements.js +122 -0
- package/dist/application/frontend/client/input.js +202 -0
- package/dist/application/frontend/client/node-details.js +191 -0
- package/dist/application/frontend/client/rendering.js +296 -0
- package/dist/application/frontend/client/scope-theme.js +114 -0
- package/dist/application/frontend/client/spatial.js +98 -0
- package/dist/application/frontend/client/storage.js +215 -0
- package/dist/application/frontend/client/upload.js +90 -0
- package/dist/application/frontend/client/worker-bootstrap.js +147 -0
- package/dist/application/frontend/client-js.js +24 -1837
- package/dist/application/frontend/client-render-worker-js.js +1 -1
- package/dist/application/index-vault-phases.js +189 -0
- package/dist/application/index-vault.js +44 -165
- package/dist/application/server/routes.js +12 -9
- package/dist/cli/commands/write/dedupe-commands.js +59 -0
- package/dist/cli/commands/write/index-commands.js +205 -0
- package/dist/cli/commands/write/link-commands.js +68 -0
- package/dist/cli/commands/write/note-commands.js +146 -0
- package/dist/cli/commands/write/server-commands.js +553 -0
- package/dist/cli/commands/write/shared.js +35 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
- package/dist/cli/commands/write-commands.js +12 -1303
- package/dist/cli/main.js +39 -3
- package/dist/domain/context.js +39 -3
- package/dist/domain/embeddings.js +31 -5
- package/dist/domain/graph-contexts.js +62 -57
- package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
- package/dist/domain/graph-layout/collisions.js +100 -0
- package/dist/domain/graph-layout/hierarchy.js +135 -0
- package/dist/domain/graph-layout/metrics.js +111 -0
- package/dist/domain/graph-layout/segments.js +76 -0
- package/dist/domain/graph-layout/star-layout.js +110 -0
- package/dist/domain/graph-layout.js +4 -625
- package/dist/infrastructure/config.js +10 -4
- package/dist/infrastructure/file-index.js +13 -4
- package/dist/infrastructure/semantic-prefilter.js +24 -0
- package/dist/mcp/server.js +7 -0
- package/dist/mcp/tool-guard.js +29 -0
- package/dist/mcp/tools/maintenance-tools.js +409 -0
- package/dist/mcp/tools/read-tools.js +504 -0
- package/dist/mcp/tools/shared.js +216 -0
- package/dist/mcp/tools/write-tools.js +247 -0
- package/dist/mcp/tools.js +3 -1357
- package/docs/AGENT_USAGE.md +4 -4
- package/docs/QUICKSTART.md +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
export const createControlsJs = () => `
|
|
2
|
+
const setupControls = () => {
|
|
3
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
4
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
8
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
elements.fit.addEventListener('click', () => {
|
|
12
|
+
fitFromChunk()
|
|
13
|
+
scheduleChunkFetch()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
elements.releaseNode.addEventListener('click', () => {
|
|
17
|
+
releaseSelectedNodePosition()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
elements.reset.addEventListener('click', () => {
|
|
21
|
+
clearStoredNodePositions()
|
|
22
|
+
clearNodePositionsOnServer()
|
|
23
|
+
state.camera = { x: 0, y: 0, scale: 0.22 }
|
|
24
|
+
updateWorkerCamera()
|
|
25
|
+
scheduleChunkFetch({ fit: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
elements.contentClose.addEventListener('click', () => {
|
|
29
|
+
closeContentDialog()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
elements.copyWikiLink.addEventListener('click', () => {
|
|
33
|
+
copySelectedWikiLink().catch((error) => {
|
|
34
|
+
elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
elements.suggestNodeLinks.addEventListener('click', () => {
|
|
39
|
+
loadSelectedLinkSuggestions().catch((error) => {
|
|
40
|
+
elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
elements.contentLinkSuggestions.addEventListener('click', (event) => {
|
|
45
|
+
const button = event.target.closest('button[data-title]')
|
|
46
|
+
if (!button) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const value = '[[' + button.dataset.title + ']]'
|
|
50
|
+
navigator.clipboard.writeText(value).then(() => {
|
|
51
|
+
elements.contentActionStatus.textContent = 'Copied ' + value
|
|
52
|
+
}).catch(() => {
|
|
53
|
+
elements.contentActionStatus.textContent = value
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
elements.contentDialog.addEventListener('click', (event) => {
|
|
58
|
+
if (event.target === elements.contentDialog) {
|
|
59
|
+
closeContentDialog()
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
elements.search.addEventListener('input', () => {
|
|
64
|
+
if (state.searchTimer) {
|
|
65
|
+
clearTimeout(state.searchTimer)
|
|
66
|
+
}
|
|
67
|
+
state.searchTimer = setTimeout(() => {
|
|
68
|
+
state.searchTimer = null
|
|
69
|
+
runGraphSearch().catch((error) => console.error(error))
|
|
70
|
+
}, 160)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const runGraphSearch = async () => {
|
|
75
|
+
const token = ++state.searchToken
|
|
76
|
+
const query = (elements.search.value || '').trim()
|
|
77
|
+
if (!query) {
|
|
78
|
+
state.searchResultIds = new Set()
|
|
79
|
+
setFocusedNodeIds(new Set())
|
|
80
|
+
if (state.renderWorker && state.workerReady) {
|
|
81
|
+
state.renderWorker.postMessage({ type: 'highlight', ids: [] })
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const response = await fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error('Failed to search graph')
|
|
89
|
+
}
|
|
90
|
+
const payload = await response.json()
|
|
91
|
+
if (token !== state.searchToken) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ids = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter((id) => typeof id === 'string' && id.length > 0) : []
|
|
96
|
+
state.searchResultIds = new Set(ids)
|
|
97
|
+
setFocusedNodeIds(state.searchResultIds)
|
|
98
|
+
if (state.renderWorker && state.workerReady) {
|
|
99
|
+
state.renderWorker.postMessage({ type: 'highlight', ids })
|
|
100
|
+
}
|
|
101
|
+
if (ids.length > 0 && state.graphMode === 'far') {
|
|
102
|
+
state.camera.scale = Math.max(state.camera.scale, 0.82)
|
|
103
|
+
updateWorkerCamera()
|
|
104
|
+
scheduleChunkFetch()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const loadAgents = async () => {
|
|
109
|
+
const response = await fetch('/api/agents')
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error('Failed to load agents')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const payload = await response.json()
|
|
115
|
+
const agents = Array.isArray(payload?.agents) ? payload.agents : []
|
|
116
|
+
|
|
117
|
+
elements.agent.innerHTML = agents
|
|
118
|
+
.map((agent) => {
|
|
119
|
+
const id = String(agent?.id || '')
|
|
120
|
+
const count = Number.isFinite(agent?.documentCount) ? agent.documentCount : 0
|
|
121
|
+
const label = id === 'shared' ? 'shared' : id
|
|
122
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(label) + ' (' + count + ')</option>'
|
|
123
|
+
})
|
|
124
|
+
.join('')
|
|
125
|
+
|
|
126
|
+
const preferredAgent = initialAgentFromUrl || readStoredAgent()
|
|
127
|
+
const hasPreferred = preferredAgent && agents.some((agent) => agent?.id === preferredAgent)
|
|
128
|
+
state.agentId = hasPreferred ? preferredAgent : String(agents[0]?.id || '')
|
|
129
|
+
elements.agent.value = state.agentId
|
|
130
|
+
|
|
131
|
+
elements.agent.addEventListener('change', () => {
|
|
132
|
+
state.agentId = elements.agent.value || ''
|
|
133
|
+
writeStoredAgent(state.agentId)
|
|
134
|
+
syncAgentInUrl(state.agentId)
|
|
135
|
+
loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
syncAgentInUrl(state.agentId)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const loadContexts = async () => {
|
|
142
|
+
const response = await fetch('/api/graph-contexts' + (state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''))
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error('Failed to load graph contexts')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const payload = await response.json()
|
|
148
|
+
const contexts = Array.isArray(payload?.contexts) ? payload.contexts : []
|
|
149
|
+
const options = [
|
|
150
|
+
'<option value="">All contexts</option>',
|
|
151
|
+
...contexts.map((context) => {
|
|
152
|
+
const id = String(context?.id || '')
|
|
153
|
+
const title = String(context?.title || id || 'Untitled')
|
|
154
|
+
const count = Number.isFinite(context?.nodeCount) ? context.nodeCount : 0
|
|
155
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(title) + ' (' + count + ')</option>'
|
|
156
|
+
})
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
elements.context.innerHTML = options.join('')
|
|
160
|
+
|
|
161
|
+
const preferredContext = initialContextFromUrl || readStoredContext()
|
|
162
|
+
const hasPreferred = preferredContext && contexts.some((context) => context?.id === preferredContext)
|
|
163
|
+
state.contextId = hasPreferred ? preferredContext : ''
|
|
164
|
+
elements.context.value = state.contextId
|
|
165
|
+
writeStoredContext(state.contextId)
|
|
166
|
+
syncContextInUrl(state.contextId)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const setupContextControl = () => {
|
|
170
|
+
elements.context.addEventListener('change', () => {
|
|
171
|
+
state.contextId = elements.context.value || ''
|
|
172
|
+
state.selectedNodeId = null
|
|
173
|
+
writeStoredContext(state.contextId)
|
|
174
|
+
syncContextInUrl(state.contextId)
|
|
175
|
+
scheduleChunkFetch({ fit: true })
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
`;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const createElementsJs = () => `let canvas = document.getElementById('graph')
|
|
2
|
+
let ctx2dFallback = null
|
|
3
|
+
let inputGlobalsBound = false
|
|
4
|
+
const byId = (id) => document.getElementById(id)
|
|
5
|
+
const elements = {
|
|
6
|
+
search: byId('search'),
|
|
7
|
+
agent: byId('agent'),
|
|
8
|
+
context: byId('context'),
|
|
9
|
+
nodeCount: byId('nodeCount'),
|
|
10
|
+
edgeCount: byId('edgeCount'),
|
|
11
|
+
zoomIn: byId('zoomIn'),
|
|
12
|
+
zoomOut: byId('zoomOut'),
|
|
13
|
+
fit: byId('fit'),
|
|
14
|
+
releaseNode: byId('releaseNode'),
|
|
15
|
+
reset: byId('reset'),
|
|
16
|
+
uploadOpen: byId('uploadOpen'),
|
|
17
|
+
uploadDialog: byId('uploadDialog'),
|
|
18
|
+
uploadForm: byId('uploadForm'),
|
|
19
|
+
uploadFile: byId('uploadFile'),
|
|
20
|
+
uploadTitleInput: byId('uploadTitleInput'),
|
|
21
|
+
uploadAllowSensitive: byId('uploadAllowSensitive'),
|
|
22
|
+
uploadClose: byId('uploadClose'),
|
|
23
|
+
uploadSubmit: byId('uploadSubmit'),
|
|
24
|
+
uploadStatus: byId('uploadStatus'),
|
|
25
|
+
labels: byId('graphLabels'),
|
|
26
|
+
tooltip: byId('graphTooltip'),
|
|
27
|
+
miniMap: byId('miniMap'),
|
|
28
|
+
contentDialog: byId('contentDialog'),
|
|
29
|
+
contentTitle: byId('contentTitle'),
|
|
30
|
+
contentPath: byId('contentPath'),
|
|
31
|
+
contentFacts: byId('contentFacts'),
|
|
32
|
+
contentContextLinks: byId('contentContextLinks'),
|
|
33
|
+
contentTags: byId('contentTags'),
|
|
34
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
35
|
+
contentIncoming: byId('contentIncoming'),
|
|
36
|
+
contentBody: byId('contentBody'),
|
|
37
|
+
contentClose: byId('contentClose'),
|
|
38
|
+
copyWikiLink: byId('copyWikiLink'),
|
|
39
|
+
suggestNodeLinks: byId('suggestNodeLinks'),
|
|
40
|
+
contentActionStatus: byId('contentActionStatus'),
|
|
41
|
+
contentLinkSuggestions: byId('contentLinkSuggestions')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const state = {
|
|
45
|
+
camera: {
|
|
46
|
+
x: 0,
|
|
47
|
+
y: 0,
|
|
48
|
+
scale: 0.22
|
|
49
|
+
},
|
|
50
|
+
pointer: {
|
|
51
|
+
down: false,
|
|
52
|
+
moved: false,
|
|
53
|
+
dragging: false,
|
|
54
|
+
dragNodeId: '',
|
|
55
|
+
x: 0,
|
|
56
|
+
y: 0,
|
|
57
|
+
startX: 0,
|
|
58
|
+
startY: 0,
|
|
59
|
+
startWorldX: 0,
|
|
60
|
+
startWorldY: 0,
|
|
61
|
+
nodeStartX: 0,
|
|
62
|
+
nodeStartY: 0,
|
|
63
|
+
worldAnchorX: 0,
|
|
64
|
+
worldAnchorY: 0
|
|
65
|
+
},
|
|
66
|
+
viewport: {
|
|
67
|
+
width: 320,
|
|
68
|
+
height: 320,
|
|
69
|
+
ratio: window.devicePixelRatio || 1
|
|
70
|
+
},
|
|
71
|
+
workerReady: false,
|
|
72
|
+
rendererMode: 'worker',
|
|
73
|
+
renderWorker: null,
|
|
74
|
+
agentId: '',
|
|
75
|
+
contextId: '',
|
|
76
|
+
graphSignature: '',
|
|
77
|
+
graphMode: 'near',
|
|
78
|
+
nodePositionsSignature: '',
|
|
79
|
+
nodePositionsScope: '',
|
|
80
|
+
serverNodePositionsScope: '',
|
|
81
|
+
nodePositions: new Map(),
|
|
82
|
+
hoveredNodeId: '',
|
|
83
|
+
focusedNodeIds: new Set(),
|
|
84
|
+
spatialIndex: {
|
|
85
|
+
key: '',
|
|
86
|
+
cells: new Map()
|
|
87
|
+
},
|
|
88
|
+
miniMapView: null,
|
|
89
|
+
miniMapDirty: true,
|
|
90
|
+
overlayScheduled: false,
|
|
91
|
+
overlayIdleTimer: null,
|
|
92
|
+
chunk: {
|
|
93
|
+
nodes: [],
|
|
94
|
+
edges: []
|
|
95
|
+
},
|
|
96
|
+
selectedNodeId: null,
|
|
97
|
+
searchToken: 0,
|
|
98
|
+
searchTimer: null,
|
|
99
|
+
searchResultIds: new Set(),
|
|
100
|
+
fetchToken: 0,
|
|
101
|
+
fetchTimer: null,
|
|
102
|
+
fetchAbortController: null,
|
|
103
|
+
lastChunkRequestKey: '',
|
|
104
|
+
cameraSyncScheduled: false,
|
|
105
|
+
lastWheelAt: 0,
|
|
106
|
+
lastVisibleNodes: 0,
|
|
107
|
+
lastVisibleEdges: 0,
|
|
108
|
+
totals: {
|
|
109
|
+
nodes: 0,
|
|
110
|
+
edges: 0
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const zoomRange = {
|
|
115
|
+
min: 0.0002,
|
|
116
|
+
max: 4.5
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
120
|
+
const selectedContextStorageKey = 'brainlink:selected-context'
|
|
121
|
+
const nodePositionsStoragePrefix = 'brainlink:graph-node-positions:'
|
|
122
|
+
`;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export const createInputJs = () => `
|
|
2
|
+
const resolvePointer = (event) => {
|
|
3
|
+
const rect = canvas.getBoundingClientRect()
|
|
4
|
+
return {
|
|
5
|
+
x: event.clientX - rect.left,
|
|
6
|
+
y: event.clientY - rect.top
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const setupInput = () => {
|
|
11
|
+
const dragActivationDistance = 6
|
|
12
|
+
const resetPointerState = (pointerId = null) => {
|
|
13
|
+
state.pointer.down = false
|
|
14
|
+
state.pointer.dragging = false
|
|
15
|
+
state.pointer.dragNodeId = ''
|
|
16
|
+
canvas.classList.remove('is-node-dragging')
|
|
17
|
+
if (pointerId !== null) {
|
|
18
|
+
try {
|
|
19
|
+
if (canvas.hasPointerCapture(pointerId)) {
|
|
20
|
+
canvas.releasePointerCapture(pointerId)
|
|
21
|
+
}
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
canvas.addEventListener('wheel', (event) => {
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
state.lastWheelAt = performance.now()
|
|
29
|
+
const pointer = resolvePointer(event)
|
|
30
|
+
const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
|
|
31
|
+
zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
|
|
32
|
+
}, { passive: false })
|
|
33
|
+
|
|
34
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
35
|
+
event.preventDefault()
|
|
36
|
+
const pointer = resolvePointer(event)
|
|
37
|
+
const candidateNode = pickFallbackNode(pointer.x, pointer.y)
|
|
38
|
+
const candidateNodeId = isRealGraphNode(candidateNode) && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
|
|
39
|
+
const candidateX = Number(candidateNode?.[2])
|
|
40
|
+
const candidateY = Number(candidateNode?.[3])
|
|
41
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
42
|
+
state.pointer.down = true
|
|
43
|
+
state.pointer.moved = false
|
|
44
|
+
state.pointer.dragging = false
|
|
45
|
+
state.pointer.dragNodeId = candidateNodeId
|
|
46
|
+
state.pointer.x = pointer.x
|
|
47
|
+
state.pointer.y = pointer.y
|
|
48
|
+
state.pointer.startX = pointer.x
|
|
49
|
+
state.pointer.startY = pointer.y
|
|
50
|
+
state.pointer.startWorldX = world.x
|
|
51
|
+
state.pointer.startWorldY = world.y
|
|
52
|
+
state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
|
|
53
|
+
state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
|
|
54
|
+
state.pointer.worldAnchorX = world.x
|
|
55
|
+
state.pointer.worldAnchorY = world.y
|
|
56
|
+
try {
|
|
57
|
+
canvas.setPointerCapture(event.pointerId)
|
|
58
|
+
} catch {}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
canvas.addEventListener('pointermove', (event) => {
|
|
62
|
+
if (state.pointer.down) {
|
|
63
|
+
event.preventDefault()
|
|
64
|
+
}
|
|
65
|
+
const pointer = resolvePointer(event)
|
|
66
|
+
|
|
67
|
+
if (state.pointer.down) {
|
|
68
|
+
const dx = pointer.x - state.pointer.x
|
|
69
|
+
const dy = pointer.y - state.pointer.y
|
|
70
|
+
const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
|
|
71
|
+
if (distanceFromStart >= dragActivationDistance) {
|
|
72
|
+
state.pointer.moved = true
|
|
73
|
+
state.pointer.dragging = true
|
|
74
|
+
canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
|
|
75
|
+
}
|
|
76
|
+
if (!state.pointer.dragging) {
|
|
77
|
+
state.pointer.x = pointer.x
|
|
78
|
+
state.pointer.y = pointer.y
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (state.pointer.dragNodeId) {
|
|
82
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
83
|
+
const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
|
|
84
|
+
const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
|
|
85
|
+
state.nodePositions.set(state.pointer.dragNodeId, { x, y })
|
|
86
|
+
updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
|
|
87
|
+
state.pointer.x = pointer.x
|
|
88
|
+
state.pointer.y = pointer.y
|
|
89
|
+
drawFallback()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
state.camera.x += dx
|
|
93
|
+
state.camera.y += dy
|
|
94
|
+
state.pointer.x = pointer.x
|
|
95
|
+
state.pointer.y = pointer.y
|
|
96
|
+
updateWorkerCamera()
|
|
97
|
+
drawFallback()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hovered = pickFallbackNode(pointer.x, pointer.y)
|
|
102
|
+
const hoveredId = isRealGraphNode(hovered) && typeof hovered?.[0] === 'string' ? hovered[0] : ''
|
|
103
|
+
if (state.hoveredNodeId !== hoveredId) {
|
|
104
|
+
state.hoveredNodeId = hoveredId
|
|
105
|
+
canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
|
|
106
|
+
updateGraphOverlays()
|
|
107
|
+
}
|
|
108
|
+
if (hoveredId) {
|
|
109
|
+
showTooltip(hovered, pointer)
|
|
110
|
+
} else {
|
|
111
|
+
hideTooltip()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
116
|
+
const pointer = resolvePointer(event)
|
|
117
|
+
const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
|
|
118
|
+
const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
|
|
119
|
+
const shouldRefreshAfterDrag = state.pointer.dragging
|
|
120
|
+
const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
|
|
121
|
+
resetPointerState(event.pointerId)
|
|
122
|
+
|
|
123
|
+
if (shouldPick) {
|
|
124
|
+
pickAt(pointer.x, pointer.y)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (shouldPersistNodePosition) {
|
|
128
|
+
writeStoredNodePositions()
|
|
129
|
+
persistNodePositionsToServer()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (shouldRefreshAfterDrag) {
|
|
133
|
+
scheduleChunkFetch()
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
canvas.addEventListener('pointerleave', () => {
|
|
138
|
+
state.hoveredNodeId = ''
|
|
139
|
+
canvas.classList.remove('is-node-hover')
|
|
140
|
+
hideTooltip()
|
|
141
|
+
updateGraphOverlays()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
canvas.addEventListener('pointercancel', (event) => {
|
|
145
|
+
resetPointerState(event.pointerId)
|
|
146
|
+
hideTooltip()
|
|
147
|
+
updateGraphOverlays()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
canvas.addEventListener('lostpointercapture', () => {
|
|
151
|
+
resetPointerState()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Listeners globais (mini mapa e teclado) só podem ser ligados uma vez —
|
|
155
|
+
// setupInput pode ser re-executado ao trocar o canvas no fallback.
|
|
156
|
+
if (!inputGlobalsBound) {
|
|
157
|
+
elements.miniMap.addEventListener('click', (event) => {
|
|
158
|
+
if (!state.miniMapView) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const rect = elements.miniMap.getBoundingClientRect()
|
|
162
|
+
const x = event.clientX - rect.left
|
|
163
|
+
const y = event.clientY - rect.top
|
|
164
|
+
const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
|
|
165
|
+
const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
|
|
166
|
+
state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
|
|
167
|
+
state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
|
|
168
|
+
updateWorkerCamera()
|
|
169
|
+
scheduleChunkFetch()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
window.addEventListener('keydown', (event) => {
|
|
173
|
+
if (event.key === 'Escape' && !elements.uploadDialog.hidden) {
|
|
174
|
+
closeUploadDialog()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (event.key === 'Escape' && !elements.contentDialog.hidden) {
|
|
178
|
+
closeContentDialog()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (event.key === '+') {
|
|
182
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (event.key === '-') {
|
|
186
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (event.key === '0') {
|
|
190
|
+
scheduleChunkFetch({ fit: true })
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
inputGlobalsBound = true
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
canvas.addEventListener('dblclick', (event) => {
|
|
198
|
+
const pointer = resolvePointer(event)
|
|
199
|
+
zoomAtPoint(pointer.x, pointer.y, 1.065)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
`;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
export const createNodeDetailsJs = () => `
|
|
2
|
+
const list = (items) => {
|
|
3
|
+
const rows = normalizeList(items)
|
|
4
|
+
if (rows.length === 0) {
|
|
5
|
+
return '<li><small>No links found.</small></li>'
|
|
6
|
+
}
|
|
7
|
+
return rows
|
|
8
|
+
.map((item) => {
|
|
9
|
+
const title = typeof item?.title === 'string' ? item.title : 'Untitled'
|
|
10
|
+
const id = typeof item?.id === 'string' ? item.id : ''
|
|
11
|
+
const path = typeof item?.path === 'string' ? item.path : ''
|
|
12
|
+
const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
|
|
13
|
+
return '<li>' +
|
|
14
|
+
(id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
|
|
15
|
+
'<small>' + escapeHtml(path) + meta + '</small>' +
|
|
16
|
+
'</li>'
|
|
17
|
+
})
|
|
18
|
+
.join('')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const buildFacts = (node, outgoingCount, incomingCount) => {
|
|
22
|
+
const content = typeof node?.content === 'string' ? node.content : ''
|
|
23
|
+
const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
|
|
24
|
+
return [
|
|
25
|
+
{ label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
|
|
26
|
+
{ label: 'Words', value: String(words) },
|
|
27
|
+
{ label: 'Chars', value: String(content.length) },
|
|
28
|
+
{ label: 'Outgoing', value: String(outgoingCount) },
|
|
29
|
+
{ label: 'Backlinks', value: String(incomingCount) }
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const listFacts = (facts) => facts
|
|
34
|
+
.map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
|
|
35
|
+
.join('')
|
|
36
|
+
|
|
37
|
+
const listContextLinks = (links) => {
|
|
38
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
39
|
+
return '<li><small>No context links found.</small></li>'
|
|
40
|
+
}
|
|
41
|
+
return links
|
|
42
|
+
.map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
|
|
43
|
+
.join('')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nodeContextLinks = (node, outgoing) => {
|
|
47
|
+
const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
|
|
48
|
+
const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
|
|
49
|
+
|
|
50
|
+
return titles
|
|
51
|
+
.map((title) => {
|
|
52
|
+
const match = outgoingByTitle.get(String(title).toLowerCase())
|
|
53
|
+
return {
|
|
54
|
+
title,
|
|
55
|
+
priority: match?.priority || 'normal'
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const linkedNodes = (node) => {
|
|
62
|
+
const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
|
|
63
|
+
const edges = normalizeList(state.chunk.edges)
|
|
64
|
+
|
|
65
|
+
const outgoing = []
|
|
66
|
+
const incoming = []
|
|
67
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
68
|
+
const edge = edges[index]
|
|
69
|
+
if (edge[0] === node.id) {
|
|
70
|
+
const target = nodeById.get(edge[1])
|
|
71
|
+
if (target) {
|
|
72
|
+
outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (edge[1] === node.id) {
|
|
76
|
+
const source = nodeById.get(edge[0])
|
|
77
|
+
if (source) {
|
|
78
|
+
incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { outgoing, incoming }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const openContentDialog = () => {
|
|
87
|
+
elements.contentDialog.hidden = false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const closeContentDialog = () => {
|
|
91
|
+
elements.contentDialog.hidden = true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const selectedNode = () => {
|
|
95
|
+
if (!state.selectedNodeId) {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const packed = nodeByIdFromChunk().get(state.selectedNodeId)
|
|
100
|
+
if (!packed) {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id: packed[0],
|
|
106
|
+
title: packed[1],
|
|
107
|
+
path: packed[4] || ''
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const copySelectedWikiLink = async () => {
|
|
112
|
+
const node = selectedNode()
|
|
113
|
+
if (!node) {
|
|
114
|
+
elements.contentActionStatus.textContent = 'Select a note first.'
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const value = '[[' + node.title + ']]'
|
|
119
|
+
try {
|
|
120
|
+
await navigator.clipboard.writeText(value)
|
|
121
|
+
elements.contentActionStatus.textContent = 'Copied ' + value
|
|
122
|
+
} catch {
|
|
123
|
+
elements.contentActionStatus.textContent = value
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const loadSelectedLinkSuggestions = async () => {
|
|
128
|
+
const content = elements.contentBody.textContent || ''
|
|
129
|
+
if (!content.trim()) {
|
|
130
|
+
elements.contentActionStatus.textContent = 'Selected note has no content.'
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
elements.contentActionStatus.textContent = 'Loading suggestions...'
|
|
135
|
+
const response = await fetch('/api/suggest-links?limit=5&content=' + encodeURIComponent(content.slice(0, 2000)) + scopeQuery('&'))
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
throw new Error('Failed to load link suggestions')
|
|
138
|
+
}
|
|
139
|
+
const payload = await response.json()
|
|
140
|
+
const suggestions = Array.isArray(payload.suggestions) ? payload.suggestions : []
|
|
141
|
+
elements.contentLinkSuggestions.innerHTML = suggestions.length > 0
|
|
142
|
+
? suggestions.map((item) => '<li><button type="button" data-title="' + escapeHtml(item.title) + '">[[' + escapeHtml(item.title) + ']]</button></li>').join('')
|
|
143
|
+
: '<li>No strong suggestions</li>'
|
|
144
|
+
elements.contentActionStatus.textContent = suggestions.length > 0 ? 'Suggested Context Links' : 'No strong suggestions found.'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const loadNodeDetails = async (nodeId) => {
|
|
148
|
+
if (!nodeId) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error('Failed to load graph node details')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const payload = await response.json()
|
|
158
|
+
if (!payload || typeof payload !== 'object' || !payload.node) {
|
|
159
|
+
throw new Error('Invalid graph node payload')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const node = payload.node
|
|
163
|
+
state.selectedNodeId = node.id
|
|
164
|
+
setFocusedNodeIds(linkedNodeIds(node.id))
|
|
165
|
+
|
|
166
|
+
if (state.renderWorker && state.workerReady) {
|
|
167
|
+
state.renderWorker.postMessage({ type: 'select', id: node.id })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
elements.contentTitle.textContent = node.title || 'Untitled'
|
|
171
|
+
elements.contentPath.textContent = node.path || ''
|
|
172
|
+
|
|
173
|
+
const tags = Array.isArray(node.tags) ? node.tags : []
|
|
174
|
+
elements.contentTags.innerHTML = tags.length > 0
|
|
175
|
+
? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
|
|
176
|
+
: '<span>No tags</span>'
|
|
177
|
+
|
|
178
|
+
const related = linkedNodes(node)
|
|
179
|
+
const contextLinks = nodeContextLinks(node, related.outgoing)
|
|
180
|
+
const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
|
|
181
|
+
elements.contentFacts.innerHTML = listFacts(facts)
|
|
182
|
+
elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
|
|
183
|
+
elements.contentOutgoing.innerHTML = list(related.outgoing)
|
|
184
|
+
elements.contentIncoming.innerHTML = list(related.incoming)
|
|
185
|
+
elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
|
|
186
|
+
elements.contentActionStatus.textContent = ''
|
|
187
|
+
elements.contentLinkSuggestions.innerHTML = ''
|
|
188
|
+
|
|
189
|
+
openContentDialog()
|
|
190
|
+
}
|
|
191
|
+
`;
|