282 lines
12 KiB
TypeScript
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>
|
|
);
|
|
} |