Add dashboard token usage history (#240)

This commit is contained in:
Lingxuan Zuo
2026-03-02 13:20:33 +08:00
committed by GitHub
Unverified
parent 0bc4b7cbc2
commit 62108bdc23
10 changed files with 718 additions and 6 deletions

View File

@@ -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)
*/

View File

@@ -51,6 +51,7 @@ const electronAPI = {
'settings:set',
'settings:getAll',
'settings:reset',
'usage:recentTokenHistory',
// Update
'update:status',
'update:version',

View 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;
}

View 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);
}