From 62108bdc23dc63659e2b012f1655221aae1b4051 Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Mon, 2 Mar 2026 13:20:33 +0800 Subject: [PATCH] Add dashboard token usage history (#240) --- AGENTS.md | 1 + electron/main/ipc-handlers.ts | 12 + electron/preload/index.ts | 1 + electron/utils/token-usage-core.ts | 89 +++++++ electron/utils/token-usage.ts | 69 ++++++ src/i18n/locales/en/dashboard.json | 30 ++- src/i18n/locales/ja/dashboard.json | 30 ++- src/i18n/locales/zh/dashboard.json | 30 ++- src/pages/Dashboard/index.tsx | 379 +++++++++++++++++++++++++++++ tests/unit/token-usage.test.ts | 83 +++++++ 10 files changed, 718 insertions(+), 6 deletions(-) create mode 100644 electron/utils/token-usage-core.ts create mode 100644 electron/utils/token-usage.ts create mode 100644 tests/unit/token-usage.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6f88b2581..84d67dcf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 0fe327a52..268bea77b 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -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) */ diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 98724a55b..7f9b7caa9 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -51,6 +51,7 @@ const electronAPI = { 'settings:set', 'settings:getAll', 'settings:reset', + 'usage:recentTokenHistory', // Update 'update:status', 'update:version', diff --git a/electron/utils/token-usage-core.ts b/electron/utils/token-usage-core.ts new file mode 100644 index 000000000..ce25aaba2 --- /dev/null +++ b/electron/utils/token-usage-core.ts @@ -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; +} diff --git a/electron/utils/token-usage.ts b/electron/utils/token-usage.ts new file mode 100644 index 000000000..eb61e34ff --- /dev/null +++ b/electron/utils/token-usage.ts @@ -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> { + 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 { + 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); +} diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index fc9dd5d14..81f4a4c03 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -24,5 +24,31 @@ "activeSkills": "Active Skills", "noSkills": "No skills enabled", "enableSome": "Enable some skills", - "more": "+{{count}} more" -} \ No newline at end of file + "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}}" + } +} diff --git a/src/i18n/locales/ja/dashboard.json b/src/i18n/locales/ja/dashboard.json index 7e6ccafb8..9918a77e9 100644 --- a/src/i18n/locales/ja/dashboard.json +++ b/src/i18n/locales/ja/dashboard.json @@ -24,5 +24,31 @@ "activeSkills": "アクティブなスキル", "noSkills": "有効なスキルがありません", "enableSome": "スキルを有効にする", - "more": "+{{count}} 件" -} \ No newline at end of file + "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}}" + } +} diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index 89e93ee86..00168ca65 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -24,5 +24,31 @@ "activeSkills": "已启用技能", "noSkills": "未启用技能", "enableSome": "启用一些技能", - "more": "+{{count}} 更多" -} \ No newline at end of file + "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}}" + } +} diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index e1a4d459d..c1141d568 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -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([]); + const [usageGroupBy, setUsageGroupBy] = useState('model'); + const [usageWindow, setUsageWindow] = useState('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() { + + + + {t('recentTokenHistory.title')} + {t('recentTokenHistory.description')} + + + {usageLoading ? ( +
{t('recentTokenHistory.loading')}
+ ) : visibleUsageHistory.length === 0 ? ( +
+ +

{t('recentTokenHistory.empty')}

+
+ ) : filteredUsageHistory.length === 0 ? ( +
+ +

{t('recentTokenHistory.emptyForWindow')}

+
+ ) : ( +
+
+
+
+ + +
+
+ + + +
+
+

+ {t('recentTokenHistory.showingLast', { count: filteredUsageHistory.length })} +

+
+ + + +
+ {pagedUsageHistory.map((entry) => ( +
+
+
+

+ {entry.model || t('recentTokenHistory.unknownModel')} +

+

+ {[entry.provider, entry.agentId, entry.sessionId].filter(Boolean).join(' • ')} +

+
+
+

{formatTokenCount(entry.totalTokens)}

+

+ {formatUsageTimestamp(entry.timestamp)} +

+
+
+
+ {t('recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })} + {t('recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })} + {entry.cacheReadTokens > 0 && ( + {t('recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })} + )} + {entry.cacheWriteTokens > 0 && ( + {t('recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })} + )} + {typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && ( + {t('recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })} + )} +
+
+ ))} +
+ +
+

+ {t('recentTokenHistory.page', { current: safeUsagePage, total: usageTotalPages })} +

+
+ + +
+
+
+ )} +
+
); } @@ -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(); + + 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 ( +
+ {emptyLabel} +
+ ); + } + + const maxTokens = Math.max(...groups.map((group) => group.totalTokens), 1); + + return ( +
+
+ + + {inputLabel} + + + + {outputLabel} + + + + {cacheLabel} + +
+ {groups.map((group) => ( +
+
+ {group.label} + + {totalLabel}: {formatTokenCount(group.totalTokens)} + +
+
+
+ {group.inputTokens > 0 && ( +
+ )} + {group.outputTokens > 0 && ( +
+ )} + {group.cacheTokens > 0 && ( +
+ )} +
+
+
+ ))} +
+ ); +} + export default Dashboard; diff --git a/tests/unit/token-usage.test.ts b/tests/unit/token-usage.test.ts new file mode 100644 index 000000000..0e4665654 --- /dev/null +++ b/tests/unit/token-usage.test.ts @@ -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([]); + }); +});