blob: 0ed56e8f873406ce8a1795dabde743bd6ce8c16f [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import type { TimelineMessage } from "./types";
2import vegaEmbed from "vega-embed";
3import { TopLevelSpec } from "vega-lite";
4
5/**
6 * ChartManager handles all chart-related functionality for the timeline.
7 * This includes rendering charts, calculating data, and managing chart state.
8 */
9export class ChartManager {
10 private chartData: { timestamp: Date; cost: number }[] = [];
11
12 /**
13 * Create a new ChartManager instance
14 */
15 constructor() {
16 this.chartData = [];
17 }
18
19 /**
20 * Calculate cumulative cost data from messages
21 */
22 public calculateCumulativeCostData(
23 messages: TimelineMessage[],
24 ): { timestamp: Date; cost: number }[] {
25 if (!messages || messages.length === 0) {
26 return [];
27 }
28
29 let cumulativeCost = 0;
30 const data: { timestamp: Date; cost: number }[] = [];
31
32 for (const message of messages) {
33 if (message.timestamp && message.usage && message.usage.cost_usd) {
34 const timestamp = new Date(message.timestamp);
35 cumulativeCost += message.usage.cost_usd;
36
37 data.push({
38 timestamp,
39 cost: cumulativeCost,
40 });
41 }
42 }
43
44 return data;
45 }
46
47 /**
48 * Get the current chart data
49 */
50 public getChartData(): { timestamp: Date; cost: number }[] {
51 return this.chartData;
52 }
53
54 /**
55 * Set chart data
56 */
57 public setChartData(data: { timestamp: Date; cost: number }[]): void {
58 this.chartData = data;
59 }
60
61 /**
62 * Fetch all messages to generate chart data
63 */
64 public async fetchAllMessages(): Promise<void> {
65 try {
66 // Fetch all messages in a single request
67 const response = await fetch("messages");
68 if (!response.ok) {
69 throw new Error(`Failed to fetch messages: ${response.status}`);
70 }
71
72 const allMessages = await response.json();
73 if (Array.isArray(allMessages)) {
74 // Sort messages chronologically
75 allMessages.sort((a, b) => {
76 const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
77 const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
78 return dateA - dateB;
79 });
80
81 // Calculate cumulative cost data
82 this.chartData = this.calculateCumulativeCostData(allMessages);
83 }
84 } catch (error) {
85 console.error("Error fetching messages for chart:", error);
86 this.chartData = [];
87 }
88 }
89
90 /**
91 * Render all charts in the chart view
92 */
93 public async renderCharts(): Promise<void> {
94 const chartContainer = document.getElementById("chartContainer");
95 if (!chartContainer) return;
96
97 try {
98 // Show loading state
99 chartContainer.innerHTML = "<div class='loader'></div>";
100
101 // Fetch messages if necessary
102 if (this.chartData.length === 0) {
103 await this.fetchAllMessages();
104 }
105
106 // Clear the container for multiple charts
107 chartContainer.innerHTML = "";
108
109 // Create cost chart container
110 const costChartDiv = document.createElement("div");
111 costChartDiv.className = "chart-section";
112 costChartDiv.innerHTML =
113 "<h3>Dollar Usage Over Time</h3><div id='costChart'></div>";
114 chartContainer.appendChild(costChartDiv);
115
116 // Create messages chart container
117 const messagesChartDiv = document.createElement("div");
118 messagesChartDiv.className = "chart-section";
119 messagesChartDiv.innerHTML =
120 "<h3>Message Timeline</h3><div id='messagesChart'></div>";
121 chartContainer.appendChild(messagesChartDiv);
122
123 // Render both charts
124 await this.renderDollarUsageChart();
125 await this.renderMessagesChart();
126 } catch (error) {
127 console.error("Error rendering charts:", error);
128 chartContainer.innerHTML = `<p>Error rendering charts: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
129 }
130 }
131
132 /**
133 * Render the dollar usage chart using Vega-Lite
134 */
135 private async renderDollarUsageChart(): Promise<void> {
136 const costChartContainer = document.getElementById("costChart");
137 if (!costChartContainer) return;
138
139 try {
140 // Display cost chart using Vega-Lite
141 if (this.chartData.length === 0) {
142 costChartContainer.innerHTML =
143 "<p>No cost data available to display.</p>";
144 return;
145 }
146
147 // Create a Vega-Lite spec for the line chart
148 // eslint-disable-next-line @typescript-eslint/no-explicit-any
149 const costSpec: any = {
150 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
151 description: "Cumulative cost over time",
152 width: "container",
153 height: 300,
154 data: {
155 values: this.chartData.map((d) => ({
156 timestamp: d.timestamp.toISOString(),
157 cost: d.cost,
158 })),
159 },
160 mark: {
161 type: "line",
162 point: true,
163 },
164 encoding: {
165 x: {
166 field: "timestamp",
167 type: "temporal",
168 title: "Time",
169 axis: {
170 format: "%H:%M:%S",
171 title: "Time",
172 labelAngle: -45,
173 },
174 },
175 y: {
176 field: "cost",
177 type: "quantitative",
178 title: "Cumulative Cost (USD)",
179 axis: {
180 format: "$,.4f",
181 },
182 },
183 tooltip: [
184 {
185 field: "timestamp",
186 type: "temporal",
187 title: "Time",
188 format: "%Y-%m-%d %H:%M:%S",
189 },
190 {
191 field: "cost",
192 type: "quantitative",
193 title: "Cumulative Cost",
194 format: "$,.4f",
195 },
196 ],
197 },
198 };
199
200 // Render the cost chart
201 await vegaEmbed(costChartContainer, costSpec, {
202 actions: true,
203 renderer: "svg",
204 });
205 } catch (error) {
206 console.error("Error rendering dollar usage chart:", error);
207 costChartContainer.innerHTML = `<p>Error rendering dollar usage chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
208 }
209 }
210
211 /**
212 * Render the messages timeline chart using Vega-Lite
213 */
214 private async renderMessagesChart(): Promise<void> {
215 const messagesChartContainer = document.getElementById("messagesChart");
216 if (!messagesChartContainer) return;
217
218 try {
219 // Get all messages
220 const response = await fetch("messages");
221 if (!response.ok) {
222 throw new Error(`Failed to fetch messages: ${response.status}`);
223 }
224
225 const allMessages = await response.json();
226 if (!Array.isArray(allMessages) || allMessages.length === 0) {
227 messagesChartContainer.innerHTML =
228 "<p>No messages available to display.</p>";
229 return;
230 }
231
232 // Sort messages chronologically
233 allMessages.sort((a, b) => {
234 const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
235 const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
236 return dateA - dateB;
237 });
238
239 // Create unique indexes for all messages
240 const messageIndexMap = new Map<string, number>();
241 allMessages.forEach((msg, index) => {
242 // Create a unique ID for each message to track its position
243 const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
244 messageIndexMap.set(msgId, index);
245 });
246
247 // Prepare data for messages with start_time and end_time (bar marks)
248 const barData = allMessages
249 .filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
250 .map((msg) => {
251 // Parse start and end times
252 const startTime = new Date(msg.start_time!);
253 const endTime = new Date(msg.end_time!);
254
255 // Get the index for this message
256 const msgId = msg.timestamp ? msg.timestamp.toString() : "";
257 const index = messageIndexMap.get(msgId) || 0;
258
259 // Truncate content for tooltip readability
260 const displayContent = msg.content
261 ? msg.content.length > 100
262 ? msg.content.substring(0, 100) + "..."
263 : msg.content
264 : "No content";
265
266 // Prepare tool input and output for tooltip if applicable
267 const toolInput = msg.input
268 ? msg.input.length > 100
269 ? msg.input.substring(0, 100) + "..."
270 : msg.input
271 : "";
272
273 const toolResult = msg.tool_result
274 ? msg.tool_result.length > 100
275 ? msg.tool_result.substring(0, 100) + "..."
276 : msg.tool_result
277 : "";
278
279 return {
280 index: index,
281 message_type: msg.type,
282 content: displayContent,
283 tool_name: msg.tool_name || "",
284 tool_input: toolInput,
285 tool_result: toolResult,
286 start_time: startTime.toISOString(),
287 end_time: endTime.toISOString(),
288 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
289 };
290 });
291
292 // Prepare data for messages with timestamps only (point marks)
293 const pointData = allMessages
294 .filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
295 .map((msg) => {
296 // Get the timestamp
297 const timestamp = new Date(msg.timestamp!);
298
299 // Get the index for this message
300 const msgId = msg.timestamp ? msg.timestamp.toString() : "";
301 const index = messageIndexMap.get(msgId) || 0;
302
303 // Truncate content for tooltip readability
304 const displayContent = msg.content
305 ? msg.content.length > 100
306 ? msg.content.substring(0, 100) + "..."
307 : msg.content
308 : "No content";
309
310 // Prepare tool input and output for tooltip if applicable
311 const toolInput = msg.input
312 ? msg.input.length > 100
313 ? msg.input.substring(0, 100) + "..."
314 : msg.input
315 : "";
316
317 const toolResult = msg.tool_result
318 ? msg.tool_result.length > 100
319 ? msg.tool_result.substring(0, 100) + "..."
320 : msg.tool_result
321 : "";
322
323 return {
324 index: index,
325 message_type: msg.type,
326 content: displayContent,
327 tool_name: msg.tool_name || "",
328 tool_input: toolInput,
329 tool_result: toolResult,
330 time: timestamp.toISOString(),
331 message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
332 };
333 });
334
335 // Check if we have any data to display
336 if (barData.length === 0 && pointData.length === 0) {
337 messagesChartContainer.innerHTML =
338 "<p>No message timing data available to display.</p>";
339 return;
340 }
341
342 // Calculate height based on number of unique messages
343 const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
344
345 // Create a layered Vega-Lite spec combining bars and points
346 const messagesSpec: TopLevelSpec = {
347 $schema: "https://vega.github.io/schema/vega-lite/v5.json",
348 description: "Message Timeline",
349 width: "container",
350 height: chartHeight,
351 layer: [],
352 };
353
354 // Add bar layer if we have bar data
355 if (barData.length > 0) {
356 messagesSpec.layer.push({
357 data: { values: barData },
358 mark: {
359 type: "bar",
360 height: 16,
361 },
362 encoding: {
363 x: {
364 field: "start_time",
365 type: "temporal",
366 title: "Time",
367 axis: {
368 format: "%H:%M:%S",
369 title: "Time",
370 labelAngle: -45,
371 },
372 },
373 x2: { field: "end_time" },
374 y: {
375 field: "index",
376 type: "ordinal",
377 title: "Message Index",
378 axis: {
379 grid: true,
380 },
381 },
382 color: {
383 field: "message_type",
384 type: "nominal",
385 title: "Message Type",
386 legend: {},
387 },
388 tooltip: [
389 { field: "message_type", type: "nominal", title: "Type" },
390 { field: "tool_name", type: "nominal", title: "Tool" },
391 {
392 field: "start_time",
393 type: "temporal",
394 title: "Start Time",
395 format: "%H:%M:%S.%L",
396 },
397 {
398 field: "end_time",
399 type: "temporal",
400 title: "End Time",
401 format: "%H:%M:%S.%L",
402 },
403 { field: "content", type: "nominal", title: "Content" },
404 { field: "tool_input", type: "nominal", title: "Tool Input" },
405 { field: "tool_result", type: "nominal", title: "Tool Result" },
406 ],
407 },
408 });
409 }
410
411 // Add point layer if we have point data
412 if (pointData.length > 0) {
413 messagesSpec.layer.push({
414 data: { values: pointData },
415 mark: {
416 type: "point",
417 size: 100,
418 filled: true,
419 },
420 encoding: {
421 x: {
422 field: "time",
423 type: "temporal",
424 title: "Time",
425 axis: {
426 format: "%H:%M:%S",
427 title: "Time",
428 labelAngle: -45,
429 },
430 },
431 y: {
432 field: "index",
433 type: "ordinal",
434 title: "Message Index",
435 },
436 color: {
437 field: "message_type",
438 type: "nominal",
439 title: "Message Type",
440 },
441 tooltip: [
442 { field: "message_type", type: "nominal", title: "Type" },
443 { field: "tool_name", type: "nominal", title: "Tool" },
444 {
445 field: "time",
446 type: "temporal",
447 title: "Timestamp",
448 format: "%H:%M:%S.%L",
449 },
450 { field: "content", type: "nominal", title: "Content" },
451 { field: "tool_input", type: "nominal", title: "Tool Input" },
452 { field: "tool_result", type: "nominal", title: "Tool Result" },
453 ],
454 },
455 });
456 }
457
458 // Render the messages timeline chart
459 await vegaEmbed(messagesChartContainer, messagesSpec, {
460 actions: true,
461 renderer: "svg",
462 });
463 } catch (error) {
464 console.error("Error rendering messages chart:", error);
465 messagesChartContainer.innerHTML = `<p>Error rendering messages chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
466 }
467 }
468}