Files
AIUI/frontend/src/components/chat/TokenUsagePanel.tsx
T

282 lines
12 KiB
TypeScript

import { useState, useEffect } from 'react';
import { RefreshCw, Trash2, BarChart3 } from 'lucide-react';
interface TokenUsageSummary {
promptTokens: number;
completionTokens: number;
totalTokens: number;
requestCount: number;
}
interface ModelBreakdown {
[key: string]: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
requestCount: number;
};
}
interface TokenUsageHistory {
timestamp: number;
conversationId: string;
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
interface TokenUsageData {
summary: TokenUsageSummary;
modelBreakdown: ModelBreakdown;
history: TokenUsageHistory[];
}
type TimePeriod = 'all' | 'today' | 'week';
export default function TokenUsagePanel() {
const [data, setData] = useState<TokenUsageData | null>(null);
const [loading, setLoading] = useState(false);
const [period, setPeriod] = useState<TimePeriod>('all');
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch(`/api/token-usage?period=${period}`);
const result = await res.json();
setData(result);
} catch (err) {
console.error('Failed to fetch token usage:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [period]);
const handleClear = async () => {
if (!confirm('Clear all token usage data?')) return;
try {
await fetch('/api/token-usage', { method: 'DELETE' });
setData(null);
fetchData();
} catch (err) {
console.error('Failed to clear token usage:', err);
}
};
const formatNumber = (n: number) => n.toLocaleString();
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[var(--color-text)]">
<BarChart3 size={20} className="text-[var(--color-accent)]" />
<h3 className="text-sm font-medium">Token Usage</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchData}
disabled={loading}
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={handleClear}
disabled={!data || data.summary.requestCount === 0}
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-red-400 transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
<Trash2 size={16} />
</button>
</div>
</div>
{/* Period filter */}
<div className="flex gap-1 p-1 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
{(['all', 'today', 'week'] as TimePeriod[]).map((p) => {
const labels = { all: 'All', today: 'Today', week: 'Week' };
return (
<button
key={p}
onClick={() => setPeriod(p)}
className={`flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
period === p
? 'bg-[var(--color-accent)] text-white'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
>
{labels[p]}
</button>
);
})}
</div>
{/* Summary */}
{data && data.summary.requestCount > 0 ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-1">Total Tokens</p>
<p className="text-lg font-semibold text-[var(--color-text)]">
{formatNumber(data.summary.totalTokens)}
</p>
</div>
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-1">Requests</p>
<p className="text-lg font-semibold text-[var(--color-text)]">
{data.summary.requestCount}
</p>
</div>
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-1">Prompt Tokens</p>
<p className="text-sm font-medium text-[var(--color-text)]">
{formatNumber(data.summary.promptTokens)}
</p>
</div>
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-1">Completion Tokens</p>
<p className="text-sm font-medium text-[var(--color-text)]">
{formatNumber(data.summary.completionTokens)}
</p>
</div>
</div>
) : (
<div className="text-center py-8 text-[var(--color-text-muted)]">
{loading ? 'Loading...' : 'No token usage data yet'}
</div>
)}
{/* Model breakdown */}
{data && Object.keys(data.modelBreakdown).length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-[var(--color-text)]">By Model</p>
{Object.entries(data.modelBreakdown)
.sort((a, b) => b[1].totalTokens - a[1].totalTokens)
.map(([model, stats]) => (
<div
key={model}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] overflow-hidden"
>
<button
onClick={() => setExpandedModel(expandedModel === model ? null : model)}
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)] truncate">
{model}
</p>
<p className="text-xs text-[var(--color-text-muted)]">
{formatNumber(stats.totalTokens)} tokens · {stats.requestCount} requests
</p>
</div>
<div className="text-right shrink-0 ml-4">
<p className="text-xs text-[var(--color-text-secondary)]">
{formatNumber(stats.promptTokens)} / {formatNumber(stats.completionTokens)}
</p>
</div>
</button>
{expandedModel === model && (
<div className="px-4 pb-3 border-t border-[var(--color-border)] pt-3">
{/* Simple bar chart */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-muted)] w-16">Prompt</span>
<div className="flex-1 h-3 rounded bg-[var(--color-surface)] overflow-hidden">
<div
className="h-full rounded bg-[var(--color-accent)] transition-all"
style={{
width: `${
stats.totalTokens > 0
? (stats.promptTokens / stats.totalTokens) * 100
: 0
}%`,
}}
/>
</div>
<span className="text-xs text-[var(--color-text)] w-16 text-right">
{formatNumber(stats.promptTokens)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-muted)] w-16">Completion</span>
<div className="flex-1 h-3 rounded bg-[var(--color-surface)] overflow-hidden">
<div
className="h-full rounded bg-green-500 transition-all"
style={{
width: `${
stats.totalTokens > 0
? (stats.completionTokens / stats.totalTokens) * 100
: 0
}%`,
}}
/>
</div>
<span className="text-xs text-[var(--color-text)] w-16 text-right">
{formatNumber(stats.completionTokens)}
</span>
</div>
</div>
{/* Show recent history for this model */}
{data.history.filter((h) => h.model === model).length > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-2">Recent requests</p>
<div className="space-y-1 max-h-32 overflow-y-auto">
{data.history
.filter((h) => h.model === model)
.slice(-5)
.reverse()
.map((entry, i) => (
<div
key={i}
className="flex items-center justify-between text-xs px-2 py-1 rounded bg-[var(--color-surface)]"
>
<span className="text-[var(--color-text-muted)]">{formatTime(entry.timestamp)}</span>
<span className="text-[var(--color-text)]">
{formatNumber(entry.totalTokens)} tokens
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* History timeline */}
{data && data.history.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-[var(--color-text)]">Recent History</p>
{data.history.slice(-10).reverse().map((entry, i) => (
<div
key={i}
className="flex items-center gap-3 text-xs px-3 py-2 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]"
>
<span className="text-[var(--color-text-muted)] w-12 shrink-0">
{formatTime(entry.timestamp)}
</span>
<span className="text-[var(--color-text)] truncate flex-1">{entry.model}</span>
<span className="text-[var(--color-text-secondary)] shrink-0">
{formatNumber(entry.totalTokens)}
</span>
</div>
))}
</div>
)}
</div>
);
}