Files
DeskClaw/src/pages/Models/index.tsx

629 lines
26 KiB
TypeScript

import { useEffect, useReducer, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ChevronLeft,
ChevronRight,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useGatewayStore } from '@/stores/gateway';
import { useSettingsStore } from '@/stores/settings';
import { hostApiFetch } from '@/lib/host-api';
import { trackUiEvent } from '@/lib/telemetry';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import { FeedbackState } from '@/components/common/FeedbackState';
import {
filterUsageHistoryByWindow,
groupUsageHistory,
type UsageGroupBy,
type UsageHistoryEntry,
type UsageWindow,
} from './usage-history';
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2;
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3;
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
export function Models() {
const { t } = useTranslation(['dashboard', 'settings']);
const gatewayStatus = useGatewayStore((state) => state.status);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const isGatewayRunning = gatewayStatus.state === 'running';
const usageFetchMaxAttempts = window.electron.platform === 'win32'
? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS
: DEFAULT_USAGE_FETCH_MAX_ATTEMPTS;
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
const [usagePage, setUsagePage] = useState(1);
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
const HIDDEN_USAGE_SOURCES = new Set([
'gateway-injected',
'delivery-mirror',
]);
function isHiddenUsageSource(source?: string): boolean {
if (!source) return false;
const normalizedSource = source.trim().toLowerCase();
return (
HIDDEN_USAGE_SOURCES.has(normalizedSource)
|| normalizedSource.includes('gateway-injected')
|| normalizedSource.includes('delivery-mirror')
);
}
function formatUsageSource(source?: string): string | undefined {
if (!source) return undefined;
if (isHiddenUsageSource(source)) {
return undefined;
}
return source;
}
function shouldHideUsageEntry(entry: UsageHistoryEntry): boolean {
return (
isHiddenUsageSource(entry.provider)
|| isHiddenUsageSource(entry.model)
);
}
type FetchState = {
status: 'idle' | 'loading' | 'done';
data: UsageHistoryEntry[];
};
type FetchAction =
| { type: 'start' }
| { type: 'done'; data: UsageHistoryEntry[] }
| { type: 'reset' };
const [fetchState, dispatchFetch] = useReducer(
(state: FetchState, action: FetchAction): FetchState => {
switch (action.type) {
case 'start':
return { status: 'loading', data: state.data };
case 'done':
return { status: 'done', data: action.data };
case 'reset':
return { status: 'idle', data: [] };
default:
return state;
}
},
{ status: 'idle' as const, data: [] as UsageHistoryEntry[] },
);
const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const usageFetchGenerationRef = useRef(0);
useEffect(() => {
trackUiEvent('models.page_viewed');
}, []);
useEffect(() => {
if (usageFetchTimerRef.current) {
clearTimeout(usageFetchTimerRef.current);
usageFetchTimerRef.current = null;
}
if (!isGatewayRunning) {
dispatchFetch({ type: 'reset' });
return;
}
dispatchFetch({ type: 'start' });
const generation = usageFetchGenerationRef.current + 1;
usageFetchGenerationRef.current = generation;
const restartMarker = `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}`;
trackUiEvent('models.token_usage_fetch_started', {
generation,
restartMarker,
});
// Safety timeout: if the fetch cycle hasn't resolved after 30 s,
// force-resolve to "done" with empty data to avoid an infinite spinner.
const safetyTimeout = setTimeout(() => {
if (usageFetchGenerationRef.current !== generation) return;
trackUiEvent('models.token_usage_fetch_safety_timeout', {
generation,
restartMarker,
});
dispatchFetch({ type: 'done', data: [] });
}, 30_000);
const fetchUsageHistoryWithRetry = async (attempt: number) => {
trackUiEvent('models.token_usage_fetch_attempt', {
generation,
attempt,
restartMarker,
});
try {
const entries = await hostApiFetch<UsageHistoryEntry[]>('/api/usage/recent-token-history');
if (usageFetchGenerationRef.current !== generation) return;
const normalized = Array.isArray(entries) ? entries : [];
setUsagePage(1);
trackUiEvent('models.token_usage_fetch_succeeded', {
generation,
attempt,
records: normalized.length,
restartMarker,
});
if (normalized.length === 0 && attempt < usageFetchMaxAttempts) {
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
generation,
attempt,
reason: 'empty',
restartMarker,
});
usageFetchTimerRef.current = setTimeout(() => {
void fetchUsageHistoryWithRetry(attempt + 1);
}, USAGE_FETCH_RETRY_DELAY_MS);
} else {
if (normalized.length === 0) {
trackUiEvent('models.token_usage_fetch_exhausted', {
generation,
attempt,
reason: 'empty',
restartMarker,
});
}
dispatchFetch({ type: 'done', data: normalized });
}
} catch (error) {
if (usageFetchGenerationRef.current !== generation) return;
trackUiEvent('models.token_usage_fetch_failed_attempt', {
generation,
attempt,
restartMarker,
message: error instanceof Error ? error.message : String(error),
});
if (attempt < usageFetchMaxAttempts) {
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
generation,
attempt,
reason: 'error',
restartMarker,
});
usageFetchTimerRef.current = setTimeout(() => {
void fetchUsageHistoryWithRetry(attempt + 1);
}, USAGE_FETCH_RETRY_DELAY_MS);
return;
}
dispatchFetch({ type: 'done', data: [] });
trackUiEvent('models.token_usage_fetch_exhausted', {
generation,
attempt,
reason: 'error',
restartMarker,
});
}
};
void fetchUsageHistoryWithRetry(1);
return () => {
clearTimeout(safetyTimeout);
if (usageFetchTimerRef.current) {
clearTimeout(usageFetchTimerRef.current);
usageFetchTimerRef.current = null;
}
};
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
const visibleUsageHistory = isGatewayRunning
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
: [];
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
const usagePageSize = 5;
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
const safeUsagePage = Math.min(usagePage, usageTotalPages);
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
const usageLoading = isGatewayRunning && fetchState.status === 'loading';
return (
<div data-testid="models-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
<div>
<h1 data-testid="models-page-title" className="text-5xl md:text-6xl font-serif text-foreground mb-3 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('dashboard:models.title')}
</h1>
<p className="text-[17px] text-foreground/70 font-medium">
{t('dashboard:models.subtitle')}
</p>
</div>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2 space-y-12">
{/* AI Providers Section */}
<ProvidersSettings />
{/* Token Usage History Section */}
<div>
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('dashboard:recentTokenHistory.title', 'Token Usage History')}
</h2>
<div>
{usageLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed">
<FeedbackState state="loading" title={t('dashboard:recentTokenHistory.loading')} />
</div>
) : visibleUsageHistory.length === 0 ? (
<div className="flex items-center justify-center py-12 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed">
<FeedbackState state="empty" title={t('dashboard:recentTokenHistory.empty')} />
</div>
) : filteredUsageHistory.length === 0 ? (
<div className="flex items-center justify-center py-12 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed">
<FeedbackState state="empty" title={t('dashboard:recentTokenHistory.emptyForWindow')} />
</div>
) : (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="flex rounded-xl bg-transparent p-1 border border-black/10 dark:border-white/10">
<Button
variant={usageGroupBy === 'model' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageGroupBy('model');
setUsagePage(1);
}}
className={usageGroupBy === 'model' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
>
{t('dashboard:recentTokenHistory.groupByModel')}
</Button>
<Button
variant={usageGroupBy === 'day' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageGroupBy('day');
setUsagePage(1);
}}
className={usageGroupBy === 'day' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
>
{t('dashboard:recentTokenHistory.groupByTime')}
</Button>
</div>
<div className="flex rounded-xl bg-transparent p-1 border border-black/10 dark:border-white/10">
<Button
variant={usageWindow === '7d' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageWindow('7d');
setUsagePage(1);
}}
className={usageWindow === '7d' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
>
{t('dashboard:recentTokenHistory.last7Days')}
</Button>
<Button
variant={usageWindow === '30d' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageWindow('30d');
setUsagePage(1);
}}
className={usageWindow === '30d' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
>
{t('dashboard:recentTokenHistory.last30Days')}
</Button>
<Button
variant={usageWindow === 'all' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageWindow('all');
setUsagePage(1);
}}
className={usageWindow === 'all' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
>
{t('dashboard:recentTokenHistory.allTime')}
</Button>
</div>
</div>
<p className="text-[13px] font-medium text-muted-foreground">
{t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
</p>
</div>
<UsageBarChart
groups={usageGroups}
emptyLabel={t('dashboard:recentTokenHistory.empty')}
totalLabel={t('dashboard:recentTokenHistory.totalTokens')}
inputLabel={t('dashboard:recentTokenHistory.inputShort')}
outputLabel={t('dashboard:recentTokenHistory.outputShort')}
cacheLabel={t('dashboard:recentTokenHistory.cacheShort')}
/>
<div className="space-y-3 pt-2">
{pagedUsageHistory.map((entry) => (
<div
key={`${entry.sessionId}-${entry.timestamp}`}
data-testid="token-usage-entry"
className="rounded-2xl bg-transparent border border-black/10 dark:border-white/10 p-5 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold text-[15px] text-foreground truncate">
{entry.model || t('dashboard:recentTokenHistory.unknownModel')}
</p>
<p className="text-[13px] text-muted-foreground truncate mt-0.5">
{[formatUsageSource(entry.provider), formatUsageSource(entry.agentId), entry.sessionId].filter(Boolean).join(' • ')}
</p>
</div>
<div className="text-right shrink-0">
<p className={getUsageTotalClass(entry)}>
{formatUsageTotal(entry)}
</p>
{entry.usageStatus === 'missing' && (
<p className="text-[12px] text-muted-foreground mt-0.5">
{t('dashboard:recentTokenHistory.noUsage')}
</p>
)}
{entry.usageStatus === 'error' && (
<p className="text-[12px] text-red-500 dark:text-red-400 mt-0.5">
{t('dashboard:recentTokenHistory.usageParseError')}
</p>
)}
<p className="text-[12px] text-muted-foreground mt-0.5">
{formatUsageTimestamp(entry.timestamp)}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-[12.5px] font-medium text-muted-foreground">
{entry.usageStatus === 'available' || entry.usageStatus === undefined ? (
<>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-sky-500"></div>{t('dashboard:recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-violet-500"></div>{t('dashboard:recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
{entry.cacheReadTokens > 0 && (
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
)}
{entry.cacheWriteTokens > 0 && (
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
)}
</>
) : (
<span className="text-[12px]">
{entry.usageStatus === 'missing'
? t('dashboard:recentTokenHistory.noUsage')
: t('dashboard:recentTokenHistory.usageParseError')}
</span>
)}
{typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && (
<span className="flex items-center gap-1.5 ml-auto text-foreground/80 bg-black/5 dark:bg-white/5 px-2 py-0.5 rounded-md">{t('dashboard:recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })}</span>
)}
{devModeUnlocked && entry.content && (
<Button
variant="outline"
size="sm"
className="h-6 rounded-full px-2.5 text-[11.5px] border-black/10 dark:border-white/10"
onClick={() => setSelectedUsageEntry(entry)}
>
{t('dashboard:recentTokenHistory.viewContent')}
</Button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 pt-2">
<p className="text-[13px] font-medium text-muted-foreground">
{t('dashboard:recentTokenHistory.page', { current: safeUsagePage, total: usageTotalPages })}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setUsagePage((page) => Math.max(1, page - 1))}
disabled={safeUsagePage <= 1}
className="rounded-full px-4 h-9 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
>
<ChevronLeft className="h-4 w-4 mr-1" />
{t('dashboard:recentTokenHistory.prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setUsagePage((page) => Math.min(usageTotalPages, page + 1))}
disabled={safeUsagePage >= usageTotalPages}
className="rounded-full px-4 h-9 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
>
{t('dashboard:recentTokenHistory.next')}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
{devModeUnlocked && selectedUsageEntry && (
<UsageContentPopup
entry={selectedUsageEntry}
onClose={() => setSelectedUsageEntry(null)}
title={t('dashboard:recentTokenHistory.contentDialogTitle')}
closeLabel={t('dashboard:recentTokenHistory.close')}
unknownModelLabel={t('dashboard:recentTokenHistory.unknownModel')}
/>
)}
</div>
);
}
function formatTokenCount(value: number): string {
return Intl.NumberFormat().format(value);
}
function getUsageTotalClass(entry: UsageHistoryEntry): string {
if (entry.usageStatus === 'error') return 'font-bold text-[15px] text-red-500 dark:text-red-400';
if (entry.usageStatus === 'missing') return 'font-bold text-[15px] text-muted-foreground';
return 'font-bold text-[15px]';
}
function formatUsageTotal(entry: UsageHistoryEntry): string {
if (entry.usageStatus === 'error') return '✕';
if (entry.usageStatus === 'missing') return '—';
return formatTokenCount(entry.totalTokens);
}
function formatUsageTimestamp(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function UsageBarChart({
groups,
emptyLabel,
totalLabel,
inputLabel,
outputLabel,
cacheLabel,
}: {
groups: Array<{
label: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
}>;
emptyLabel: string;
totalLabel: string;
inputLabel: string;
outputLabel: string;
cacheLabel: string;
}) {
if (groups.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-black/10 dark:border-white/10 p-8 text-center text-[14px] font-medium text-muted-foreground">
{emptyLabel}
</div>
);
}
const maxTokens = Math.max(...groups.map((group) => group.totalTokens), 1);
return (
<div className="space-y-4 bg-transparent p-5 rounded-2xl border border-black/10 dark:border-white/10">
<div className="flex flex-wrap gap-4 text-[13px] font-medium text-muted-foreground mb-2">
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-sky-500" />
{inputLabel}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-violet-500" />
{outputLabel}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
{cacheLabel}
</span>
</div>
{groups.map((group) => (
<div key={group.label} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-[13.5px]">
<span className="truncate font-semibold text-foreground">{group.label}</span>
<span className="text-muted-foreground font-medium">
{totalLabel}: {formatTokenCount(group.totalTokens)}
</span>
</div>
<div className="h-3.5 overflow-hidden rounded-full bg-black/5 dark:bg-white/5">
<div
className="flex h-full overflow-hidden rounded-full"
style={{
width: group.totalTokens > 0
? `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%`
: '0%',
}}
>
{group.inputTokens > 0 && (
<div
className="h-full bg-sky-500"
style={{ width: `${(group.inputTokens / group.totalTokens) * 100}%` }}
/>
)}
{group.outputTokens > 0 && (
<div
className="h-full bg-violet-500"
style={{ width: `${(group.outputTokens / group.totalTokens) * 100}%` }}
/>
)}
{group.cacheTokens > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(group.cacheTokens / group.totalTokens) * 100}%` }}
/>
)}
</div>
</div>
</div>
))}
</div>
);
}
export default Models;
function UsageContentPopup({
entry,
onClose,
title,
closeLabel,
unknownModelLabel,
}: {
entry: UsageHistoryEntry;
onClose: () => void;
title: string;
closeLabel: string;
unknownModelLabel: string;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-3xl rounded-2xl border border-black/10 dark:border-white/10 bg-background shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-black/10 dark:border-white/10 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{(entry.model || unknownModelLabel)} {formatUsageTimestamp(entry.timestamp)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={onClose}
aria-label={closeLabel}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="max-h-[65vh] overflow-y-auto px-5 py-4">
<pre className="whitespace-pre-wrap break-words text-sm text-foreground font-mono">
{entry.content}
</pre>
</div>
<div className="flex justify-end border-t border-black/10 dark:border-white/10 px-5 py-3">
<Button variant="outline" onClick={onClose}>
{closeLabel}
</Button>
</div>
</div>
</div>
);
}