mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 18:45:34 +08:00
fix: refine token usage reporting (#807)
This commit is contained in:
@@ -97,17 +97,10 @@ export type CostCommandData = {
|
||||
tokenUsage?: {
|
||||
used?: number;
|
||||
total?: number;
|
||||
percentage?: number;
|
||||
};
|
||||
cost?: {
|
||||
input?: string;
|
||||
output?: string;
|
||||
total?: string;
|
||||
};
|
||||
tokenBreakdown?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache?: number;
|
||||
};
|
||||
provider?: string;
|
||||
model?: string;
|
||||
|
||||
@@ -624,19 +624,23 @@ export function useChatSessionState({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||
|
||||
// Token usage fetch for Claude
|
||||
// Initial token usage fetch for providers with file-backed usage data.
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id) {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude') return;
|
||||
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
// Token usage endpoint is now keyed by the DB projectId.
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
||||
const params = new URLSearchParams({ provider: sessionProvider });
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
setTokenBudget(await response.json());
|
||||
|
||||
@@ -18,7 +18,7 @@ import ClaudeStatus from './ClaudeStatus';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||
import TokenUsagePie from './TokenUsagePie';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputHeader,
|
||||
@@ -60,7 +60,7 @@ interface ChatComposerProps {
|
||||
onModeSwitch: () => void;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||
tokenBudget: { used?: number; total?: number } | null;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
@@ -361,7 +361,7 @@ export default function ChatComposer({
|
||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||
)}
|
||||
|
||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
||||
<TokenUsageSummary usage={tokenBudget} />
|
||||
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.showAllCommands') }}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Coins,
|
||||
Command as CommandIcon,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Package,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
Timer,
|
||||
RefreshCw,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
|
||||
@@ -84,7 +82,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
|
||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||
{ name: '/models', description: 'Browse available models for the active provider.' },
|
||||
{ name: '/cost', description: 'Review context usage and estimated token spend.' },
|
||||
{ name: '/cost', description: 'Review token usage for the active session.' },
|
||||
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
|
||||
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
|
||||
{ name: '/config', description: 'Open settings and configuration.' },
|
||||
@@ -99,13 +97,6 @@ const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') =>
|
||||
return PROVIDER_LABELS[provider] || provider;
|
||||
};
|
||||
|
||||
const clampPercentage = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, value));
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0';
|
||||
@@ -113,11 +104,6 @@ const formatNumber = (value: number) => {
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | string | undefined) => {
|
||||
const numeric = Number(value ?? 0);
|
||||
return `$${Number.isFinite(numeric) ? numeric.toFixed(4) : '0.0000'}`;
|
||||
};
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
@@ -507,62 +493,71 @@ function ModelsContent({
|
||||
function CostContent({ data }: { data: CostCommandData }) {
|
||||
const used = Number(data.tokenUsage?.used ?? 0);
|
||||
const total = Number(data.tokenUsage?.total ?? 0);
|
||||
const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0));
|
||||
const model = data.model || 'Unknown';
|
||||
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
|
||||
const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
|
||||
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
|
||||
const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
|
||||
const totalCost = Number(data.cost?.total ?? 0);
|
||||
const hasBreakdown =
|
||||
typeof data.tokenBreakdown?.input === 'number' ||
|
||||
typeof data.tokenBreakdown?.output === 'number';
|
||||
const usageRows = [
|
||||
{ label: 'Total tokens used', value: formatNumber(used), icon: Activity },
|
||||
...(hasBreakdown
|
||||
? [
|
||||
{
|
||||
label: 'Input tokens',
|
||||
value: formatNumber(Number(data.tokenBreakdown?.input ?? 0)),
|
||||
icon: TerminalSquare,
|
||||
},
|
||||
{
|
||||
label: 'Output tokens',
|
||||
value: formatNumber(Number(data.tokenBreakdown?.output ?? 0)),
|
||||
icon: Coins,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Breakdown',
|
||||
value: 'Unavailable',
|
||||
icon: TerminalSquare,
|
||||
},
|
||||
]),
|
||||
...(total > 0
|
||||
? [{ label: 'Context window', value: formatNumber(total), icon: Gauge }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
|
||||
<div className="rounded-3xl border border-primary/25 bg-primary/10 p-5 text-center">
|
||||
<div
|
||||
className="mx-auto grid h-40 w-40 place-items-center rounded-full p-2 shadow-inner"
|
||||
style={{
|
||||
background: `conic-gradient(hsl(var(--primary)) ${percentage * 3.6}deg, hsl(var(--muted)) 0deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="grid h-full w-full place-items-center rounded-full border border-border/70 bg-popover">
|
||||
<div>
|
||||
<p className="font-mono text-3xl font-semibold text-foreground">{percentage.toFixed(1)}%</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">context</p>
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-background/75">
|
||||
{usageRows.map((row) => {
|
||||
const Icon = row.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-foreground">{row.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-sm font-semibold text-foreground">{row.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{formatNumber(used)} of {formatNumber(total)} tokens used
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricCard label="Input" value={formatCurrency(data.cost?.input)} icon={Zap} />
|
||||
<MetricCard label="Output" value={formatCurrency(data.cost?.output)} icon={Activity} />
|
||||
<MetricCard label="Total" value={formatCurrency(totalCost)} icon={Coins} tone="primary" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricCard label="Input tokens" value={formatNumber(inputTokens)} icon={CommandIcon} />
|
||||
<MetricCard label="Output tokens" value={formatNumber(outputTokens)} icon={TerminalSquare} />
|
||||
<MetricCard label="Cache tokens" value={formatNumber(cacheTokens)} icon={Package} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
Cost is an estimate based on the available token counters and default provider rates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -636,8 +631,8 @@ export default function CommandResultModal({
|
||||
},
|
||||
cost: {
|
||||
eyebrow: 'Session telemetry',
|
||||
title: 'Usage & Cost',
|
||||
subtitle: 'Token budget, context pressure, and estimated spend for this session.',
|
||||
title: 'Token Usage',
|
||||
subtitle: 'Input, output, and total token counts for this session.',
|
||||
icon: Coins,
|
||||
},
|
||||
status: {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
type TokenUsagePieProps = {
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
|
||||
// Token usage visualization component
|
||||
// Only bail out on missing values or non‐positive totals; allow used===0 to render 0%
|
||||
if (used == null || total == null || total <= 0) return null;
|
||||
|
||||
const percentage = Math.min(100, (used / total) * 100);
|
||||
const radius = 10;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
// Color based on usage level
|
||||
const getColor = () => {
|
||||
if (percentage < 50) return '#3b82f6'; // blue
|
||||
if (percentage < 75) return '#f59e0b'; // orange
|
||||
return '#ef4444'; // red
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={getColor()}
|
||||
strokeWidth="2"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/chat/view/subcomponents/TokenUsageSummary.tsx
Normal file
53
src/components/chat/view/subcomponents/TokenUsageSummary.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ActivityIcon } from 'lucide-react';
|
||||
|
||||
type TokenUsageSummaryProps = {
|
||||
usage: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const formatTokenCount = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
|
||||
}
|
||||
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`;
|
||||
}
|
||||
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
const readUsageNumber = (value: unknown) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
||||
const breakdown =
|
||||
usage?.breakdown && typeof usage.breakdown === 'object'
|
||||
? usage.breakdown as Record<string, unknown>
|
||||
: null;
|
||||
const inputTokens = readUsageNumber(usage?.inputTokens ?? breakdown?.input);
|
||||
const outputTokens = readUsageNumber(usage?.outputTokens ?? breakdown?.output);
|
||||
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
|
||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||
>
|
||||
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
||||
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user