import type { EChartsCoreOption } from "echarts/core"; import { useMemo, useState } from "react"; import type { TokenUsageGranularity } from "../../api"; import { formatCompactInteger } from "../../utils/numberFormat"; import { EChart } from "./EChart"; import styles from "./DailyTokenUsageChart.module.scss"; export type DailyTokenUsage = { bucketStartMs: number; inputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; outputTokens: number; }; type TooltipItem = { dataIndex: number; }; const seriesColors = { input: "#0091ff", cacheRead: "#40c463", cacheWrite: "#3A62BA", output: "#E7B40B", } as const; const levelLineColor = "#E7B40B"; const emptyBarColor = "rgba(139, 148, 158, 0.20)"; const EMPTY_BAR_RATIO = 1; const DATA_HEIGHT_RATIO = 1; const seriesFocus = { emphasis: { focus: "series" }, blur: { itemStyle: { opacity: 0.2 } }, } as const; function colorMark(color: string): string { return ``; } function formatDay(value: Date) { return `${value.getUTCMonth() + 1}/${value.getUTCDate()}`; } function pad(value: number) { return String(value).padStart(2, "0"); } function formatAxisLabel(bucketStartMs: number, granularity: TokenUsageGranularity) { const value = new Date(bucketStartMs); if (granularity === "minute") return `${pad(value.getHours())}:${pad(value.getMinutes())}`; if (granularity === "hour") return `${pad(value.getHours())}:00`; const day = value.getUTCDay(); if (day === 6) return t("周六"); if (day === 0) return t("周日"); return formatDay(value); } function formatTooltipTime(bucketStartMs: number, granularity: TokenUsageGranularity) { const value = new Date(bucketStartMs); if (granularity === "day") return formatDay(value); const time = `${pad(value.getHours())}:${pad(value.getMinutes())}`; return `${value.getMonth() + 1}/${value.getDate()} ${time}`; } function totalTokens(day: DailyTokenUsage) { return day.inputTokens + day.cacheReadTokens + day.cacheWriteTokens + day.outputTokens; } export function DailyTokenUsageChart({ data, granularity, }: { data: DailyTokenUsage[]; granularity: TokenUsageGranularity; }) { const [hovered, setHovered] = useState(false); const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0); const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO); const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO; const nonZeroTotals = data.map(totalTokens).filter((total) => total !== 0); const averageLevel = nonZeroTotals.length === 0 ? 0 : nonZeroTotals.reduce((sum, total) => sum + total, 0) / nonZeroTotals.length; const option = useMemo(() => ({ animationDuration: 450, animationEasing: "cubicOut", grid: { top: 8, right: 0, bottom: 0, left: 0 }, tooltip: { trigger: "axis", confine: true, backgroundColor: "var(--vscode-editorHoverWidget-background)", borderColor: "var(--vscode-editorHoverWidget-border)", textStyle: { color: "var(--vscode-foreground)", fontFamily: "PingFang-Medium" }, extraCssText: "border-radius: 8px; box-shadow: 0 12px 32px rgb(0 0 0 / 30%); font-size: var(--daily-token-tooltip-font-size); line-height: 1.5;", axisPointer: { type: "shadow", shadowStyle: { color: "rgba(139, 148, 158, 0.14)" }, }, formatter: (params: unknown) => { const first = (params as TooltipItem[])[0]; const day = data[first.dataIndex]; return [ formatTooltipTime(day.bucketStartMs, granularity), `${t("总请求")}:${formatCompactInteger(totalTokens(day))}`, `${colorMark(seriesColors.input)}${t("输入(非缓存)")}:${formatCompactInteger(day.inputTokens)}`, `${colorMark(seriesColors.cacheRead)}${t("缓存输入")}:${formatCompactInteger(day.cacheReadTokens)}`, `${colorMark(seriesColors.cacheWrite)}${t("缓存写入")}:${formatCompactInteger(day.cacheWriteTokens)}`, `${colorMark(seriesColors.output)}${t("模型输出")}:${formatCompactInteger(day.outputTokens)}`, ].join("
"); }, }, xAxis: { type: "category", data: data.map(({ bucketStartMs }) => bucketStartMs), axisTick: { show: false }, axisLine: { lineStyle: { color: "rgba(139, 148, 158, 0.32)" } }, axisLabel: { interval: "auto", hideOverlap: true, formatter: (_value: string, index: number) => formatAxisLabel(data[index].bucketStartMs, granularity), color: "#8c8c8c", fontFamily: "HFKos", margin: 14, }, }, yAxis: { type: "value", show: false, min: 0, max: axisMaximum, }, series: [ { name: t("无用量"), type: "bar", stack: "empty-placeholder", data: data.map((day) => totalTokens(day) === 0 ? emptyBarHeight : 0), barMaxWidth: 18, silent: true, z: 0, itemStyle: { color: emptyBarColor, borderRadius: [2, 2, 0, 0] }, emphasis: { disabled: true }, }, { name: t("输入(非缓存)"), type: "bar", stack: "tokens", data: data.map(({ inputTokens }) => inputTokens), barMaxWidth: 18, itemStyle: { color: seriesColors.input }, ...seriesFocus, }, { name: t("缓存输入"), type: "bar", stack: "tokens", data: data.map(({ cacheReadTokens }) => cacheReadTokens), barMaxWidth: 18, itemStyle: { color: seriesColors.cacheRead }, ...seriesFocus, }, { name: t("缓存写入"), type: "bar", stack: "tokens", data: data.map(({ cacheWriteTokens }) => cacheWriteTokens), barMaxWidth: 18, itemStyle: { color: seriesColors.cacheWrite }, ...seriesFocus, }, { name: t("模型输出"), type: "bar", stack: "tokens", data: data.map(({ outputTokens }) => outputTokens), barMaxWidth: 18, barGap: "-100%", itemStyle: { color: seriesColors.output, borderRadius: [2, 2, 0, 0] }, markLine: { silent: true, symbol: "none", lineStyle: { color: levelLineColor, opacity: hovered ? 1 : 0, type: "dashed", width: 2 }, label: { show: hovered, position: "insideStartTop", formatter: t("平均"), color: levelLineColor, distance: 8, }, data: [{ yAxis: averageLevel }], }, ...seriesFocus, }, ], }), [averageLevel, axisMaximum, data, emptyBarHeight, granularity, hovered]); return setHovered(true)} onMouseLeave={() => setHovered(false)} />; }