Files
claudia/src/components/TokenUsageTrend.tsx
2025-08-10 01:46:10 +08:00

219 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useMemo, useState } from "react";
import { Card } from "@/components/ui/card";
import type { DailyUsage } from "@/lib/api";
interface TokenUsageTrendProps {
days: DailyUsage[];
}
// Simple number formatters
const fmtTokens = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return `${n}`;
};
const fmtUSD = (n: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(n);
/**
* A lightweight multi-series line/area chart implemented with SVG and basic UI primitives.
* - Left axis: Tokens (input/output/cache write/cache read)
* - Right axis: Cost (USD) and Requests count (normalized to its own max)
* - Tooltip closely matches the screenshot content
*/
export const TokenUsageTrend: React.FC<TokenUsageTrendProps> = ({ days }) => {
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const { labels, series, maxTokens, maxCost, maxReq } = useMemo(() => {
const sorted = days.slice().reverse(); // chronological left->right
const labels = sorted.map((d) =>
new Date(d.date.replace(/-/g, "/")).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" })
);
const series = {
input: sorted.map((d) => d.input_tokens || 0),
output: sorted.map((d) => d.output_tokens || 0),
cacheW: sorted.map((d) => d.cache_creation_tokens || 0),
cacheR: sorted.map((d) => d.cache_read_tokens || 0),
cost: sorted.map((d) => d.total_cost || 0),
reqs: sorted.map((d) => d.request_count || 0),
sumTokens: sorted.map(
(d) => (d.input_tokens || 0) + (d.output_tokens || 0) + (d.cache_creation_tokens || 0) + (d.cache_read_tokens || 0)
),
} as const;
const maxTokens = Math.max(1, ...series.sumTokens, ...series.input, ...series.output, ...series.cacheW, ...series.cacheR);
const maxCost = Math.max(1, ...series.cost);
const maxReq = Math.max(1, ...series.reqs);
return { labels, series, maxTokens, maxCost, maxReq };
}, [days]);
const width = 900;
const height = 260;
const padL = 56; // room for left ticks
const padR = 56; // room for right ticks
const padT = 16;
const padB = 36;
const plotW = width - padL - padR;
const plotH = height - padT - padB;
const n = labels.length;
const x = (i: number) => padL + (plotW * i) / Math.max(1, n - 1);
const yToken = (v: number) => padT + plotH * (1 - v / maxTokens);
const yCost = (v: number) => padT + plotH * (1 - v / maxCost);
const yReq = (v: number) => padT + plotH * (1 - v / maxReq);
const pathFrom = (vals: number[], y: (v: number) => number) =>
vals.map((v, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(v)}`).join(" ");
const colors = {
input: "#3b82f6", // blue-500
output: "#ec4899", // pink-500
cacheW: "#60a5fa", // blue-400
cacheR: "#a78bfa", // violet-400
cost: "#22c55e", // green-500
req: "#16a34a", // green-600
grid: "var(--border)",
text: "var(--muted-foreground)",
} as const;
const hovered = hoverIndex != null ? hoverIndex : null;
const renderTooltip = () => {
if (hovered == null) return null;
const dateText = new Date(days.slice().reverse()[hovered].date.replace(/-/g, "/")).toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
});
const d = days.slice().reverse()[hovered];
return (
<div className="absolute -translate-x-1/2 bottom-full mb-2 left-1/2 pointer-events-none">
<div className="bg-background border border-border rounded-lg shadow-lg p-3 text-xs whitespace-nowrap">
<div className="text-sm font-semibold mb-1">{dateText}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cost }} />
USD{fmtUSD(d.total_cost)}
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cacheR }} />
Token: {fmtTokens(d.cache_read_tokens || 0)} tokens
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cacheW }} />
Token: {fmtTokens(d.cache_creation_tokens || 0)} tokens
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.output }} />
Token: {fmtTokens(d.output_tokens || 0)} tokens
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.input }} />
Token: {fmtTokens(d.input_tokens || 0)} tokens
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.req }} />
{d.request_count || 0}
</div>
</div>
</div>
</div>
);
};
return (
<Card className="p-6">
<h3 className="text-sm font-semibold mb-4">Token使用趋势</h3>
<div className="relative w-full overflow-x-auto">
<svg width={width} height={height} className="min-w-[900px]">
{/* axes */}
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} stroke={colors.grid} />
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke={colors.grid} />
{/* left ticks (tokens) 0, 25%, 50%, 75%, 100% */}
{[0, 0.25, 0.5, 0.75, 1].map((t) => (
<g key={t}>
<text x={8} y={padT + plotH * (1 - t)} className="text-[10px]" fill={colors.text}>
{fmtTokens(Math.round(maxTokens * t))}
</text>
<line
x1={padL}
y1={padT + plotH * (1 - t)}
x2={padL + plotW}
y2={padT + plotH * (1 - t)}
stroke={colors.grid}
strokeDasharray="2,4"
/>
</g>
))}
{/* right ticks (cost/requests) */}
{[0, 0.5, 1].map((t) => (
<g key={`r-${t}`}>
<text x={padL + plotW + 4} y={padT + plotH * (1 - t)} className="text-[10px]" fill={colors.text}>
{t === 1 ? fmtUSD(maxCost) : t === 0.5 ? fmtUSD(maxCost / 2) : "$0"}
</text>
</g>
))}
{/* token lines */}
<path d={pathFrom(series.input, yToken)} fill="none" stroke={colors.input} strokeWidth={2} />
<path d={pathFrom(series.output, yToken)} fill="none" stroke={colors.output} strokeWidth={2} />
<path d={pathFrom(series.cacheW, yToken)} fill="none" stroke={colors.cacheW} strokeWidth={2} />
<path d={pathFrom(series.cacheR, yToken)} fill="none" stroke={colors.cacheR} strokeWidth={2} />
{/* cost line (right axis) */}
<path d={pathFrom(series.cost, yCost)} fill="none" stroke={colors.cost} strokeWidth={2} />
{/* requests as small circles on right scale */}
{series.reqs.map((v, i) => (
<circle key={`req-${i}`} cx={x(i)} cy={yReq(v)} r={2.5} fill={colors.req} />
))}
{/* x labels and hover hit-areas */}
{labels.map((lab, i) => (
<g key={i}
onMouseEnter={() => setHoverIndex(i)}
onMouseLeave={() => setHoverIndex(null)}>
<text
x={x(i)}
y={padT + plotH + 16}
textAnchor="middle"
className="text-[10px]"
fill={colors.text}
>
{lab}
</text>
{/* vertical hover guide */}
{hoverIndex === i && (
<line x1={x(i)} y1={padT} x2={x(i)} y2={padT + plotH} stroke={colors.grid} />
)}
{/* invisible hit area */}
<rect x={x(i) - plotW / Math.max(1, n - 1) / 2}
y={padT}
width={plotW / Math.max(1, n - 1)}
height={plotH}
fill="transparent" />
</g>
))}
</svg>
{/* Tooltip container */}
{hoverIndex != null && (
<div
className="absolute"
style={{ left: `${((padL + (plotW * hoverIndex) / Math.max(1, n - 1)) / width) * 100}%`, bottom: padB + 8 }}
>
{renderTooltip()}
</div>
)}
</div>
{/* legend */}
<div className="flex flex-wrap gap-4 mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.input }} />Token</div>
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.output }} />Token</div>
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cacheW }} />Token</div>
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cacheR }} />Token</div>
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cost }} />USD</div>
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.req }} /></div>
</div>
</Card>
);
};