@jungjaehoon/mama-os 0.62.0 → 0.64.0
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/CHANGELOG.md +66 -0
- package/README.md +1 -1
- package/dist/api/attachment-actions.d.ts.map +1 -1
- package/dist/api/attachment-actions.js +6 -2
- package/dist/api/attachment-actions.js.map +1 -1
- package/dist/api/drive-actions.d.ts +2 -1
- package/dist/api/drive-actions.d.ts.map +1 -1
- package/dist/api/drive-actions.js +1 -0
- package/dist/api/drive-actions.js.map +1 -1
- package/dist/api/drive-delivery.d.ts +16 -0
- package/dist/api/drive-delivery.d.ts.map +1 -0
- package/dist/api/drive-delivery.js +190 -0
- package/dist/api/drive-delivery.js.map +1 -0
- package/dist/api/file-delivery.d.ts +2 -2
- package/dist/api/file-delivery.d.ts.map +1 -1
- package/dist/api/file-delivery.js +5 -5
- package/dist/api/file-delivery.js.map +1 -1
- package/dist/api/security-events.d.ts +5 -4
- package/dist/api/security-events.d.ts.map +1 -1
- package/dist/api/security-events.js +25 -3
- package/dist/api/security-events.js.map +1 -1
- package/dist/api/viewer-data.d.ts +82 -1
- package/dist/api/viewer-data.d.ts.map +1 -1
- package/dist/api/viewer-data.js +116 -0
- package/dist/api/viewer-data.js.map +1 -1
- package/dist/api/viewer-server.d.ts +2 -2
- package/dist/api/viewer-server.d.ts.map +1 -1
- package/dist/api/viewer-server.js +154 -5
- package/dist/api/viewer-server.js.map +1 -1
- package/dist/api/wiki-actions.d.ts.map +1 -1
- package/dist/api/wiki-actions.js +44 -0
- package/dist/api/wiki-actions.js.map +1 -1
- package/dist/api/work-actions.d.ts +2 -0
- package/dist/api/work-actions.d.ts.map +1 -1
- package/dist/api/work-actions.js +4 -5
- package/dist/api/work-actions.js.map +1 -1
- package/dist/cli/commands/daemon.d.ts.map +1 -1
- package/dist/cli/commands/daemon.js +14 -7
- package/dist/cli/commands/daemon.js.map +1 -1
- package/dist/gateways/discord.d.ts.map +1 -1
- package/dist/gateways/discord.js +1 -1
- package/dist/gateways/discord.js.map +1 -1
- package/dist/gateways/slack.d.ts.map +1 -1
- package/dist/gateways/slack.js +1 -1
- package/dist/gateways/slack.js.map +1 -1
- package/dist/gateways/telegram.d.ts.map +1 -1
- package/dist/gateways/telegram.js +1 -1
- package/dist/gateways/telegram.js.map +1 -1
- package/dist/replay/replay-source-catalog.js +1 -1
- package/dist/replay/replay-source-catalog.js.map +1 -1
- package/dist/runtime/action-surface.d.ts +4 -1
- package/dist/runtime/action-surface.d.ts.map +1 -1
- package/dist/runtime/action-surface.js +19 -1
- package/dist/runtime/action-surface.js.map +1 -1
- package/dist/runtime/config.d.ts +20 -0
- package/dist/runtime/config.d.ts.map +1 -1
- package/dist/runtime/config.js +48 -3
- package/dist/runtime/config.js.map +1 -1
- package/dist/runtime/outbound-attempts.d.ts +4 -2
- package/dist/runtime/outbound-attempts.d.ts.map +1 -1
- package/dist/runtime/outbound-attempts.js +17 -1
- package/dist/runtime/outbound-attempts.js.map +1 -1
- package/dist/runtime/owner-runtime.d.ts +6 -1
- package/dist/runtime/owner-runtime.d.ts.map +1 -1
- package/dist/runtime/owner-runtime.js +4 -1
- package/dist/runtime/owner-runtime.js.map +1 -1
- package/dist/runtime/owner-system-prompt.d.ts +1 -1
- package/dist/runtime/owner-system-prompt.d.ts.map +1 -1
- package/dist/runtime/owner-system-prompt.js +10 -4
- package/dist/runtime/owner-system-prompt.js.map +1 -1
- package/dist/runtime/report-scheduler.d.ts +2 -1
- package/dist/runtime/report-scheduler.d.ts.map +1 -1
- package/dist/runtime/report-scheduler.js +1 -1
- package/dist/runtime/report-scheduler.js.map +1 -1
- package/dist/runtime/stimulus-delivery.js +1 -1
- package/dist/runtime/stimulus-delivery.js.map +1 -1
- package/dist/runtime/turn-orders.d.ts.map +1 -1
- package/dist/runtime/turn-orders.js +7 -3
- package/dist/runtime/turn-orders.js.map +1 -1
- package/dist/wiki/obsidian-writer.d.ts +0 -3
- package/dist/wiki/obsidian-writer.d.ts.map +1 -1
- package/dist/wiki/obsidian-writer.js +8 -108
- package/dist/wiki/obsidian-writer.js.map +1 -1
- package/dist/wiki/path-safety.d.ts +1 -1
- package/dist/wiki/path-safety.d.ts.map +1 -1
- package/dist/wiki/path-safety.js +2 -2
- package/dist/wiki/path-safety.js.map +1 -1
- package/dist/wiki/wiki-move.d.ts +12 -0
- package/dist/wiki/wiki-move.d.ts.map +1 -0
- package/dist/wiki/wiki-move.js +95 -0
- package/dist/wiki/wiki-move.js.map +1 -0
- package/dist/wiki/wiki-read.d.ts +7 -0
- package/dist/wiki/wiki-read.d.ts.map +1 -1
- package/dist/wiki/wiki-read.js +38 -17
- package/dist/wiki/wiki-read.js.map +1 -1
- package/package.json +2 -2
- package/public/viewer/js/modules/graph.js +266 -992
- package/public/viewer/js/modules/memory-timeline.js +216 -0
- package/public/viewer/js/modules/wiki.js +4 -4
- package/public/viewer/js/utils/api.js +7 -26
- package/public/viewer/src/modules/graph.ts +307 -1185
- package/public/viewer/src/modules/memory-timeline.ts +280 -0
- package/public/viewer/src/modules/wiki.ts +4 -4
- package/public/viewer/src/utils/api.ts +48 -47
- package/public/viewer/viewer.css +257 -7
- package/public/viewer/viewer.html +68 -292
|
@@ -1,304 +1,301 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Graph Module -
|
|
2
|
+
* Graph Module - the filtered memory as a graph
|
|
3
3
|
* @module modules/graph
|
|
4
|
-
* @version 1.0.0
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - Detail panel for node information
|
|
11
|
-
* - BFS traversal for connected nodes
|
|
5
|
+
* The memory view's list and graph show the same records: whatever the period, kind and search
|
|
6
|
+
* select. One dot per record, one per work item for its updates in the period, and faded dots for
|
|
7
|
+
* the records they link to outside the filter. Lines are the links between records; the source
|
|
8
|
+
* messages a record rests on are listed in its detail.
|
|
12
9
|
*/
|
|
13
10
|
/* eslint-env browser */
|
|
14
11
|
/* global vis */
|
|
15
|
-
import { escapeHtml,
|
|
12
|
+
import { escapeHtml, getElementByIdOrNull } from '../utils/dom.js';
|
|
16
13
|
import { DebugLogger } from '../utils/debug-logger.js';
|
|
17
|
-
import { API
|
|
14
|
+
import { API } from '../utils/api.js';
|
|
18
15
|
import { renderSafeMarkdown } from '../utils/markdown.js';
|
|
16
|
+
import { groupInfo } from './memory-timeline.js';
|
|
19
17
|
const logger = new DebugLogger('Graph');
|
|
20
|
-
|
|
18
|
+
/** What a record's stored kind is called in the detail panel. */
|
|
19
|
+
export const KIND_LABELS = {
|
|
20
|
+
decision: 'Decision',
|
|
21
|
+
preference: 'Preference',
|
|
22
|
+
constraint: 'Constraint',
|
|
23
|
+
lesson: 'Lesson',
|
|
24
|
+
workflow: 'Workflow',
|
|
25
|
+
fact: 'Fact',
|
|
26
|
+
commitment: 'Work update',
|
|
27
|
+
observation: 'Source message',
|
|
28
|
+
memory: 'Memory without a kind',
|
|
29
|
+
registry: 'Registered name',
|
|
30
|
+
};
|
|
31
|
+
/** Every relation the engine stores, read from the record that holds the link. */
|
|
32
|
+
export const RELATION_LABELS = {
|
|
33
|
+
derived_from: 'rests on (evidence)',
|
|
34
|
+
mentions: 'mentions',
|
|
35
|
+
builds_on: 'builds on',
|
|
36
|
+
amends: 'amends',
|
|
37
|
+
refines: 'refines',
|
|
38
|
+
supersedes: 'replaces',
|
|
39
|
+
contradicts: 'contradicts',
|
|
40
|
+
debates: 'argues with',
|
|
41
|
+
synthesizes: 'combines',
|
|
42
|
+
case_member: 'belongs to case',
|
|
43
|
+
alias_of: 'another name for',
|
|
44
|
+
next_action_for: 'next step for',
|
|
45
|
+
blocks: 'blocks',
|
|
46
|
+
};
|
|
47
|
+
const OUTSIDE_COLOR = '#D1D5DB';
|
|
48
|
+
const LABEL_LENGTH = 40;
|
|
49
|
+
function shortLabel(text) {
|
|
50
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
51
|
+
return flat.length > LABEL_LENGTH ? `${flat.slice(0, LABEL_LENGTH - 1)}…` : flat;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The graph of what the filters selected. A work item is one dot for all its updates; a link
|
|
55
|
+
* between two updates of one item is not drawn, and a link that touches nothing selected is left
|
|
56
|
+
* out. Days arrive newest first, so an item's first update seen is its latest.
|
|
57
|
+
*/
|
|
58
|
+
export function composeFilterGraph(timeline, links) {
|
|
59
|
+
const nodes = new Map();
|
|
60
|
+
const recordNode = {};
|
|
61
|
+
for (const day of timeline.days) {
|
|
62
|
+
for (const group of day.groups) {
|
|
63
|
+
for (const record of group.records ?? []) {
|
|
64
|
+
nodes.set(record.id, {
|
|
65
|
+
id: record.id,
|
|
66
|
+
group: group.group,
|
|
67
|
+
kind: record.kind,
|
|
68
|
+
label: record.topic,
|
|
69
|
+
detailId: record.id,
|
|
70
|
+
updates: 0,
|
|
71
|
+
outside: false,
|
|
72
|
+
});
|
|
73
|
+
recordNode[record.id] = record.id;
|
|
74
|
+
}
|
|
75
|
+
for (const item of group.items ?? []) {
|
|
76
|
+
const id = `item:${item.commitmentId}`;
|
|
77
|
+
const node = nodes.get(id) ?? {
|
|
78
|
+
id,
|
|
79
|
+
group: 'work',
|
|
80
|
+
kind: 'commitment',
|
|
81
|
+
label: item.title ?? item.topic,
|
|
82
|
+
detailId: item.revisions[0].id,
|
|
83
|
+
updates: 0,
|
|
84
|
+
outside: false,
|
|
85
|
+
};
|
|
86
|
+
node.updates += item.revisions.length;
|
|
87
|
+
nodes.set(id, node);
|
|
88
|
+
for (const revision of item.revisions)
|
|
89
|
+
recordNode[revision.id] = id;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Links name both ends with a node; a work update outside the filter is drawn as its item.
|
|
94
|
+
const dotFor = (ref) => {
|
|
95
|
+
const listed = recordNode[ref];
|
|
96
|
+
if (listed !== undefined)
|
|
97
|
+
return listed;
|
|
98
|
+
const commitmentId = links.nodes[ref].commitmentId;
|
|
99
|
+
return commitmentId === null ? ref : `item:${commitmentId}`;
|
|
100
|
+
};
|
|
101
|
+
const edges = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const outside = [];
|
|
104
|
+
for (const link of links.edges) {
|
|
105
|
+
const from = dotFor(link.from);
|
|
106
|
+
const to = dotFor(link.to);
|
|
107
|
+
const key = `${from}\0${to}\0${link.relation}`;
|
|
108
|
+
if (from === to || seen.has(key) || (!nodes.has(from) && !nodes.has(to)))
|
|
109
|
+
continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
edges.push({ from, to, relation: link.relation });
|
|
112
|
+
for (const [dot, ref] of [
|
|
113
|
+
[from, link.from],
|
|
114
|
+
[to, link.to],
|
|
115
|
+
]) {
|
|
116
|
+
if (nodes.has(dot))
|
|
117
|
+
continue;
|
|
118
|
+
const node = {
|
|
119
|
+
id: dot,
|
|
120
|
+
group: 'outside',
|
|
121
|
+
kind: links.nodes[ref].kind,
|
|
122
|
+
label: links.nodes[ref].label,
|
|
123
|
+
detailId: ref,
|
|
124
|
+
updates: 0,
|
|
125
|
+
outside: true,
|
|
126
|
+
};
|
|
127
|
+
nodes.set(dot, node);
|
|
128
|
+
outside.push(node);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
nodes: [...nodes.values()].filter((node) => !node.outside).concat(outside),
|
|
133
|
+
edges,
|
|
134
|
+
recordNode,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
21
137
|
/**
|
|
22
138
|
* Graph Module Class
|
|
23
139
|
*/
|
|
24
140
|
export class GraphModule {
|
|
25
141
|
network = null;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
similarDecisionClickHandler = (event) => {
|
|
33
|
-
const target = event.target;
|
|
34
|
-
const btn = target.closest('.similar-decision-btn');
|
|
35
|
-
if (!btn || !btn.dataset.nodeId) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
this.navigateToNode(btn.dataset.nodeId);
|
|
39
|
-
};
|
|
40
|
-
nodeColors = {
|
|
41
|
-
decision: '#60A5FA',
|
|
42
|
-
preference: '#C084FC',
|
|
43
|
-
constraint: '#FB7185',
|
|
44
|
-
lesson: '#FBBF24',
|
|
45
|
-
fact: '#2DD4BF',
|
|
46
|
-
commitment: '#FB923C',
|
|
47
|
-
observation: '#94A3B8',
|
|
48
|
-
memory: '#CBD5E1', // Legacy memories may have no stored classification.
|
|
49
|
-
registry: '#A3E635',
|
|
50
|
-
};
|
|
142
|
+
graph = { nodes: [], edges: [], recordNode: {} };
|
|
143
|
+
// The links between records do not depend on the filter: read once per page load.
|
|
144
|
+
links = null;
|
|
145
|
+
// A slower earlier read must not draw or describe what the owner has moved away from.
|
|
146
|
+
drawRequest = 0;
|
|
147
|
+
detailRequest = 0;
|
|
51
148
|
edgeStyles = {
|
|
52
149
|
supersedes: { color: '#666666', dashes: false, width: 2 },
|
|
53
150
|
mentions: { color: '#6B4C9A', dashes: false, width: 2 },
|
|
54
151
|
derived_from: { color: '#3A9E7E', dashes: false, width: 2 },
|
|
55
152
|
amends: { color: '#2563EB', dashes: [8, 4], width: 2 },
|
|
56
153
|
refines: { color: '#B8860B', dashes: false, width: 2 },
|
|
57
|
-
builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 },
|
|
58
|
-
|
|
59
|
-
|
|
154
|
+
builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 },
|
|
155
|
+
contradicts: { color: '#DC143C', dashes: false, width: 2.5 },
|
|
156
|
+
debates: { color: '#DC143C', dashes: [5, 5], width: 2.5 },
|
|
157
|
+
synthesizes: { color: '#6B4C9A', width: 3, dashes: false },
|
|
60
158
|
};
|
|
61
|
-
constructor() {
|
|
62
|
-
// Network state
|
|
63
|
-
}
|
|
64
|
-
// =============================================
|
|
65
|
-
// Data Loading
|
|
66
|
-
// =============================================
|
|
67
159
|
/**
|
|
68
|
-
*
|
|
160
|
+
* Draw what the filters selected, from the same timeline answer the list shows.
|
|
69
161
|
*/
|
|
70
|
-
async
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
catch (error) {
|
|
77
|
-
logger.error('Failed to fetch graph:', error);
|
|
162
|
+
async showSelection(timeline) {
|
|
163
|
+
const request = ++this.drawRequest;
|
|
164
|
+
// A failed read is not kept: the next filter change reads the links again.
|
|
165
|
+
this.links ??= API.getMemoryLinks().catch((error) => {
|
|
166
|
+
this.links = null;
|
|
78
167
|
throw error;
|
|
79
|
-
}
|
|
168
|
+
});
|
|
169
|
+
const links = await this.links;
|
|
170
|
+
if (request !== this.drawRequest)
|
|
171
|
+
return;
|
|
172
|
+
this.init(composeFilterGraph(timeline, links));
|
|
80
173
|
}
|
|
81
|
-
// =============================================
|
|
82
|
-
// Graph Initialization
|
|
83
|
-
// =============================================
|
|
84
174
|
/**
|
|
85
|
-
*
|
|
175
|
+
* A listed record was picked: select its dot and open the record's own detail.
|
|
86
176
|
*/
|
|
87
|
-
|
|
177
|
+
focusRecord(recordId) {
|
|
178
|
+
const dot = this.graph.recordNode[recordId];
|
|
179
|
+
const node = this.graph.nodes.find((candidate) => candidate.id === dot);
|
|
180
|
+
if (this.network && dot !== undefined) {
|
|
181
|
+
this.network.selectNodes([dot]);
|
|
182
|
+
this.network.focus(dot, { scale: 1.2, animation: { duration: 400 } });
|
|
183
|
+
}
|
|
184
|
+
void this.showDetail({
|
|
185
|
+
detailId: recordId,
|
|
186
|
+
label: node?.label ?? recordId,
|
|
187
|
+
kind: node?.kind ?? null,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
init(graph) {
|
|
88
191
|
const container = getElementByIdOrNull('graph-canvas');
|
|
89
192
|
if (!container) {
|
|
90
193
|
logger.error('graph-canvas element not found');
|
|
91
194
|
return;
|
|
92
195
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
196
|
+
this.graph = graph;
|
|
197
|
+
this.network?.destroy?.();
|
|
198
|
+
const empty = getElementByIdOrNull('graph-empty');
|
|
199
|
+
if (empty) {
|
|
200
|
+
empty.style.display = graph.nodes.length === 0 ? '' : 'none';
|
|
201
|
+
empty.textContent = 'Nothing saved for this filter.';
|
|
96
202
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// Build adjacency list for BFS
|
|
100
|
-
this.buildAdjacencyList(data.edges);
|
|
101
|
-
// Calculate connection counts for sizing
|
|
102
|
-
const connectionCounts = this.calculateConnectionCounts(data.nodes, data.edges);
|
|
103
|
-
// Map nodes to vis-network format
|
|
104
|
-
const nodes = data.nodes.map((n) => ({
|
|
105
|
-
id: n.id,
|
|
106
|
-
label: n.label || n.topic || String(n.id).substring(0, 20),
|
|
107
|
-
title: this.createNodeTooltip(n),
|
|
108
|
-
color: {
|
|
109
|
-
background: this.getNodeColor(n.kind),
|
|
110
|
-
// Only a recorded outcome draws a ring; otherwise the kind colour stays visible.
|
|
111
|
-
border: this.getOutcomeBorderColor(n.outcome, this.getNodeColor(n.kind)),
|
|
112
|
-
highlight: { background: this.getNodeColor(n.kind), border: '#fff' },
|
|
113
|
-
},
|
|
114
|
-
size: this.getNodeSize(connectionCounts[String(n.id)] || 0),
|
|
115
|
-
font: { color: '#131313', size: 14, vadjust: -2 },
|
|
116
|
-
borderWidth: n.outcome ? 3 : 1,
|
|
117
|
-
data: n,
|
|
118
|
-
}));
|
|
119
|
-
// Map edges to vis-network format
|
|
120
|
-
const edges = data.edges.map((e) => {
|
|
121
|
-
const style = this.getEdgeStyle(e.relationship);
|
|
203
|
+
const nodes = graph.nodes.map((node) => {
|
|
204
|
+
const color = node.outside ? OUTSIDE_COLOR : groupInfo(node.group).color;
|
|
122
205
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
206
|
+
id: node.id,
|
|
207
|
+
label: shortLabel(node.label),
|
|
208
|
+
title: this.createNodeTooltip(node),
|
|
209
|
+
color: {
|
|
210
|
+
background: color,
|
|
211
|
+
border: color,
|
|
212
|
+
highlight: { background: color, border: '#131313' },
|
|
213
|
+
},
|
|
214
|
+
size: node.outside ? 7 : node.updates > 0 ? 10 + Math.min(node.updates, 20) / 2 : 11,
|
|
215
|
+
font: { color: node.outside ? '#9CA3AF' : '#131313', size: 12 },
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
const edges = graph.edges.map((edge) => {
|
|
219
|
+
const style = this.getEdgeStyle(edge.relation);
|
|
220
|
+
return {
|
|
221
|
+
from: edge.from,
|
|
222
|
+
to: edge.to,
|
|
125
223
|
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
|
|
126
224
|
color: style.color,
|
|
127
225
|
dashes: style.dashes,
|
|
128
|
-
width: style.width
|
|
129
|
-
title:
|
|
226
|
+
width: style.width,
|
|
227
|
+
title: this.relationLabel(edge.relation),
|
|
130
228
|
};
|
|
131
229
|
});
|
|
132
|
-
|
|
133
|
-
nodes:
|
|
134
|
-
edges:
|
|
135
|
-
};
|
|
136
|
-
const options = {
|
|
137
|
-
nodes: {
|
|
138
|
-
shape: 'dot',
|
|
139
|
-
scaling: {
|
|
140
|
-
min: 10,
|
|
141
|
-
max: 30,
|
|
142
|
-
label: { enabled: true, min: 12, max: 22, maxVisible: 22, drawThreshold: 8 },
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
edges: {
|
|
146
|
-
smooth: { type: 'continuous', roundness: 0.5 },
|
|
147
|
-
width: 2,
|
|
148
|
-
},
|
|
230
|
+
this.network = new vis.Network(container, { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) }, {
|
|
231
|
+
nodes: { shape: 'dot' },
|
|
232
|
+
edges: { smooth: { type: 'continuous', roundness: 0.5 }, width: 2 },
|
|
149
233
|
physics: {
|
|
150
|
-
// The initial layout always runs; the stabilized handler turns physics off afterwards.
|
|
151
234
|
enabled: true,
|
|
152
235
|
barnesHut: {
|
|
153
|
-
gravitationalConstant: -
|
|
154
|
-
centralGravity: 0.
|
|
155
|
-
springLength:
|
|
236
|
+
gravitationalConstant: -4000,
|
|
237
|
+
centralGravity: 0.5,
|
|
238
|
+
springLength: 120,
|
|
156
239
|
springConstant: 0.04,
|
|
157
240
|
damping: 0.09,
|
|
158
|
-
avoidOverlap:
|
|
159
|
-
},
|
|
160
|
-
stabilization: {
|
|
161
|
-
enabled: true,
|
|
162
|
-
iterations: data.nodes.length > 500 ? 100 : 200,
|
|
163
|
-
updateInterval: 25,
|
|
241
|
+
avoidOverlap: 0.6,
|
|
164
242
|
},
|
|
243
|
+
stabilization: { enabled: true, iterations: 200, updateInterval: 25 },
|
|
165
244
|
},
|
|
166
|
-
interaction: {
|
|
167
|
-
hover: true,
|
|
168
|
-
tooltipDelay: 100,
|
|
169
|
-
zoomView: true,
|
|
170
|
-
dragView: true,
|
|
171
|
-
},
|
|
172
|
-
};
|
|
173
|
-
this.network = new vis.Network(container, networkData, options);
|
|
174
|
-
// Event handlers
|
|
175
|
-
this.network.on('click', (params) => {
|
|
176
|
-
try {
|
|
177
|
-
if (params.nodes.length > 0) {
|
|
178
|
-
const nodeId = params.nodes[0];
|
|
179
|
-
const targetId = String(nodeId);
|
|
180
|
-
const node = data.nodes.find((n) => String(n.id) === targetId);
|
|
181
|
-
if (node) {
|
|
182
|
-
logger.debug('Node clicked:', nodeId, node);
|
|
183
|
-
this.showDetail(node);
|
|
184
|
-
this.highlightConnectedNodes(targetId);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
this.closeDetail();
|
|
189
|
-
this.resetNodeHighlight();
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
logger.error('Error handling click:', error);
|
|
194
|
-
if (error instanceof Error && error.stack) {
|
|
195
|
-
logger.error('Error stack:', error.stack);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
245
|
+
interaction: { hover: true, tooltipDelay: 100, zoomView: true, dragView: true },
|
|
198
246
|
});
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const targetVisualSize = 16; // px on screen
|
|
204
|
-
const fontSize = Math.round(targetVisualSize / scale);
|
|
205
|
-
networkData.nodes.update({
|
|
206
|
-
id: params.node,
|
|
207
|
-
font: { color: '#131313', size: Math.max(fontSize, 14), vadjust: -2 },
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
/* ignore */
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
this.network.on('blurNode', (params) => {
|
|
215
|
-
try {
|
|
216
|
-
networkData.nodes.update({
|
|
217
|
-
id: params.node,
|
|
218
|
-
font: { color: '#131313', size: 14, vadjust: -2 },
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
/* ignore */
|
|
223
|
-
}
|
|
247
|
+
this.network.on('click', (params) => {
|
|
248
|
+
const node = graph.nodes.find((candidate) => candidate.id === String(params.nodes[0]));
|
|
249
|
+
if (node)
|
|
250
|
+
void this.showDetail(node);
|
|
224
251
|
});
|
|
225
|
-
// A large graph may never reach vis-network's 'stabilized'; stop when the bounded iterations end.
|
|
226
252
|
this.network.on('stabilizationIterationsDone', () => {
|
|
227
253
|
this.network?.setOptions?.({ physics: { enabled: false } });
|
|
228
254
|
});
|
|
229
|
-
this.network.on('stabilized', () => {
|
|
230
|
-
const loadingEl = getElementByIdOrNull('graph-loading');
|
|
231
|
-
if (loadingEl) {
|
|
232
|
-
loadingEl.style.display = 'none';
|
|
233
|
-
}
|
|
234
|
-
// Disable physics after initial layout to free CPU
|
|
235
|
-
this.network?.setOptions?.({ physics: { enabled: false } });
|
|
236
|
-
logger.info('Graph stabilized, physics disabled');
|
|
237
|
-
});
|
|
238
|
-
// Backup: hide loading after 3 seconds even if stabilization doesn't complete
|
|
239
|
-
setTimeout(() => {
|
|
240
|
-
const loadingEl = getElementByIdOrNull('graph-loading');
|
|
241
|
-
if (loadingEl && loadingEl.style.display !== 'none') {
|
|
242
|
-
loadingEl.style.display = 'none';
|
|
243
|
-
logger.warn('Graph loading hidden by timeout');
|
|
244
|
-
}
|
|
245
|
-
}, 3000);
|
|
246
|
-
// Populate topic filter — sorted by most recent created_at (newest first)
|
|
247
|
-
const topicLatest = new Map();
|
|
248
|
-
for (const n of data.nodes) {
|
|
249
|
-
const t = n.topic || '';
|
|
250
|
-
const ts = typeof n.created_at === 'number' ? n.created_at : 0;
|
|
251
|
-
if (!topicLatest.has(t) || ts > topicLatest.get(t)) {
|
|
252
|
-
topicLatest.set(t, ts);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
const topics = [...topicLatest.entries()].sort((a, b) => b[1] - a[1]).map(([topic]) => topic);
|
|
256
|
-
this.populateTopicFilter(topics);
|
|
257
|
-
this.populateDecisionList(data.nodes);
|
|
258
255
|
this.renderLegend();
|
|
256
|
+
getElementByIdOrNull('legend-panel')?.classList.add('visible');
|
|
259
257
|
const stats = getElementByIdOrNull('graph-stats');
|
|
260
258
|
if (stats) {
|
|
261
|
-
|
|
259
|
+
const selected = graph.nodes.filter((node) => !node.outside).length;
|
|
260
|
+
const outside = graph.nodes.length - selected;
|
|
261
|
+
stats.style.display = '';
|
|
262
|
+
stats.textContent = `${selected} records and work items · ${graph.edges.length} links${outside === 0 ? '' : ` · ${outside} linked records outside this filter, faded`} · click a dot for its detail`;
|
|
262
263
|
}
|
|
263
|
-
logger.info('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
|
|
264
264
|
}
|
|
265
265
|
// =============================================
|
|
266
|
-
// Styling
|
|
266
|
+
// Styling and legend
|
|
267
267
|
// =============================================
|
|
268
|
-
|
|
269
|
-
return this.
|
|
268
|
+
getEdgeStyle(relationship) {
|
|
269
|
+
return (this.edgeStyles[relationship ?? 'default'] ?? { color: '#4a4a6a', dashes: false, width: 2 });
|
|
270
|
+
}
|
|
271
|
+
kindLabel(kind) {
|
|
272
|
+
return kind === null ? KIND_LABELS.memory : (KIND_LABELS[kind] ?? kind);
|
|
273
|
+
}
|
|
274
|
+
relationLabel(relationship) {
|
|
275
|
+
return RELATION_LABELS[relationship] ?? relationship;
|
|
270
276
|
}
|
|
271
|
-
/** Count
|
|
277
|
+
/** Count what is drawn, by group and by relationship, with the words the legend shows. */
|
|
272
278
|
getLegendEntries() {
|
|
273
|
-
const
|
|
274
|
-
.filter((node) => !node.hidden)
|
|
275
|
-
.map((node) => String(node.id)));
|
|
276
|
-
const kinds = new Map();
|
|
279
|
+
const groups = new Map();
|
|
277
280
|
const relations = new Map();
|
|
278
|
-
for (const node of this.
|
|
279
|
-
|
|
280
|
-
continue;
|
|
281
|
-
const kind = node.kind ?? 'memory';
|
|
282
|
-
kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
|
|
281
|
+
for (const node of this.graph.nodes) {
|
|
282
|
+
groups.set(node.group, (groups.get(node.group) ?? 0) + 1);
|
|
283
283
|
}
|
|
284
|
-
for (const edge of this.
|
|
285
|
-
|
|
286
|
-
continue;
|
|
287
|
-
const relation = edge.relationship ?? 'default';
|
|
288
|
-
relations.set(relation, (relations.get(relation) ?? 0) + 1);
|
|
284
|
+
for (const edge of this.graph.edges) {
|
|
285
|
+
relations.set(edge.relation, (relations.get(edge.relation) ?? 0) + 1);
|
|
289
286
|
}
|
|
290
287
|
return {
|
|
291
|
-
nodes: [...
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
kind,
|
|
288
|
+
nodes: [...groups].map(([group, count]) => ({
|
|
289
|
+
group,
|
|
290
|
+
label: group === 'outside' ? 'Linked, outside this filter' : groupInfo(group).label,
|
|
295
291
|
count,
|
|
296
|
-
color:
|
|
292
|
+
color: group === 'outside' ? OUTSIDE_COLOR : groupInfo(group).color,
|
|
297
293
|
})),
|
|
298
294
|
edges: [...relations]
|
|
299
|
-
.sort(([a], [b]) => a
|
|
295
|
+
.sort(([, a], [, b]) => b - a)
|
|
300
296
|
.map(([relationship, count]) => ({
|
|
301
297
|
relationship,
|
|
298
|
+
label: this.relationLabel(relationship),
|
|
302
299
|
count,
|
|
303
300
|
...this.getEdgeStyle(relationship),
|
|
304
301
|
})),
|
|
@@ -311,785 +308,62 @@ export class GraphModule {
|
|
|
311
308
|
const entries = this.getLegendEntries();
|
|
312
309
|
const section = (title, rows) => rows.length === 0
|
|
313
310
|
? ''
|
|
314
|
-
: `<div><div class="
|
|
315
|
-
const row = (sample, label, count) => `<div class="
|
|
311
|
+
: `<div><div class="legend-heading">${title}</div>${rows.join('')}</div>`;
|
|
312
|
+
const row = (sample, label, count) => `<div class="legend-row">${sample}<span class="legend-label">${escapeHtml(label)}</span><span class="legend-count">${count}</span></div>`;
|
|
316
313
|
container.innerHTML =
|
|
317
|
-
section('
|
|
318
|
-
section('
|
|
314
|
+
section('Dots (a work item is one dot for its updates)', entries.nodes.map(({ label, count, color }) => row(`<span class="legend-dot" style="background:${color}"></span>`, label, count))) +
|
|
315
|
+
section('Lines (arrow points at what the record links to)', entries.edges.map(({ label, count, color, dashes, width }) => {
|
|
319
316
|
const dash = Array.isArray(dashes) ? dashes.join(' ') : dashes ? '5 5' : 'none';
|
|
320
|
-
return row(`<svg width="
|
|
317
|
+
return row(`<svg width="28" height="10" class="shrink-0" aria-hidden="true"><line x1="0" y1="5" x2="28" y2="5" stroke="${color}" stroke-width="${width}" stroke-dasharray="${dash}" /></svg>`, label, count);
|
|
321
318
|
}));
|
|
322
319
|
if (entries.nodes.length === 0)
|
|
323
|
-
container.textContent = '
|
|
320
|
+
container.textContent = 'Nothing drawn';
|
|
324
321
|
}
|
|
325
|
-
/**
|
|
326
|
-
* Get border color based on outcome
|
|
327
|
-
*/
|
|
328
|
-
getOutcomeBorderColor(outcome, fallback = '#4a4a6a') {
|
|
329
|
-
switch (outcome?.toLowerCase()) {
|
|
330
|
-
case 'success':
|
|
331
|
-
return '#22c55e';
|
|
332
|
-
case 'failed':
|
|
333
|
-
return '#ef4444';
|
|
334
|
-
case 'partial':
|
|
335
|
-
return '#f59e0b';
|
|
336
|
-
default:
|
|
337
|
-
return fallback;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Get edge style by relationship type
|
|
342
|
-
*/
|
|
343
|
-
getEdgeStyle(relationship) {
|
|
344
|
-
return (this.edgeStyles[relationship || 'default'] || { color: '#4a4a6a', dashes: false, width: 2 });
|
|
345
|
-
}
|
|
346
|
-
/**
|
|
347
|
-
* Get node size based on connection count
|
|
348
|
-
*/
|
|
349
|
-
getNodeSize(connectionCount) {
|
|
350
|
-
if (connectionCount <= 2) {
|
|
351
|
-
return 12;
|
|
352
|
-
}
|
|
353
|
-
if (connectionCount <= 5) {
|
|
354
|
-
return 18;
|
|
355
|
-
}
|
|
356
|
-
if (connectionCount <= 10) {
|
|
357
|
-
return 24;
|
|
358
|
-
}
|
|
359
|
-
return 30;
|
|
360
|
-
}
|
|
361
|
-
/**
|
|
362
|
-
* Create node tooltip
|
|
363
|
-
*/
|
|
364
322
|
createNodeTooltip(node) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
${node.outcome ? `Outcome: ${escapeHtml(node.outcome)}<br>` : ''}
|
|
369
|
-
Confidence: ${Math.round((node.confidence || 0) * 100)}%
|
|
370
|
-
`;
|
|
371
|
-
}
|
|
372
|
-
// =============================================
|
|
373
|
-
// Data Processing
|
|
374
|
-
// =============================================
|
|
375
|
-
/**
|
|
376
|
-
* Build adjacency list for BFS
|
|
377
|
-
*/
|
|
378
|
-
buildAdjacencyList(edges) {
|
|
379
|
-
this.adjacencyList = new Map();
|
|
380
|
-
edges.forEach((edge) => {
|
|
381
|
-
const from = String(edge.from);
|
|
382
|
-
const to = String(edge.to);
|
|
383
|
-
if (!this.adjacencyList.has(from)) {
|
|
384
|
-
this.adjacencyList.set(from, []);
|
|
385
|
-
}
|
|
386
|
-
if (!this.adjacencyList.has(to)) {
|
|
387
|
-
this.adjacencyList.set(to, []);
|
|
388
|
-
}
|
|
389
|
-
this.adjacencyList.get(from).push(to);
|
|
390
|
-
this.adjacencyList.get(to).push(from);
|
|
391
|
-
});
|
|
392
|
-
logger.debug('Adjacency list built with', this.adjacencyList.size, 'nodes');
|
|
393
|
-
}
|
|
394
|
-
/**
|
|
395
|
-
* Calculate connection count for each node
|
|
396
|
-
*/
|
|
397
|
-
calculateConnectionCounts(nodes, edges) {
|
|
398
|
-
const counts = {};
|
|
399
|
-
nodes.forEach((n) => {
|
|
400
|
-
counts[String(n.id)] = 0;
|
|
401
|
-
});
|
|
402
|
-
edges.forEach((edge) => {
|
|
403
|
-
const from = String(edge.from);
|
|
404
|
-
const to = String(edge.to);
|
|
405
|
-
if (counts[from] !== undefined) {
|
|
406
|
-
counts[from]++;
|
|
407
|
-
}
|
|
408
|
-
if (counts[to] !== undefined) {
|
|
409
|
-
counts[to]++;
|
|
410
|
-
}
|
|
411
|
-
});
|
|
412
|
-
return counts;
|
|
413
|
-
}
|
|
414
|
-
// =============================================
|
|
415
|
-
// Graph Traversal & Highlighting
|
|
416
|
-
// =============================================
|
|
417
|
-
/**
|
|
418
|
-
* Get connected node IDs using BFS
|
|
419
|
-
*/
|
|
420
|
-
getConnectedNodeIds(nodeId, maxDepth = 3) {
|
|
421
|
-
const visited = new Set();
|
|
422
|
-
const queue = [{ id: nodeId, depth: 0 }];
|
|
423
|
-
visited.add(nodeId);
|
|
424
|
-
while (queue.length > 0) {
|
|
425
|
-
const next = queue.shift();
|
|
426
|
-
if (!next) {
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
const { id, depth } = next;
|
|
430
|
-
if (depth >= maxDepth) {
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
const neighbors = this.adjacencyList.get(id) || [];
|
|
434
|
-
neighbors.forEach((neighborId) => {
|
|
435
|
-
if (!visited.has(neighborId)) {
|
|
436
|
-
visited.add(neighborId);
|
|
437
|
-
queue.push({ id: neighborId, depth: depth + 1 });
|
|
438
|
-
}
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
return Array.from(visited);
|
|
442
|
-
}
|
|
443
|
-
/**
|
|
444
|
-
* Highlight connected nodes
|
|
445
|
-
*/
|
|
446
|
-
highlightConnectedNodes(nodeId) {
|
|
447
|
-
const targetId = String(nodeId);
|
|
448
|
-
if (!this.network) {
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
const connectedIds = this.getConnectedNodeIds(targetId, 3);
|
|
452
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
453
|
-
const nodeUpdates = allNodes.map((node) => {
|
|
454
|
-
const isConnected = connectedIds.includes(String(node.id));
|
|
455
|
-
const opacity = isConnected ? 1.0 : 0.2;
|
|
456
|
-
return {
|
|
457
|
-
id: node.id,
|
|
458
|
-
opacity: opacity,
|
|
459
|
-
font: { ...node.font, color: isConnected ? '#131313' : '#999' },
|
|
460
|
-
};
|
|
461
|
-
});
|
|
462
|
-
this.network.body.data.nodes.update(nodeUpdates);
|
|
463
|
-
const allEdges = this.network.body.data.edges.get();
|
|
464
|
-
const edgeUpdates = allEdges.map((edge) => {
|
|
465
|
-
const isConnected = connectedIds.includes(String(edge.from)) && connectedIds.includes(String(edge.to));
|
|
466
|
-
return {
|
|
467
|
-
id: edge.id,
|
|
468
|
-
opacity: isConnected ? 1.0 : 0.1,
|
|
469
|
-
};
|
|
470
|
-
});
|
|
471
|
-
this.network.body.data.edges.update(edgeUpdates);
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* Reset node highlight
|
|
475
|
-
*/
|
|
476
|
-
resetNodeHighlight() {
|
|
477
|
-
if (!this.network) {
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
481
|
-
this.network.body.data.nodes.update(allNodes.map((node) => ({
|
|
482
|
-
id: node.id,
|
|
483
|
-
opacity: 1.0,
|
|
484
|
-
font: { ...node.font, color: '#131313' },
|
|
485
|
-
})));
|
|
486
|
-
const allEdges = this.network.body.data.edges.get();
|
|
487
|
-
this.network.body.data.edges.update(allEdges.map((edge) => ({
|
|
488
|
-
id: edge.id,
|
|
489
|
-
opacity: 1.0,
|
|
490
|
-
})));
|
|
323
|
+
const group = node.outside ? 'Linked, outside this filter' : groupInfo(node.group).label;
|
|
324
|
+
const updates = node.updates > 0 ? `\n${node.updates} updates in this period` : '';
|
|
325
|
+
return `${group} · ${this.kindLabel(node.kind)}\n${node.label.slice(0, 160)}${updates}`;
|
|
491
326
|
}
|
|
492
327
|
// =============================================
|
|
493
328
|
// Detail Panel
|
|
494
329
|
// =============================================
|
|
495
|
-
|
|
496
|
-
* Show node detail panel
|
|
497
|
-
*/
|
|
498
|
-
async showDetail(node) {
|
|
499
|
-
try {
|
|
500
|
-
logger.debug('showDetail called with node:', node);
|
|
501
|
-
this.currentNodeId = String(node.id);
|
|
502
|
-
const panel = getElementByIdOrNull('decision-detail-modal');
|
|
503
|
-
if (!panel) {
|
|
504
|
-
logger.error('decision-detail-modal element not found');
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
// Update existing DOM elements with markdown rendering
|
|
508
|
-
const topicEl = getElementByIdOrNull('detail-topic');
|
|
509
|
-
const decisionEl = getElementByIdOrNull('detail-decision');
|
|
510
|
-
const reasoningEl = getElementByIdOrNull('detail-reasoning');
|
|
511
|
-
if (!decisionEl || !reasoningEl) {
|
|
512
|
-
logger.error('Required detail elements missing');
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
if (topicEl) {
|
|
516
|
-
topicEl.textContent = node.topic || 'Unknown Topic';
|
|
517
|
-
}
|
|
518
|
-
decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
|
|
519
|
-
reasoningEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
|
|
520
|
-
const outcomeSelect = getElementByIdOrNull('detail-outcome-select');
|
|
521
|
-
if (outcomeSelect) {
|
|
522
|
-
outcomeSelect.value = node.outcome ? node.outcome.toUpperCase() : '';
|
|
523
|
-
outcomeSelect.disabled = Boolean((node.kind && node.kind !== 'memory') || (node.state && node.state !== 'current'));
|
|
524
|
-
}
|
|
525
|
-
// Clear outcome status
|
|
526
|
-
const outcomeStatus = getElementByIdOrNull('outcome-status');
|
|
527
|
-
if (outcomeStatus) {
|
|
528
|
-
outcomeStatus.textContent = '';
|
|
529
|
-
outcomeStatus.className = '';
|
|
530
|
-
}
|
|
531
|
-
const confidenceEl = getElementByIdOrNull('detail-confidence');
|
|
532
|
-
if (confidenceEl) {
|
|
533
|
-
confidenceEl.textContent = node.confidence ? `${(node.confidence * 100).toFixed(0)}%` : '-';
|
|
534
|
-
}
|
|
535
|
-
const createdEl = getElementByIdOrNull('detail-created');
|
|
536
|
-
if (createdEl) {
|
|
537
|
-
createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
|
|
538
|
-
}
|
|
539
|
-
// Reset reasoning toggle
|
|
540
|
-
const reasoningArrow = getElementByIdOrNull('reasoning-arrow');
|
|
541
|
-
if (reasoningArrow) {
|
|
542
|
-
reasoningArrow.textContent = '▶';
|
|
543
|
-
}
|
|
544
|
-
reasoningEl.classList.add('hidden');
|
|
545
|
-
// Show loading state for similar decisions
|
|
546
|
-
const similarEl = getElementByIdOrNull('detail-similar');
|
|
547
|
-
if (similarEl) {
|
|
548
|
-
similarEl.innerHTML = '<span class="loading-similar">Searching...</span>';
|
|
549
|
-
}
|
|
550
|
-
// Show panel
|
|
551
|
-
panel.classList.add('visible');
|
|
552
|
-
const detail = await API.getGraphDetail(String(node.id));
|
|
553
|
-
const detailNode = detail.node || node;
|
|
554
|
-
const localNode = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
|
|
555
|
-
if (localNode) {
|
|
556
|
-
Object.assign(localNode, detailNode);
|
|
557
|
-
}
|
|
558
|
-
decisionEl.innerHTML = renderSafeMarkdown(String(detailNode.decision || detailNode.decision_preview || '-'));
|
|
559
|
-
reasoningEl.innerHTML = renderSafeMarkdown(String(detailNode.reasoning || '-'));
|
|
560
|
-
// Canonical graph relations are already visible as edges. The old
|
|
561
|
-
// vector-similarity endpoint only understands legacy decision ids.
|
|
562
|
-
if (node.kind) {
|
|
563
|
-
if (similarEl)
|
|
564
|
-
similarEl.textContent = 'Related records are shown as graph edges.';
|
|
565
|
-
}
|
|
566
|
-
else {
|
|
567
|
-
void this.fetchSimilarDecisions(String(node.id));
|
|
568
|
-
}
|
|
569
|
-
logger.debug('showDetail completed successfully');
|
|
570
|
-
}
|
|
571
|
-
catch (error) {
|
|
572
|
-
logger.error('Error in showDetail:', error);
|
|
573
|
-
if (error instanceof Error && error.stack) {
|
|
574
|
-
logger.error('Error stack:', error.stack);
|
|
575
|
-
}
|
|
576
|
-
logger.error('Node data:', node);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
/**
|
|
580
|
-
* Get outcome icon name
|
|
581
|
-
*/
|
|
582
|
-
getOutcomeIcon(outcome) {
|
|
583
|
-
const outcomeMap = {
|
|
584
|
-
pending: 'clock',
|
|
585
|
-
success: 'check-circle',
|
|
586
|
-
failed: 'x-circle',
|
|
587
|
-
partial: 'alert-circle',
|
|
588
|
-
};
|
|
589
|
-
return outcomeMap[(outcome || 'pending').toLowerCase()] || 'clock';
|
|
590
|
-
}
|
|
591
|
-
/**
|
|
592
|
-
* Toggle reasoning full text
|
|
593
|
-
*/
|
|
594
|
-
toggleReasoning() {
|
|
595
|
-
const arrow = getElementByIdOrNull('reasoning-arrow');
|
|
596
|
-
const content = getElementByIdOrNull('detail-reasoning');
|
|
597
|
-
if (arrow && content) {
|
|
598
|
-
const isHidden = content.classList.contains('hidden');
|
|
599
|
-
content.classList.toggle('hidden');
|
|
600
|
-
arrow.textContent = isHidden ? '▼' : '▶';
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
/**
|
|
604
|
-
* Close detail panel
|
|
605
|
-
*/
|
|
606
|
-
closeDetail() {
|
|
330
|
+
async showDetail(target) {
|
|
607
331
|
const panel = getElementByIdOrNull('decision-detail-modal');
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
* Fetch similar decisions
|
|
616
|
-
*/
|
|
617
|
-
async fetchSimilarDecisions(nodeId) {
|
|
618
|
-
logger.debug('fetchSimilarDecisions called for node:', nodeId);
|
|
619
|
-
const container = getElementByIdOrNull('detail-similar');
|
|
620
|
-
if (!container) {
|
|
621
|
-
logger.warn('detail-similar element not found');
|
|
332
|
+
const topicEl = getElementByIdOrNull('detail-topic');
|
|
333
|
+
const kindEl = getElementByIdOrNull('detail-kind');
|
|
334
|
+
const decisionEl = getElementByIdOrNull('detail-decision');
|
|
335
|
+
const reasoningEl = getElementByIdOrNull('detail-reasoning');
|
|
336
|
+
const createdEl = getElementByIdOrNull('detail-created');
|
|
337
|
+
if (!panel || !decisionEl || !reasoningEl) {
|
|
338
|
+
logger.error('Detail panel elements missing');
|
|
622
339
|
return;
|
|
623
340
|
}
|
|
341
|
+
if (topicEl)
|
|
342
|
+
topicEl.textContent = target.label;
|
|
343
|
+
if (kindEl)
|
|
344
|
+
kindEl.textContent = this.kindLabel(target.kind);
|
|
345
|
+
if (createdEl)
|
|
346
|
+
createdEl.textContent = '-';
|
|
347
|
+
decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
|
|
348
|
+
reasoningEl.innerHTML = '';
|
|
349
|
+
panel.classList.add('visible');
|
|
350
|
+
const request = ++this.detailRequest;
|
|
624
351
|
try {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
logger.debug('Similar decisions received:', data);
|
|
628
|
-
const similar = (data.similar || []);
|
|
629
|
-
if (similar.length === 0) {
|
|
630
|
-
logger.debug('[MAMA] No similar decisions found');
|
|
631
|
-
container.innerHTML = '<span style="color:#666">No similar decisions found</span>';
|
|
352
|
+
const detail = await API.getGraphDetail(target.detailId);
|
|
353
|
+
if (request !== this.detailRequest)
|
|
632
354
|
return;
|
|
355
|
+
const detailNode = detail.node;
|
|
356
|
+
if (createdEl && detailNode.created_at) {
|
|
357
|
+
createdEl.textContent = new Date(detailNode.created_at).toLocaleString();
|
|
633
358
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
.map((s) => `
|
|
637
|
-
<button class="similar-decision-btn w-full text-left p-2 mb-2 bg-gray-100 dark:bg-gray-800 hover:bg-indigo-100 dark:hover:bg-indigo-900/30 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors" data-node-id="${escapeAttr(String(s.id))}">
|
|
638
|
-
<div class="text-xs font-semibold text-indigo-600 dark:text-indigo-400">${escapeHtml(String(s.topic || ''))}</div>
|
|
639
|
-
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">${escapeHtml(String(s.decision || '').substring(0, 80))}...</div>
|
|
640
|
-
<div class="text-xs text-gray-500 mt-1">${Math.round((s.similarity || 0) * 100)}% match</div>
|
|
641
|
-
</button>
|
|
642
|
-
`)
|
|
643
|
-
.join('');
|
|
644
|
-
logger.debug('[MAMA] Setting similar decisions HTML');
|
|
645
|
-
container.innerHTML = html;
|
|
646
|
-
// Bind click handler once to avoid duplicate listeners.
|
|
647
|
-
container.removeEventListener('click', this.similarDecisionClickHandler);
|
|
648
|
-
container.addEventListener('click', this.similarDecisionClickHandler);
|
|
649
|
-
logger.debug('[MAMA] fetchSimilarDecisions completed');
|
|
650
|
-
}
|
|
651
|
-
catch (error) {
|
|
652
|
-
const message = getErrorMessage(error);
|
|
653
|
-
logger.error('[MAMA] Failed to fetch similar decisions:', message, error);
|
|
654
|
-
if (error instanceof Error && error.stack) {
|
|
655
|
-
logger.error('Error stack:', error.stack);
|
|
656
|
-
}
|
|
657
|
-
container.innerHTML = '<span style="color:#f66">Failed to load</span>';
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
/**
|
|
661
|
-
* Save outcome for current node
|
|
662
|
-
*/
|
|
663
|
-
async saveOutcome() {
|
|
664
|
-
const current = this.graphData.nodes.find((node) => String(node.id) === this.currentNodeId);
|
|
665
|
-
if ((current?.kind && current.kind !== 'memory') ||
|
|
666
|
-
(current?.state && current.state !== 'current'))
|
|
667
|
-
return;
|
|
668
|
-
const select = getElementByIdOrNull('detail-outcome-select');
|
|
669
|
-
if (!select) {
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
const newOutcome = select.value;
|
|
673
|
-
if (!this.currentNodeId || !newOutcome) {
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
try {
|
|
677
|
-
await API.updateOutcome(this.currentNodeId, newOutcome);
|
|
678
|
-
// Update local data
|
|
679
|
-
const node = this.graphData.nodes.find((n) => String(n.id) === this.currentNodeId);
|
|
680
|
-
if (node) {
|
|
681
|
-
node.outcome = newOutcome.toUpperCase();
|
|
682
|
-
// Update visualization
|
|
683
|
-
this.network.body.data.nodes.update({
|
|
684
|
-
id: this.currentNodeId,
|
|
685
|
-
color: {
|
|
686
|
-
border: this.getOutcomeBorderColor(newOutcome),
|
|
687
|
-
},
|
|
688
|
-
});
|
|
689
|
-
// Refresh detail panel
|
|
690
|
-
this.showDetail(node);
|
|
691
|
-
}
|
|
692
|
-
logger.debug('[MAMA] Outcome updated:', this.currentNodeId, newOutcome);
|
|
359
|
+
decisionEl.innerHTML = renderSafeMarkdown(String(detailNode.decision || detailNode.decision_preview || '-'));
|
|
360
|
+
reasoningEl.innerHTML = renderSafeMarkdown(String(detailNode.reasoning || '-'));
|
|
693
361
|
}
|
|
694
362
|
catch (error) {
|
|
695
|
-
|
|
696
|
-
logger.error('[MAMA] Failed to update outcome:', message);
|
|
697
|
-
alert(`Failed to update outcome: ${message}`);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
// =============================================
|
|
701
|
-
// Navigation
|
|
702
|
-
// =============================================
|
|
703
|
-
/**
|
|
704
|
-
* Navigate to specific node
|
|
705
|
-
*/
|
|
706
|
-
async navigateToNode(nodeId) {
|
|
707
|
-
if (!this.network) {
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
|
-
let nodeIdString = String(nodeId);
|
|
711
|
-
// Try exact match first
|
|
712
|
-
let node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
|
|
713
|
-
// If not found, try partial match (for short IDs from checkpoints)
|
|
714
|
-
if (!node) {
|
|
715
|
-
logger.debug('[MAMA] Exact match not found, trying partial match for:', nodeIdString);
|
|
716
|
-
node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
|
|
717
|
-
if (node) {
|
|
718
|
-
logger.debug('[MAMA] Found node via partial match:', node.id);
|
|
719
|
-
nodeIdString = String(node.id); // Update to the full ID
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
// If node not in current graph, reload without filters
|
|
723
|
-
if (!node) {
|
|
724
|
-
logger.warn('[MAMA] Node not in current graph, reloading all nodes...');
|
|
725
|
-
// Reset topic filter
|
|
726
|
-
const topicFilter = getElementByIdOrNull('topic-filter');
|
|
727
|
-
if (topicFilter) {
|
|
728
|
-
topicFilter.value = '';
|
|
729
|
-
}
|
|
730
|
-
// Reload graph: fetch fresh data and reinitialize
|
|
731
|
-
await this.fetchData({ full: true });
|
|
732
|
-
this.init(this.graphData);
|
|
733
|
-
// Try exact match again
|
|
734
|
-
node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
|
|
735
|
-
// If still not found, try partial match
|
|
736
|
-
if (!node) {
|
|
737
|
-
node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
|
|
738
|
-
if (node) {
|
|
739
|
-
logger.debug('[MAMA] Found node via partial match after reload:', node.id);
|
|
740
|
-
nodeIdString = String(node.id);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
if (!node) {
|
|
744
|
-
logger.warn('[MAMA] Node not found even after reload:', nodeIdString);
|
|
745
|
-
showToast('⚠️ Decision not found in graph');
|
|
363
|
+
if (request !== this.detailRequest)
|
|
746
364
|
return;
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
// Focus on node (use resolved nodeIdString, not original nodeId)
|
|
750
|
-
this.network.focus(nodeIdString, {
|
|
751
|
-
scale: 1.5,
|
|
752
|
-
animation: { duration: 500, easingFunction: 'easeInOutQuad' },
|
|
753
|
-
});
|
|
754
|
-
// Select node (triggers click event)
|
|
755
|
-
this.network.selectNodes([nodeIdString]);
|
|
756
|
-
// Show detail
|
|
757
|
-
this.showDetail(node);
|
|
758
|
-
this.highlightConnectedNodes(nodeIdString);
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Get connected edge types
|
|
762
|
-
*/
|
|
763
|
-
getConnectedEdges(nodeId) {
|
|
764
|
-
const edges = this.graphData.edges.filter((e) => String(e.from) === nodeId || String(e.to) === nodeId);
|
|
765
|
-
const outgoing = edges.filter((e) => String(e.from) === nodeId);
|
|
766
|
-
const incoming = edges.filter((e) => String(e.to) === nodeId);
|
|
767
|
-
return { outgoing, incoming, all: edges };
|
|
768
|
-
}
|
|
769
|
-
// =============================================
|
|
770
|
-
// Filtering
|
|
771
|
-
// =============================================
|
|
772
|
-
/**
|
|
773
|
-
* Populate topic filter dropdown
|
|
774
|
-
*/
|
|
775
|
-
populateTopicFilter(topics) {
|
|
776
|
-
const datalist = getElementByIdOrNull('topic-filter-list');
|
|
777
|
-
if (!datalist) {
|
|
778
|
-
// Fallback for old select element
|
|
779
|
-
const select = getElementByIdOrNull('topic-filter');
|
|
780
|
-
if (!select)
|
|
781
|
-
return;
|
|
782
|
-
select.innerHTML = '<option value="">All Topics</option>';
|
|
783
|
-
topics.forEach((topic) => {
|
|
784
|
-
const option = document.createElement('option');
|
|
785
|
-
option.value = topic;
|
|
786
|
-
option.textContent = topic;
|
|
787
|
-
select.appendChild(option);
|
|
788
|
-
});
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
datalist.innerHTML = '';
|
|
792
|
-
topics.forEach((topic) => {
|
|
793
|
-
const option = document.createElement('option');
|
|
794
|
-
option.value = topic;
|
|
795
|
-
datalist.appendChild(option);
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
/**
|
|
799
|
-
* Populate left-side decision list (time-sorted, newest first)
|
|
800
|
-
*/
|
|
801
|
-
populateDecisionList(nodes) {
|
|
802
|
-
const listEl = getElementByIdOrNull('decision-list');
|
|
803
|
-
const countEl = getElementByIdOrNull('decision-list-count');
|
|
804
|
-
if (!listEl)
|
|
805
|
-
return;
|
|
806
|
-
const sorted = [...nodes].sort((a, b) => {
|
|
807
|
-
const ta = typeof a.created_at === 'number' ? a.created_at : 0;
|
|
808
|
-
const tb = typeof b.created_at === 'number' ? b.created_at : 0;
|
|
809
|
-
return tb - ta;
|
|
810
|
-
});
|
|
811
|
-
if (countEl)
|
|
812
|
-
countEl.textContent = String(sorted.length);
|
|
813
|
-
if (sorted.length === 0) {
|
|
814
|
-
listEl.innerHTML =
|
|
815
|
-
'<div style="padding:16px;text-align:center;color:#9E9891;font-size:11px">No decisions yet</div>';
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
const formatTime = (ts) => {
|
|
819
|
-
if (!ts)
|
|
820
|
-
return '';
|
|
821
|
-
const d = new Date(typeof ts === 'number' ? ts : parseInt(String(ts), 10));
|
|
822
|
-
const now = Date.now();
|
|
823
|
-
const diffH = Math.floor((now - d.getTime()) / 3600000);
|
|
824
|
-
if (diffH < 1)
|
|
825
|
-
return 'just now';
|
|
826
|
-
if (diffH < 24)
|
|
827
|
-
return `${diffH}h ago`;
|
|
828
|
-
const diffD = Math.floor(diffH / 24);
|
|
829
|
-
return `${diffD}d ago`;
|
|
830
|
-
};
|
|
831
|
-
const topicLabel = (topic) => {
|
|
832
|
-
const parts = topic.split('/').filter(Boolean);
|
|
833
|
-
return parts[parts.length - 1] || topic;
|
|
834
|
-
};
|
|
835
|
-
listEl.innerHTML = sorted
|
|
836
|
-
.map((n) => {
|
|
837
|
-
const preview = escapeHtml((n.decision_preview || n.decision || '').slice(0, 80));
|
|
838
|
-
const topic = escapeHtml(topicLabel(n.topic || ''));
|
|
839
|
-
const time = formatTime(n.created_at);
|
|
840
|
-
const id = escapeAttr(String(n.id));
|
|
841
|
-
return `<div class="mama-decision-item" data-node-id="${id}" onclick="window.graphModule?.focusNode('${id}')" style="padding:6px 8px;border-bottom:1px solid #F5F2ED;cursor:pointer;transition:background 0.1s" onmouseover="this.style.background='#FFFDF5'" onmouseout="this.style.background=''"><div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px"><span style="font-size:11px;font-weight:600;color:#1A1A1A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px">${topic}</span><span style="font-size:9px;color:#9E9891;flex-shrink:0;font-variant-numeric:tabular-nums">${time}</span></div><div style="font-size:10px;color:#6B6560;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical">${preview}</div></div>`;
|
|
842
|
-
})
|
|
843
|
-
.join('');
|
|
844
|
-
}
|
|
845
|
-
/**
|
|
846
|
-
* Focus a specific node in the graph (called from decision list click)
|
|
847
|
-
*/
|
|
848
|
-
focusNode(nodeId) {
|
|
849
|
-
if (!this.network)
|
|
850
|
-
return;
|
|
851
|
-
try {
|
|
852
|
-
this.network.selectNodes([nodeId]);
|
|
853
|
-
this.network.focus(nodeId, {
|
|
854
|
-
scale: 1.5,
|
|
855
|
-
animation: { duration: 300, easingFunction: 'easeInOutQuad' },
|
|
856
|
-
});
|
|
857
|
-
// Trigger the detail panel
|
|
858
|
-
const nodeData = this.graphData.nodes.find((n) => String(n.id) === nodeId);
|
|
859
|
-
if (nodeData) {
|
|
860
|
-
this.showDetail(nodeData);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
catch (err) {
|
|
864
|
-
logger.warn('focusNode failed:', err);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
/**
|
|
868
|
-
* Filter by topic
|
|
869
|
-
*/
|
|
870
|
-
filterByTopic(topic) {
|
|
871
|
-
if (!this.network) {
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
875
|
-
if (!topic) {
|
|
876
|
-
// Show all
|
|
877
|
-
allNodes.forEach((node) => {
|
|
878
|
-
this.network.body.data.nodes.update({
|
|
879
|
-
id: node.id,
|
|
880
|
-
hidden: false,
|
|
881
|
-
});
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
else {
|
|
885
|
-
// Filter — supports exact match from datalist or partial substring match from typing
|
|
886
|
-
const lowerTopic = topic.toLowerCase();
|
|
887
|
-
allNodes.forEach((node) => {
|
|
888
|
-
const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
|
|
889
|
-
const nodeTopic = nodeData?.topic || '';
|
|
890
|
-
const matches = nodeTopic === topic || nodeTopic.toLowerCase().includes(lowerTopic);
|
|
891
|
-
this.network.body.data.nodes.update({
|
|
892
|
-
id: node.id,
|
|
893
|
-
hidden: !matches,
|
|
894
|
-
});
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
this.renderLegend();
|
|
898
|
-
logger.debug('[MAMA] Filtered by topic:', topic || 'all');
|
|
899
|
-
}
|
|
900
|
-
/**
|
|
901
|
-
* Filter by outcome
|
|
902
|
-
*/
|
|
903
|
-
filterByOutcome(outcome) {
|
|
904
|
-
if (!this.network) {
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
908
|
-
if (!outcome) {
|
|
909
|
-
// Show all
|
|
910
|
-
allNodes.forEach((node) => {
|
|
911
|
-
this.network.body.data.nodes.update({
|
|
912
|
-
id: node.id,
|
|
913
|
-
hidden: false,
|
|
914
|
-
});
|
|
915
|
-
});
|
|
916
|
-
}
|
|
917
|
-
else {
|
|
918
|
-
// Filter by outcome
|
|
919
|
-
allNodes.forEach((node) => {
|
|
920
|
-
const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
|
|
921
|
-
const nodeOutcome = (nodeData?.outcome || '').toLowerCase();
|
|
922
|
-
this.network.body.data.nodes.update({
|
|
923
|
-
id: node.id,
|
|
924
|
-
hidden: nodeOutcome !== outcome.toLowerCase(),
|
|
925
|
-
});
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
this.renderLegend();
|
|
929
|
-
logger.debug('[MAMA] Filtered by outcome:', outcome || 'all');
|
|
930
|
-
}
|
|
931
|
-
/**
|
|
932
|
-
* Clear all filters
|
|
933
|
-
*/
|
|
934
|
-
clearFilters() {
|
|
935
|
-
if (!this.network) {
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
// Show all nodes
|
|
939
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
940
|
-
allNodes.forEach((node) => {
|
|
941
|
-
this.network.body.data.nodes.update({
|
|
942
|
-
id: node.id,
|
|
943
|
-
hidden: false,
|
|
944
|
-
opacity: 1.0,
|
|
945
|
-
font: { ...node.font, color: '#131313' },
|
|
946
|
-
});
|
|
947
|
-
});
|
|
948
|
-
// Show all edges
|
|
949
|
-
const allEdges = this.network.body.data.edges.get();
|
|
950
|
-
allEdges.forEach((edge) => {
|
|
951
|
-
this.network.body.data.edges.update({
|
|
952
|
-
id: edge.id,
|
|
953
|
-
opacity: 1.0,
|
|
954
|
-
});
|
|
955
|
-
});
|
|
956
|
-
// Clear search state
|
|
957
|
-
this.searchMatches = [];
|
|
958
|
-
this.currentSearchIndex = 0;
|
|
959
|
-
const countEl = getElementByIdOrNull('search-count');
|
|
960
|
-
if (countEl) {
|
|
961
|
-
countEl.style.display = 'none';
|
|
962
|
-
}
|
|
963
|
-
this.renderLegend();
|
|
964
|
-
logger.debug('[MAMA] All filters cleared');
|
|
965
|
-
}
|
|
966
|
-
// =============================================
|
|
967
|
-
// Search
|
|
968
|
-
// =============================================
|
|
969
|
-
/**
|
|
970
|
-
* Perform search
|
|
971
|
-
*/
|
|
972
|
-
search() {
|
|
973
|
-
const queryInput = getElementByIdOrNull('search-input');
|
|
974
|
-
const query = queryInput ? queryInput.value.trim().toLowerCase() : '';
|
|
975
|
-
if (!query) {
|
|
976
|
-
this.clearSearch();
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
// Search in topic, decision, and reasoning
|
|
980
|
-
this.searchMatches = this.graphData.nodes.filter((node) => (node.topic || '').toLowerCase().includes(query) ||
|
|
981
|
-
(node.decision || '').toLowerCase().includes(query) ||
|
|
982
|
-
(node.reasoning || '').toLowerCase().includes(query));
|
|
983
|
-
this.currentSearchIndex = 0;
|
|
984
|
-
this.updateSearchResults();
|
|
985
|
-
if (this.searchMatches.length > 0) {
|
|
986
|
-
this.highlightSearchResults();
|
|
987
|
-
this.navigateToNode(String(this.searchMatches[0].id));
|
|
988
|
-
}
|
|
989
|
-
logger.debug('[MAMA] Search:', query, '- Found', this.searchMatches.length, 'matches');
|
|
990
|
-
}
|
|
991
|
-
/**
|
|
992
|
-
* Handle search input
|
|
993
|
-
*/
|
|
994
|
-
handleSearchInput(event) {
|
|
995
|
-
if (event.key === 'Enter' && this.searchMatches.length > 0) {
|
|
996
|
-
this.nextSearchResult();
|
|
997
|
-
}
|
|
998
|
-
else {
|
|
999
|
-
this.debouncedSearch();
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
/**
|
|
1003
|
-
* Navigate to next search result
|
|
1004
|
-
*/
|
|
1005
|
-
nextSearchResult() {
|
|
1006
|
-
if (this.searchMatches.length === 0) {
|
|
1007
|
-
return;
|
|
1008
|
-
}
|
|
1009
|
-
this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchMatches.length;
|
|
1010
|
-
this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
|
|
1011
|
-
this.updateSearchResults();
|
|
1012
|
-
}
|
|
1013
|
-
/**
|
|
1014
|
-
* Navigate to previous search result
|
|
1015
|
-
*/
|
|
1016
|
-
prevSearchResult() {
|
|
1017
|
-
if (this.searchMatches.length === 0) {
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
this.currentSearchIndex =
|
|
1021
|
-
(this.currentSearchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
|
|
1022
|
-
this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
|
|
1023
|
-
this.updateSearchResults();
|
|
1024
|
-
}
|
|
1025
|
-
/**
|
|
1026
|
-
* Update search count display
|
|
1027
|
-
*/
|
|
1028
|
-
updateSearchResults() {
|
|
1029
|
-
const countEl = getElementByIdOrNull('search-count');
|
|
1030
|
-
if (!countEl) {
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
if (this.searchMatches.length > 0) {
|
|
1034
|
-
countEl.textContent = `${this.currentSearchIndex + 1} / ${this.searchMatches.length}`;
|
|
1035
|
-
countEl.style.display = 'inline';
|
|
1036
|
-
}
|
|
1037
|
-
else {
|
|
1038
|
-
countEl.textContent = 'No results';
|
|
1039
|
-
countEl.style.display = 'inline';
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
/**
|
|
1043
|
-
* Highlight search results
|
|
1044
|
-
*/
|
|
1045
|
-
highlightSearchResults() {
|
|
1046
|
-
if (!this.network) {
|
|
1047
|
-
return;
|
|
1048
|
-
}
|
|
1049
|
-
const matchIds = this.searchMatches.map((n) => n.id);
|
|
1050
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
1051
|
-
allNodes.forEach((node) => {
|
|
1052
|
-
const isMatch = matchIds.includes(String(node.id));
|
|
1053
|
-
this.network.body.data.nodes.update({
|
|
1054
|
-
id: node.id,
|
|
1055
|
-
opacity: isMatch ? 1.0 : 0.2,
|
|
1056
|
-
font: { ...node.font, color: isMatch ? '#131313' : '#999' },
|
|
1057
|
-
});
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
/**
|
|
1061
|
-
* Clear search
|
|
1062
|
-
*/
|
|
1063
|
-
clearSearch() {
|
|
1064
|
-
this.searchMatches = [];
|
|
1065
|
-
this.currentSearchIndex = 0;
|
|
1066
|
-
this.resetNodeHighlight();
|
|
1067
|
-
const countEl = getElementByIdOrNull('search-count');
|
|
1068
|
-
if (countEl) {
|
|
1069
|
-
countEl.style.display = 'none';
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
/**
|
|
1073
|
-
* Open search panel
|
|
1074
|
-
*/
|
|
1075
|
-
openSearch() {
|
|
1076
|
-
const searchContainer = getElementByIdOrNull('search-container');
|
|
1077
|
-
const searchInput = getElementByIdOrNull('search-input');
|
|
1078
|
-
if (!searchContainer || !searchInput) {
|
|
1079
|
-
return;
|
|
1080
|
-
}
|
|
1081
|
-
searchContainer.style.display = 'flex';
|
|
1082
|
-
searchInput.focus();
|
|
1083
|
-
}
|
|
1084
|
-
/**
|
|
1085
|
-
* Close search panel
|
|
1086
|
-
*/
|
|
1087
|
-
closeSearch() {
|
|
1088
|
-
const searchContainer = getElementByIdOrNull('search-container');
|
|
1089
|
-
if (!searchContainer) {
|
|
1090
|
-
return;
|
|
365
|
+
logger.error('Failed to read record detail:', error);
|
|
366
|
+
decisionEl.textContent = `Could not read this record: ${error instanceof Error ? error.message : String(error)}`;
|
|
1091
367
|
}
|
|
1092
|
-
searchContainer.style.display = 'none';
|
|
1093
|
-
this.clearSearch();
|
|
1094
368
|
}
|
|
1095
369
|
}
|