Add dashboard token usage history (#240)
This commit is contained in:
committed by
GitHub
Unverified
parent
0bc4b7cbc2
commit
62108bdc23
@@ -30,3 +30,4 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
|
||||
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
|
||||
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages with `message.usage`, and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
|
||||
|
||||
@@ -49,6 +49,7 @@ import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
|
||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||
|
||||
/**
|
||||
* For custom/ollama providers, derive a unique key for OpenClaw config files
|
||||
@@ -105,6 +106,9 @@ export function registerIpcHandlers(
|
||||
// Log handlers (for UI to read gateway/app logs)
|
||||
registerLogHandlers();
|
||||
|
||||
// Usage handlers
|
||||
registerUsageHandlers();
|
||||
|
||||
// Skill config handlers (direct file access, no Gateway RPC)
|
||||
registerSkillConfigHandlers();
|
||||
|
||||
@@ -1751,6 +1755,14 @@ function registerAppHandlers(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function registerUsageHandlers(): void {
|
||||
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
|
||||
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
|
||||
? Math.min(Math.max(Math.floor(limit), 1), 100)
|
||||
: 20;
|
||||
return await getRecentTokenUsageHistory(safeLimit);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Window control handlers (for custom title bar on Windows/Linux)
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,7 @@ const electronAPI = {
|
||||
'settings:set',
|
||||
'settings:getAll',
|
||||
'settings:reset',
|
||||
'usage:recentTokenHistory',
|
||||
// Update
|
||||
'update:status',
|
||||
'update:version',
|
||||
|
||||
89
electron/utils/token-usage-core.ts
Normal file
89
electron/utils/token-usage-core.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export interface TokenUsageHistoryEntry {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
interface TranscriptUsageShape {
|
||||
input?: number;
|
||||
output?: number;
|
||||
total?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
cost?: {
|
||||
total?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TranscriptLineShape {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
provider?: string;
|
||||
usage?: TranscriptUsageShape;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUsageEntriesFromJsonl(
|
||||
content: string,
|
||||
context: { sessionId: string; agentId: string },
|
||||
limit = 20,
|
||||
): TokenUsageHistoryEntry[] {
|
||||
const entries: TokenUsageHistoryEntry[] = [];
|
||||
const lines = content.split(/\r?\n/).filter(Boolean);
|
||||
|
||||
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i -= 1) {
|
||||
let parsed: TranscriptLineShape;
|
||||
try {
|
||||
parsed = JSON.parse(lines[i]) as TranscriptLineShape;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = parsed.message;
|
||||
if (!message || message.role !== 'assistant' || !message.usage || !parsed.timestamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const usage = message.usage;
|
||||
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
|
||||
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
|
||||
const cacheReadTokens = usage.cacheRead ?? 0;
|
||||
const cacheWriteTokens = usage.cacheWrite ?? 0;
|
||||
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
||||
|
||||
if (totalTokens <= 0 && !usage.cost?.total) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
timestamp: parsed.timestamp,
|
||||
sessionId: context.sessionId,
|
||||
agentId: context.agentId,
|
||||
model: message.model ?? message.modelRef,
|
||||
provider: message.provider,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
totalTokens,
|
||||
costUsd: usage.cost?.total,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
69
electron/utils/token-usage.ts
Normal file
69
electron/utils/token-usage.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { readdir, readFile, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { getOpenClawConfigDir } from './paths';
|
||||
import { logger } from './logger';
|
||||
import { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
|
||||
|
||||
export { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
|
||||
|
||||
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
|
||||
const openclawDir = getOpenClawConfigDir();
|
||||
const agentsDir = join(openclawDir, 'agents');
|
||||
|
||||
try {
|
||||
const agentEntries = await readdir(agentsDir);
|
||||
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
|
||||
|
||||
for (const agentId of agentEntries) {
|
||||
const sessionsDir = join(agentsDir, agentId, 'sessions');
|
||||
try {
|
||||
const sessionEntries = await readdir(sessionsDir);
|
||||
|
||||
for (const fileName of sessionEntries) {
|
||||
if (!fileName.endsWith('.jsonl') || fileName.includes('.deleted.')) continue;
|
||||
const filePath = join(sessionsDir, fileName);
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
files.push({
|
||||
filePath,
|
||||
sessionId: fileName.replace(/\.jsonl$/, ''),
|
||||
agentId,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentTokenUsageHistory(limit = 20): Promise<TokenUsageHistoryEntry[]> {
|
||||
const files = await listRecentSessionFiles();
|
||||
const results: TokenUsageHistoryEntry[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (results.length >= limit) break;
|
||||
try {
|
||||
const content = await readFile(file.filePath, 'utf8');
|
||||
const entries = parseUsageEntriesFromJsonl(content, {
|
||||
sessionId: file.sessionId,
|
||||
agentId: file.agentId,
|
||||
}, limit - results.length);
|
||||
results.push(...entries);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to read token usage transcript ${file.filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
@@ -24,5 +24,31 @@
|
||||
"activeSkills": "Active Skills",
|
||||
"noSkills": "No skills enabled",
|
||||
"enableSome": "Enable some skills",
|
||||
"more": "+{{count}} more"
|
||||
}
|
||||
"more": "+{{count}} more",
|
||||
"recentTokenHistory": {
|
||||
"title": "Recent Token Usage",
|
||||
"description": "The latest assistant responses with recorded token usage.",
|
||||
"loading": "Loading token usage history...",
|
||||
"empty": "No token usage history yet",
|
||||
"groupByModel": "By model",
|
||||
"groupByTime": "By time",
|
||||
"last7Days": "7 days",
|
||||
"last30Days": "30 days",
|
||||
"allTime": "All",
|
||||
"showingLast": "Showing the latest {{count}} records",
|
||||
"totalTokens": "total tokens",
|
||||
"inputShort": "Input",
|
||||
"outputShort": "Output",
|
||||
"cacheShort": "Cache",
|
||||
"page": "Page {{current}} / {{total}}",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"unknownModel": "Unknown model",
|
||||
"emptyForWindow": "No token usage history in this time range",
|
||||
"input": "Input {{value}}",
|
||||
"output": "Output {{value}}",
|
||||
"cacheRead": "Cache read {{value}}",
|
||||
"cacheWrite": "Cache write {{value}}",
|
||||
"cost": "Cost ${{amount}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,31 @@
|
||||
"activeSkills": "アクティブなスキル",
|
||||
"noSkills": "有効なスキルがありません",
|
||||
"enableSome": "スキルを有効にする",
|
||||
"more": "+{{count}} 件"
|
||||
}
|
||||
"more": "+{{count}} 件",
|
||||
"recentTokenHistory": {
|
||||
"title": "最近のトークン使用量",
|
||||
"description": "トークン使用量が記録された最新のアシスタント応答を表示します。",
|
||||
"loading": "トークン使用履歴を読み込み中...",
|
||||
"empty": "トークン使用履歴はまだありません",
|
||||
"groupByModel": "モデル別",
|
||||
"groupByTime": "時間別",
|
||||
"last7Days": "7日",
|
||||
"last30Days": "30日",
|
||||
"allTime": "すべて",
|
||||
"showingLast": "最新 {{count}} 件を表示",
|
||||
"totalTokens": "合計トークン",
|
||||
"inputShort": "入力",
|
||||
"outputShort": "出力",
|
||||
"cacheShort": "キャッシュ",
|
||||
"page": "{{current}} / {{total}} ページ",
|
||||
"prev": "前へ",
|
||||
"next": "次へ",
|
||||
"unknownModel": "不明なモデル",
|
||||
"emptyForWindow": "この期間のトークン使用履歴はありません",
|
||||
"input": "入力 {{value}}",
|
||||
"output": "出力 {{value}}",
|
||||
"cacheRead": "キャッシュ読取 {{value}}",
|
||||
"cacheWrite": "キャッシュ書込 {{value}}",
|
||||
"cost": "コスト ${{amount}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,31 @@
|
||||
"activeSkills": "已启用技能",
|
||||
"noSkills": "未启用技能",
|
||||
"enableSome": "启用一些技能",
|
||||
"more": "+{{count}} 更多"
|
||||
}
|
||||
"more": "+{{count}} 更多",
|
||||
"recentTokenHistory": {
|
||||
"title": "最近 Token 消耗",
|
||||
"description": "展示最近带有 token 用量记录的助手回复。",
|
||||
"loading": "正在加载 token 消耗历史...",
|
||||
"empty": "还没有 token 消耗历史",
|
||||
"groupByModel": "按模型",
|
||||
"groupByTime": "按时间",
|
||||
"last7Days": "7 天",
|
||||
"last30Days": "30 天",
|
||||
"allTime": "全部",
|
||||
"showingLast": "显示最近 {{count}} 条记录",
|
||||
"totalTokens": "总 token",
|
||||
"inputShort": "输入",
|
||||
"outputShort": "输出",
|
||||
"cacheShort": "缓存",
|
||||
"page": "第 {{current}} / {{total}} 页",
|
||||
"prev": "上一页",
|
||||
"next": "下一页",
|
||||
"unknownModel": "未知模型",
|
||||
"emptyForWindow": "当前时间范围内没有 token 消耗历史",
|
||||
"input": "输入 {{value}}",
|
||||
"output": "输出 {{value}}",
|
||||
"cacheRead": "缓存读取 {{value}}",
|
||||
"cacheWrite": "缓存写入 {{value}}",
|
||||
"cost": "费用 ${{amount}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
Settings,
|
||||
Plus,
|
||||
Terminal,
|
||||
Coins,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -24,6 +27,23 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { StatusBadge } from '@/components/common/StatusBadge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type UsageHistoryEntry = {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
type UsageWindow = '7d' | '30d' | 'all';
|
||||
type UsageGroupBy = 'model' | 'day';
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
@@ -33,18 +53,38 @@ export function Dashboard() {
|
||||
|
||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||
const [uptime, setUptime] = useState(0);
|
||||
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
|
||||
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
|
||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
||||
const [usagePage, setUsagePage] = useState(1);
|
||||
|
||||
// Fetch data only when gateway is running
|
||||
useEffect(() => {
|
||||
if (isGatewayRunning) {
|
||||
fetchChannels();
|
||||
fetchSkills();
|
||||
window.electron.ipcRenderer.invoke('usage:recentTokenHistory', 60)
|
||||
.then((entries) => {
|
||||
setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []);
|
||||
setUsagePage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
setUsageHistory([]);
|
||||
});
|
||||
}
|
||||
}, [fetchChannels, fetchSkills, isGatewayRunning]);
|
||||
|
||||
// Calculate statistics safely
|
||||
const connectedChannels = Array.isArray(channels) ? channels.filter((c) => c.status === 'connected').length : 0;
|
||||
const enabledSkills = Array.isArray(skills) ? skills.filter((s) => s.enabled).length : 0;
|
||||
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
|
||||
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 && visibleUsageHistory.length === 0;
|
||||
|
||||
// Update uptime periodically
|
||||
useEffect(() => {
|
||||
@@ -274,6 +314,166 @@ export function Dashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('recentTokenHistory.title')}</CardTitle>
|
||||
<CardDescription>{t('recentTokenHistory.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usageLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('recentTokenHistory.loading')}</div>
|
||||
) : visibleUsageHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('recentTokenHistory.empty')}</p>
|
||||
</div>
|
||||
) : filteredUsageHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('recentTokenHistory.emptyForWindow')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex rounded-lg border p-1">
|
||||
<Button
|
||||
variant={usageGroupBy === 'model' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageGroupBy('model');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
>
|
||||
{t('recentTokenHistory.groupByModel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={usageGroupBy === 'day' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageGroupBy('day');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
>
|
||||
{t('recentTokenHistory.groupByTime')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex rounded-lg border p-1">
|
||||
<Button
|
||||
variant={usageWindow === '7d' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageWindow('7d');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
>
|
||||
{t('recentTokenHistory.last7Days')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={usageWindow === '30d' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageWindow('30d');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
>
|
||||
{t('recentTokenHistory.last30Days')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={usageWindow === 'all' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageWindow('all');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
>
|
||||
{t('recentTokenHistory.allTime')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UsageBarChart
|
||||
groups={usageGroups}
|
||||
emptyLabel={t('recentTokenHistory.empty')}
|
||||
totalLabel={t('recentTokenHistory.totalTokens')}
|
||||
inputLabel={t('recentTokenHistory.inputShort')}
|
||||
outputLabel={t('recentTokenHistory.outputShort')}
|
||||
cacheLabel={t('recentTokenHistory.cacheShort')}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{pagedUsageHistory.map((entry) => (
|
||||
<div
|
||||
key={`${entry.sessionId}-${entry.timestamp}`}
|
||||
className="rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{entry.model || t('recentTokenHistory.unknownModel')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{[entry.provider, entry.agentId, entry.sessionId].filter(Boolean).join(' • ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="font-semibold">{formatTokenCount(entry.totalTokens)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatUsageTimestamp(entry.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t('recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
|
||||
<span>{t('recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
|
||||
{entry.cacheReadTokens > 0 && (
|
||||
<span>{t('recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
|
||||
)}
|
||||
{entry.cacheWriteTokens > 0 && (
|
||||
<span>{t('recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
|
||||
)}
|
||||
{typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && (
|
||||
<span>{t('recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t pt-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('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}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
{t('recentTokenHistory.prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUsagePage((page) => Math.min(usageTotalPages, page + 1))}
|
||||
disabled={safeUsagePage >= usageTotalPages}
|
||||
>
|
||||
{t('recentTokenHistory.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -295,4 +495,183 @@ function formatUptime(seconds: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
return Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
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 groupUsageHistory(
|
||||
entries: UsageHistoryEntry[],
|
||||
groupBy: UsageGroupBy,
|
||||
): Array<{
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
sortKey: number | string;
|
||||
}> {
|
||||
const grouped = new Map<string, {
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
sortKey: number | string;
|
||||
}>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const label = groupBy === 'model'
|
||||
? (entry.model || 'Unknown')
|
||||
: formatUsageDay(entry.timestamp);
|
||||
const current = grouped.get(label) ?? {
|
||||
label,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
|
||||
};
|
||||
current.totalTokens += entry.totalTokens;
|
||||
current.inputTokens += entry.inputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
|
||||
grouped.set(label, current);
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.sort((a, b) => {
|
||||
if (groupBy === 'day') {
|
||||
return Number(a.sortKey) - Number(b.sortKey);
|
||||
}
|
||||
return b.totalTokens - a.totalTokens;
|
||||
})
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function formatUsageDay(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getUsageDaySortKey(timestamp: string): number {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return 0;
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function filterUsageHistoryByWindow(entries: UsageHistoryEntry[], window: UsageWindow): UsageHistoryEntry[] {
|
||||
if (window === 'all') return entries;
|
||||
|
||||
const now = Date.now();
|
||||
const days = window === '7d' ? 7 : 30;
|
||||
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const timestamp = Date.parse(entry.timestamp);
|
||||
return Number.isFinite(timestamp) && timestamp >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
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-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxTokens = Math.max(...groups.map((group) => group.totalTokens), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate font-medium">{group.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{totalLabel}: {formatTokenCount(group.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="flex h-full overflow-hidden rounded-full"
|
||||
style={{ width: `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%` }}
|
||||
>
|
||||
{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 Dashboard;
|
||||
|
||||
83
tests/unit/token-usage.test.ts
Normal file
83
tests/unit/token-usage.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseUsageEntriesFromJsonl } from '@electron/utils/token-usage-core';
|
||||
|
||||
describe('parseUsageEntriesFromJsonl', () => {
|
||||
it('extracts assistant usage entries in reverse chronological order', () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
timestamp: '2026-02-28T10:00:00.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'gpt-5',
|
||||
provider: 'openai',
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 50,
|
||||
total: 150,
|
||||
cost: { total: 0.0012 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
timestamp: '2026-02-28T10:05:00.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
modelRef: 'claude-sonnet',
|
||||
provider: 'anthropic',
|
||||
usage: {
|
||||
promptTokens: 200,
|
||||
completionTokens: 80,
|
||||
cacheRead: 25,
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
timestamp: '2026-02-28T10:06:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
},
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||
{
|
||||
timestamp: '2026-02-28T10:05:00.000Z',
|
||||
sessionId: 'abc',
|
||||
agentId: 'default',
|
||||
model: 'claude-sonnet',
|
||||
provider: 'anthropic',
|
||||
inputTokens: 200,
|
||||
outputTokens: 80,
|
||||
cacheReadTokens: 25,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 305,
|
||||
costUsd: undefined,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-02-28T10:00:00.000Z',
|
||||
sessionId: 'abc',
|
||||
agentId: 'default',
|
||||
model: 'gpt-5',
|
||||
provider: 'openai',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 150,
|
||||
costUsd: 0.0012,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips lines without assistant usage', () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({ type: 'message', timestamp: '2026-02-28T10:00:00.000Z', message: { role: 'assistant' } }),
|
||||
JSON.stringify({ type: 'message', timestamp: '2026-02-28T10:01:00.000Z', message: { role: 'user', usage: { total: 123 } } }),
|
||||
].join('\n');
|
||||
|
||||
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user