blob: 3bde418cd8c7d598881068d4688a871196c04c6a [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";
5import type { TimelineMessage } from "../types";
6import "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 })
16 messages: TimelineMessage[] = [];
17
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(
80 messages: TimelineMessage[]
81 ): { 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;
127
128 // 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 });
134
135 // 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;
143
144 // 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({
150 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 || '',
157 start_time: new Date(resultMsg.start_time).toISOString(),
158 end_time: new Date(resultMsg.end_time).toISOString(),
159 message: JSON.stringify(resultMsg, null, 2)
160 });
161 } else if (resultMsg.timestamp) {
162 toolCallData.push({
163 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 || '',
170 time: new Date(resultMsg.timestamp).toISOString(),
171 message: JSON.stringify(resultMsg, null, 2)
172 });
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 });
265
266 // Add tool call data to the appropriate arrays
267 const toolBarData = toolCallData.filter(d => d.type === 'bar').map(d => {
268 delete d.type;
269 return d;
270 });
271
272 const toolPointData = toolCallData.filter(d => d.type === 'point').map(d => {
273 delete d.type;
274 return d;
275 });
276
277 // Check if we have any data to display
278 if (barData.length === 0 && pointData.length === 0 &&
279 toolBarData.length === 0 && toolPointData.length === 0) {
280 return null;
281 }
282
283 // Calculate height based on number of unique messages
284 const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
285
286 // Create a layered Vega-Lite spec combining bars and points
287 const messagesSpec: TopLevelSpec = {
288 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
289 description: "Message Timeline",
290 width: "container",
291 height: chartHeight,
292 layer: [],
293 };
294
295 // Add bar layer if we have bar data
296 if (barData.length > 0 || toolBarData.length > 0) {
297 const combinedBarData = [...barData, ...toolBarData];
298 messagesSpec.layer.push({
299 data: { values: combinedBarData },
300 mark: {
301 type: "bar",
302 height: 16,
303 },
304 encoding: {
305 x: {
306 field: "start_time",
307 type: "temporal",
308 title: "Time",
309 axis: {
310 format: "%H:%M:%S",
311 title: "Time",
312 labelAngle: -45,
313 },
314 },
315 x2: { field: "end_time" },
316 y: {
317 field: "index",
318 type: "ordinal",
319 title: "Message Index",
320 axis: {
321 grid: true,
322 },
323 },
324 color: {
325 field: "message_type",
326 type: "nominal",
327 title: "Message Type",
328 legend: {},
329 },
330 tooltip: [
331 { field: "message_type", type: "nominal", title: "Type" },
332 { field: "tool_name", type: "nominal", title: "Tool" },
333 {
334 field: "start_time",
335 type: "temporal",
336 title: "Start Time",
337 format: "%H:%M:%S.%L",
338 },
339 {
340 field: "end_time",
341 type: "temporal",
342 title: "End Time",
343 format: "%H:%M:%S.%L",
344 },
345 { field: "content", type: "nominal", title: "Content" },
346 { field: "tool_input", type: "nominal", title: "Tool Input" },
347 { field: "tool_result", type: "nominal", title: "Tool Result" },
348 ],
349 },
350 });
351 }
352
353 // Add point layer if we have point data
354 if (pointData.length > 0 || toolPointData.length > 0) {
355 const combinedPointData = [...pointData, ...toolPointData];
356 messagesSpec.layer.push({
357 data: { values: combinedPointData },
358 mark: {
359 type: "point",
360 size: 100,
361 filled: true,
362 },
363 encoding: {
364 x: {
365 field: "time",
366 type: "temporal",
367 title: "Time",
368 axis: {
369 format: "%H:%M:%S",
370 title: "Time",
371 labelAngle: -45,
372 },
373 },
374 y: {
375 field: "index",
376 type: "ordinal",
377 title: "Message Index",
378 },
379 color: {
380 field: "message_type",
381 type: "nominal",
382 title: "Message Type",
383 },
384 tooltip: [
385 { field: "message_type", type: "nominal", title: "Type" },
386 { field: "tool_name", type: "nominal", title: "Tool" },
387 {
388 field: "time",
389 type: "temporal",
390 title: "Timestamp",
391 format: "%H:%M:%S.%L",
392 },
393 { field: "content", type: "nominal", title: "Content" },
394 { field: "tool_input", type: "nominal", title: "Tool Input" },
395 { field: "tool_result", type: "nominal", title: "Tool Result" },
396 ],
397 },
398 });
399 }
400 return messagesSpec;
401 } catch (error) {
402 console.error("Error rendering messages chart:", error);
403 }
404 }
405
406 render() {
407 const costSpec = this.createCostChartSpec();
408 const messagesSpec = this.getMessagesChartSpec();
409
410 return html`
411 <div class="chart-container" id="chartContainer">
412 <div class="chart-section">
413 <h3>Dollar Usage Over Time</h3>
414 <div class="chart-content">
415 ${this.chartData.length > 0 ?
416 html`<vega-embed .spec=${costSpec}></vega-embed>`
417 : html`<p>No cost data available to display.</p>`}
418 </div>
419 </div>
420 <div class="chart-section">
421 <h3>Message Timeline</h3>
422 <div class="chart-content">
423 ${messagesSpec?.data ?
424 html`<vega-embed .spec=${messagesSpec}></vega-embed>`
425 : html`<p>No messages available to display.</p>`}
426 </div>
427 </div>
428 </div>
429 `;
430 }
431
432 private createCostChartSpec(): VisualizationSpec {
433 return {
434 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
435 description: "Cumulative cost over time",
436 width: "container",
437 height: 300,
438 data: {
439 values: this.chartData.map((d) => ({
440 timestamp: d.timestamp.toISOString(),
441 cost: d.cost,
442 })),
443 },
444 mark: {
445 type: "line",
446 point: true,
447 },
448 encoding: {
449 x: {
450 field: "timestamp",
451 type: "temporal",
452 title: "Time",
453 axis: {
454 format: "%H:%M:%S",
455 title: "Time",
456 labelAngle: -45,
457 },
458 },
459 y: {
460 field: "cost",
461 type: "quantitative",
462 title: "Cumulative Cost (USD)",
463 axis: {
464 format: "$,.4f",
465 },
466 },
467 tooltip: [
468 {
469 field: "timestamp",
470 type: "temporal",
471 title: "Time",
472 format: "%Y-%m-%d %H:%M:%S",
473 },
474 {
475 field: "cost",
476 type: "quantitative",
477 title: "Cumulative Cost",
478 format: "$,.4f",
479 },
480 ],
481 },
482 };
483 }
484}
485
486declare global {
487 interface HTMLElementTagNameMap {
488 "sketch-charts": SketchCharts;
489 }
490}