Fix token usage history gaps and add dev detail popup (#386)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
d3960a3d0f
commit
80e89ddc5c
@@ -2513,8 +2513,7 @@ function registerFileHandlers(): void {
|
|||||||
* Performs a soft-delete of a session's JSONL transcript on disk.
|
* Performs a soft-delete of a session's JSONL transcript on disk.
|
||||||
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
|
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
|
||||||
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
|
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
|
||||||
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list and token-usage
|
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list.
|
||||||
* (both already filter out filenames containing ".deleted.").
|
|
||||||
*/
|
*/
|
||||||
function registerSessionHandlers(): void {
|
function registerSessionHandlers(): void {
|
||||||
ipcMain.handle('session:delete', async (_, sessionKey: string) => {
|
ipcMain.handle('session:delete', async (_, sessionKey: string) => {
|
||||||
|
|||||||
@@ -130,12 +130,37 @@ async function discoverAgentIds(): Promise<string[]> {
|
|||||||
// ── OpenClaw Config Helpers ──────────────────────────────────────
|
// ── OpenClaw Config Helpers ──────────────────────────────────────
|
||||||
|
|
||||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||||
|
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
|
||||||
|
|
||||||
async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
||||||
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
|
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAgentsDefaultsCompactionMode(config: Record<string, unknown>): void {
|
||||||
|
const agents = (config.agents && typeof config.agents === 'object'
|
||||||
|
? config.agents as Record<string, unknown>
|
||||||
|
: null);
|
||||||
|
if (!agents) return;
|
||||||
|
|
||||||
|
const defaults = (agents.defaults && typeof agents.defaults === 'object'
|
||||||
|
? agents.defaults as Record<string, unknown>
|
||||||
|
: null);
|
||||||
|
if (!defaults) return;
|
||||||
|
|
||||||
|
const compaction = (defaults.compaction && typeof defaults.compaction === 'object'
|
||||||
|
? defaults.compaction as Record<string, unknown>
|
||||||
|
: null);
|
||||||
|
if (!compaction) return;
|
||||||
|
|
||||||
|
const mode = compaction.mode;
|
||||||
|
if (typeof mode === 'string' && mode.length > 0 && !VALID_COMPACTION_MODES.has(mode)) {
|
||||||
|
compaction.mode = 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||||
|
normalizeAgentsDefaultsCompactionMode(config);
|
||||||
|
|
||||||
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
|
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
|
||||||
const commands = (
|
const commands = (
|
||||||
config.commands && typeof config.commands === 'object'
|
config.commands && typeof config.commands === 'object'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface TokenUsageHistoryEntry {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
content?: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
cacheReadTokens: number;
|
cacheReadTokens: number;
|
||||||
@@ -12,6 +13,14 @@ export interface TokenUsageHistoryEntry {
|
|||||||
costUsd?: number;
|
costUsd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
|
||||||
|
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
|
||||||
|
return fileName
|
||||||
|
.replace(/\.jsonl\.reset\..+$/, '')
|
||||||
|
.replace(/\.deleted\.jsonl$/, '')
|
||||||
|
.replace(/\.jsonl$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
interface TranscriptUsageShape {
|
interface TranscriptUsageShape {
|
||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
@@ -35,9 +44,59 @@ interface TranscriptLineShape {
|
|||||||
modelRef?: string;
|
modelRef?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
usage?: TranscriptUsageShape;
|
usage?: TranscriptUsageShape;
|
||||||
|
details?: {
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
usage?: TranscriptUsageShape;
|
||||||
|
content?: unknown;
|
||||||
|
externalContent?: {
|
||||||
|
provider?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUsageContent(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const chunks = value
|
||||||
|
.map((item) => normalizeUsageContent(item))
|
||||||
|
.filter((item): item is string => Boolean(item));
|
||||||
|
if (chunks.length === 0) return undefined;
|
||||||
|
return chunks.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.text === 'string') {
|
||||||
|
const trimmed = record.text.trim();
|
||||||
|
if (trimmed.length > 0) return trimmed;
|
||||||
|
}
|
||||||
|
if (typeof record.content === 'string') {
|
||||||
|
const trimmed = record.content.trim();
|
||||||
|
if (trimmed.length > 0) return trimmed;
|
||||||
|
}
|
||||||
|
if (Array.isArray(record.content)) {
|
||||||
|
return normalizeUsageContent(record.content);
|
||||||
|
}
|
||||||
|
if (typeof record.thinking === 'string') {
|
||||||
|
const trimmed = record.thinking.trim();
|
||||||
|
if (trimmed.length > 0) return trimmed;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(record, null, 2);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseUsageEntriesFromJsonl(
|
export function parseUsageEntriesFromJsonl(
|
||||||
content: string,
|
content: string,
|
||||||
context: { sessionId: string; agentId: string },
|
context: { sessionId: string; agentId: string },
|
||||||
@@ -58,18 +117,66 @@ export function parseUsageEntriesFromJsonl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = parsed.message;
|
const message = parsed.message;
|
||||||
if (!message || message.role !== 'assistant' || !message.usage || !parsed.timestamp) {
|
if (!message || !parsed.timestamp) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = message.usage;
|
if (message.role === 'assistant' && message.usage) {
|
||||||
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
|
const usage = message.usage;
|
||||||
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
|
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
|
||||||
const cacheReadTokens = usage.cacheRead ?? 0;
|
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
|
||||||
const cacheWriteTokens = usage.cacheWrite ?? 0;
|
const cacheReadTokens = usage.cacheRead ?? 0;
|
||||||
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
const cacheWriteTokens = usage.cacheWrite ?? 0;
|
||||||
|
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
||||||
|
|
||||||
if (totalTokens <= 0 && !usage.cost?.total) {
|
if (totalTokens <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
|
||||||
|
entries.push({
|
||||||
|
timestamp: parsed.timestamp,
|
||||||
|
sessionId: context.sessionId,
|
||||||
|
agentId: context.agentId,
|
||||||
|
model: message.model ?? message.modelRef,
|
||||||
|
provider: message.provider,
|
||||||
|
...(contentText ? { content: contentText } : {}),
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens,
|
||||||
|
totalTokens,
|
||||||
|
costUsd: usage.cost?.total,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role !== 'toolResult') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = message.details;
|
||||||
|
if (!details) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = details.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;
|
||||||
|
|
||||||
|
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
|
||||||
|
const model = details.model ?? message.model ?? message.modelRef;
|
||||||
|
const contentText = normalizeUsageContent(details.content)
|
||||||
|
?? normalizeUsageContent((message as Record<string, unknown>).content);
|
||||||
|
|
||||||
|
if (!provider && !model) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTokens <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +184,15 @@ export function parseUsageEntriesFromJsonl(
|
|||||||
timestamp: parsed.timestamp,
|
timestamp: parsed.timestamp,
|
||||||
sessionId: context.sessionId,
|
sessionId: context.sessionId,
|
||||||
agentId: context.agentId,
|
agentId: context.agentId,
|
||||||
model: message.model ?? message.modelRef,
|
model,
|
||||||
provider: message.provider,
|
provider,
|
||||||
|
...(contentText ? { content: contentText } : {}),
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
cacheWriteTokens,
|
cacheWriteTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
costUsd: usage.cost?.total,
|
costUsd: usage?.cost?.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import { readdir, readFile, stat } from 'fs/promises';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getOpenClawConfigDir } from './paths';
|
import { getOpenClawConfigDir } from './paths';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import {
|
||||||
|
extractSessionIdFromTranscriptFileName,
|
||||||
|
parseUsageEntriesFromJsonl,
|
||||||
|
type TokenUsageHistoryEntry,
|
||||||
|
} from './token-usage-core';
|
||||||
import { listConfiguredAgentIds } from './agent-config';
|
import { listConfiguredAgentIds } from './agent-config';
|
||||||
import { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
|
|
||||||
|
|
||||||
export { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
|
export {
|
||||||
|
extractSessionIdFromTranscriptFileName,
|
||||||
|
parseUsageEntriesFromJsonl,
|
||||||
|
type TokenUsageHistoryEntry,
|
||||||
|
} from './token-usage-core';
|
||||||
|
|
||||||
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
|
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
|
||||||
const openclawDir = getOpenClawConfigDir();
|
const openclawDir = getOpenClawConfigDir();
|
||||||
@@ -21,13 +29,14 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
|
|||||||
const sessionEntries = await readdir(sessionsDir);
|
const sessionEntries = await readdir(sessionsDir);
|
||||||
|
|
||||||
for (const fileName of sessionEntries) {
|
for (const fileName of sessionEntries) {
|
||||||
if (!fileName.endsWith('.jsonl') || fileName.includes('.deleted.')) continue;
|
const sessionId = extractSessionIdFromTranscriptFileName(fileName);
|
||||||
|
if (!sessionId) continue;
|
||||||
const filePath = join(sessionsDir, fileName);
|
const filePath = join(sessionsDir, fileName);
|
||||||
try {
|
try {
|
||||||
const fileStat = await stat(filePath);
|
const fileStat = await stat(filePath);
|
||||||
files.push({
|
files.push({
|
||||||
filePath,
|
filePath,
|
||||||
sessionId: fileName.replace(/\.jsonl$/, ''),
|
sessionId,
|
||||||
agentId,
|
agentId,
|
||||||
mtimeMs: fileStat.mtimeMs,
|
mtimeMs: fileStat.mtimeMs,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"more": "+{{count}} more",
|
"more": "+{{count}} more",
|
||||||
"recentTokenHistory": {
|
"recentTokenHistory": {
|
||||||
"title": "Recent Token Usage",
|
"title": "Recent Token Usage",
|
||||||
"description": "The latest assistant responses with recorded token usage.",
|
"description": "Recent assistant responses and tool results with model/provider usage data.",
|
||||||
"loading": "Loading token usage history...",
|
"loading": "Loading token usage history...",
|
||||||
"empty": "No token usage history yet",
|
"empty": "No token usage history yet",
|
||||||
"groupByModel": "By model",
|
"groupByModel": "By model",
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
"output": "Output {{value}}",
|
"output": "Output {{value}}",
|
||||||
"cacheRead": "Cache read {{value}}",
|
"cacheRead": "Cache read {{value}}",
|
||||||
"cacheWrite": "Cache write {{value}}",
|
"cacheWrite": "Cache write {{value}}",
|
||||||
"cost": "Cost ${{amount}}"
|
"cost": "Cost ${{amount}}",
|
||||||
|
"viewContent": "View content",
|
||||||
|
"contentDialogTitle": "Usage detail content",
|
||||||
|
"close": "Close"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"more": "+{{count}} 件",
|
"more": "+{{count}} 件",
|
||||||
"recentTokenHistory": {
|
"recentTokenHistory": {
|
||||||
"title": "最近のトークン使用量",
|
"title": "最近のトークン使用量",
|
||||||
"description": "トークン使用量が記録された最新のアシスタント応答を表示します。",
|
"description": "モデル/プロバイダー使用情報を含む、最近のアシスタント応答とツール結果を表示します。",
|
||||||
"loading": "トークン使用履歴を読み込み中...",
|
"loading": "トークン使用履歴を読み込み中...",
|
||||||
"empty": "トークン使用履歴はまだありません",
|
"empty": "トークン使用履歴はまだありません",
|
||||||
"groupByModel": "モデル別",
|
"groupByModel": "モデル別",
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
"output": "出力 {{value}}",
|
"output": "出力 {{value}}",
|
||||||
"cacheRead": "キャッシュ読取 {{value}}",
|
"cacheRead": "キャッシュ読取 {{value}}",
|
||||||
"cacheWrite": "キャッシュ書込 {{value}}",
|
"cacheWrite": "キャッシュ書込 {{value}}",
|
||||||
"cost": "コスト ${{amount}}"
|
"cost": "コスト ${{amount}}",
|
||||||
|
"viewContent": "内容を見る",
|
||||||
|
"contentDialogTitle": "使用量詳細の内容",
|
||||||
|
"close": "閉じる"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"more": "+{{count}} 更多",
|
"more": "+{{count}} 更多",
|
||||||
"recentTokenHistory": {
|
"recentTokenHistory": {
|
||||||
"title": "最近 Token 消耗",
|
"title": "最近 Token 消耗",
|
||||||
"description": "展示最近带有 token 用量记录的助手回复。",
|
"description": "展示最近的助手回复与工具结果中的模型/提供商用量记录。",
|
||||||
"loading": "正在加载 token 消耗历史...",
|
"loading": "正在加载 token 消耗历史...",
|
||||||
"empty": "还没有 token 消耗历史",
|
"empty": "还没有 token 消耗历史",
|
||||||
"groupByModel": "按模型",
|
"groupByModel": "按模型",
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
"output": "输出 {{value}}",
|
"output": "输出 {{value}}",
|
||||||
"cacheRead": "缓存读取 {{value}}",
|
"cacheRead": "缓存读取 {{value}}",
|
||||||
"cacheWrite": "缓存写入 {{value}}",
|
"cacheWrite": "缓存写入 {{value}}",
|
||||||
"cost": "费用 ${{amount}}"
|
"cost": "费用 ${{amount}}",
|
||||||
|
"viewContent": "查看内容",
|
||||||
|
"contentDialogTitle": "用量明细内容",
|
||||||
|
"close": "关闭"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { trackUiEvent } from '@/lib/telemetry';
|
import { trackUiEvent } from '@/lib/telemetry';
|
||||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||||
@@ -17,6 +19,7 @@ type UsageHistoryEntry = {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
content?: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
cacheReadTokens: number;
|
cacheReadTokens: number;
|
||||||
@@ -31,12 +34,14 @@ type UsageGroupBy = 'model' | 'day';
|
|||||||
export function Models() {
|
export function Models() {
|
||||||
const { t } = useTranslation(['dashboard', 'settings']);
|
const { t } = useTranslation(['dashboard', 'settings']);
|
||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
|
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||||
|
|
||||||
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
|
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
|
||||||
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
|
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
|
||||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
||||||
const [usagePage, setUsagePage] = useState(1);
|
const [usagePage, setUsagePage] = useState(1);
|
||||||
|
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackUiEvent('models.page_viewed');
|
trackUiEvent('models.page_viewed');
|
||||||
@@ -216,6 +221,16 @@ export function Models() {
|
|||||||
{typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && (
|
{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>
|
<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>
|
||||||
))}
|
))}
|
||||||
@@ -255,6 +270,15 @@ export function Models() {
|
|||||||
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -410,7 +434,11 @@ function UsageBarChart({
|
|||||||
<div className="h-3.5 overflow-hidden rounded-full bg-black/5 dark:bg-white/5">
|
<div className="h-3.5 overflow-hidden rounded-full bg-black/5 dark:bg-white/5">
|
||||||
<div
|
<div
|
||||||
className="flex h-full overflow-hidden rounded-full"
|
className="flex h-full overflow-hidden rounded-full"
|
||||||
style={{ width: `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%` }}
|
style={{
|
||||||
|
width: group.totalTokens > 0
|
||||||
|
? `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%`
|
||||||
|
: '0%',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{group.inputTokens > 0 && (
|
{group.inputTokens > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -439,3 +467,51 @@ function UsageBarChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default Models;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1093,7 +1093,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
deleteSession: async (key: string) => {
|
deleteSession: async (key: string) => {
|
||||||
// Soft-delete the session's JSONL transcript on disk.
|
// Soft-delete the session's JSONL transcript on disk.
|
||||||
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||||
// sessions.list and token-usage queries both skip it automatically.
|
// sessions.list skips it automatically.
|
||||||
try {
|
try {
|
||||||
const result = await hostApiFetch<{
|
const result = await hostApiFetch<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function createSessionActions(
|
|||||||
deleteSession: async (key: string) => {
|
deleteSession: async (key: string) => {
|
||||||
// Soft-delete the session's JSONL transcript on disk.
|
// Soft-delete the session's JSONL transcript on disk.
|
||||||
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||||
// sessions.list and token-usage queries both skip it automatically.
|
// sessions.list skips it automatically.
|
||||||
try {
|
try {
|
||||||
const result = await invokeIpc('session:delete', key) as {
|
const result = await invokeIpc('session:delete', key) as {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
21
tests/unit/token-usage-files.test.ts
Normal file
21
tests/unit/token-usage-files.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extractSessionIdFromTranscriptFileName } from '@electron/utils/token-usage-core';
|
||||||
|
|
||||||
|
describe('extractSessionIdFromTranscriptFileName', () => {
|
||||||
|
it('parses normal jsonl transcript names', () => {
|
||||||
|
expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl')).toBe('abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses deleted transcript names', () => {
|
||||||
|
expect(extractSessionIdFromTranscriptFileName('abc-123.deleted.jsonl')).toBe('abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses reset transcript names', () => {
|
||||||
|
expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for non-transcript files', () => {
|
||||||
|
expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined();
|
||||||
|
expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,136 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips tool result entries without positive token usage', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T02:17:04.057Z',
|
||||||
|
message: {
|
||||||
|
role: 'toolResult',
|
||||||
|
toolName: 'web_search',
|
||||||
|
details: {
|
||||||
|
provider: 'kimi',
|
||||||
|
model: 'moonshot-v1-128k',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses tool result usage when provided', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T02:17:04.057Z',
|
||||||
|
message: {
|
||||||
|
role: 'toolResult',
|
||||||
|
details: {
|
||||||
|
provider: 'kimi',
|
||||||
|
model: 'moonshot-v1-128k',
|
||||||
|
usage: {
|
||||||
|
promptTokens: 120,
|
||||||
|
completionTokens: 30,
|
||||||
|
cacheRead: 10,
|
||||||
|
totalTokens: 160,
|
||||||
|
cost: { total: 0.0009 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T02:17:04.057Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
model: 'moonshot-v1-128k',
|
||||||
|
provider: 'kimi',
|
||||||
|
inputTokens: 120,
|
||||||
|
outputTokens: 30,
|
||||||
|
cacheReadTokens: 10,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 160,
|
||||||
|
costUsd: 0.0009,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts assistant response text into content', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T02:20:04.057Z',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
content: [{ type: 'text', text: '这是一条测试回复内容。' }],
|
||||||
|
usage: {
|
||||||
|
totalTokens: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T02:20:04.057Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
content: '这是一条测试回复内容。',
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 100,
|
||||||
|
costUsd: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts tool result details content into content', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T02:21:04.057Z',
|
||||||
|
message: {
|
||||||
|
role: 'toolResult',
|
||||||
|
details: {
|
||||||
|
provider: 'kimi',
|
||||||
|
model: 'moonshot-v1-128k',
|
||||||
|
content: '外部搜索原文内容',
|
||||||
|
usage: {
|
||||||
|
totalTokens: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T02:21:04.057Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
model: 'moonshot-v1-128k',
|
||||||
|
provider: 'kimi',
|
||||||
|
content: '外部搜索原文内容',
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 50,
|
||||||
|
costUsd: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns all matching entries when no limit is provided', () => {
|
it('returns all matching entries when no limit is provided', () => {
|
||||||
const jsonl = [
|
const jsonl = [
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user