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