finchvox 0.0.1__py3-none-any.whl
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.
- finchvox/__init__.py +0 -0
- finchvox/__main__.py +81 -0
- finchvox/audio_recorder.py +278 -0
- finchvox/audio_utils.py +123 -0
- finchvox/cli.py +127 -0
- finchvox/collector/__init__.py +0 -0
- finchvox/collector/__main__.py +22 -0
- finchvox/collector/audio_handler.py +146 -0
- finchvox/collector/collector_routes.py +186 -0
- finchvox/collector/config.py +64 -0
- finchvox/collector/server.py +126 -0
- finchvox/collector/service.py +43 -0
- finchvox/collector/writer.py +86 -0
- finchvox/server.py +201 -0
- finchvox/trace.py +115 -0
- finchvox/ui/css/app.css +774 -0
- finchvox/ui/images/favicon.ico +0 -0
- finchvox/ui/images/finchvox-logo.png +0 -0
- finchvox/ui/js/time-utils.js +97 -0
- finchvox/ui/js/trace_detail.js +1228 -0
- finchvox/ui/js/traces_list.js +26 -0
- finchvox/ui/lib/alpine.min.js +5 -0
- finchvox/ui/lib/wavesurfer.min.js +1 -0
- finchvox/ui/trace_detail.html +313 -0
- finchvox/ui/traces_list.html +63 -0
- finchvox/ui_routes.py +362 -0
- finchvox-0.0.1.dist-info/METADATA +189 -0
- finchvox-0.0.1.dist-info/RECORD +31 -0
- finchvox-0.0.1.dist-info/WHEEL +4 -0
- finchvox-0.0.1.dist-info/entry_points.txt +2 -0
- finchvox-0.0.1.dist-info/licenses/LICENSE +24 -0
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
function traceDetailApp() {
|
|
2
|
+
return {
|
|
3
|
+
// State
|
|
4
|
+
traceId: null,
|
|
5
|
+
serviceName: null, // Service name from first span with resource.attributes
|
|
6
|
+
spans: [], // Original spans from API
|
|
7
|
+
waterfallSpans: [], // Flat array in display order for waterfall view
|
|
8
|
+
expandedSpanIds: new Set(), // Set of span IDs that are expanded
|
|
9
|
+
expansionInitialized: false, // Flag to ensure we only auto-expand on first load
|
|
10
|
+
isWaterfallExpanded: false, // Global expand/collapse state for the waterfall
|
|
11
|
+
selectedSpan: null, // Span shown in the details panel
|
|
12
|
+
highlightedSpan: null, // Span highlighted in the waterfall (for keyboard navigation)
|
|
13
|
+
hoveredSpan: null, // Span being hovered over (for chunk highlighting)
|
|
14
|
+
chunkHoveredSpan: null, // Span hovered from chunk (applies .selected to waterfall)
|
|
15
|
+
isPanelOpen: false, // Controls panel visibility and transitions
|
|
16
|
+
|
|
17
|
+
// Audio state
|
|
18
|
+
wavesurfer: null,
|
|
19
|
+
playing: false,
|
|
20
|
+
currentTime: 0,
|
|
21
|
+
duration: 0,
|
|
22
|
+
audioError: false,
|
|
23
|
+
|
|
24
|
+
// Copy state
|
|
25
|
+
spanCopied: false,
|
|
26
|
+
|
|
27
|
+
// Timeline state
|
|
28
|
+
minTime: 0,
|
|
29
|
+
maxTime: 0,
|
|
30
|
+
|
|
31
|
+
// Real-time polling state
|
|
32
|
+
isPolling: false,
|
|
33
|
+
pollInterval: null,
|
|
34
|
+
lastSpanCount: 0,
|
|
35
|
+
consecutiveErrors: 0,
|
|
36
|
+
|
|
37
|
+
// Hover marker state
|
|
38
|
+
hoverMarker: {
|
|
39
|
+
visible: false,
|
|
40
|
+
time: 0,
|
|
41
|
+
source: null // 'waveform' or 'waterfall'
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async init() {
|
|
45
|
+
// Extract trace_id from URL path: /traces/{trace_id}
|
|
46
|
+
const pathParts = window.location.pathname.split('/');
|
|
47
|
+
this.traceId = pathParts[pathParts.length - 1];
|
|
48
|
+
|
|
49
|
+
if (!this.traceId) {
|
|
50
|
+
console.error('No trace ID in URL');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await this.loadTraceData();
|
|
55
|
+
|
|
56
|
+
// Start polling if trace appears to be active
|
|
57
|
+
const conversationSpan = this.spans.find(s => s.name === 'conversation');
|
|
58
|
+
const shouldPoll = !conversationSpan || // No conversation span yet - might be created later
|
|
59
|
+
(conversationSpan && !conversationSpan.end_time_unix_nano); // Conversation exists but not ended
|
|
60
|
+
|
|
61
|
+
if (shouldPoll) {
|
|
62
|
+
this.startPolling();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.initAudioPlayer();
|
|
66
|
+
this.initKeyboardShortcuts();
|
|
67
|
+
this.initCleanup();
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async loadTraceData() {
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(`/api/trace/${this.traceId}`);
|
|
73
|
+
const data = await response.json();
|
|
74
|
+
|
|
75
|
+
// Parse and enrich spans
|
|
76
|
+
this.spans = data.spans.map(span => ({
|
|
77
|
+
...span,
|
|
78
|
+
startMs: Number(span.start_time_unix_nano) / 1_000_000,
|
|
79
|
+
endMs: Number(span.end_time_unix_nano) / 1_000_000,
|
|
80
|
+
durationMs: (Number(span.end_time_unix_nano) - Number(span.start_time_unix_nano)) / 1_000_000
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
// Extract service name from first span with resource attributes
|
|
84
|
+
for (const span of this.spans) {
|
|
85
|
+
if (span.resource && span.resource.attributes) {
|
|
86
|
+
const serviceAttr = span.resource.attributes.find(attr => attr.key === 'service.name');
|
|
87
|
+
if (serviceAttr && serviceAttr.value && serviceAttr.value.string_value) {
|
|
88
|
+
this.serviceName = serviceAttr.value.string_value;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Calculate timeline bounds
|
|
95
|
+
this.minTime = Math.min(...this.spans.map(s => s.startMs));
|
|
96
|
+
this.maxTime = Math.max(...this.spans.map(s => s.endMs));
|
|
97
|
+
|
|
98
|
+
// Build waterfall tree structure
|
|
99
|
+
this.buildWaterfallTree();
|
|
100
|
+
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('Failed to load trace:', error);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
buildWaterfallTree() {
|
|
107
|
+
// Step 1: Build parent-child map
|
|
108
|
+
const childrenMap = {}; // spanId -> [child spans]
|
|
109
|
+
const rootSpans = [];
|
|
110
|
+
const spanIds = new Set();
|
|
111
|
+
|
|
112
|
+
// Track all span IDs for orphan detection
|
|
113
|
+
this.spans.forEach(span => {
|
|
114
|
+
spanIds.add(span.span_id_hex);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.spans.forEach(span => {
|
|
118
|
+
const parentId = span.parent_span_id_hex;
|
|
119
|
+
|
|
120
|
+
// Check if this is a root span or orphaned span
|
|
121
|
+
const isOrphan = parentId && !spanIds.has(parentId);
|
|
122
|
+
const isRoot = !parentId;
|
|
123
|
+
|
|
124
|
+
if (isRoot || isOrphan) {
|
|
125
|
+
rootSpans.push(span);
|
|
126
|
+
} else if (parentId) {
|
|
127
|
+
if (!childrenMap[parentId]) {
|
|
128
|
+
childrenMap[parentId] = [];
|
|
129
|
+
}
|
|
130
|
+
childrenMap[parentId].push(span);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Step 2: Sort children by start time
|
|
135
|
+
Object.values(childrenMap).forEach(children => {
|
|
136
|
+
children.sort((a, b) => a.startMs - b.startMs);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Step 3: Traverse tree depth-first and flatten to display order
|
|
140
|
+
this.waterfallSpans = [];
|
|
141
|
+
|
|
142
|
+
const traverse = (span, depth) => {
|
|
143
|
+
span.depth = depth;
|
|
144
|
+
span.children = childrenMap[span.span_id_hex] || [];
|
|
145
|
+
span.childCount = span.children.length;
|
|
146
|
+
|
|
147
|
+
// Add this span to the display list
|
|
148
|
+
this.waterfallSpans.push(span);
|
|
149
|
+
|
|
150
|
+
// If expanded, add children recursively
|
|
151
|
+
const isExpanded = this.expandedSpanIds.has(span.span_id_hex);
|
|
152
|
+
if (isExpanded && span.children.length > 0) {
|
|
153
|
+
span.children.forEach(child => traverse(child, depth + 1));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Step 4: Start traversal from root spans (sorted by start time)
|
|
158
|
+
rootSpans.sort((a, b) => a.startMs - b.startMs);
|
|
159
|
+
rootSpans.forEach(span => traverse(span, 0));
|
|
160
|
+
|
|
161
|
+
// Step 5: Initialize expansion state (collapsed by default - only show conversation and turns)
|
|
162
|
+
// Only run this on first load, not on subsequent rebuilds
|
|
163
|
+
if (!this.expansionInitialized) {
|
|
164
|
+
let addedExpansions = false;
|
|
165
|
+
|
|
166
|
+
this.spans.forEach(span => {
|
|
167
|
+
// Only expand conversation spans by default
|
|
168
|
+
// This shows conversation and its children (turns), but not children of turns
|
|
169
|
+
if (span.name === 'conversation') {
|
|
170
|
+
const children = childrenMap[span.span_id_hex] || [];
|
|
171
|
+
if (children.length > 0 && !this.expandedSpanIds.has(span.span_id_hex)) {
|
|
172
|
+
this.expandedSpanIds.add(span.span_id_hex);
|
|
173
|
+
addedExpansions = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// If we just initialized spans as expanded, rebuild the tree
|
|
179
|
+
if (addedExpansions) {
|
|
180
|
+
this.waterfallSpans = [];
|
|
181
|
+
rootSpans.forEach(span => traverse(span, 0));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.expansionInitialized = true;
|
|
185
|
+
this.isWaterfallExpanded = false; // Start in collapsed state
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
toggleSpanExpansion(span) {
|
|
190
|
+
if (span.childCount === 0) {
|
|
191
|
+
// No children, just select the span
|
|
192
|
+
this.selectSpan(span);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Toggle expansion state
|
|
197
|
+
if (this.expandedSpanIds.has(span.span_id_hex)) {
|
|
198
|
+
this.expandedSpanIds.delete(span.span_id_hex);
|
|
199
|
+
} else {
|
|
200
|
+
this.expandedSpanIds.add(span.span_id_hex);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Rebuild the waterfall tree
|
|
204
|
+
this.buildWaterfallTree();
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
toggleWaterfallExpansion() {
|
|
208
|
+
if (this.isWaterfallExpanded) {
|
|
209
|
+
this.collapseAll();
|
|
210
|
+
} else {
|
|
211
|
+
this.expandAll();
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
expandAll() {
|
|
216
|
+
// Expand all spans with children
|
|
217
|
+
this.spans.forEach(span => {
|
|
218
|
+
// Find children for this span
|
|
219
|
+
const children = this.spans.filter(s => s.parent_span_id_hex === span.span_id_hex);
|
|
220
|
+
if (children.length > 0) {
|
|
221
|
+
this.expandedSpanIds.add(span.span_id_hex);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
this.isWaterfallExpanded = true;
|
|
226
|
+
this.buildWaterfallTree();
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
collapseAll() {
|
|
230
|
+
// Collapse everything below turns (depth 2+)
|
|
231
|
+
// Only expand conversation to show turns, but don't expand turns
|
|
232
|
+
this.expandedSpanIds.clear();
|
|
233
|
+
|
|
234
|
+
// Find all conversation spans and expand them (this shows turns but not their children)
|
|
235
|
+
this.spans.forEach(span => {
|
|
236
|
+
if (span.name === 'conversation') {
|
|
237
|
+
const children = this.spans.filter(s => s.parent_span_id_hex === span.span_id_hex);
|
|
238
|
+
if (children.length > 0) {
|
|
239
|
+
this.expandedSpanIds.add(span.span_id_hex);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
this.isWaterfallExpanded = false;
|
|
245
|
+
this.buildWaterfallTree();
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
getTimelineBarStyle(span) {
|
|
250
|
+
const totalDuration = this.maxTime - this.minTime;
|
|
251
|
+
const startPercent = ((span.startMs - this.minTime) / totalDuration) * 100;
|
|
252
|
+
const durationPercent = (span.durationMs / totalDuration) * 100;
|
|
253
|
+
const widthPercent = Math.max(durationPercent, 0.15); // Minimum 0.15% for visibility
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
left: `${startPercent}%`,
|
|
257
|
+
width: `${widthPercent}%`,
|
|
258
|
+
isShort: durationPercent < 2
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
getTimelineBarClasses(span) {
|
|
263
|
+
const style = this.getTimelineBarStyle(span);
|
|
264
|
+
return {
|
|
265
|
+
[`bar-${span.name}`]: true,
|
|
266
|
+
'short-bar': style.isShort
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
getExpandButtonStyle(span) {
|
|
271
|
+
const barStyle = this.getTimelineBarStyle(span);
|
|
272
|
+
const startPercent = parseFloat(barStyle.left);
|
|
273
|
+
|
|
274
|
+
// Position button 26px to the left of the bar (16px button + 10px gap)
|
|
275
|
+
// But ensure it doesn't go below 2px from the left edge
|
|
276
|
+
if (startPercent < 3) {
|
|
277
|
+
// For spans starting near 0%, position button at the start of the timeline (2px)
|
|
278
|
+
return {
|
|
279
|
+
left: '2px'
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
left: `calc(${startPercent}% - 26px)`
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
handleRowClick(span) {
|
|
289
|
+
// Expand span children if not already expanded
|
|
290
|
+
if (span.childCount > 0 && !this.expandedSpanIds.has(span.span_id_hex)) {
|
|
291
|
+
this.expandedSpanIds.add(span.span_id_hex);
|
|
292
|
+
this.buildWaterfallTree();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.selectSpan(span, true); // Always seek audio when clicking
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
initAudioPlayer() {
|
|
299
|
+
// Only initialize if not already created
|
|
300
|
+
if (this.wavesurfer) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.wavesurfer = WaveSurfer.create({
|
|
305
|
+
container: '#waveform',
|
|
306
|
+
waveColor: '#a855f7', // Purple (fallback)
|
|
307
|
+
progressColor: '#7c3aed', // Darker purple (fallback)
|
|
308
|
+
cursorColor: '#ffffff',
|
|
309
|
+
height: 40,
|
|
310
|
+
barWidth: 2,
|
|
311
|
+
barGap: 1,
|
|
312
|
+
barRadius: 2,
|
|
313
|
+
normalize: true,
|
|
314
|
+
backend: 'WebAudio',
|
|
315
|
+
splitChannels: [
|
|
316
|
+
{
|
|
317
|
+
waveColor: getComputedStyle(document.documentElement).getPropertyValue('--span-stt').trim(),
|
|
318
|
+
progressColor: getComputedStyle(document.documentElement).getPropertyValue('--span-stt-progress').trim()
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
waveColor: '#a855f7', // Purple for channel 1 (bot)
|
|
322
|
+
progressColor: '#7c3aed' // Darker purple
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
this.wavesurfer.load(`/api/audio/${this.traceId}`);
|
|
328
|
+
|
|
329
|
+
// Event listeners
|
|
330
|
+
this.wavesurfer.on('play', () => { this.playing = true; });
|
|
331
|
+
this.wavesurfer.on('pause', () => { this.playing = false; });
|
|
332
|
+
this.wavesurfer.on('audioprocess', (time) => { this.currentTime = time; });
|
|
333
|
+
this.wavesurfer.on('seek', (progress) => {
|
|
334
|
+
this.currentTime = progress * this.duration;
|
|
335
|
+
});
|
|
336
|
+
this.wavesurfer.on('ready', () => {
|
|
337
|
+
this.duration = this.wavesurfer.getDuration();
|
|
338
|
+
console.log('Audio ready, duration:', this.duration);
|
|
339
|
+
|
|
340
|
+
// Clear error state when audio loads successfully
|
|
341
|
+
if (this.audioError) {
|
|
342
|
+
console.log('Audio loaded successfully, clearing error state');
|
|
343
|
+
this.audioError = false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Generate timeline markers
|
|
347
|
+
this.generateTimeline();
|
|
348
|
+
|
|
349
|
+
// Setup hover marker listeners
|
|
350
|
+
this.initWaveformHover();
|
|
351
|
+
});
|
|
352
|
+
this.wavesurfer.on('error', (error) => {
|
|
353
|
+
console.error('Audio loading error:', error);
|
|
354
|
+
this.audioError = true;
|
|
355
|
+
});
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
generateTimeline() {
|
|
359
|
+
const timeline = document.getElementById('timeline');
|
|
360
|
+
if (!timeline || !this.duration) return;
|
|
361
|
+
|
|
362
|
+
timeline.innerHTML = ''; // Clear existing
|
|
363
|
+
|
|
364
|
+
// Show exactly 15 equally-spaced markers
|
|
365
|
+
const markerCount = 15;
|
|
366
|
+
const interval = this.duration / markerCount;
|
|
367
|
+
|
|
368
|
+
// Create timeline container with relative positioning
|
|
369
|
+
timeline.style.display = 'block';
|
|
370
|
+
timeline.style.position = 'relative';
|
|
371
|
+
timeline.style.width = '100%';
|
|
372
|
+
timeline.style.height = '20px';
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i <= markerCount; i++) {
|
|
375
|
+
const time = i * interval;
|
|
376
|
+
const percent = (time / this.duration) * 100;
|
|
377
|
+
|
|
378
|
+
const marker = document.createElement('div');
|
|
379
|
+
marker.className = 'timeline-marker';
|
|
380
|
+
marker.style.position = 'absolute';
|
|
381
|
+
marker.style.left = `${percent}%`;
|
|
382
|
+
marker.style.height = '20px';
|
|
383
|
+
|
|
384
|
+
// Create tick mark
|
|
385
|
+
const tick = document.createElement('div');
|
|
386
|
+
tick.style.position = 'absolute';
|
|
387
|
+
tick.style.left = '0';
|
|
388
|
+
tick.style.bottom = '0';
|
|
389
|
+
tick.style.width = '1px';
|
|
390
|
+
tick.style.height = '6px';
|
|
391
|
+
tick.style.backgroundColor = '#6b7280';
|
|
392
|
+
|
|
393
|
+
// Create label
|
|
394
|
+
const label = document.createElement('span');
|
|
395
|
+
label.style.position = 'absolute';
|
|
396
|
+
label.style.left = '0';
|
|
397
|
+
label.style.top = '0';
|
|
398
|
+
|
|
399
|
+
// Align first label left, last label right, middle labels centered
|
|
400
|
+
if (i === 0) {
|
|
401
|
+
label.style.transform = 'translateX(0)';
|
|
402
|
+
} else if (i === markerCount) {
|
|
403
|
+
label.style.transform = 'translateX(-100%)';
|
|
404
|
+
} else {
|
|
405
|
+
label.style.transform = 'translateX(-50%)';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
label.style.fontSize = '10px';
|
|
409
|
+
label.style.color = '#9ca3af';
|
|
410
|
+
label.style.fontFamily = 'monospace';
|
|
411
|
+
label.textContent = this.formatTimelineLabel(time);
|
|
412
|
+
|
|
413
|
+
marker.appendChild(tick);
|
|
414
|
+
marker.appendChild(label);
|
|
415
|
+
timeline.appendChild(marker);
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
formatTimelineLabel(seconds) {
|
|
420
|
+
// Convert seconds to milliseconds and use unified formatter with 0 decimals
|
|
421
|
+
return formatDuration(seconds * 1000, 0);
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
togglePlay() {
|
|
425
|
+
if (this.wavesurfer) {
|
|
426
|
+
this.wavesurfer.playPause();
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
skipBackward(seconds) {
|
|
431
|
+
if (!this.wavesurfer || !this.duration) return;
|
|
432
|
+
|
|
433
|
+
const currentTime = this.wavesurfer.getCurrentTime();
|
|
434
|
+
const newTime = Math.max(0, currentTime - seconds);
|
|
435
|
+
const progress = newTime / this.duration;
|
|
436
|
+
|
|
437
|
+
this.wavesurfer.seekTo(progress);
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
skipForward(seconds) {
|
|
441
|
+
if (!this.wavesurfer || !this.duration) return;
|
|
442
|
+
|
|
443
|
+
const currentTime = this.wavesurfer.getCurrentTime();
|
|
444
|
+
const newTime = Math.min(this.duration, currentTime + seconds);
|
|
445
|
+
const progress = newTime / this.duration;
|
|
446
|
+
|
|
447
|
+
this.wavesurfer.seekTo(progress);
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
initKeyboardShortcuts() {
|
|
451
|
+
// Prevent adding listener multiple times
|
|
452
|
+
if (window.__keyboardShortcutsInitialized) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
window.__keyboardShortcutsInitialized = true;
|
|
456
|
+
|
|
457
|
+
// Store reference to component context for event listener
|
|
458
|
+
const self = this;
|
|
459
|
+
|
|
460
|
+
// Add global keyboard event listener for YouTube-style controls
|
|
461
|
+
document.addEventListener('keydown', (event) => {
|
|
462
|
+
// Check if user is typing in an input field
|
|
463
|
+
const activeElement = document.activeElement;
|
|
464
|
+
const isTyping = activeElement && (
|
|
465
|
+
activeElement.tagName === 'INPUT' ||
|
|
466
|
+
activeElement.tagName === 'TEXTAREA' ||
|
|
467
|
+
activeElement.contentEditable === 'true'
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// Don't process shortcuts if user is typing
|
|
471
|
+
if (isTyping) return;
|
|
472
|
+
|
|
473
|
+
// Handle keyboard shortcuts
|
|
474
|
+
switch (event.key) {
|
|
475
|
+
case ' ': // Space - Play/Pause
|
|
476
|
+
event.preventDefault();
|
|
477
|
+
if (self.wavesurfer) {
|
|
478
|
+
self.wavesurfer.playPause();
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
|
|
482
|
+
case 'ArrowLeft': // Left Arrow - Skip backward 5 seconds
|
|
483
|
+
event.preventDefault();
|
|
484
|
+
if (self.wavesurfer && self.duration) {
|
|
485
|
+
const currentTime = self.wavesurfer.getCurrentTime();
|
|
486
|
+
const newTime = Math.max(0, currentTime - 5);
|
|
487
|
+
const progress = newTime / self.duration;
|
|
488
|
+
self.wavesurfer.seekTo(progress);
|
|
489
|
+
}
|
|
490
|
+
break;
|
|
491
|
+
|
|
492
|
+
case 'ArrowRight': // Right Arrow - Skip forward 5 seconds
|
|
493
|
+
event.preventDefault();
|
|
494
|
+
if (self.wavesurfer && self.duration) {
|
|
495
|
+
const currentTime = self.wavesurfer.getCurrentTime();
|
|
496
|
+
const newTime = Math.min(self.duration, currentTime + 5);
|
|
497
|
+
const progress = newTime / self.duration;
|
|
498
|
+
self.wavesurfer.seekTo(progress);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
|
|
502
|
+
case 'ArrowUp': // Up Arrow - Navigate to previous span
|
|
503
|
+
event.preventDefault();
|
|
504
|
+
self.navigateToPreviousSpan();
|
|
505
|
+
break;
|
|
506
|
+
|
|
507
|
+
case 'ArrowDown': // Down Arrow - Navigate to next span
|
|
508
|
+
event.preventDefault();
|
|
509
|
+
self.navigateToNextSpan();
|
|
510
|
+
break;
|
|
511
|
+
|
|
512
|
+
case 'Escape': // Escape - Close details panel
|
|
513
|
+
if (self.selectedSpan) {
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
self.closePanel();
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
|
|
519
|
+
case 'Enter': // Enter - Select highlighted span (open panel and seek)
|
|
520
|
+
event.preventDefault();
|
|
521
|
+
if (self.highlightedSpan) {
|
|
522
|
+
self.selectSpan(self.highlightedSpan, true);
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
initCleanup() {
|
|
530
|
+
// Stop polling when user navigates away
|
|
531
|
+
window.addEventListener('beforeunload', () => {
|
|
532
|
+
if (this.isPolling) {
|
|
533
|
+
this.stopPolling();
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
navigateToNextSpan() {
|
|
539
|
+
if (this.waterfallSpans.length === 0) return;
|
|
540
|
+
|
|
541
|
+
const panelWasOpen = this.selectedSpan !== null;
|
|
542
|
+
const currentSpan = this.highlightedSpan || this.selectedSpan;
|
|
543
|
+
|
|
544
|
+
if (!currentSpan) {
|
|
545
|
+
// No highlight, start at first span
|
|
546
|
+
const nextSpan = this.waterfallSpans[0];
|
|
547
|
+
this.highlightedSpan = nextSpan;
|
|
548
|
+
if (panelWasOpen) {
|
|
549
|
+
this.selectedSpan = nextSpan;
|
|
550
|
+
}
|
|
551
|
+
this.navigateToSpan(nextSpan); // Visual feedback only, no audio seek
|
|
552
|
+
} else {
|
|
553
|
+
// Find current index and move to next
|
|
554
|
+
const currentIndex = this.waterfallSpans.findIndex(
|
|
555
|
+
s => s.span_id_hex === currentSpan.span_id_hex
|
|
556
|
+
);
|
|
557
|
+
if (currentIndex !== -1 && currentIndex < this.waterfallSpans.length - 1) {
|
|
558
|
+
const nextSpan = this.waterfallSpans[currentIndex + 1];
|
|
559
|
+
this.highlightedSpan = nextSpan;
|
|
560
|
+
if (panelWasOpen) {
|
|
561
|
+
this.selectedSpan = nextSpan;
|
|
562
|
+
}
|
|
563
|
+
this.navigateToSpan(nextSpan); // Visual feedback only, no audio seek
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
navigateToPreviousSpan() {
|
|
569
|
+
if (this.waterfallSpans.length === 0) return;
|
|
570
|
+
|
|
571
|
+
const panelWasOpen = this.selectedSpan !== null;
|
|
572
|
+
const currentSpan = this.highlightedSpan || this.selectedSpan;
|
|
573
|
+
|
|
574
|
+
if (!currentSpan) {
|
|
575
|
+
// No highlight, start at last span
|
|
576
|
+
const prevSpan = this.waterfallSpans[this.waterfallSpans.length - 1];
|
|
577
|
+
this.highlightedSpan = prevSpan;
|
|
578
|
+
if (panelWasOpen) {
|
|
579
|
+
this.selectedSpan = prevSpan;
|
|
580
|
+
}
|
|
581
|
+
this.navigateToSpan(prevSpan); // Visual feedback only, no audio seek
|
|
582
|
+
} else {
|
|
583
|
+
// Find current index and move to previous
|
|
584
|
+
const currentIndex = this.waterfallSpans.findIndex(
|
|
585
|
+
s => s.span_id_hex === currentSpan.span_id_hex
|
|
586
|
+
);
|
|
587
|
+
if (currentIndex > 0) {
|
|
588
|
+
const prevSpan = this.waterfallSpans[currentIndex - 1];
|
|
589
|
+
this.highlightedSpan = prevSpan;
|
|
590
|
+
if (panelWasOpen) {
|
|
591
|
+
this.selectedSpan = prevSpan;
|
|
592
|
+
}
|
|
593
|
+
this.navigateToSpan(prevSpan); // Visual feedback only, no audio seek
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
navigateToSpan(span) {
|
|
599
|
+
// Show hover marker at span position (visual feedback only)
|
|
600
|
+
this.showMarkerAtSpan(span);
|
|
601
|
+
|
|
602
|
+
// Scroll the span into view
|
|
603
|
+
setTimeout(() => {
|
|
604
|
+
this.scrollSpanIntoView(span);
|
|
605
|
+
}, 0);
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
seekToSpan(span) {
|
|
609
|
+
// Show hover marker at span position
|
|
610
|
+
this.showMarkerAtSpan(span);
|
|
611
|
+
|
|
612
|
+
// Seek audio to span start time if audio is not playing
|
|
613
|
+
if (this.wavesurfer && this.duration) {
|
|
614
|
+
const isPlaying = this.wavesurfer.isPlaying();
|
|
615
|
+
if (!isPlaying) {
|
|
616
|
+
const audioTime = (span.startMs - this.minTime) / 1000;
|
|
617
|
+
const progress = audioTime / this.duration;
|
|
618
|
+
this.wavesurfer.seekTo(progress);
|
|
619
|
+
// Update currentTime directly for immediate UI feedback
|
|
620
|
+
this.currentTime = audioTime;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Scroll the span into view
|
|
625
|
+
setTimeout(() => {
|
|
626
|
+
this.scrollSpanIntoView(span);
|
|
627
|
+
}, 0);
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
selectSpan(span, shouldSeekAudio = false) {
|
|
631
|
+
const panelWasOpen = this.isPanelOpen;
|
|
632
|
+
|
|
633
|
+
// Update selected span content (doesn't trigger transition)
|
|
634
|
+
this.selectedSpan = span;
|
|
635
|
+
this.highlightedSpan = span; // Keep highlight in sync when clicking
|
|
636
|
+
|
|
637
|
+
// Open panel if not already open (triggers transition only when opening)
|
|
638
|
+
if (!panelWasOpen && span) {
|
|
639
|
+
this.isPanelOpen = true;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Seek audio to span start time if requested
|
|
643
|
+
if (shouldSeekAudio) {
|
|
644
|
+
this.seekToSpan(span);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// If panel state changed (opened), refresh marker position after transition
|
|
648
|
+
if (!panelWasOpen && span) {
|
|
649
|
+
setTimeout(() => {
|
|
650
|
+
this.refreshMarkerPosition();
|
|
651
|
+
}, 350); // Wait for CSS transition (0.3s) + small buffer
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
closePanel() {
|
|
656
|
+
this.isPanelOpen = false; // Triggers close transition
|
|
657
|
+
this.selectedSpan = null; // Clear panel content
|
|
658
|
+
// Refresh marker position after panel closes
|
|
659
|
+
setTimeout(() => {
|
|
660
|
+
this.refreshMarkerPosition();
|
|
661
|
+
}, 350); // Wait for CSS transition (0.3s) + small buffer
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
scrollSpanIntoView(span) {
|
|
665
|
+
// Find the DOM element for this span
|
|
666
|
+
const spanElement = document.querySelector(`[data-span-id="${span.span_id_hex}"]`);
|
|
667
|
+
if (spanElement) {
|
|
668
|
+
// Use native scrollIntoView with center alignment
|
|
669
|
+
// This automatically handles edge cases (top/bottom boundaries)
|
|
670
|
+
spanElement.scrollIntoView({
|
|
671
|
+
behavior: 'smooth',
|
|
672
|
+
// block: 'center',
|
|
673
|
+
inline: 'nearest'
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
handleSpanClick(span, event, clickedOnBadge = false) {
|
|
679
|
+
if (clickedOnBadge && span.childCount > 0) {
|
|
680
|
+
// Clicked on badge - toggle expansion
|
|
681
|
+
this.toggleSpanExpansion(span);
|
|
682
|
+
} else {
|
|
683
|
+
// Clicked on row - select span and seek audio if not playing
|
|
684
|
+
this.selectSpan(span, true);
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
|
|
688
|
+
formatTime(seconds) {
|
|
689
|
+
// Convert seconds to milliseconds and use unified formatter
|
|
690
|
+
if (!seconds) return formatDuration(0);
|
|
691
|
+
return formatDuration(seconds * 1000);
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
formatSpanDuration(span) {
|
|
695
|
+
// Format span duration using unified formatter
|
|
696
|
+
if (!span) return '';
|
|
697
|
+
return formatDuration(span.durationMs);
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
formatRelativeStartTime(span) {
|
|
701
|
+
// Format relative start time using unified formatter
|
|
702
|
+
if (!span) return '';
|
|
703
|
+
const relativeMs = span.startMs - this.minTime;
|
|
704
|
+
return formatDuration(relativeMs);
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
formatTimestamp(nanos) {
|
|
708
|
+
if (!nanos) return '';
|
|
709
|
+
const date = new Date(Number(nanos) / 1_000_000);
|
|
710
|
+
return date.toISOString().replace('T', ' ').substring(0, 23);
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
formatAttributes(span) {
|
|
714
|
+
if (!span || !span.attributes) return '{}';
|
|
715
|
+
|
|
716
|
+
// Flatten attributes array to object
|
|
717
|
+
const attrs = {};
|
|
718
|
+
span.attributes.forEach(attr => {
|
|
719
|
+
const value = attr.value.string_value ||
|
|
720
|
+
attr.value.int_value ||
|
|
721
|
+
attr.value.double_value ||
|
|
722
|
+
attr.value.bool_value;
|
|
723
|
+
attrs[attr.key] = value;
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
return JSON.stringify(attrs, null, 2);
|
|
727
|
+
},
|
|
728
|
+
|
|
729
|
+
formatResourceAttributes(span) {
|
|
730
|
+
if (!span || !span.resource || !span.resource.attributes) return '{}';
|
|
731
|
+
|
|
732
|
+
// Flatten resource attributes array to object
|
|
733
|
+
const attrs = {};
|
|
734
|
+
span.resource.attributes.forEach(attr => {
|
|
735
|
+
const value = attr.value.string_value ||
|
|
736
|
+
attr.value.int_value ||
|
|
737
|
+
attr.value.double_value ||
|
|
738
|
+
attr.value.bool_value;
|
|
739
|
+
attrs[attr.key] = value;
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
return JSON.stringify(attrs, null, 2);
|
|
743
|
+
},
|
|
744
|
+
|
|
745
|
+
getTranscriptText(span) {
|
|
746
|
+
if (!span || !span.attributes) return '';
|
|
747
|
+
|
|
748
|
+
const transcriptAttr = span.attributes.find(attr => attr.key === 'transcript');
|
|
749
|
+
if (!transcriptAttr) return '';
|
|
750
|
+
|
|
751
|
+
return transcriptAttr.value.string_value || '';
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
getOutputText(span) {
|
|
755
|
+
if (!span || !span.attributes) return '';
|
|
756
|
+
|
|
757
|
+
const outputAttr = span.attributes.find(attr => attr.key === 'output');
|
|
758
|
+
if (!outputAttr) return '';
|
|
759
|
+
|
|
760
|
+
return outputAttr.value.string_value || '';
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
// Get a specific attribute value
|
|
764
|
+
getAttribute(span, key) {
|
|
765
|
+
if (!span || !span.attributes) return null;
|
|
766
|
+
|
|
767
|
+
const attr = span.attributes.find(a => a.key === key);
|
|
768
|
+
if (!attr) return null;
|
|
769
|
+
|
|
770
|
+
return attr.value.string_value ||
|
|
771
|
+
attr.value.int_value ||
|
|
772
|
+
attr.value.double_value ||
|
|
773
|
+
attr.value.bool_value;
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
// Check if a specific attribute exists
|
|
777
|
+
hasAttribute(span, key) {
|
|
778
|
+
return this.getAttribute(span, key) !== null;
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
// Format a single attribute value as JSON or plain text
|
|
782
|
+
formatAttributeValue(span, key) {
|
|
783
|
+
const value = this.getAttribute(span, key);
|
|
784
|
+
if (value === null) return '';
|
|
785
|
+
|
|
786
|
+
// Try to parse as JSON for pretty printing
|
|
787
|
+
try {
|
|
788
|
+
const parsed = JSON.parse(value);
|
|
789
|
+
return JSON.stringify(parsed, null, 2);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
// If not JSON, return as plain text
|
|
792
|
+
return value;
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
|
|
796
|
+
// Get TTFB (Time to First Byte) value from span attributes
|
|
797
|
+
getTTFB(span) {
|
|
798
|
+
if (!span || !span.attributes) return null;
|
|
799
|
+
|
|
800
|
+
const ttfbAttr = span.attributes.find(a => a.key === 'metrics.ttfb');
|
|
801
|
+
if (!ttfbAttr || !ttfbAttr.value.double_value) return null;
|
|
802
|
+
|
|
803
|
+
return ttfbAttr.value.double_value;
|
|
804
|
+
},
|
|
805
|
+
|
|
806
|
+
// Format TTFB value for display
|
|
807
|
+
formatTTFB(span) {
|
|
808
|
+
const ttfbSeconds = this.getTTFB(span);
|
|
809
|
+
if (ttfbSeconds === null) return '';
|
|
810
|
+
|
|
811
|
+
// Convert seconds to milliseconds and format
|
|
812
|
+
return formatDuration(ttfbSeconds * 1000);
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
// Get user-bot latency value from span attributes
|
|
816
|
+
getUserBotLatency(span) {
|
|
817
|
+
if (!span || !span.attributes) return null;
|
|
818
|
+
|
|
819
|
+
const latencyAttr = span.attributes.find(a => a.key === 'turn.user_bot_latency_seconds');
|
|
820
|
+
if (!latencyAttr || !latencyAttr.value.double_value) return null;
|
|
821
|
+
|
|
822
|
+
return latencyAttr.value.double_value;
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
// Check if latency is >= 2 seconds (slow response)
|
|
826
|
+
isSlowLatency(span) {
|
|
827
|
+
const latencySeconds = this.getUserBotLatency(span);
|
|
828
|
+
return latencySeconds !== null && latencySeconds >= 2.0;
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
// Format user-bot latency value for display with turtle icon if slow
|
|
832
|
+
formatUserBotLatency(span) {
|
|
833
|
+
const latencySeconds = this.getUserBotLatency(span);
|
|
834
|
+
if (latencySeconds === null) return '';
|
|
835
|
+
|
|
836
|
+
// Convert seconds to milliseconds and format
|
|
837
|
+
const formattedTime = formatDuration(latencySeconds * 1000);
|
|
838
|
+
|
|
839
|
+
// Add turtle icon if latency >= 2 seconds
|
|
840
|
+
if (latencySeconds >= 2.0) {
|
|
841
|
+
return `${formattedTime} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="width: 16px; height: 16px; display: inline-block; vertical-align: middle; margin-left: 4px; color: white; fill: currentColor;">
|
|
842
|
+
<path d="M511.325,275.018c-0.416-0.982-0.974-1.799-1.54-2.432c-1.117-1.241-2.199-1.891-3.157-2.382 c-1.808-0.892-3.391-1.274-5.107-1.633c-2.982-0.592-6.348-0.916-10.13-1.183c-5.64-0.4-12.13-0.633-18.419-1.016 c-3.166-0.192-6.29-0.433-9.18-0.734c0.3-1.449,0.474-2.932,0.467-4.432c0.008-3.732-0.975-7.447-2.725-10.896 c-1.757-3.458-4.24-6.698-7.372-9.831c-2.991-2.982-6.69-7.489-10.847-12.979c-7.289-9.613-16.045-22.243-26.233-35.738 c-15.311-20.252-33.847-42.503-56.24-59.93c-11.196-8.714-23.376-16.212-36.63-21.56c-13.246-5.339-27.574-8.505-42.853-8.505 c-23.292-0.008-44.302,7.356-62.796,18.544c-13.896,8.398-26.45,18.935-37.813,30.307c-17.036,17.045-31.44,35.955-43.486,52.45 c-6.023,8.239-11.454,15.878-16.27,22.326c-2.757,3.69-5.314,6.981-7.648,9.763c-0.783-0.741-1.549-1.475-2.283-2.208 c-3.582-3.599-6.489-7.139-8.672-12.03c-2.174-4.89-3.699-11.33-3.706-20.876c-0.009-8.781,1.332-20.143,4.673-34.872 c0.642-2.832,0.95-5.656,0.95-8.43c0-6.448-1.691-12.571-4.573-17.961c-4.323-8.114-11.205-14.653-19.318-19.235 c-8.139-4.574-17.578-7.214-27.316-7.223c-9.863-0.008-20.077,2.79-29.032,9.146c-8.181,5.824-13.979,11.18-17.953,16.495 c-1.974,2.658-3.491,5.315-4.531,8.023C0.542,148.685,0,151.442,0,154.141c-0.008,3.124,0.742,6.106,1.974,8.672 c1.075,2.258,2.491,4.216,4.057,5.906c2.741,2.966,5.94,5.182,9.139,6.998c4.816,2.691,9.722,4.449,13.496,5.599 c0.332,0.1,0.649,0.2,0.974,0.283c1.442,21.226,4.307,38.638,8.081,53.033c6.131,23.392,14.728,38.87,23.317,49.425 c4.282,5.274,8.547,9.305,12.346,12.462c3.799,3.158,7.156,5.474,9.464,7.215c5.465,4.098,10.696,7.047,15.687,8.996 c3.673,1.433,7.223,2.316,10.613,2.683v0.009c4.799,2.874,16.695,9.555,35.147,16.694c-0.183,0.666-0.5,1.491-0.925,2.4 c-1.124,2.432-2.99,5.464-5.123,8.463c-3.232,4.541-7.089,9.08-10.113,12.437c-1.516,1.675-2.808,3.058-3.724,4.024 c-0.467,0.484-0.816,0.85-1.075,1.084l-0.15,0.166c-0.016,0.017-0.091,0.1-0.2,0.208c-0.792,0.758-3.816,3.69-6.956,7.898 c-1.766,2.4-3.599,5.198-5.074,8.389c-1.458,3.199-2.616,6.798-2.64,10.888c-0.017,2.899,0.666,6.056,2.274,8.93 c0.883,1.608,2.007,2.933,3.224,4.041c2.124,1.958,4.54,3.357,7.09,4.482c3.857,1.699,8.097,2.824,12.546,3.582 c4.448,0.758,9.056,1.124,13.504,1.124c5.298-0.016,10.313-0.5,14.778-1.675c2.233-0.616,4.332-1.39,6.365-2.607 c1.016-0.608,2.008-1.342,2.949-2.308c0.925-0.933,1.808-2.133,2.441-3.599c0.366-0.883,1.1-2.466,2.049-4.44 c3.316-6.94,9.297-18.802,14.404-28.857c2.566-5.04,4.907-9.63,6.606-12.954c0.85-1.674,1.55-3.024,2.033-3.965 c0.475-0.924,0.733-1.442,0.733-1.442l0.016-0.033l0.042-0.042c0.033-0.067,0.075-0.142,0.092-0.217 c23.226,4.758,50.517,8.048,81.565,8.048c1.641,0,3.266,0,4.907-0.025h0.025c23.184-0.274,43.978-2.416,62.23-5.606 c2.25,4.39,7.597,14.812,12.804,25.15c2.657,5.256,5.274,10.497,7.414,14.87c1.092,2.174,2.05,4.148,2.824,5.79 c0.774,1.624,1.383,2.956,1.716,3.723c0.624,1.466,1.491,2.666,2.432,3.599c1.666,1.666,3.433,2.699,5.256,3.507 c2.75,1.2,5.69,1.9,8.84,2.383c3.157,0.475,6.514,0.7,9.98,0.7c6.814-0.016,13.937-0.833,20.318-2.64 c3.174-0.917,6.181-2.083,8.93-3.691c1.383-0.808,2.691-1.732,3.907-2.857c1.199-1.108,2.324-2.433,3.215-4.041 c1.625-2.874,2.283-6.031,2.266-8.93c0-4.09-1.158-7.689-2.616-10.888c-2.215-4.774-5.223-8.722-7.681-11.638 c-2.099-2.457-3.799-4.132-4.374-4.648v-0.016c-0.016-0.026-0.033-0.042-0.05-0.059c-0.024-0.016-0.024-0.033-0.042-0.033 c-0.033-0.042-0.05-0.058-0.091-0.1c-0.991-0.991-5.665-5.806-10.422-11.654c-2.641-3.232-5.274-6.772-7.306-10.039 c-0.7-1.107-1.308-2.199-1.832-3.215c20.868-7.689,33.806-15.295,38.438-18.227c0.883-0.05,1.848-0.125,2.907-0.225 c7.248-0.725,18.752-2.816,30.956-7.847c6.098-2.516,12.354-5.774,18.269-10.022c5.914-4.249,11.488-9.497,16.103-15.953 l0.166-0.242l0.158-0.258c0.341-0.575,0.666-1.241,0.916-2.024c0.241-0.776,0.408-1.683,0.408-2.641 C512,277.21,511.759,276.027,511.325,275.018z"/>
|
|
843
|
+
</svg>`;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return formattedTime;
|
|
847
|
+
},
|
|
848
|
+
|
|
849
|
+
// Get interruption status from span attributes
|
|
850
|
+
wasInterrupted(span) {
|
|
851
|
+
if (!span || !span.attributes) return null;
|
|
852
|
+
|
|
853
|
+
const interruptedAttr = span.attributes.find(a => a.key === 'turn.was_interrupted');
|
|
854
|
+
if (!interruptedAttr || interruptedAttr.value.bool_value === undefined) return null;
|
|
855
|
+
|
|
856
|
+
return interruptedAttr.value.bool_value;
|
|
857
|
+
},
|
|
858
|
+
|
|
859
|
+
// Format interruption status for display
|
|
860
|
+
formatInterrupted(span) {
|
|
861
|
+
const interrupted = this.wasInterrupted(span);
|
|
862
|
+
if (interrupted === null) return '';
|
|
863
|
+
|
|
864
|
+
if (interrupted) {
|
|
865
|
+
return `Yes <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width: 16px; height: 16px; display: inline-block; vertical-align: middle; margin-left: 4px; color: white;">
|
|
866
|
+
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 0 0 1.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06ZM17.78 9.22a.75.75 0 1 0-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L20.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-1.72 1.72-1.72-1.72Z" />
|
|
867
|
+
</svg>`;
|
|
868
|
+
} else {
|
|
869
|
+
return 'No';
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
// Format bot chunk text (interrupt icon removed for cleaner display)
|
|
874
|
+
formatBotChunkText(chunk) {
|
|
875
|
+
if (!chunk.botText) return '';
|
|
876
|
+
return chunk.botText;
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
// Format bar duration with interruption and slow latency icons if needed
|
|
880
|
+
formatBarDuration(span) {
|
|
881
|
+
if (!span) return '';
|
|
882
|
+
|
|
883
|
+
let result = formatDuration(span.durationMs);
|
|
884
|
+
|
|
885
|
+
// Add interrupt icon for interrupted turns
|
|
886
|
+
if (span.name === 'turn' && this.wasInterrupted(span)) {
|
|
887
|
+
result += ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width: 14px; height: 14px; display: inline-block; vertical-align: top; margin-left: 3px; color: white;">
|
|
888
|
+
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 0 0 1.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06ZM17.78 9.22a.75.75 0 1 0-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L20.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-1.72 1.72-1.72-1.72Z" />
|
|
889
|
+
</svg>`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Add turtle icon for slow latency (>= 2s)
|
|
893
|
+
if (span.name === 'turn' && this.isSlowLatency(span)) {
|
|
894
|
+
result += ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="width: 14px; height: 14px; display: inline-block; vertical-align: top; margin-left: 3px; color: white; fill: currentColor;">
|
|
895
|
+
<path d="M511.325,275.018c-0.416-0.982-0.974-1.799-1.54-2.432c-1.117-1.241-2.199-1.891-3.157-2.382 c-1.808-0.892-3.391-1.274-5.107-1.633c-2.982-0.592-6.348-0.916-10.13-1.183c-5.64-0.4-12.13-0.633-18.419-1.016 c-3.166-0.192-6.29-0.433-9.18-0.734c0.3-1.449,0.474-2.932,0.467-4.432c0.008-3.732-0.975-7.447-2.725-10.896 c-1.757-3.458-4.24-6.698-7.372-9.831c-2.991-2.982-6.69-7.489-10.847-12.979c-7.289-9.613-16.045-22.243-26.233-35.738 c-15.311-20.252-33.847-42.503-56.24-59.93c-11.196-8.714-23.376-16.212-36.63-21.56c-13.246-5.339-27.574-8.505-42.853-8.505 c-23.292-0.008-44.302,7.356-62.796,18.544c-13.896,8.398-26.45,18.935-37.813,30.307c-17.036,17.045-31.44,35.955-43.486,52.45 c-6.023,8.239-11.454,15.878-16.27,22.326c-2.757,3.69-5.314,6.981-7.648,9.763c-0.783-0.741-1.549-1.475-2.283-2.208 c-3.582-3.599-6.489-7.139-8.672-12.03c-2.174-4.89-3.699-11.33-3.706-20.876c-0.009-8.781,1.332-20.143,4.673-34.872 c0.642-2.832,0.95-5.656,0.95-8.43c0-6.448-1.691-12.571-4.573-17.961c-4.323-8.114-11.205-14.653-19.318-19.235 c-8.139-4.574-17.578-7.214-27.316-7.223c-9.863-0.008-20.077,2.79-29.032,9.146c-8.181,5.824-13.979,11.18-17.953,16.495 c-1.974,2.658-3.491,5.315-4.531,8.023C0.542,148.685,0,151.442,0,154.141c-0.008,3.124,0.742,6.106,1.974,8.672 c1.075,2.258,2.491,4.216,4.057,5.906c2.741,2.966,5.94,5.182,9.139,6.998c4.816,2.691,9.722,4.449,13.496,5.599 c0.332,0.1,0.649,0.2,0.974,0.283c1.442,21.226,4.307,38.638,8.081,53.033c6.131,23.392,14.728,38.87,23.317,49.425 c4.282,5.274,8.547,9.305,12.346,12.462c3.799,3.158,7.156,5.474,9.464,7.215c5.465,4.098,10.696,7.047,15.687,8.996 c3.673,1.433,7.223,2.316,10.613,2.683v0.009c4.799,2.874,16.695,9.555,35.147,16.694c-0.183,0.666-0.5,1.491-0.925,2.4 c-1.124,2.432-2.99,5.464-5.123,8.463c-3.232,4.541-7.089,9.08-10.113,12.437c-1.516,1.675-2.808,3.058-3.724,4.024 c-0.467,0.484-0.816,0.85-1.075,1.084l-0.15,0.166c-0.016,0.017-0.091,0.1-0.2,0.208c-0.792,0.758-3.816,3.69-6.956,7.898 c-1.766,2.4-3.599,5.198-5.074,8.389c-1.458,3.199-2.616,6.798-2.64,10.888c-0.017,2.899,0.666,6.056,2.274,8.93 c0.883,1.608,2.007,2.933,3.224,4.041c2.124,1.958,4.54,3.357,7.09,4.482c3.857,1.699,8.097,2.824,12.546,3.582 c4.448,0.758,9.056,1.124,13.504,1.124c5.298-0.016,10.313-0.5,14.778-1.675c2.233-0.616,4.332-1.39,6.365-2.607 c1.016-0.608,2.008-1.342,2.949-2.308c0.925-0.933,1.808-2.133,2.441-3.599c0.366-0.883,1.1-2.466,2.049-4.44 c3.316-6.94,9.297-18.802,14.404-28.857c2.566-5.04,4.907-9.63,6.606-12.954c0.85-1.674,1.55-3.024,2.033-3.965 c0.475-0.924,0.733-1.442,0.733-1.442l0.016-0.033l0.042-0.042c0.033-0.067,0.075-0.142,0.092-0.217 c23.226,4.758,50.517,8.048,81.565,8.048c1.641,0,3.266,0,4.907-0.025h0.025c23.184-0.274,43.978-2.416,62.23-5.606 c2.25,4.39,7.597,14.812,12.804,25.15c2.657,5.256,5.274,10.497,7.414,14.87c1.092,2.174,2.05,4.148,2.824,5.79 c0.774,1.624,1.383,2.956,1.716,3.723c0.624,1.466,1.491,2.666,2.432,3.599c1.666,1.666,3.433,2.699,5.256,3.507 c2.75,1.2,5.69,1.9,8.84,2.383c3.157,0.475,6.514,0.7,9.98,0.7c6.814-0.016,13.937-0.833,20.318-2.64 c3.174-0.917,6.181-2.083,8.93-3.691c1.383-0.808,2.691-1.732,3.907-2.857c1.199-1.108,2.324-2.433,3.215-4.041 c1.625-2.874,2.283-6.031,2.266-8.93c0-4.09-1.158-7.689-2.616-10.888c-2.215-4.774-5.223-8.722-7.681-11.638 c-2.099-2.457-3.799-4.132-4.374-4.648v-0.016c-0.016-0.026-0.033-0.042-0.05-0.059c-0.024-0.016-0.024-0.033-0.042-0.033 c-0.033-0.042-0.05-0.058-0.091-0.1c-0.991-0.991-5.665-5.806-10.422-11.654c-2.641-3.232-5.274-6.772-7.306-10.039 c-0.7-1.107-1.308-2.199-1.832-3.215c20.868-7.689,33.806-15.295,38.438-18.227c0.883-0.05,1.848-0.125,2.907-0.225 c7.248-0.725,18.752-2.816,30.956-7.847c6.098-2.516,12.354-5.774,18.269-10.022c5.914-4.249,11.488-9.497,16.103-15.953 l0.166-0.242l0.158-0.258c0.341-0.575,0.666-1.241,0.916-2.024c0.241-0.776,0.408-1.683,0.408-2.641 C512,277.21,511.759,276.027,511.325,275.018z"/>
|
|
896
|
+
</svg>`;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return result;
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
// Get all tool calls from the input attribute
|
|
903
|
+
getToolCalls(span) {
|
|
904
|
+
if (!span || span.name !== 'llm') return [];
|
|
905
|
+
|
|
906
|
+
const inputValue = this.getAttribute(span, 'input');
|
|
907
|
+
if (!inputValue) return [];
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
const messages = JSON.parse(inputValue);
|
|
911
|
+
if (!Array.isArray(messages)) return [];
|
|
912
|
+
|
|
913
|
+
const toolCalls = [];
|
|
914
|
+
// Iterate through all messages and collect tool calls
|
|
915
|
+
messages.forEach(msg => {
|
|
916
|
+
if (msg.role === 'assistant' && msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
|
917
|
+
toolCalls.push(...msg.tool_calls);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
return toolCalls;
|
|
922
|
+
} catch (e) {
|
|
923
|
+
console.error('Error parsing tool calls:', e);
|
|
924
|
+
return [];
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
// Format tool calls as JSON
|
|
929
|
+
formatToolCalls(span) {
|
|
930
|
+
const toolCalls = this.getToolCalls(span);
|
|
931
|
+
if (toolCalls.length === 0) return '[]';
|
|
932
|
+
|
|
933
|
+
return JSON.stringify(toolCalls, null, 2);
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
// Get raw span JSON (excluding computed properties)
|
|
937
|
+
getRawSpanJSON(span) {
|
|
938
|
+
if (!span) return '{}';
|
|
939
|
+
|
|
940
|
+
// Exclude computed properties added by the frontend
|
|
941
|
+
const { startMs, endMs, durationMs, depth, children, childCount, ...rawSpan } = span;
|
|
942
|
+
|
|
943
|
+
return JSON.stringify(rawSpan, null, 2);
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
// Copy span JSON to clipboard with visual feedback
|
|
947
|
+
async copySpanToClipboard() {
|
|
948
|
+
if (!this.selectedSpan) return;
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
const spanJSON = this.getRawSpanJSON(this.selectedSpan);
|
|
952
|
+
await navigator.clipboard.writeText(spanJSON);
|
|
953
|
+
|
|
954
|
+
// Visual feedback: set copied state
|
|
955
|
+
this.spanCopied = true;
|
|
956
|
+
|
|
957
|
+
// Reset after 1.5 seconds
|
|
958
|
+
setTimeout(() => {
|
|
959
|
+
this.spanCopied = false;
|
|
960
|
+
}, 1500);
|
|
961
|
+
} catch (err) {
|
|
962
|
+
console.error('Failed to copy span:', err);
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
|
|
966
|
+
// Initialize waveform hover listeners
|
|
967
|
+
initWaveformHover() {
|
|
968
|
+
const waveformContainer = document.getElementById('waveform');
|
|
969
|
+
if (!waveformContainer) return;
|
|
970
|
+
|
|
971
|
+
waveformContainer.addEventListener('mousemove', (e) => {
|
|
972
|
+
const rect = waveformContainer.getBoundingClientRect();
|
|
973
|
+
const x = e.clientX - rect.left;
|
|
974
|
+
const percent = x / rect.width;
|
|
975
|
+
const time = percent * this.duration;
|
|
976
|
+
|
|
977
|
+
this.hoverMarker.time = time;
|
|
978
|
+
this.hoverMarker.source = 'waveform';
|
|
979
|
+
this.hoverMarker.visible = true;
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
waveformContainer.addEventListener('mouseleave', () => {
|
|
983
|
+
if (this.hoverMarker.source === 'waveform') {
|
|
984
|
+
this.hoverMarker.visible = false;
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
// Show hover marker at span start time (called from waterfall row hover)
|
|
990
|
+
showMarkerAtSpan(span) {
|
|
991
|
+
// Calculate relative time from trace start (minTime is already in ms)
|
|
992
|
+
const relativeMs = span.startMs - this.minTime;
|
|
993
|
+
this.hoverMarker.time = relativeMs / 1000; // Convert to seconds
|
|
994
|
+
this.hoverMarker.source = 'waterfall';
|
|
995
|
+
this.hoverMarker.visible = true;
|
|
996
|
+
},
|
|
997
|
+
|
|
998
|
+
// Hide hover marker (called from waterfall row leave)
|
|
999
|
+
hideMarkerFromWaterfall() {
|
|
1000
|
+
if (this.hoverMarker.source === 'waterfall') {
|
|
1001
|
+
this.hoverMarker.visible = false;
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
|
|
1005
|
+
// Refresh hover marker position (called when waveform width changes, e.g., panel open/close)
|
|
1006
|
+
refreshMarkerPosition() {
|
|
1007
|
+
if (this.hoverMarker.visible && this.hoverMarker.source === 'waterfall') {
|
|
1008
|
+
// Force Alpine to recalculate by triggering a reactive update
|
|
1009
|
+
// We temporarily store the time value, toggle visibility, and restore
|
|
1010
|
+
const savedTime = this.hoverMarker.time;
|
|
1011
|
+
this.hoverMarker.visible = false;
|
|
1012
|
+
this.$nextTick(() => {
|
|
1013
|
+
this.hoverMarker.time = savedTime;
|
|
1014
|
+
this.hoverMarker.visible = true;
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
// Get hover marker position in pixels
|
|
1020
|
+
getMarkerPosition() {
|
|
1021
|
+
if (!this.duration || !this.hoverMarker.visible) return '32px'; // 2rem = 32px
|
|
1022
|
+
|
|
1023
|
+
const waveform = document.getElementById('waveform');
|
|
1024
|
+
if (!waveform) return '32px';
|
|
1025
|
+
|
|
1026
|
+
const waveformWidth = waveform.offsetWidth;
|
|
1027
|
+
const percent = this.hoverMarker.time / this.duration;
|
|
1028
|
+
const offsetInWaveform = percent * waveformWidth;
|
|
1029
|
+
const totalOffset = 32 + offsetInWaveform; // 32px = 2rem padding
|
|
1030
|
+
|
|
1031
|
+
return `${totalOffset}px`;
|
|
1032
|
+
},
|
|
1033
|
+
|
|
1034
|
+
// Format hover marker time label
|
|
1035
|
+
getMarkerTimeLabel() {
|
|
1036
|
+
if (!this.hoverMarker.visible) return '';
|
|
1037
|
+
return this.formatTime(this.hoverMarker.time);
|
|
1038
|
+
},
|
|
1039
|
+
|
|
1040
|
+
// Highlight span when hovering over chunk or waterfall row
|
|
1041
|
+
highlightSpan(span) {
|
|
1042
|
+
this.hoveredSpan = span;
|
|
1043
|
+
this.showMarkerAtSpan(span);
|
|
1044
|
+
},
|
|
1045
|
+
|
|
1046
|
+
// Unhighlight span when leaving chunk or waterfall row
|
|
1047
|
+
unhighlightSpan() {
|
|
1048
|
+
this.hoveredSpan = null;
|
|
1049
|
+
this.hideMarkerFromWaterfall();
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
// Highlight span from chunk hover (also applies .selected to waterfall)
|
|
1053
|
+
highlightSpanFromChunk(span) {
|
|
1054
|
+
this.hoveredSpan = span;
|
|
1055
|
+
this.chunkHoveredSpan = span;
|
|
1056
|
+
this.showMarkerAtSpan(span);
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
// Unhighlight span when leaving chunk
|
|
1060
|
+
unhighlightSpanFromChunk() {
|
|
1061
|
+
this.hoveredSpan = null;
|
|
1062
|
+
this.chunkHoveredSpan = null;
|
|
1063
|
+
this.hideMarkerFromWaterfall();
|
|
1064
|
+
},
|
|
1065
|
+
|
|
1066
|
+
// Handle chunk click - delegates to span click handler and expands children
|
|
1067
|
+
handleChunkClick(span) {
|
|
1068
|
+
// Expand span children if not already expanded
|
|
1069
|
+
if (span.childCount > 0 && !this.expandedSpanIds.has(span.span_id_hex)) {
|
|
1070
|
+
this.expandedSpanIds.add(span.span_id_hex);
|
|
1071
|
+
this.buildWaterfallTree();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Execute normal click behavior
|
|
1075
|
+
this.handleRowClick(span);
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
getTurnChunks() {
|
|
1079
|
+
// Return empty array if no data or audio not ready
|
|
1080
|
+
if (!this.spans || this.spans.length === 0 || !this.duration) {
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Find all turn spans
|
|
1085
|
+
const turnSpans = this.spans.filter(s => s.name === 'turn');
|
|
1086
|
+
|
|
1087
|
+
// Map each turn to a chunk object with text and positioning
|
|
1088
|
+
return turnSpans.map(turn => {
|
|
1089
|
+
// Find all STT and LLM children
|
|
1090
|
+
const children = this.spans.filter(s => s.parent_span_id_hex === turn.span_id_hex);
|
|
1091
|
+
const sttChildren = children.filter(c => c.name === 'stt');
|
|
1092
|
+
const llmChildren = children.filter(c => c.name === 'llm');
|
|
1093
|
+
|
|
1094
|
+
// Concatenate text from all STT spans
|
|
1095
|
+
const humanText = sttChildren
|
|
1096
|
+
.map(child => this.getTranscriptText(child))
|
|
1097
|
+
.filter(text => text) // Remove empty strings
|
|
1098
|
+
.join(' ');
|
|
1099
|
+
|
|
1100
|
+
// Concatenate text from all LLM spans
|
|
1101
|
+
const botText = llmChildren
|
|
1102
|
+
.map(child => this.getOutputText(child))
|
|
1103
|
+
.filter(text => text) // Remove empty strings
|
|
1104
|
+
.join(' ');
|
|
1105
|
+
|
|
1106
|
+
// Calculate positioning (reuses existing method)
|
|
1107
|
+
const style = this.getTimelineBarStyle(turn);
|
|
1108
|
+
|
|
1109
|
+
return {
|
|
1110
|
+
span_id_hex: turn.span_id_hex,
|
|
1111
|
+
span: turn, // Store reference for hover marker
|
|
1112
|
+
humanText: humanText,
|
|
1113
|
+
botText: botText,
|
|
1114
|
+
style: style, // { left: "X%", width: "Y%" }
|
|
1115
|
+
wasInterrupted: this.wasInterrupted(turn)
|
|
1116
|
+
};
|
|
1117
|
+
});
|
|
1118
|
+
},
|
|
1119
|
+
|
|
1120
|
+
// Real-time polling methods
|
|
1121
|
+
startPolling() {
|
|
1122
|
+
if (this.isPolling) return;
|
|
1123
|
+
|
|
1124
|
+
this.isPolling = true;
|
|
1125
|
+
this.lastSpanCount = this.spans.length;
|
|
1126
|
+
console.log('Starting real-time polling for synchronized trace updates');
|
|
1127
|
+
|
|
1128
|
+
// Poll for new spans every 1 second (audio reloads when spans update)
|
|
1129
|
+
this.pollInterval = setInterval(() => {
|
|
1130
|
+
this.pollForSpans();
|
|
1131
|
+
}, 1000);
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
stopPolling() {
|
|
1135
|
+
if (!this.isPolling) return;
|
|
1136
|
+
|
|
1137
|
+
console.log('Stopping real-time polling');
|
|
1138
|
+
this.isPolling = false;
|
|
1139
|
+
if (this.pollInterval) clearInterval(this.pollInterval);
|
|
1140
|
+
this.pollInterval = null;
|
|
1141
|
+
},
|
|
1142
|
+
|
|
1143
|
+
async reloadAudioIfNotPlaying() {
|
|
1144
|
+
// Only reload audio if it's not currently playing
|
|
1145
|
+
if (this.wavesurfer && !this.wavesurfer.isPlaying()) {
|
|
1146
|
+
console.log('Reloading audio waveform (synchronized with spans)');
|
|
1147
|
+
// Add cache-busting parameter to force reload
|
|
1148
|
+
const audioUrl = `/api/audio/${this.traceId}?t=${Date.now()}`;
|
|
1149
|
+
this.wavesurfer.load(audioUrl);
|
|
1150
|
+
} else if (this.wavesurfer) {
|
|
1151
|
+
console.log('Audio is playing, skipping reload');
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
async pollForSpans() {
|
|
1156
|
+
try {
|
|
1157
|
+
const response = await fetch(`/api/trace/${this.traceId}`);
|
|
1158
|
+
if (!response.ok) {
|
|
1159
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const data = await response.json();
|
|
1163
|
+
this.consecutiveErrors = 0;
|
|
1164
|
+
|
|
1165
|
+
// Check stopping condition 1: Conversation complete (if conversation span exists)
|
|
1166
|
+
const conversationSpan = data.spans.find(s => s.name === 'conversation');
|
|
1167
|
+
if (conversationSpan && conversationSpan.end_time_unix_nano) {
|
|
1168
|
+
console.log('Conversation complete, stopping polling');
|
|
1169
|
+
this.stopPolling();
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Check stopping condition 2: Trace abandoned (10 minutes since last span)
|
|
1174
|
+
if (data.last_span_time) {
|
|
1175
|
+
const lastSpanMs = Number(data.last_span_time) / 1_000_000;
|
|
1176
|
+
const nowMs = Date.now();
|
|
1177
|
+
const tenMinutesMs = 10 * 60 * 1000;
|
|
1178
|
+
|
|
1179
|
+
if ((nowMs - lastSpanMs) > tenMinutesMs) {
|
|
1180
|
+
console.log('Trace abandoned (>10 min since last span), stopping polling');
|
|
1181
|
+
this.stopPolling();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Check if new spans arrived
|
|
1187
|
+
if (data.spans.length > this.lastSpanCount) {
|
|
1188
|
+
console.log(`New spans detected: ${data.spans.length - this.lastSpanCount} new spans`);
|
|
1189
|
+
|
|
1190
|
+
// Get new spans by comparing span IDs
|
|
1191
|
+
const existingSpanIds = new Set(this.spans.map(s => s.span_id_hex));
|
|
1192
|
+
const newSpans = data.spans.filter(s => !existingSpanIds.has(s.span_id_hex));
|
|
1193
|
+
|
|
1194
|
+
// Add computed properties to new spans (same as loadTraceData)
|
|
1195
|
+
newSpans.forEach(span => {
|
|
1196
|
+
span.startMs = Number(span.start_time_unix_nano) / 1_000_000;
|
|
1197
|
+
span.endMs = Number(span.end_time_unix_nano) / 1_000_000;
|
|
1198
|
+
span.durationMs = (Number(span.end_time_unix_nano) - Number(span.start_time_unix_nano)) / 1_000_000;
|
|
1199
|
+
this.spans.push(span);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// Update timeline bounds
|
|
1203
|
+
this.minTime = Math.min(...this.spans.map(s => s.startMs));
|
|
1204
|
+
this.maxTime = Math.max(...this.spans.map(s => s.endMs));
|
|
1205
|
+
|
|
1206
|
+
// Rebuild waterfall tree with new spans
|
|
1207
|
+
this.buildWaterfallTree();
|
|
1208
|
+
|
|
1209
|
+
// Reload audio waveform (synchronized with span update)
|
|
1210
|
+
await this.reloadAudioIfNotPlaying();
|
|
1211
|
+
|
|
1212
|
+
// Update count
|
|
1213
|
+
this.lastSpanCount = this.spans.length;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
console.error('Error polling for spans:', error);
|
|
1218
|
+
this.consecutiveErrors++;
|
|
1219
|
+
|
|
1220
|
+
// Stop polling after 3 consecutive errors
|
|
1221
|
+
if (this.consecutiveErrors >= 3) {
|
|
1222
|
+
console.error('Too many consecutive errors, stopping polling');
|
|
1223
|
+
this.stopPolling();
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
}
|