blob: 0ed56e8f873406ce8a1795dabde743bd6ce8c16f [file] [log] [blame]
import type { TimelineMessage } from "./types";
import vegaEmbed from "vega-embed";
import { TopLevelSpec } from "vega-lite";
/**
* ChartManager handles all chart-related functionality for the timeline.
* This includes rendering charts, calculating data, and managing chart state.
*/
export class ChartManager {
private chartData: { timestamp: Date; cost: number }[] = [];
/**
* Create a new ChartManager instance
*/
constructor() {
this.chartData = [];
}
/**
* Calculate cumulative cost data from messages
*/
public calculateCumulativeCostData(
messages: TimelineMessage[],
): { timestamp: Date; cost: number }[] {
if (!messages || messages.length === 0) {
return [];
}
let cumulativeCost = 0;
const data: { timestamp: Date; cost: number }[] = [];
for (const message of messages) {
if (message.timestamp && message.usage && message.usage.cost_usd) {
const timestamp = new Date(message.timestamp);
cumulativeCost += message.usage.cost_usd;
data.push({
timestamp,
cost: cumulativeCost,
});
}
}
return data;
}
/**
* Get the current chart data
*/
public getChartData(): { timestamp: Date; cost: number }[] {
return this.chartData;
}
/**
* Set chart data
*/
public setChartData(data: { timestamp: Date; cost: number }[]): void {
this.chartData = data;
}
/**
* Fetch all messages to generate chart data
*/
public async fetchAllMessages(): Promise<void> {
try {
// Fetch all messages in a single request
const response = await fetch("messages");
if (!response.ok) {
throw new Error(`Failed to fetch messages: ${response.status}`);
}
const allMessages = await response.json();
if (Array.isArray(allMessages)) {
// Sort messages chronologically
allMessages.sort((a, b) => {
const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return dateA - dateB;
});
// Calculate cumulative cost data
this.chartData = this.calculateCumulativeCostData(allMessages);
}
} catch (error) {
console.error("Error fetching messages for chart:", error);
this.chartData = [];
}
}
/**
* Render all charts in the chart view
*/
public async renderCharts(): Promise<void> {
const chartContainer = document.getElementById("chartContainer");
if (!chartContainer) return;
try {
// Show loading state
chartContainer.innerHTML = "<div class='loader'></div>";
// Fetch messages if necessary
if (this.chartData.length === 0) {
await this.fetchAllMessages();
}
// Clear the container for multiple charts
chartContainer.innerHTML = "";
// Create cost chart container
const costChartDiv = document.createElement("div");
costChartDiv.className = "chart-section";
costChartDiv.innerHTML =
"<h3>Dollar Usage Over Time</h3><div id='costChart'></div>";
chartContainer.appendChild(costChartDiv);
// Create messages chart container
const messagesChartDiv = document.createElement("div");
messagesChartDiv.className = "chart-section";
messagesChartDiv.innerHTML =
"<h3>Message Timeline</h3><div id='messagesChart'></div>";
chartContainer.appendChild(messagesChartDiv);
// Render both charts
await this.renderDollarUsageChart();
await this.renderMessagesChart();
} catch (error) {
console.error("Error rendering charts:", error);
chartContainer.innerHTML = `<p>Error rendering charts: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
}
}
/**
* Render the dollar usage chart using Vega-Lite
*/
private async renderDollarUsageChart(): Promise<void> {
const costChartContainer = document.getElementById("costChart");
if (!costChartContainer) return;
try {
// Display cost chart using Vega-Lite
if (this.chartData.length === 0) {
costChartContainer.innerHTML =
"<p>No cost data available to display.</p>";
return;
}
// Create a Vega-Lite spec for the line chart
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const costSpec: any = {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
description: "Cumulative cost over time",
width: "container",
height: 300,
data: {
values: this.chartData.map((d) => ({
timestamp: d.timestamp.toISOString(),
cost: d.cost,
})),
},
mark: {
type: "line",
point: true,
},
encoding: {
x: {
field: "timestamp",
type: "temporal",
title: "Time",
axis: {
format: "%H:%M:%S",
title: "Time",
labelAngle: -45,
},
},
y: {
field: "cost",
type: "quantitative",
title: "Cumulative Cost (USD)",
axis: {
format: "$,.4f",
},
},
tooltip: [
{
field: "timestamp",
type: "temporal",
title: "Time",
format: "%Y-%m-%d %H:%M:%S",
},
{
field: "cost",
type: "quantitative",
title: "Cumulative Cost",
format: "$,.4f",
},
],
},
};
// Render the cost chart
await vegaEmbed(costChartContainer, costSpec, {
actions: true,
renderer: "svg",
});
} catch (error) {
console.error("Error rendering dollar usage chart:", error);
costChartContainer.innerHTML = `<p>Error rendering dollar usage chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
}
}
/**
* Render the messages timeline chart using Vega-Lite
*/
private async renderMessagesChart(): Promise<void> {
const messagesChartContainer = document.getElementById("messagesChart");
if (!messagesChartContainer) return;
try {
// Get all messages
const response = await fetch("messages");
if (!response.ok) {
throw new Error(`Failed to fetch messages: ${response.status}`);
}
const allMessages = await response.json();
if (!Array.isArray(allMessages) || allMessages.length === 0) {
messagesChartContainer.innerHTML =
"<p>No messages available to display.</p>";
return;
}
// Sort messages chronologically
allMessages.sort((a, b) => {
const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return dateA - dateB;
});
// Create unique indexes for all messages
const messageIndexMap = new Map<string, number>();
allMessages.forEach((msg, index) => {
// Create a unique ID for each message to track its position
const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
messageIndexMap.set(msgId, index);
});
// Prepare data for messages with start_time and end_time (bar marks)
const barData = allMessages
.filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
.map((msg) => {
// Parse start and end times
const startTime = new Date(msg.start_time!);
const endTime = new Date(msg.end_time!);
// Get the index for this message
const msgId = msg.timestamp ? msg.timestamp.toString() : "";
const index = messageIndexMap.get(msgId) || 0;
// Truncate content for tooltip readability
const displayContent = msg.content
? msg.content.length > 100
? msg.content.substring(0, 100) + "..."
: msg.content
: "No content";
// Prepare tool input and output for tooltip if applicable
const toolInput = msg.input
? msg.input.length > 100
? msg.input.substring(0, 100) + "..."
: msg.input
: "";
const toolResult = msg.tool_result
? msg.tool_result.length > 100
? msg.tool_result.substring(0, 100) + "..."
: msg.tool_result
: "";
return {
index: index,
message_type: msg.type,
content: displayContent,
tool_name: msg.tool_name || "",
tool_input: toolInput,
tool_result: toolResult,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
};
});
// Prepare data for messages with timestamps only (point marks)
const pointData = allMessages
.filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
.map((msg) => {
// Get the timestamp
const timestamp = new Date(msg.timestamp!);
// Get the index for this message
const msgId = msg.timestamp ? msg.timestamp.toString() : "";
const index = messageIndexMap.get(msgId) || 0;
// Truncate content for tooltip readability
const displayContent = msg.content
? msg.content.length > 100
? msg.content.substring(0, 100) + "..."
: msg.content
: "No content";
// Prepare tool input and output for tooltip if applicable
const toolInput = msg.input
? msg.input.length > 100
? msg.input.substring(0, 100) + "..."
: msg.input
: "";
const toolResult = msg.tool_result
? msg.tool_result.length > 100
? msg.tool_result.substring(0, 100) + "..."
: msg.tool_result
: "";
return {
index: index,
message_type: msg.type,
content: displayContent,
tool_name: msg.tool_name || "",
tool_input: toolInput,
tool_result: toolResult,
time: timestamp.toISOString(),
message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
};
});
// Check if we have any data to display
if (barData.length === 0 && pointData.length === 0) {
messagesChartContainer.innerHTML =
"<p>No message timing data available to display.</p>";
return;
}
// Calculate height based on number of unique messages
const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
// Create a layered Vega-Lite spec combining bars and points
const messagesSpec: TopLevelSpec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
description: "Message Timeline",
width: "container",
height: chartHeight,
layer: [],
};
// Add bar layer if we have bar data
if (barData.length > 0) {
messagesSpec.layer.push({
data: { values: barData },
mark: {
type: "bar",
height: 16,
},
encoding: {
x: {
field: "start_time",
type: "temporal",
title: "Time",
axis: {
format: "%H:%M:%S",
title: "Time",
labelAngle: -45,
},
},
x2: { field: "end_time" },
y: {
field: "index",
type: "ordinal",
title: "Message Index",
axis: {
grid: true,
},
},
color: {
field: "message_type",
type: "nominal",
title: "Message Type",
legend: {},
},
tooltip: [
{ field: "message_type", type: "nominal", title: "Type" },
{ field: "tool_name", type: "nominal", title: "Tool" },
{
field: "start_time",
type: "temporal",
title: "Start Time",
format: "%H:%M:%S.%L",
},
{
field: "end_time",
type: "temporal",
title: "End Time",
format: "%H:%M:%S.%L",
},
{ field: "content", type: "nominal", title: "Content" },
{ field: "tool_input", type: "nominal", title: "Tool Input" },
{ field: "tool_result", type: "nominal", title: "Tool Result" },
],
},
});
}
// Add point layer if we have point data
if (pointData.length > 0) {
messagesSpec.layer.push({
data: { values: pointData },
mark: {
type: "point",
size: 100,
filled: true,
},
encoding: {
x: {
field: "time",
type: "temporal",
title: "Time",
axis: {
format: "%H:%M:%S",
title: "Time",
labelAngle: -45,
},
},
y: {
field: "index",
type: "ordinal",
title: "Message Index",
},
color: {
field: "message_type",
type: "nominal",
title: "Message Type",
},
tooltip: [
{ field: "message_type", type: "nominal", title: "Type" },
{ field: "tool_name", type: "nominal", title: "Tool" },
{
field: "time",
type: "temporal",
title: "Timestamp",
format: "%H:%M:%S.%L",
},
{ field: "content", type: "nominal", title: "Content" },
{ field: "tool_input", type: "nominal", title: "Tool Input" },
{ field: "tool_result", type: "nominal", title: "Tool Result" },
],
},
});
}
// Render the messages timeline chart
await vegaEmbed(messagesChartContainer, messagesSpec, {
actions: true,
renderer: "svg",
});
} catch (error) {
console.error("Error rendering messages chart:", error);
messagesChartContainer.innerHTML = `<p>Error rendering messages chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
}
}
}