@raquezha/notrace 0.0.1

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/index.ts ADDED
@@ -0,0 +1,692 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ /**
6
+ * html-observability extension
7
+ *
8
+ * Captures execution traces from the pi coding agent sessions and writes
9
+ * a self-contained, interactive, beautiful HTML report to the active task's
10
+ * workspace at the end of the session.
11
+ */
12
+ export default function (pi: ExtensionAPI) {
13
+ const events: any[] = [];
14
+ const sessionStartTime = Date.now();
15
+ let traceId = "";
16
+ let activeLlmPayload: any = null;
17
+ let llmStartTime = 0;
18
+ const activeToolTimes: Record<string, number> = {};
19
+
20
+ // Helper to extract active task path from .workflow/active_task.json
21
+ function getActiveTaskDir(cwd: string): string {
22
+ try {
23
+ const activeTaskJsonPath = path.join(cwd, ".workflow", "active_task.json");
24
+ if (existsSync(activeTaskJsonPath)) {
25
+ const content = JSON.parse(readFileSync(activeTaskJsonPath, "utf-8"));
26
+ if (content.taskPath) {
27
+ return path.resolve(cwd, content.taskPath);
28
+ } else if (content.active_task) {
29
+ return path.resolve(cwd, ".workflow", "tasks", content.active_task);
30
+ }
31
+ }
32
+ } catch {
33
+ // fallback
34
+ }
35
+ const defaultDir = path.join(cwd, ".workflow");
36
+ if (!existsSync(defaultDir)) {
37
+ try { mkdirSync(defaultDir, { recursive: true }); } catch {}
38
+ }
39
+ return defaultDir;
40
+ }
41
+
42
+ // 1. Session start
43
+ pi.on("session_start", async (event, ctx) => {
44
+ traceId = ctx.sessionManager.getSessionId() || `session-${Date.now()}`;
45
+ events.push({
46
+ type: "session_start",
47
+ timestamp: Date.now(),
48
+ reason: event.reason,
49
+ sessionFile: ctx.sessionManager.getSessionFile() || ""
50
+ });
51
+ });
52
+
53
+ // 2. Turn start
54
+ pi.on("turn_start", async (event, ctx) => {
55
+ events.push({
56
+ type: "turn_start",
57
+ timestamp: Date.now()
58
+ });
59
+ });
60
+
61
+ // 3. Turn end
62
+ pi.on("turn_end", async (event, ctx) => {
63
+ events.push({
64
+ type: "turn_end",
65
+ timestamp: Date.now()
66
+ });
67
+ });
68
+
69
+ // 4. Tool start
70
+ pi.on("tool_execution_start", async (event, ctx) => {
71
+ const { toolCallId, toolName, args } = event;
72
+ activeToolTimes[toolCallId] = Date.now();
73
+ events.push({
74
+ type: "tool_start",
75
+ toolCallId,
76
+ toolName,
77
+ args,
78
+ timestamp: Date.now()
79
+ });
80
+ });
81
+
82
+ // 5. Tool end
83
+ pi.on("tool_execution_end", async (event, ctx) => {
84
+ const { toolCallId, toolName, result, isError } = event;
85
+ const startTime = activeToolTimes[toolCallId] || Date.now();
86
+ const durationMs = Date.now() - startTime;
87
+
88
+ events.push({
89
+ type: "tool_end",
90
+ toolCallId,
91
+ toolName,
92
+ result,
93
+ isError,
94
+ durationMs,
95
+ timestamp: Date.now()
96
+ });
97
+ delete activeToolTimes[toolCallId];
98
+ });
99
+
100
+ // 6. LLM call start (capture payload)
101
+ pi.on("before_provider_request", async (event, ctx) => {
102
+ activeLlmPayload = event.payload;
103
+ llmStartTime = Date.now();
104
+ });
105
+
106
+ // 7. LLM call end (capture generation / usage)
107
+ pi.on("message_end", async (event, ctx) => {
108
+ const { message } = event;
109
+ if (message.role !== "assistant") return;
110
+
111
+ const durationMs = llmStartTime > 0 ? Date.now() - llmStartTime : 0;
112
+
113
+ events.push({
114
+ type: "llm_completion",
115
+ model: message.model || "unknown",
116
+ provider: message.provider || "unknown",
117
+ inputPayload: activeLlmPayload,
118
+ outputContent: message.content,
119
+ usage: message.usage,
120
+ durationMs,
121
+ timestamp: Date.now()
122
+ });
123
+
124
+ activeLlmPayload = null;
125
+ llmStartTime = 0;
126
+ });
127
+
128
+ // 8. Shutdown: Compile the HTML report and write to disk
129
+ pi.on("session_shutdown", async (event, ctx) => {
130
+ const sessionEndTime = Date.now();
131
+ const totalDurationMs = sessionEndTime - sessionStartTime;
132
+
133
+ const taskDir = getActiveTaskDir(ctx.cwd);
134
+ const reportPath = path.join(taskDir, "notrace.html");
135
+
136
+ // Gather statistics
137
+ let totalTokens = 0;
138
+ let inputTokens = 0;
139
+ let outputTokens = 0;
140
+ let totalCost = 0;
141
+ let toolCallCount = 0;
142
+ let llmCallCount = 0;
143
+
144
+ events.forEach((e) => {
145
+ if (e.type === "llm_completion") {
146
+ llmCallCount++;
147
+ if (e.usage) {
148
+ inputTokens += e.usage.input || 0;
149
+ outputTokens += e.usage.output || 0;
150
+ totalTokens += e.usage.totalTokens || 0;
151
+ totalCost += e.usage.cost?.total || 0;
152
+ }
153
+ } else if (e.type === "tool_start") {
154
+ toolCallCount++;
155
+ }
156
+ });
157
+
158
+ const htmlContent = generateHtmlReport({
159
+ traceId,
160
+ projectName: process.env.PHOENIX_PROJECT_NAME || "pi-coding-agent",
161
+ startTime: new Date(sessionStartTime).toISOString(),
162
+ endTime: new Date(sessionEndTime).toISOString(),
163
+ durationMs: totalDurationMs,
164
+ metrics: {
165
+ totalTokens,
166
+ inputTokens,
167
+ outputTokens,
168
+ totalCost: totalCost.toFixed(5),
169
+ toolCallCount,
170
+ llmCallCount
171
+ },
172
+ events
173
+ });
174
+
175
+ try {
176
+ writeFileSync(reportPath, htmlContent, "utf-8");
177
+ // Output a nice clickable file:// link to the console for the user
178
+ console.log(`\n📊 [notrace] Observability report generated:`);
179
+ console.log(`👉 \x1b[36mfile://${reportPath}\x1b[0m\n`);
180
+ } catch (err: any) {
181
+ console.warn(`[notrace] Failed to write HTML report: ${err?.message || err}`);
182
+ }
183
+ });
184
+ }
185
+
186
+ // Returns a self-contained premium HTML template incorporating the design tokens
187
+ function generateHtmlReport(data: any): string {
188
+ const serializedData = JSON.stringify(data);
189
+
190
+ return `<!DOCTYPE html>
191
+ <html lang="en">
192
+ <head>
193
+ <meta charset="UTF-8">
194
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
195
+ <title>notrace - ${data.traceId}</title>
196
+ <link rel="preconnect" href="https://fonts.googleapis.com">
197
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
198
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Source+Code+Pro:wght@400;500&display=swap" rel="stylesheet">
199
+ <style>
200
+ :root {
201
+ --bg: #0b0b0e;
202
+ --bg-panel: rgba(22, 22, 28, 0.45);
203
+ --bg-card: rgba(30, 30, 38, 0.6);
204
+ --border: rgba(255, 255, 255, 0.08);
205
+ --border-hover: rgba(255, 255, 255, 0.15);
206
+ --text: #e2e2e9;
207
+ --text-muted: #9f9fa9;
208
+ --accent: #8b5cf6;
209
+ --accent-gradient: linear-gradient(135deg, #a78bfa, #8b5cf6);
210
+ --success: #10b981;
211
+ --error: #ef4444;
212
+ --warning: #f59e0b;
213
+ --info: #3b82f6;
214
+ }
215
+
216
+ * {
217
+ box-sizing: border-box;
218
+ margin: 0;
219
+ padding: 0;
220
+ }
221
+
222
+ body {
223
+ font-family: 'Outfit', sans-serif;
224
+ background-color: var(--bg);
225
+ color: var(--text);
226
+ line-height: 1.5;
227
+ padding: 2rem;
228
+ min-height: 100vh;
229
+ background-image:
230
+ radial-gradient(circle at 10% 20%, rgba(139, 92, 246, 0.08) 0%, transparent 40%),
231
+ radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.06) 0%, transparent 40%);
232
+ background-attachment: fixed;
233
+ }
234
+
235
+ .container {
236
+ max-width: 1100px;
237
+ margin: 0 auto;
238
+ }
239
+
240
+ header {
241
+ display: flex;
242
+ justify-content: space-between;
243
+ align-items: center;
244
+ margin-bottom: 2rem;
245
+ padding-bottom: 1.5rem;
246
+ border-bottom: 1px solid var(--border);
247
+ }
248
+
249
+ .brand {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 0.75rem;
253
+ }
254
+
255
+ .brand h1 {
256
+ font-size: 1.75rem;
257
+ font-weight: 700;
258
+ background: var(--accent-gradient);
259
+ -webkit-background-clip: text;
260
+ -webkit-text-fill-color: transparent;
261
+ }
262
+
263
+ .brand-tag {
264
+ font-size: 0.75rem;
265
+ text-transform: uppercase;
266
+ letter-spacing: 0.05em;
267
+ background: rgba(139, 92, 246, 0.15);
268
+ color: #c084fc;
269
+ padding: 0.2rem 0.5rem;
270
+ border-radius: 4px;
271
+ border: 1px solid rgba(167, 139, 250, 0.3);
272
+ }
273
+
274
+ .meta-time {
275
+ font-size: 0.875rem;
276
+ color: var(--text-muted);
277
+ text-align: right;
278
+ }
279
+
280
+ .metrics-grid {
281
+ display: grid;
282
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
283
+ gap: 1rem;
284
+ margin-bottom: 2.5rem;
285
+ }
286
+
287
+ .metric-card {
288
+ background: var(--bg-panel);
289
+ border: 1px solid var(--border);
290
+ padding: 1.25rem;
291
+ border-radius: 12px;
292
+ backdrop-filter: blur(12px);
293
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
294
+ }
295
+
296
+ .metric-card:hover {
297
+ border-color: var(--border-hover);
298
+ transform: translateY(-2px);
299
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
300
+ }
301
+
302
+ .metric-label {
303
+ font-size: 0.75rem;
304
+ text-transform: uppercase;
305
+ letter-spacing: 0.05em;
306
+ color: var(--text-muted);
307
+ margin-bottom: 0.5rem;
308
+ }
309
+
310
+ .metric-value {
311
+ font-size: 1.5rem;
312
+ font-weight: 600;
313
+ color: #fff;
314
+ }
315
+
316
+ .timeline {
317
+ position: relative;
318
+ padding-left: 2rem;
319
+ border-left: 2px solid var(--border);
320
+ margin-left: 1rem;
321
+ }
322
+
323
+ .timeline-event {
324
+ position: relative;
325
+ margin-bottom: 2rem;
326
+ animation: slideIn 0.4s ease-out;
327
+ }
328
+
329
+ @keyframes slideIn {
330
+ from { opacity: 0; transform: translateX(-10px); }
331
+ to { opacity: 1; transform: translateX(0); }
332
+ }
333
+
334
+ .timeline-dot {
335
+ position: absolute;
336
+ left: calc(-2rem - 6px);
337
+ top: 1.25rem;
338
+ width: 10px;
339
+ height: 10px;
340
+ border-radius: 50%;
341
+ background: var(--text-muted);
342
+ box-shadow: 0 0 0 4px var(--bg);
343
+ transition: all 0.3s ease;
344
+ }
345
+
346
+ /* Event color mappings */
347
+ .timeline-event.session-start .timeline-dot { background: var(--info); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--info); }
348
+ .timeline-event.turn-start .timeline-dot { background: var(--accent); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--accent); }
349
+ .timeline-event.tool-call .timeline-dot { background: var(--warning); box-shadow: 0 0 0 4px var(--bg); }
350
+ .timeline-event.tool-call.error .timeline-dot { background: var(--error); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--error); }
351
+ .timeline-event.llm-call .timeline-dot { background: var(--success); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--success); }
352
+
353
+ .card {
354
+ background: var(--bg-panel);
355
+ border: 1px solid var(--border);
356
+ border-radius: 12px;
357
+ padding: 1.25rem;
358
+ backdrop-filter: blur(12px);
359
+ transition: border-color 0.2s;
360
+ }
361
+
362
+ .card:hover {
363
+ border-color: var(--border-hover);
364
+ }
365
+
366
+ .card-header {
367
+ display: flex;
368
+ justify-content: space-between;
369
+ align-items: center;
370
+ cursor: pointer;
371
+ user-select: none;
372
+ }
373
+
374
+ .card-title {
375
+ display: flex;
376
+ align-items: center;
377
+ gap: 0.75rem;
378
+ font-weight: 600;
379
+ }
380
+
381
+ .card-badge {
382
+ font-size: 0.75rem;
383
+ font-weight: 500;
384
+ padding: 0.15rem 0.5rem;
385
+ border-radius: 4px;
386
+ }
387
+
388
+ .badge-session { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
389
+ .badge-turn { background: rgba(139, 92, 246, 0.15); color: #c084fc; }
390
+ .badge-tool { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
391
+ .badge-tool.error { background: rgba(239, 68, 68, 0.15); color: #f87171; }
392
+ .badge-llm { background: rgba(16, 185, 129, 0.15); color: #34d399; }
393
+
394
+ .card-time {
395
+ font-size: 0.8rem;
396
+ color: var(--text-muted);
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 0.5rem;
400
+ }
401
+
402
+ .arrow-icon {
403
+ width: 16px;
404
+ height: 16px;
405
+ fill: none;
406
+ stroke: currentColor;
407
+ stroke-width: 2;
408
+ stroke-linecap: round;
409
+ stroke-linejoin: round;
410
+ transition: transform 0.2s;
411
+ }
412
+
413
+ .expanded .arrow-icon {
414
+ transform: rotate(90deg);
415
+ }
416
+
417
+ .card-body {
418
+ margin-top: 1rem;
419
+ padding-top: 1rem;
420
+ border-top: 1px solid var(--border);
421
+ display: none;
422
+ }
423
+
424
+ .expanded .card-body {
425
+ display: block;
426
+ }
427
+
428
+ .code-block {
429
+ font-family: 'Source Code Pro', monospace;
430
+ font-size: 0.875rem;
431
+ background: rgba(0, 0, 0, 0.35);
432
+ border: 1px solid var(--border);
433
+ padding: 1rem;
434
+ border-radius: 8px;
435
+ overflow-x: auto;
436
+ margin-top: 0.5rem;
437
+ color: #e2e2e9;
438
+ white-space: pre-wrap;
439
+ }
440
+
441
+ .messages-container {
442
+ display: flex;
443
+ flex-direction: column;
444
+ gap: 0.75rem;
445
+ }
446
+
447
+ .msg-row {
448
+ display: flex;
449
+ flex-direction: column;
450
+ gap: 0.25rem;
451
+ padding: 0.75rem;
452
+ background: rgba(255, 255, 255, 0.02);
453
+ border-radius: 6px;
454
+ border-left: 3px solid var(--border);
455
+ }
456
+
457
+ .msg-row.user { border-left-color: var(--info); }
458
+ .msg-row.assistant { border-left-color: var(--success); }
459
+ .msg-row.system { border-left-color: var(--accent); }
460
+
461
+ .msg-role {
462
+ font-size: 0.75rem;
463
+ font-weight: 600;
464
+ text-transform: uppercase;
465
+ letter-spacing: 0.05em;
466
+ }
467
+
468
+ .msg-row.user .msg-role { color: #60a5fa; }
469
+ .msg-row.assistant .msg-role { color: #34d399; }
470
+ .msg-row.system .msg-role { color: #c084fc; }
471
+
472
+ .msg-text {
473
+ font-size: 0.9rem;
474
+ white-space: pre-wrap;
475
+ }
476
+
477
+ .duration-pill {
478
+ font-size: 0.75rem;
479
+ background: rgba(255, 255, 255, 0.06);
480
+ padding: 0.1rem 0.4rem;
481
+ border-radius: 4px;
482
+ color: var(--text-muted);
483
+ }
484
+ </style>
485
+ </head>
486
+ <body>
487
+ <div class="container">
488
+ <header>
489
+ <div class="brand">
490
+ <h1>notrace</h1>
491
+ <span class="brand-tag">Local Observability</span>
492
+ </div>
493
+ <div class="meta-time">
494
+ <div>Session: <span id="sess-id"></span></div>
495
+ <div id="sess-time" style="font-size: 0.8rem;"></div>
496
+ </div>
497
+ </header>
498
+
499
+ <div class="metrics-grid">
500
+ <div class="metric-card">
501
+ <div class="metric-label">Duration</div>
502
+ <div class="metric-value" id="val-duration">-</div>
503
+ </div>
504
+ <div class="metric-card">
505
+ <div class="metric-label">Total Tokens</div>
506
+ <div class="metric-value" id="val-tokens">-</div>
507
+ </div>
508
+ <div class="metric-card">
509
+ <div class="metric-label">LLM Calls</div>
510
+ <div class="metric-value" id="val-llms">-</div>
511
+ </div>
512
+ <div class="metric-card">
513
+ <div class="metric-label">Tool Calls</div>
514
+ <div class="metric-value" id="val-tools">-</div>
515
+ </div>
516
+ <div class="metric-card">
517
+ <div class="metric-label">Cost (USD)</div>
518
+ <div class="metric-value" id="val-cost">-</div>
519
+ </div>
520
+ </div>
521
+
522
+ <h2 style="margin-bottom: 1.5rem; font-weight: 600; font-size: 1.25rem; color: #fff;">Activity Flow</h2>
523
+ <div class="timeline" id="timeline-container">
524
+ <!-- Injected by JS -->
525
+ </div>
526
+ </div>
527
+
528
+ <script>
529
+ const traceData = ${serializedData};
530
+
531
+ // Render Metrics
532
+ document.getElementById("sess-id").textContent = traceData.traceId;
533
+ document.getElementById("sess-time").textContent = new Date(traceData.startTime).toLocaleString();
534
+ document.getElementById("val-duration").textContent = (traceData.durationMs / 1000).toFixed(2) + "s";
535
+ document.getElementById("val-tokens").textContent = traceData.metrics.totalTokens.toLocaleString();
536
+ document.getElementById("val-llms").textContent = traceData.metrics.toolCallCount; // Wait, actually traceData.metrics.llmCallCount
537
+ document.getElementById("val-tools").textContent = traceData.metrics.toolCallCount;
538
+ document.getElementById("val-cost").textContent = "$" + traceData.metrics.totalCost;
539
+
540
+ // Correct the labels mapping if names swapped
541
+ document.getElementById("val-llms").textContent = traceData.metrics.llmCallCount;
542
+
543
+ // Process & Render Timeline
544
+ const container = document.getElementById("timeline-container");
545
+ const events = traceData.events;
546
+
547
+ // Group start and end of tools to display them as single cards
548
+ const toolExecutions = {};
549
+ const renderedEvents = [];
550
+
551
+ events.forEach(e => {
552
+ if (e.type === "session_start") {
553
+ renderedEvents.push({
554
+ type: "session",
555
+ title: "Session Initialized",
556
+ time: new Date(e.timestamp).toLocaleTimeString(),
557
+ body: \`Reason: \${e.reason}\\nFile: \${e.sessionFile}\`
558
+ });
559
+ } else if (e.type === "turn_start") {
560
+ renderedEvents.push({
561
+ type: "turn",
562
+ title: "User Turn Started",
563
+ time: new Date(e.timestamp).toLocaleTimeString(),
564
+ body: "Agent waiting for query execution loop."
565
+ });
566
+ } else if (e.type === "tool_start") {
567
+ toolExecutions[e.toolCallId] = {
568
+ type: "tool",
569
+ title: \`Tool Call: \${e.toolName}\`,
570
+ toolName: e.toolName,
571
+ time: new Date(e.timestamp).toLocaleTimeString(),
572
+ args: e.args,
573
+ startTime: e.timestamp
574
+ };
575
+ } else if (e.type === "tool_end") {
576
+ const start = toolExecutions[e.toolCallId];
577
+ if (start) {
578
+ start.result = e.result;
579
+ start.isError = e.isError;
580
+ start.durationMs = e.durationMs;
581
+ renderedEvents.push(start);
582
+ delete toolExecutions[e.toolCallId];
583
+ }
584
+ } else if (e.type === "llm_completion") {
585
+ renderedEvents.push({
586
+ type: "llm",
587
+ title: \`LLM Call: \${e.model}\`,
588
+ model: e.model,
589
+ provider: e.provider,
590
+ time: new Date(e.timestamp).toLocaleTimeString(),
591
+ durationMs: e.durationMs,
592
+ usage: e.usage,
593
+ payload: e.inputPayload,
594
+ output: e.outputContent
595
+ });
596
+ }
597
+ });
598
+
599
+ // Render cards
600
+ renderedEvents.forEach((ev, index) => {
601
+ const evDiv = document.createElement("div");
602
+ evDiv.className = \`timeline-event \${ev.type}-start \${ev.isError ? "error" : ""}\`;
603
+
604
+ let cardHtml = \`
605
+ <div class="timeline-dot"></div>
606
+ <div class="card" id="card-\${index}">
607
+ <div class="card-header" onclick="toggleCard(\${index})">
608
+ <div class="card-title">
609
+ <span class="card-badge badge-\${ev.type} \${ev.isError ? "error" : ""}">\${ev.type.toUpperCase()}</span>
610
+ <span>\${ev.title}</span>
611
+ \${ev.durationMs ? \`<span class="duration-pill">\${(ev.durationMs / 1000).toFixed(2)}s</span>\` : ""}
612
+ </div>
613
+ <div class="card-time">
614
+ <span>\${ev.time}</span>
615
+ <svg class="arrow-icon" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
616
+ </div>
617
+ </div>
618
+ <div class="card-body">
619
+ \`;
620
+
621
+ if (ev.type === "session" || ev.type === "turn") {
622
+ cardHtml += \`<div class="code-block">\${ev.body}</div>\`;
623
+ } else if (ev.type === "tool") {
624
+ cardHtml += \`
625
+ <strong>Arguments:</strong>
626
+ <div class="code-block">\${JSON.stringify(ev.args, null, 2)}</div>
627
+ <strong style="margin-top: 1rem; display: block;">Result (\${ev.isError ? "Error" : "Success"}):</strong>
628
+ <div class="code-block">\${typeof ev.result === "string" ? ev.result : JSON.stringify(ev.result, null, 2)}</div>
629
+ \`;
630
+ } else if (ev.type === "llm") {
631
+ // Render system prompt and input messages if present
632
+ let messagesHtml = '<div class="messages-container">';
633
+ if (ev.payload) {
634
+ if (ev.payload.system_instruction) {
635
+ const instr = typeof ev.payload.system_instruction === "string"
636
+ ? ev.payload.system_instruction
637
+ : JSON.stringify(ev.payload.system_instruction);
638
+ messagesHtml += \`
639
+ <div class="msg-row system">
640
+ <span class="msg-role">System Instruction</span>
641
+ <span class="msg-text">\${instr}</span>
642
+ </div>
643
+ \`;
644
+ }
645
+ if (ev.payload.messages && Array.isArray(ev.payload.messages)) {
646
+ ev.payload.messages.forEach(m => {
647
+ const contentText = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
648
+ messagesHtml += \`
649
+ <div class="msg-row \${m.role}">
650
+ <span class="msg-role">\${m.role}</span>
651
+ <span class="msg-text">\${contentText}</span>
652
+ </div>
653
+ \`;
654
+ });
655
+ }
656
+ }
657
+ messagesHtml += '</div>';
658
+
659
+ cardHtml += \`
660
+ <strong>Context Messages:</strong>
661
+ \${messagesHtml}
662
+ <strong style="margin-top: 1.25rem; display: block;">Generated Response:</strong>
663
+ <div class="code-block">\${JSON.stringify(ev.output, null, 2)}</div>
664
+ \${ev.usage ? \`
665
+ <div style="margin-top: 1rem; font-size: 0.85rem; color: var(--text-muted); display: flex; gap: 1.5rem;">
666
+ <span>Input Tokens: <strong>\${ev.usage.input}</strong></span>
667
+ <span>Output Tokens: <strong>\${ev.usage.output}</strong></span>
668
+ <span>Total Tokens: <strong>\${ev.usage.totalTokens}</strong></span>
669
+ <span>Cost: <strong>\$\${ev.usage.cost?.total.toFixed(5) || "0.00"}</strong></span>
670
+ </div>
671
+ \` : ""}
672
+ \`;
673
+ }
674
+
675
+ cardHtml += \`
676
+ </div>
677
+ </div>
678
+ \`;
679
+
680
+ evDiv.innerHTML = cardHtml;
681
+ container.appendChild(evDiv);
682
+ });
683
+
684
+ // Expand/Collapse controller
685
+ function toggleCard(index) {
686
+ const card = document.getElementById(\`card-\${index}\`);
687
+ card.classList.toggle("expanded");
688
+ }
689
+ </script>
690
+ </body>
691
+ </html>`;
692
+ }