blob: df10afc0ac26397c62827626d33517276e668189 [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import "./vega-embed";
2import { css, html, LitElement, PropertyValues } from "lit";
3import { customElement, property, state } from "lit/decorators.js";
4import { TopLevelSpec } from "vega-lite";
Sean McCulloughd9f13372025-04-21 15:08:49 -07005import type { AgentMessage } from "../types";
Sean McCullough86b56862025-04-18 13:04:03 -07006import "vega-embed";
7import { VisualizationSpec } from "vega-embed";
8
9/**
10 * Web component for rendering charts related to the timeline data
11 * Displays cumulative cost over time and message timing visualization
12 */
13@customElement("sketch-charts")
14export class SketchCharts extends LitElement {
15 @property({ type: Array })
Sean McCulloughd9f13372025-04-21 15:08:49 -070016 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -070017
18 @state()
19 private chartData: { timestamp: Date; cost: number }[] = [];
20
21 // We need to make the styles available to Vega-Embed when it's rendered
22 static styles = css`
23 :host {
24 display: block;
25 width: 100%;
26 }
27
28 .chart-container {
29 padding: 20px;
30 background-color: #fff;
31 border-radius: 8px;
32 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
33 margin-bottom: 20px;
34 }
35
36 .chart-section {
37 margin-bottom: 30px;
38 }
39
40 .chart-section h3 {
41 margin-top: 0;
42 margin-bottom: 15px;
43 font-size: 18px;
44 color: #333;
45 border-bottom: 1px solid #eee;
46 padding-bottom: 8px;
47 }
48
49 .chart-content {
50 width: 100%;
51 min-height: 300px;
52 }
53
54 .loader {
55 border: 4px solid #f3f3f3;
56 border-radius: 50%;
57 border-top: 4px solid #3498db;
58 width: 40px;
59 height: 40px;
60 margin: 20px auto;
61 animation: spin 2s linear infinite;
62 }
63
64 @keyframes spin {
65 0% {
66 transform: rotate(0deg);
67 }
68 100% {
69 transform: rotate(360deg);
70 }
71 }
72 `;
73
74 constructor() {
75 super();
76 this.chartData = [];
77 }
78
Josh Bleecher Snyder1c814232025-05-02 18:34:27 +000079 @state()
80 private dollarsPerHour: number | null = null;
81
Sean McCullough86b56862025-04-18 13:04:03 -070082 private calculateCumulativeCostData(
Sean McCulloughd9f13372025-04-21 15:08:49 -070083 messages: AgentMessage[],
Sean McCullough86b56862025-04-18 13:04:03 -070084 ): { timestamp: Date; cost: number }[] {
85 if (!messages || messages.length === 0) {
86 return [];
87 }
88
89 let cumulativeCost = 0;
90 const data: { timestamp: Date; cost: number }[] = [];
91
92 for (const message of messages) {
93 if (message.timestamp && message.usage && message.usage.cost_usd) {
94 const timestamp = new Date(message.timestamp);
95 cumulativeCost += message.usage.cost_usd;
96
97 data.push({
98 timestamp,
99 cost: cumulativeCost,
100 });
101 }
102 }
103
Josh Bleecher Snyder1c814232025-05-02 18:34:27 +0000104 // Calculate dollars per hour if we have at least two data points
105 if (data.length >= 2) {
106 const startTime = data[0].timestamp;
107 const endTime = data[data.length - 1].timestamp;
108 const totalHours = (endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60);
109 const totalCost = data[data.length - 1].cost;
110
111 // Only calculate if we have a valid time span
112 if (totalHours > 0) {
113 this.dollarsPerHour = totalCost / totalHours;
114 } else {
115 this.dollarsPerHour = null;
116 }
117 } else {
118 this.dollarsPerHour = null;
119 }
120
Sean McCullough86b56862025-04-18 13:04:03 -0700121 return data;
122 }
123
124 protected willUpdate(changedProperties: PropertyValues): void {
125 if (changedProperties.has("messages")) {
126 this.chartData = this.calculateCumulativeCostData(this.messages);
127 }
128 }
129
130 private getMessagesChartSpec(): VisualizationSpec {
131 try {
132 const allMessages = this.messages;
133 if (!Array.isArray(allMessages) || allMessages.length === 0) {
134 return null;
135 }
136
137 // Sort messages chronologically
138 allMessages.sort((a, b) => {
139 const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
140 const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
141 return dateA - dateB;
142 });
143
144 // Create unique indexes for all messages
145 const messageIndexMap = new Map<string, number>();
146 let messageIdx = 0;
Sean McCullough71941bd2025-04-18 13:31:48 -0700147
Sean McCullough86b56862025-04-18 13:04:03 -0700148 // First pass: Process parent messages
149 allMessages.forEach((msg, index) => {
150 // Create a unique ID for each message to track its position
151 const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
152 messageIndexMap.set(msgId, messageIdx++);
153 });
Sean McCullough71941bd2025-04-18 13:31:48 -0700154
Sean McCullough86b56862025-04-18 13:04:03 -0700155 // Process tool calls from messages to account for filtered out tool messages
156 const toolCallData: any[] = [];
157 allMessages.forEach((msg) => {
158 if (msg.tool_calls && msg.tool_calls.length > 0) {
159 msg.tool_calls.forEach((toolCall) => {
160 if (toolCall.result_message) {
161 // Add this tool result message to our data
162 const resultMsg = toolCall.result_message;
Sean McCullough71941bd2025-04-18 13:31:48 -0700163
Sean McCullough86b56862025-04-18 13:04:03 -0700164 // Important: use the original message's idx to maintain the correct order
165 // The original message idx value is what we want to show in the chart
166 if (resultMsg.idx !== undefined) {
167 // If the tool call has start/end times, add it to bar data, otherwise to point data
168 if (resultMsg.start_time && resultMsg.end_time) {
169 toolCallData.push({
Sean McCullough71941bd2025-04-18 13:31:48 -0700170 type: "bar",
171 index: resultMsg.idx, // Use actual idx from message
172 message_type: "tool",
173 content: resultMsg.content || "",
174 tool_name: resultMsg.tool_name || toolCall.name || "",
175 tool_input: toolCall.input || "",
176 tool_result: resultMsg.tool_result || "",
Sean McCullough86b56862025-04-18 13:04:03 -0700177 start_time: new Date(resultMsg.start_time).toISOString(),
178 end_time: new Date(resultMsg.end_time).toISOString(),
Sean McCullough71941bd2025-04-18 13:31:48 -0700179 message: JSON.stringify(resultMsg, null, 2),
Sean McCullough86b56862025-04-18 13:04:03 -0700180 });
181 } else if (resultMsg.timestamp) {
182 toolCallData.push({
Sean McCullough71941bd2025-04-18 13:31:48 -0700183 type: "point",
184 index: resultMsg.idx, // Use actual idx from message
185 message_type: "tool",
186 content: resultMsg.content || "",
187 tool_name: resultMsg.tool_name || toolCall.name || "",
188 tool_input: toolCall.input || "",
189 tool_result: resultMsg.tool_result || "",
Sean McCullough86b56862025-04-18 13:04:03 -0700190 time: new Date(resultMsg.timestamp).toISOString(),
Sean McCullough71941bd2025-04-18 13:31:48 -0700191 message: JSON.stringify(resultMsg, null, 2),
Sean McCullough86b56862025-04-18 13:04:03 -0700192 });
193 }
194 }
195 }
196 });
197 }
198 });
199
200 // Prepare data for messages with start_time and end_time (bar marks)
201 const barData = allMessages
202 .filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
203 .map((msg) => {
204 // Parse start and end times
205 const startTime = new Date(msg.start_time!);
206 const endTime = new Date(msg.end_time!);
207
208 // Use the message idx directly for consistent ordering
209 const index = msg.idx;
210
211 // Truncate content for tooltip readability
212 const displayContent = msg.content
213 ? msg.content.length > 100
214 ? msg.content.substring(0, 100) + "..."
215 : msg.content
216 : "No content";
217
218 // Prepare tool input and output for tooltip if applicable
219 const toolInput = msg.input
220 ? msg.input.length > 100
221 ? msg.input.substring(0, 100) + "..."
222 : msg.input
223 : "";
224
225 const toolResult = msg.tool_result
226 ? msg.tool_result.length > 100
227 ? msg.tool_result.substring(0, 100) + "..."
228 : msg.tool_result
229 : "";
230
231 return {
232 index: index,
233 message_type: msg.type,
234 content: displayContent,
235 tool_name: msg.tool_name || "",
236 tool_input: toolInput,
237 tool_result: toolResult,
238 start_time: startTime.toISOString(),
239 end_time: endTime.toISOString(),
240 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
241 };
242 });
243
244 // Prepare data for messages with timestamps only (point marks)
245 const pointData = allMessages
246 .filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
247 .map((msg) => {
248 // Get the timestamp
249 const timestamp = new Date(msg.timestamp!);
250
251 // Use the message idx directly for consistent ordering
252 const index = msg.idx;
253
254 // Truncate content for tooltip readability
255 const displayContent = msg.content
256 ? msg.content.length > 100
257 ? msg.content.substring(0, 100) + "..."
258 : msg.content
259 : "No content";
260
261 // Prepare tool input and output for tooltip if applicable
262 const toolInput = msg.input
263 ? msg.input.length > 100
264 ? msg.input.substring(0, 100) + "..."
265 : msg.input
266 : "";
267
268 const toolResult = msg.tool_result
269 ? msg.tool_result.length > 100
270 ? msg.tool_result.substring(0, 100) + "..."
271 : msg.tool_result
272 : "";
273
274 return {
275 index: index,
276 message_type: msg.type,
277 content: displayContent,
278 tool_name: msg.tool_name || "",
279 tool_input: toolInput,
280 tool_result: toolResult,
281 time: timestamp.toISOString(),
282 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
283 };
284 });
Sean McCullough71941bd2025-04-18 13:31:48 -0700285
Sean McCullough86b56862025-04-18 13:04:03 -0700286 // Add tool call data to the appropriate arrays
Sean McCullough71941bd2025-04-18 13:31:48 -0700287 const toolBarData = toolCallData
288 .filter((d) => d.type === "bar")
289 .map((d) => {
290 delete d.type;
291 return d;
292 });
293
294 const toolPointData = toolCallData
295 .filter((d) => d.type === "point")
296 .map((d) => {
297 delete d.type;
298 return d;
299 });
Sean McCullough86b56862025-04-18 13:04:03 -0700300
301 // Check if we have any data to display
Sean McCullough71941bd2025-04-18 13:31:48 -0700302 if (
303 barData.length === 0 &&
304 pointData.length === 0 &&
305 toolBarData.length === 0 &&
306 toolPointData.length === 0
307 ) {
Sean McCullough86b56862025-04-18 13:04:03 -0700308 return null;
309 }
310
311 // Calculate height based on number of unique messages
312 const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
313
314 // Create a layered Vega-Lite spec combining bars and points
315 const messagesSpec: TopLevelSpec = {
316 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
317 description: "Message Timeline",
318 width: "container",
319 height: chartHeight,
320 layer: [],
321 };
322
323 // Add bar layer if we have bar data
324 if (barData.length > 0 || toolBarData.length > 0) {
325 const combinedBarData = [...barData, ...toolBarData];
326 messagesSpec.layer.push({
327 data: { values: combinedBarData },
328 mark: {
329 type: "bar",
330 height: 16,
331 },
332 encoding: {
333 x: {
334 field: "start_time",
335 type: "temporal",
336 title: "Time",
337 axis: {
338 format: "%H:%M:%S",
339 title: "Time",
340 labelAngle: -45,
341 },
342 },
343 x2: { field: "end_time" },
344 y: {
345 field: "index",
346 type: "ordinal",
347 title: "Message Index",
348 axis: {
349 grid: true,
350 },
351 },
352 color: {
353 field: "message_type",
354 type: "nominal",
355 title: "Message Type",
356 legend: {},
357 },
358 tooltip: [
359 { field: "message_type", type: "nominal", title: "Type" },
360 { field: "tool_name", type: "nominal", title: "Tool" },
361 {
362 field: "start_time",
363 type: "temporal",
364 title: "Start Time",
365 format: "%H:%M:%S.%L",
366 },
367 {
368 field: "end_time",
369 type: "temporal",
370 title: "End Time",
371 format: "%H:%M:%S.%L",
372 },
373 { field: "content", type: "nominal", title: "Content" },
374 { field: "tool_input", type: "nominal", title: "Tool Input" },
375 { field: "tool_result", type: "nominal", title: "Tool Result" },
376 ],
377 },
378 });
379 }
380
381 // Add point layer if we have point data
382 if (pointData.length > 0 || toolPointData.length > 0) {
383 const combinedPointData = [...pointData, ...toolPointData];
384 messagesSpec.layer.push({
385 data: { values: combinedPointData },
386 mark: {
387 type: "point",
388 size: 100,
389 filled: true,
390 },
391 encoding: {
392 x: {
393 field: "time",
394 type: "temporal",
395 title: "Time",
396 axis: {
397 format: "%H:%M:%S",
398 title: "Time",
399 labelAngle: -45,
400 },
401 },
402 y: {
403 field: "index",
404 type: "ordinal",
405 title: "Message Index",
406 },
407 color: {
408 field: "message_type",
409 type: "nominal",
410 title: "Message Type",
411 },
412 tooltip: [
413 { field: "message_type", type: "nominal", title: "Type" },
414 { field: "tool_name", type: "nominal", title: "Tool" },
415 {
416 field: "time",
417 type: "temporal",
418 title: "Timestamp",
419 format: "%H:%M:%S.%L",
420 },
421 { field: "content", type: "nominal", title: "Content" },
422 { field: "tool_input", type: "nominal", title: "Tool Input" },
423 { field: "tool_result", type: "nominal", title: "Tool Result" },
424 ],
425 },
426 });
427 }
428 return messagesSpec;
429 } catch (error) {
430 console.error("Error rendering messages chart:", error);
431 }
432 }
433
434 render() {
435 const costSpec = this.createCostChartSpec();
436 const messagesSpec = this.getMessagesChartSpec();
437
438 return html`
439 <div class="chart-container" id="chartContainer">
440 <div class="chart-section">
Josh Bleecher Snyder1c814232025-05-02 18:34:27 +0000441 <h3>Dollar Usage Over Time${this.dollarsPerHour !== null ? html` (Avg: $${this.dollarsPerHour.toFixed(2)}/hr)` : ''}</h3>
Sean McCullough86b56862025-04-18 13:04:03 -0700442 <div class="chart-content">
Sean McCullough71941bd2025-04-18 13:31:48 -0700443 ${this.chartData.length > 0
444 ? html`<vega-embed .spec=${costSpec}></vega-embed>`
445 : html`<p>No cost data available to display.</p>`}
Sean McCullough86b56862025-04-18 13:04:03 -0700446 </div>
447 </div>
448 <div class="chart-section">
449 <h3>Message Timeline</h3>
450 <div class="chart-content">
Sean McCullough71941bd2025-04-18 13:31:48 -0700451 ${messagesSpec?.data
452 ? html`<vega-embed .spec=${messagesSpec}></vega-embed>`
Sean McCullough86b56862025-04-18 13:04:03 -0700453 : html`<p>No messages available to display.</p>`}
454 </div>
455 </div>
456 </div>
457 `;
458 }
459
460 private createCostChartSpec(): VisualizationSpec {
461 return {
462 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
463 description: "Cumulative cost over time",
464 width: "container",
465 height: 300,
466 data: {
467 values: this.chartData.map((d) => ({
468 timestamp: d.timestamp.toISOString(),
469 cost: d.cost,
470 })),
471 },
472 mark: {
473 type: "line",
474 point: true,
475 },
476 encoding: {
477 x: {
478 field: "timestamp",
479 type: "temporal",
480 title: "Time",
481 axis: {
482 format: "%H:%M:%S",
483 title: "Time",
484 labelAngle: -45,
485 },
486 },
487 y: {
488 field: "cost",
489 type: "quantitative",
490 title: "Cumulative Cost (USD)",
491 axis: {
492 format: "$,.4f",
493 },
494 },
495 tooltip: [
496 {
497 field: "timestamp",
498 type: "temporal",
499 title: "Time",
500 format: "%Y-%m-%d %H:%M:%S",
501 },
502 {
503 field: "cost",
504 type: "quantitative",
505 title: "Cumulative Cost",
506 format: "$,.4f",
507 },
508 ],
509 },
510 };
511 }
512}
513
514declare global {
515 interface HTMLElementTagNameMap {
516 "sketch-charts": SketchCharts;
517 }
518}