Andrew commited on
Commit
4743f77
·
1 Parent(s): 78e67be

feat(utils): add tree layout algorithm for conversation visualization

Browse files
Files changed (1) hide show
  1. src/lib/utils/tree/layout.ts +282 -0
src/lib/utils/tree/layout.ts ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ELK from "elkjs/lib/elk.bundled.js";
2
+ import type { Message } from "$lib/types/Message";
3
+ import { MessageRole } from "$lib/types/Message";
4
+
5
+ // Initialize ELK with default options
6
+ const elk = new ELK();
7
+
8
+ export interface TreeLayoutNode {
9
+ id: string;
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ type: "user" | "assistant" | "system";
15
+ message: Message;
16
+ children: TreeLayoutNode[];
17
+ parentX?: number;
18
+ parentY?: number;
19
+ isOnActivePath?: boolean;
20
+ isActive?: boolean;
21
+ parentId?: string;
22
+ }
23
+
24
+ export interface TreeLayoutResult {
25
+ nodes: TreeLayoutNode[];
26
+ width: number;
27
+ height: number;
28
+ connections: Array<{
29
+ id: string;
30
+ source: TreeLayoutNode;
31
+ target: TreeLayoutNode;
32
+ path: string;
33
+ }>;
34
+ }
35
+
36
+ // Add explicit interface for ELK edge
37
+ interface ElkEdge {
38
+ id: string;
39
+ sources: string[];
40
+ targets: string[];
41
+ }
42
+
43
+ interface ExtendedElkNode {
44
+ id: string;
45
+ x?: number;
46
+ y?: number;
47
+ width?: number;
48
+ height?: number;
49
+ children?: ExtendedElkNode[];
50
+ edges?: ElkEdge[];
51
+ layoutOptions?: Record<string, string>;
52
+ }
53
+
54
+ // Helper to calculate node width based on content
55
+ function getNodeWidth(message: Message): number {
56
+ const baseSize = 24;
57
+ // For assistant messages with multiple persona responses, we need more width
58
+ // only if we are displaying them side-by-side or in a specific way.
59
+ // For now, the visual implementation in ConversationTreeGraph uses a fixed size
60
+ // but renders multiple icons. We should reserve space for them to prevent overlap.
61
+ if (
62
+ message.from === MessageRole.Assistant &&
63
+ message.personaResponses &&
64
+ message.personaResponses.length > 1
65
+ ) {
66
+ const iconSize = 18;
67
+ const spacing = 8;
68
+ const count = message.personaResponses.length;
69
+ // Calculate total width needed for the horizontal layout of icons
70
+ return Math.max(baseSize, count * iconSize + (count - 1) * spacing);
71
+ }
72
+ return baseSize;
73
+ }
74
+
75
+ /**
76
+ * Builds a tree layout using ELK.js
77
+ * @param messages All messages in the conversation
78
+ * @param activeMessageId The currently selected/active message ID (optional)
79
+ * @param activePathIds Optional set of message IDs that are currently visible in the chat window
80
+ */
81
+ export async function buildTreeWithPositions(
82
+ messages: Message[],
83
+ activeMessageId?: string,
84
+ activePathIds: Set<string> = new Set()
85
+ ): Promise<TreeLayoutResult> {
86
+ if (!messages || messages.length === 0) {
87
+ return { nodes: [], width: 0, height: 0, connections: [] };
88
+ }
89
+
90
+ // Map messages by ID for easy access
91
+ const messageMap = new Map(messages.map((m) => [m.id, m]));
92
+
93
+ // Identify roots (messages with no parent or parent not in the list)
94
+ const getParentId = (m: Message) => m.ancestors?.at(-1);
95
+
96
+ const roots = messages.filter(
97
+ (m) => !m.children?.length && (!getParentId(m) || !messageMap.has(getParentId(m) ?? ""))
98
+ );
99
+
100
+ // Fallback for roots if none found (circular or system msg issues)
101
+ if (roots.length === 0 && messages.length > 0) {
102
+ // Try finding messages with parentId that doesn't exist in the current set
103
+ const potentialRoots = messages.filter(
104
+ (m) => !getParentId(m) || !messageMap.has(getParentId(m) ?? "")
105
+ );
106
+ if (potentialRoots.length > 0) {
107
+ roots.push(...potentialRoots);
108
+ } else {
109
+ // Ultimate fallback: just take the first one
110
+ roots.push(messages[0]);
111
+ }
112
+ }
113
+
114
+ // Prepare ELK graph structure
115
+ const elkNodes: ExtendedElkNode[] = [];
116
+ const elkEdges: ElkEdge[] = [];
117
+
118
+ // Helper to recursively build graph
119
+ const visited = new Set<string>();
120
+
121
+ function buildSubgraph(message: Message) {
122
+ if (visited.has(message.id)) return;
123
+ visited.add(message.id);
124
+
125
+ const width = getNodeWidth(message);
126
+
127
+ elkNodes.push({
128
+ id: message.id,
129
+ width,
130
+ height: 24,
131
+ layoutOptions: {
132
+ "elk.portConstraints": "FIXED_SIDE",
133
+ "elk.portAlignment.default": "CENTER",
134
+ },
135
+ });
136
+
137
+ if (message.children) {
138
+ for (const childId of message.children) {
139
+ const child = messageMap.get(childId);
140
+ if (child) {
141
+ elkEdges.push({
142
+ id: `${message.id}-${childId}`,
143
+ sources: [message.id],
144
+ targets: [childId],
145
+ });
146
+ buildSubgraph(child);
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // Build graph from all roots
153
+ roots.forEach((root) => buildSubgraph(root));
154
+
155
+ // Also scan for any disconnected components that weren't reached from roots
156
+ // (This handles cases where message history might be fragmented)
157
+ messages.forEach((m) => {
158
+ if (!visited.has(m.id)) {
159
+ buildSubgraph(m);
160
+ }
161
+ });
162
+
163
+ const graph: ExtendedElkNode = {
164
+ id: "root",
165
+ layoutOptions: {
166
+ "elk.algorithm": "layered",
167
+ "elk.direction": "DOWN",
168
+ "elk.spacing.nodeNode": "20",
169
+ "elk.layered.spacing.nodeNodeBetweenLayers": "15",
170
+ "elk.edgeRouting": "SPLINES",
171
+ "elk.spacing.componentComponent": "30",
172
+ },
173
+ children: elkNodes,
174
+ edges: elkEdges,
175
+ };
176
+
177
+ // Run layout
178
+ const layout = await elk.layout(graph);
179
+
180
+ // Transform back to our node structure
181
+ const resultNodes: TreeLayoutNode[] = [];
182
+ let minX = Infinity;
183
+ let maxX = -Infinity;
184
+ let minY = Infinity;
185
+ let maxY = -Infinity;
186
+
187
+ // Helper to flatten ELK result
188
+ function processLayoutNode(node: ExtendedElkNode) {
189
+ if (node.children) {
190
+ node.children.forEach(processLayoutNode);
191
+ return;
192
+ }
193
+
194
+ // Leaf nodes (messages)
195
+ if (node.id && messageMap.has(node.id)) {
196
+ const msg = messageMap.get(node.id);
197
+ if (!msg) return;
198
+
199
+ const x = node.x || 0;
200
+ const y = node.y || 0;
201
+
202
+ minX = Math.min(minX, x);
203
+ maxX = Math.max(maxX, x + (node.width || 24));
204
+ minY = Math.min(minY, y);
205
+ maxY = Math.max(maxY, y + (node.height || 24));
206
+
207
+ const isOnActivePath = activePathIds.has(node.id);
208
+ const isActive = activeMessageId === node.id;
209
+
210
+ resultNodes.push({
211
+ id: node.id,
212
+ x,
213
+ y,
214
+ width: node.width || 24,
215
+ height: node.height || 24,
216
+ type:
217
+ msg.from === MessageRole.User
218
+ ? "user"
219
+ : msg.from === MessageRole.System
220
+ ? "system"
221
+ : "assistant",
222
+ message: msg,
223
+ children: [], // Will populate active children logic if needed, but visual structure is flat
224
+ isOnActivePath,
225
+ isActive,
226
+ parentId: getParentId(msg),
227
+ });
228
+ }
229
+ }
230
+
231
+ if (layout.children) {
232
+ layout.children.forEach(processLayoutNode);
233
+ }
234
+
235
+ // Post-process to link parents for drawing lines
236
+ const nodeMap = new Map(resultNodes.map((n) => [n.id, n]));
237
+ const connections: TreeLayoutResult["connections"] = [];
238
+
239
+ resultNodes.forEach((node) => {
240
+ if (node.parentId && nodeMap.has(node.parentId)) {
241
+ const parent = nodeMap.get(node.parentId);
242
+ if (!parent) return;
243
+
244
+ node.parentX = parent.x;
245
+ node.parentY = parent.y;
246
+
247
+ // Determine if this connection is on the active path
248
+ // Connection is active if BOTH source and target are on the active path
249
+ // const isConnectionActive = parent.isOnActivePath && node.isOnActivePath;
250
+
251
+ connections.push({
252
+ id: `${parent.id}-${node.id}`,
253
+ source: parent,
254
+ target: node,
255
+ path: "", // Will be calculated in component or here if we want
256
+ });
257
+ }
258
+ });
259
+
260
+ // Normalize coordinates (start at 0,0 with padding)
261
+ const padding = 20;
262
+ const width = maxX - minX + padding * 2;
263
+ const height = maxY - minY + padding * 2;
264
+
265
+ resultNodes.forEach((node) => {
266
+ node.x = node.x - minX + padding;
267
+ node.y = node.y - minY + padding;
268
+ if (node.parentX !== undefined) {
269
+ node.parentX = node.parentX - minX + padding;
270
+ }
271
+ if (node.parentY !== undefined) {
272
+ node.parentY = node.parentY - minY + padding;
273
+ }
274
+ });
275
+
276
+ return {
277
+ nodes: resultNodes,
278
+ width: Math.max(width, 0), // Ensure non-negative
279
+ height: Math.max(height, 0),
280
+ connections,
281
+ };
282
+ }