blob: 44779b2a53500b940a9f84aab0f8c000dd9b6f23 [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;
Autoformatter44f9b4c2025-05-02 18:58:20 +0000108 const totalHours =
109 (endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60);
Josh Bleecher Snyder1c814232025-05-02 18:34:27 +0000110 const totalCost = data[data.length - 1].cost;
Autoformatter44f9b4c2025-05-02 18:58:20 +0000111
Josh Bleecher Snyder1c814232025-05-02 18:34:27 +0000112 // Only calculate if we have a valid time span
113 if (totalHours > 0) {
114 this.dollarsPerHour = totalCost / totalHours;
115 } else {
116 this.dollarsPerHour = null;
117 }
118 } else {
119 this.dollarsPerHour = null;
120 }
121
Sean McCullough86b56862025-04-18 13:04:03 -0700122 return data;
123 }
124
125 protected willUpdate(changedProperties: PropertyValues): void {
126 if (changedProperties.has("messages")) {
127 this.chartData = this.calculateCumulativeCostData(this.messages);
128 }
129 }
130
131 private getMessagesChartSpec(): VisualizationSpec {
132 try {
133 const allMessages = this.messages;
134 if (!Array.isArray(allMessages) || allMessages.length === 0) {
135 return null;
136 }
137
138 // Sort messages chronologically
139 allMessages.sort((a, b) => {
140 const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
141 const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
142 return dateA - dateB;
143 });
144
145 // Create unique indexes for all messages
146 const messageIndexMap = new Map<string, number>();
147 let messageIdx = 0;
Sean McCullough71941bd2025-04-18 13:31:48 -0700148
Sean McCullough86b56862025-04-18 13:04:03 -0700149 // First pass: Process parent messages
150 allMessages.forEach((msg, index) => {
151 // Create a unique ID for each message to track its position
152 const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
153 messageIndexMap.set(msgId, messageIdx++);
154 });
Sean McCullough71941bd2025-04-18 13:31:48 -0700155
Sean McCullough86b56862025-04-18 13:04:03 -0700156 // Process tool calls from messages to account for filtered out tool messages
157 const toolCallData: any[] = [];
158 allMessages.forEach((msg) => {
159 if (msg.tool_calls && msg.tool_calls.length > 0) {
160 msg.tool_calls.forEach((toolCall) => {
161 if (toolCall.result_message) {
162 // Add this tool result message to our data
163 const resultMsg = toolCall.result_message;
Sean McCullough71941bd2025-04-18 13:31:48 -0700164
Sean McCullough86b56862025-04-18 13:04:03 -0700165 // Important: use the original message's idx to maintain the correct order
166 // The original message idx value is what we want to show in the chart
167 if (resultMsg.idx !== undefined) {
168 // If the tool call has start/end times, add it to bar data, otherwise to point data
169 if (resultMsg.start_time && resultMsg.end_time) {
170 toolCallData.push({
Sean McCullough71941bd2025-04-18 13:31:48 -0700171 type: "bar",
172 index: resultMsg.idx, // Use actual idx from message
173 message_type: "tool",
174 content: resultMsg.content || "",
175 tool_name: resultMsg.tool_name || toolCall.name || "",
176 tool_input: toolCall.input || "",
177 tool_result: resultMsg.tool_result || "",
Sean McCullough86b56862025-04-18 13:04:03 -0700178 start_time: new Date(resultMsg.start_time).toISOString(),
179 end_time: new Date(resultMsg.end_time).toISOString(),
Sean McCullough71941bd2025-04-18 13:31:48 -0700180 message: JSON.stringify(resultMsg, null, 2),
Sean McCullough86b56862025-04-18 13:04:03 -0700181 });
182 } else if (resultMsg.timestamp) {
183 toolCallData.push({
Sean McCullough71941bd2025-04-18 13:31:48 -0700184 type: "point",
185 index: resultMsg.idx, // Use actual idx from message
186 message_type: "tool",
187 content: resultMsg.content || "",
188 tool_name: resultMsg.tool_name || toolCall.name || "",
189 tool_input: toolCall.input || "",
190 tool_result: resultMsg.tool_result || "",
Sean McCullough86b56862025-04-18 13:04:03 -0700191 time: new Date(resultMsg.timestamp).toISOString(),
Sean McCullough71941bd2025-04-18 13:31:48 -0700192 message: JSON.stringify(resultMsg, null, 2),
Sean McCullough86b56862025-04-18 13:04:03 -0700193 });
194 }
195 }
196 }
197 });
198 }
199 });
200
201 // Prepare data for messages with start_time and end_time (bar marks)
202 const barData = allMessages
203 .filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
204 .map((msg) => {
205 // Parse start and end times
206 const startTime = new Date(msg.start_time!);
207 const endTime = new Date(msg.end_time!);
208
209 // Use the message idx directly for consistent ordering
210 const index = msg.idx;
211
212 // Truncate content for tooltip readability
213 const displayContent = msg.content
214 ? msg.content.length > 100
215 ? msg.content.substring(0, 100) + "..."
216 : msg.content
217 : "No content";
218
219 // Prepare tool input and output for tooltip if applicable
220 const toolInput = msg.input
221 ? msg.input.length > 100
222 ? msg.input.substring(0, 100) + "..."
223 : msg.input
224 : "";
225
226 const toolResult = msg.tool_result
227 ? msg.tool_result.length > 100
228 ? msg.tool_result.substring(0, 100) + "..."
229 : msg.tool_result
230 : "";
231
232 return {
233 index: index,
234 message_type: msg.type,
235 content: displayContent,
236 tool_name: msg.tool_name || "",
237 tool_input: toolInput,
238 tool_result: toolResult,
239 start_time: startTime.toISOString(),
240 end_time: endTime.toISOString(),
241 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
242 };
243 });
244
245 // Prepare data for messages with timestamps only (point marks)
246 const pointData = allMessages
247 .filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
248 .map((msg) => {
249 // Get the timestamp
250 const timestamp = new Date(msg.timestamp!);
251
252 // Use the message idx directly for consistent ordering
253 const index = msg.idx;
254
255 // Truncate content for tooltip readability
256 const displayContent = msg.content
257 ? msg.content.length > 100
258 ? msg.content.substring(0, 100) + "..."
259 : msg.content
260 : "No content";
261
262 // Prepare tool input and output for tooltip if applicable
263 const toolInput = msg.input
264 ? msg.input.length > 100
265 ? msg.input.substring(0, 100) + "..."
266 : msg.input
267 : "";
268
269 const toolResult = msg.tool_result
270 ? msg.tool_result.length > 100
271 ? msg.tool_result.substring(0, 100) + "..."
272 : msg.tool_result
273 : "";
274
275 return {
276 index: index,
277 message_type: msg.type,
278 content: displayContent,
279 tool_name: msg.tool_name || "",
280 tool_input: toolInput,
281 tool_result: toolResult,
282 time: timestamp.toISOString(),
283 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
284 };
285 });
Sean McCullough71941bd2025-04-18 13:31:48 -0700286
Sean McCullough86b56862025-04-18 13:04:03 -0700287 // Add tool call data to the appropriate arrays
Sean McCullough71941bd2025-04-18 13:31:48 -0700288 const toolBarData = toolCallData
289 .filter((d) => d.type === "bar")
290 .map((d) => {
291 delete d.type;
292 return d;
293 });
294
295 const toolPointData = toolCallData
296 .filter((d) => d.type === "point")
297 .map((d) => {
298 delete d.type;
299 return d;
300 });
Sean McCullough86b56862025-04-18 13:04:03 -0700301
302 // Check if we have any data to display
Sean McCullough71941bd2025-04-18 13:31:48 -0700303 if (
304 barData.length === 0 &&
305 pointData.length === 0 &&
306 toolBarData.length === 0 &&
307 toolPointData.length === 0
308 ) {
Sean McCullough86b56862025-04-18 13:04:03 -0700309 return null;
310 }
311
312 // Calculate height based on number of unique messages
313 const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
314
315 // Create a layered Vega-Lite spec combining bars and points
316 const messagesSpec: TopLevelSpec = {
317 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
318 description: "Message Timeline",
319 width: "container",
320 height: chartHeight,
321 layer: [],
322 };
323
324 // Add bar layer if we have bar data
325 if (barData.length > 0 || toolBarData.length > 0) {
326 const combinedBarData = [...barData, ...toolBarData];
327 messagesSpec.layer.push({
328 data: { values: combinedBarData },
329 mark: {
330 type: "bar",
331 height: 16,
332 },
333 encoding: {
334 x: {
335 field: "start_time",
336 type: "temporal",
337 title: "Time",
338 axis: {
339 format: "%H:%M:%S",
340 title: "Time",
341 labelAngle: -45,
342 },
343 },
344 x2: { field: "end_time" },
345 y: {
346 field: "index",
347 type: "ordinal",
348 title: "Message Index",
349 axis: {
350 grid: true,
351 },
352 },
353 color: {
354 field: "message_type",
355 type: "nominal",
356 title: "Message Type",
357 legend: {},
358 },
359 tooltip: [
360 { field: "message_type", type: "nominal", title: "Type" },
361 { field: "tool_name", type: "nominal", title: "Tool" },
362 {
363 field: "start_time",
364 type: "temporal",
365 title: "Start Time",
366 format: "%H:%M:%S.%L",
367 },
368 {
369 field: "end_time",
370 type: "temporal",
371 title: "End Time",
372 format: "%H:%M:%S.%L",
373 },
374 { field: "content", type: "nominal", title: "Content" },
375 { field: "tool_input", type: "nominal", title: "Tool Input" },
376 { field: "tool_result", type: "nominal", title: "Tool Result" },
377 ],
378 },
379 });
380 }
381
382 // Add point layer if we have point data
383 if (pointData.length > 0 || toolPointData.length > 0) {
384 const combinedPointData = [...pointData, ...toolPointData];
385 messagesSpec.layer.push({
386 data: { values: combinedPointData },
387 mark: {
388 type: "point",
389 size: 100,
390 filled: true,
391 },
392 encoding: {
393 x: {
394 field: "time",
395 type: "temporal",
396 title: "Time",
397 axis: {
398 format: "%H:%M:%S",
399 title: "Time",
400 labelAngle: -45,
401 },
402 },
403 y: {
404 field: "index",
405 type: "ordinal",
406 title: "Message Index",
407 },
408 color: {
409 field: "message_type",
410 type: "nominal",
411 title: "Message Type",
412 },
413 tooltip: [
414 { field: "message_type", type: "nominal", title: "Type" },
415 { field: "tool_name", type: "nominal", title: "Tool" },
416 {
417 field: "time",
418 type: "temporal",
419 title: "Timestamp",
420 format: "%H:%M:%S.%L",
421 },
422 { field: "content", type: "nominal", title: "Content" },
423 { field: "tool_input", type: "nominal", title: "Tool Input" },
424 { field: "tool_result", type: "nominal", title: "Tool Result" },
425 ],
426 },
427 });
428 }
429 return messagesSpec;
430 } catch (error) {
431 console.error("Error rendering messages chart:", error);
432 }
433 }
434
435 render() {
436 const costSpec = this.createCostChartSpec();
437 const messagesSpec = this.getMessagesChartSpec();
438
439 return html`
440 <div class="chart-container" id="chartContainer">
441 <div class="chart-section">
Autoformatter44f9b4c2025-05-02 18:58:20 +0000442 <h3>
443 Dollar Usage Over
444 Time${this.dollarsPerHour !== null
445 ? html` (Avg: $${this.dollarsPerHour.toFixed(2)}/hr)`
446 : ""}
447 </h3>
Sean McCullough86b56862025-04-18 13:04:03 -0700448 <div class="chart-content">
Sean McCullough71941bd2025-04-18 13:31:48 -0700449 ${this.chartData.length > 0
450 ? html`<vega-embed .spec=${costSpec}></vega-embed>`
451 : html`<p>No cost data available to display.</p>`}
Sean McCullough86b56862025-04-18 13:04:03 -0700452 </div>
453 </div>
454 <div class="chart-section">
455 <h3>Message Timeline</h3>
456 <div class="chart-content">
Sean McCullough71941bd2025-04-18 13:31:48 -0700457 ${messagesSpec?.data
458 ? html`<vega-embed .spec=${messagesSpec}></vega-embed>`
Sean McCullough86b56862025-04-18 13:04:03 -0700459 : html`<p>No messages available to display.</p>`}
460 </div>
461 </div>
462 </div>
463 `;
464 }
465
466 private createCostChartSpec(): VisualizationSpec {
467 return {
468 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
469 description: "Cumulative cost over time",
470 width: "container",
471 height: 300,
472 data: {
473 values: this.chartData.map((d) => ({
474 timestamp: d.timestamp.toISOString(),
475 cost: d.cost,
476 })),
477 },
478 mark: {
479 type: "line",
480 point: true,
481 },
482 encoding: {
483 x: {
484 field: "timestamp",
485 type: "temporal",
486 title: "Time",
487 axis: {
488 format: "%H:%M:%S",
489 title: "Time",
490 labelAngle: -45,
491 },
492 },
493 y: {
494 field: "cost",
495 type: "quantitative",
496 title: "Cumulative Cost (USD)",
497 axis: {
498 format: "$,.4f",
499 },
500 },
501 tooltip: [
502 {
503 field: "timestamp",
504 type: "temporal",
505 title: "Time",
506 format: "%Y-%m-%d %H:%M:%S",
507 },
508 {
509 field: "cost",
510 type: "quantitative",
511 title: "Cumulative Cost",
512 format: "$,.4f",
513 },
514 ],
515 },
516 };
517 }
518}
519
520declare global {
521 interface HTMLElementTagNameMap {
522 "sketch-charts": SketchCharts;
523 }
524}