blob: 8cf26069aac923a4ea820ec168ac782d9965eba2 [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
79 private calculateCumulativeCostData(
Sean McCulloughd9f13372025-04-21 15:08:49 -070080 messages: AgentMessage[],
Sean McCullough86b56862025-04-18 13:04:03 -070081 ): { timestamp: Date; cost: number }[] {
82 if (!messages || messages.length === 0) {
83 return [];
84 }
85
86 let cumulativeCost = 0;
87 const data: { timestamp: Date; cost: number }[] = [];
88
89 for (const message of messages) {
90 if (message.timestamp && message.usage && message.usage.cost_usd) {
91 const timestamp = new Date(message.timestamp);
92 cumulativeCost += message.usage.cost_usd;
93
94 data.push({
95 timestamp,
96 cost: cumulativeCost,
97 });
98 }
99 }
100
101 return data;
102 }
103
104 protected willUpdate(changedProperties: PropertyValues): void {
105 if (changedProperties.has("messages")) {
106 this.chartData = this.calculateCumulativeCostData(this.messages);
107 }
108 }
109
110 private getMessagesChartSpec(): VisualizationSpec {
111 try {
112 const allMessages = this.messages;
113 if (!Array.isArray(allMessages) || allMessages.length === 0) {
114 return null;
115 }
116
117 // Sort messages chronologically
118 allMessages.sort((a, b) => {
119 const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
120 const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
121 return dateA - dateB;
122 });
123
124 // Create unique indexes for all messages
125 const messageIndexMap = new Map<string, number>();
126 let messageIdx = 0;
Sean McCullough71941bd2025-04-18 13:31:48 -0700127
Sean McCullough86b56862025-04-18 13:04:03 -0700128 // First pass: Process parent messages
129 allMessages.forEach((msg, index) => {
130 // Create a unique ID for each message to track its position
131 const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
132 messageIndexMap.set(msgId, messageIdx++);
133 });
Sean McCullough71941bd2025-04-18 13:31:48 -0700134
Sean McCullough86b56862025-04-18 13:04:03 -0700135 // Process tool calls from messages to account for filtered out tool messages
136 const toolCallData: any[] = [];
137 allMessages.forEach((msg) => {
138 if (msg.tool_calls && msg.tool_calls.length > 0) {
139 msg.tool_calls.forEach((toolCall) => {
140 if (toolCall.result_message) {
141 // Add this tool result message to our data
142 const resultMsg = toolCall.result_message;
Sean McCullough71941bd2025-04-18 13:31:48 -0700143
Sean McCullough86b56862025-04-18 13:04:03 -0700144 // Important: use the original message's idx to maintain the correct order
145 // The original message idx value is what we want to show in the chart
146 if (resultMsg.idx !== undefined) {
147 // If the tool call has start/end times, add it to bar data, otherwise to point data
148 if (resultMsg.start_time && resultMsg.end_time) {
149 toolCallData.push({
Sean McCullough71941bd2025-04-18 13:31:48 -0700150 type: "bar",
151 index: resultMsg.idx, // Use actual idx from message
152 message_type: "tool",
153 content: resultMsg.content || "",
154 tool_name: resultMsg.tool_name || toolCall.name || "",
155 tool_input: toolCall.input || "",
156 tool_result: resultMsg.tool_result || "",
Sean McCullough86b56862025-04-18 13:04:03 -0700157 start_time: new Date(resultMsg.start_time).toISOString(),
158 end_time: new Date(resultMsg.end_time).toISOString(),
Sean McCullough71941bd2025-04-18 13:31:48 -0700159 message: JSON.stringify(resultMsg, null, 2),
Sean McCullough86b56862025-04-18 13:04:03 -0700160 });
161 } else if (resultMsg.timestamp) {
162 toolCallData.push({
Sean McCullough71941bd2025-04-18 13:31:48 -0700163 type: "point",
164 index: resultMsg.idx, // Use actual idx from message
165 message_type: "tool",
166 content: resultMsg.content || "",
167 tool_name: resultMsg.tool_name || toolCall.name || "",
168 tool_input: toolCall.input || "",
169 tool_result: resultMsg.tool_result || "",
Sean McCullough86b56862025-04-18 13:04:03 -0700170 time: new Date(resultMsg.timestamp).toISOString(),
Sean McCullough71941bd2025-04-18 13:31:48 -0700171 message: JSON.stringify(resultMsg, null, 2),
Sean McCullough86b56862025-04-18 13:04:03 -0700172 });
173 }
174 }
175 }
176 });
177 }
178 });
179
180 // Prepare data for messages with start_time and end_time (bar marks)
181 const barData = allMessages
182 .filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
183 .map((msg) => {
184 // Parse start and end times
185 const startTime = new Date(msg.start_time!);
186 const endTime = new Date(msg.end_time!);
187
188 // Use the message idx directly for consistent ordering
189 const index = msg.idx;
190
191 // Truncate content for tooltip readability
192 const displayContent = msg.content
193 ? msg.content.length > 100
194 ? msg.content.substring(0, 100) + "..."
195 : msg.content
196 : "No content";
197
198 // Prepare tool input and output for tooltip if applicable
199 const toolInput = msg.input
200 ? msg.input.length > 100
201 ? msg.input.substring(0, 100) + "..."
202 : msg.input
203 : "";
204
205 const toolResult = msg.tool_result
206 ? msg.tool_result.length > 100
207 ? msg.tool_result.substring(0, 100) + "..."
208 : msg.tool_result
209 : "";
210
211 return {
212 index: index,
213 message_type: msg.type,
214 content: displayContent,
215 tool_name: msg.tool_name || "",
216 tool_input: toolInput,
217 tool_result: toolResult,
218 start_time: startTime.toISOString(),
219 end_time: endTime.toISOString(),
220 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
221 };
222 });
223
224 // Prepare data for messages with timestamps only (point marks)
225 const pointData = allMessages
226 .filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
227 .map((msg) => {
228 // Get the timestamp
229 const timestamp = new Date(msg.timestamp!);
230
231 // Use the message idx directly for consistent ordering
232 const index = msg.idx;
233
234 // Truncate content for tooltip readability
235 const displayContent = msg.content
236 ? msg.content.length > 100
237 ? msg.content.substring(0, 100) + "..."
238 : msg.content
239 : "No content";
240
241 // Prepare tool input and output for tooltip if applicable
242 const toolInput = msg.input
243 ? msg.input.length > 100
244 ? msg.input.substring(0, 100) + "..."
245 : msg.input
246 : "";
247
248 const toolResult = msg.tool_result
249 ? msg.tool_result.length > 100
250 ? msg.tool_result.substring(0, 100) + "..."
251 : msg.tool_result
252 : "";
253
254 return {
255 index: index,
256 message_type: msg.type,
257 content: displayContent,
258 tool_name: msg.tool_name || "",
259 tool_input: toolInput,
260 tool_result: toolResult,
261 time: timestamp.toISOString(),
262 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
263 };
264 });
Sean McCullough71941bd2025-04-18 13:31:48 -0700265
Sean McCullough86b56862025-04-18 13:04:03 -0700266 // Add tool call data to the appropriate arrays
Sean McCullough71941bd2025-04-18 13:31:48 -0700267 const toolBarData = toolCallData
268 .filter((d) => d.type === "bar")
269 .map((d) => {
270 delete d.type;
271 return d;
272 });
273
274 const toolPointData = toolCallData
275 .filter((d) => d.type === "point")
276 .map((d) => {
277 delete d.type;
278 return d;
279 });
Sean McCullough86b56862025-04-18 13:04:03 -0700280
281 // Check if we have any data to display
Sean McCullough71941bd2025-04-18 13:31:48 -0700282 if (
283 barData.length === 0 &&
284 pointData.length === 0 &&
285 toolBarData.length === 0 &&
286 toolPointData.length === 0
287 ) {
Sean McCullough86b56862025-04-18 13:04:03 -0700288 return null;
289 }
290
291 // Calculate height based on number of unique messages
292 const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
293
294 // Create a layered Vega-Lite spec combining bars and points
295 const messagesSpec: TopLevelSpec = {
296 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
297 description: "Message Timeline",
298 width: "container",
299 height: chartHeight,
300 layer: [],
301 };
302
303 // Add bar layer if we have bar data
304 if (barData.length > 0 || toolBarData.length > 0) {
305 const combinedBarData = [...barData, ...toolBarData];
306 messagesSpec.layer.push({
307 data: { values: combinedBarData },
308 mark: {
309 type: "bar",
310 height: 16,
311 },
312 encoding: {
313 x: {
314 field: "start_time",
315 type: "temporal",
316 title: "Time",
317 axis: {
318 format: "%H:%M:%S",
319 title: "Time",
320 labelAngle: -45,
321 },
322 },
323 x2: { field: "end_time" },
324 y: {
325 field: "index",
326 type: "ordinal",
327 title: "Message Index",
328 axis: {
329 grid: true,
330 },
331 },
332 color: {
333 field: "message_type",
334 type: "nominal",
335 title: "Message Type",
336 legend: {},
337 },
338 tooltip: [
339 { field: "message_type", type: "nominal", title: "Type" },
340 { field: "tool_name", type: "nominal", title: "Tool" },
341 {
342 field: "start_time",
343 type: "temporal",
344 title: "Start Time",
345 format: "%H:%M:%S.%L",
346 },
347 {
348 field: "end_time",
349 type: "temporal",
350 title: "End Time",
351 format: "%H:%M:%S.%L",
352 },
353 { field: "content", type: "nominal", title: "Content" },
354 { field: "tool_input", type: "nominal", title: "Tool Input" },
355 { field: "tool_result", type: "nominal", title: "Tool Result" },
356 ],
357 },
358 });
359 }
360
361 // Add point layer if we have point data
362 if (pointData.length > 0 || toolPointData.length > 0) {
363 const combinedPointData = [...pointData, ...toolPointData];
364 messagesSpec.layer.push({
365 data: { values: combinedPointData },
366 mark: {
367 type: "point",
368 size: 100,
369 filled: true,
370 },
371 encoding: {
372 x: {
373 field: "time",
374 type: "temporal",
375 title: "Time",
376 axis: {
377 format: "%H:%M:%S",
378 title: "Time",
379 labelAngle: -45,
380 },
381 },
382 y: {
383 field: "index",
384 type: "ordinal",
385 title: "Message Index",
386 },
387 color: {
388 field: "message_type",
389 type: "nominal",
390 title: "Message Type",
391 },
392 tooltip: [
393 { field: "message_type", type: "nominal", title: "Type" },
394 { field: "tool_name", type: "nominal", title: "Tool" },
395 {
396 field: "time",
397 type: "temporal",
398 title: "Timestamp",
399 format: "%H:%M:%S.%L",
400 },
401 { field: "content", type: "nominal", title: "Content" },
402 { field: "tool_input", type: "nominal", title: "Tool Input" },
403 { field: "tool_result", type: "nominal", title: "Tool Result" },
404 ],
405 },
406 });
407 }
408 return messagesSpec;
409 } catch (error) {
410 console.error("Error rendering messages chart:", error);
411 }
412 }
413
414 render() {
415 const costSpec = this.createCostChartSpec();
416 const messagesSpec = this.getMessagesChartSpec();
417
418 return html`
419 <div class="chart-container" id="chartContainer">
420 <div class="chart-section">
421 <h3>Dollar Usage Over Time</h3>
422 <div class="chart-content">
Sean McCullough71941bd2025-04-18 13:31:48 -0700423 ${this.chartData.length > 0
424 ? html`<vega-embed .spec=${costSpec}></vega-embed>`
425 : html`<p>No cost data available to display.</p>`}
Sean McCullough86b56862025-04-18 13:04:03 -0700426 </div>
427 </div>
428 <div class="chart-section">
429 <h3>Message Timeline</h3>
430 <div class="chart-content">
Sean McCullough71941bd2025-04-18 13:31:48 -0700431 ${messagesSpec?.data
432 ? html`<vega-embed .spec=${messagesSpec}></vega-embed>`
Sean McCullough86b56862025-04-18 13:04:03 -0700433 : html`<p>No messages available to display.</p>`}
434 </div>
435 </div>
436 </div>
437 `;
438 }
439
440 private createCostChartSpec(): VisualizationSpec {
441 return {
442 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
443 description: "Cumulative cost over time",
444 width: "container",
445 height: 300,
446 data: {
447 values: this.chartData.map((d) => ({
448 timestamp: d.timestamp.toISOString(),
449 cost: d.cost,
450 })),
451 },
452 mark: {
453 type: "line",
454 point: true,
455 },
456 encoding: {
457 x: {
458 field: "timestamp",
459 type: "temporal",
460 title: "Time",
461 axis: {
462 format: "%H:%M:%S",
463 title: "Time",
464 labelAngle: -45,
465 },
466 },
467 y: {
468 field: "cost",
469 type: "quantitative",
470 title: "Cumulative Cost (USD)",
471 axis: {
472 format: "$,.4f",
473 },
474 },
475 tooltip: [
476 {
477 field: "timestamp",
478 type: "temporal",
479 title: "Time",
480 format: "%Y-%m-%d %H:%M:%S",
481 },
482 {
483 field: "cost",
484 type: "quantitative",
485 title: "Cumulative Cost",
486 format: "$,.4f",
487 },
488 ],
489 },
490 };
491 }
492}
493
494declare global {
495 interface HTMLElementTagNameMap {
496 "sketch-charts": SketchCharts;
497 }
498}