From e6cfc35beba2dcd68a6736e3f787931d4f236740 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 26 Aug 2026 15:27:58 +0800 Subject: [PATCH] fix(desktop): align usage statistics with Daily Review Read usage statistics from the selected Runtime Host projection, preserve host-scoped session identity, and surface aborted and unknown activity explicitly. Recheck durable recap results before classifying exact retries as unknown, and unwrap reconnectable Host usage results in preload before exposing them to Settings.\n\nAdd regression coverage for the Host projection, recap race, Result unwrapping, and preload contract.\n\nGenerated-by: OpenAI Codex --- .../runtime-host-read-result.test.ts | 45 ++++ .../runtime-host-usage-stats.test.ts | 175 +++++++++++++++ .../__tests__/usage-preload-contract.test.ts | 35 +++ apps/desktop/src/main/runtime-host-boot.ts | 1 + .../src/main/runtime-host-usage-ipc-main.ts | 199 +++++++++++++++++- apps/desktop/src/preload/bridge-contract.d.ts | 2 +- apps/desktop/src/preload/preload.ts | 8 +- .../renderer/locales/settings-usage-copy.ts | 36 ++-- .../src/renderer/settings/settings-nav.ts | 2 +- .../renderer/settings/settings-surface.tsx | 44 ++-- .../renderer/settings/usage-settings-page.tsx | 10 +- .../src/shared/runtime-host-read-result.ts | 30 +++ packages/core/src/settings.ts | 5 +- .../src/server/session-effect-coordinator.ts | 33 ++- 14 files changed, 576 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-read-result.test.ts create mode 100644 apps/desktop/src/main/__tests__/runtime-host-usage-stats.test.ts create mode 100644 apps/desktop/src/main/__tests__/usage-preload-contract.test.ts create mode 100644 apps/desktop/src/shared/runtime-host-read-result.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-read-result.test.ts b/apps/desktop/src/main/__tests__/runtime-host-read-result.test.ts new file mode 100644 index 0000000000..cba2f499af --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-read-result.test.ts @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { unwrapRuntimeHostReadResult } from '../../shared/runtime-host-read-result.js'; + +test('unwrapRuntimeHostReadResult returns successful data', () => { + const data = { logs: [], summary: { totalRequests: 0 } }; + assert.equal(unwrapRuntimeHostReadResult({ ok: true, data }), data); +}); + +test('unwrapRuntimeHostReadResult turns projected failures into errors', () => { + const details = { cause: 'host unavailable' }; + assert.throws( + () => + unwrapRuntimeHostReadResult({ + ok: false, + error: { code: 'USAGE_STATS_FAILED', message: 'Usage stats unavailable', details }, + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, 'Usage stats unavailable'); + assert.equal((error as Error & { code?: string }).code, 'USAGE_STATS_FAILED'); + assert.deepEqual((error as Error & { details?: unknown }).details, details); + return true; + }, + ); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-stats.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-stats.test.ts new file mode 100644 index 0000000000..f9b41f8ed1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-stats.test.ts @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import type { OperationInput, OperationOutput } from '@maka/runtime-host/protocol'; +import { loadDesktopUsageStats } from '../runtime-host-usage-ipc-main.js'; + +test('Desktop usage stats use the Host projection and load every activity page', async () => { + const calls: Array> = []; + const modelRows: Array> = Array.from( + { length: 100 }, + (_, index) => modelLog(`model-${index}`, index), + ); + modelRows.push({ + ...modelLog('aborted-model', 200), + status: 'aborted', + sessionId: undefined, + costUsd: undefined, + }); + const toolRows = [{ + source: 'tool' as const, + id: 'tool-1', + ts: 150, + toolName: 'Bash', + durationMs: 42, + status: 'aborted' as const, + bytesIn: 0, + bytesOut: 0, + startedAt: 108, + }]; + const client = { + async queryUsage(input: OperationInput<'usage.query'>): Promise> { + calls.push(input); + if (input.kind === 'summary') { + return { + kind: 'summary', + summary: { + range: { from: 0, to: 300 }, + totalRequests: 101, + totalCostUsd: 1.25, + totalTokens: { + input: 1010, + output: 202, + cacheMiss: 303, + cacheRead: 404, + cacheWrite: 505, + reasoning: 606, + total: 1212, + }, + cacheHitRequests: 1, + cacheCreateRequests: 1, + errorRequests: 0, + }, + provenance: {} as never, + } as unknown as OperationOutput<'usage.query'>; + } + if (input.kind === 'buckets') { + const key = input.groupBy === 'provider' + ? 'provider-a' + : input.groupBy === 'model' + ? 'model-a' + : 'Bash'; + return { + kind: 'buckets', + buckets: [{ + key, + label: key, + requests: input.groupBy === 'tool' ? 1 : 101, + inputTokens: 1010, + outputTokens: 202, + cacheMissTokens: 303, + cacheReadTokens: 404, + cacheWriteTokens: 505, + reasoningTokens: 606, + totalTokens: 1212, + costUsd: 1.25, + avgLatencyMs: 10, + errorRate: input.groupBy === 'tool' ? 1 : 0, + }], + offset: input.offset ?? 0, + total: 1, + nextOffset: null, + provenance: {} as never, + } as unknown as OperationOutput<'usage.query'>; + } + if (input.source === 'llm') { + const offset = input.offset ?? 0; + const rows = modelRows.slice(offset, offset + (input.limit ?? 100)); + return { + kind: 'logs', + source: 'llm', + rows, + offset, + total: modelRows.length, + nextOffset: offset + rows.length < modelRows.length ? offset + rows.length : null, + provenance: {} as never, + } as unknown as OperationOutput<'usage.query'>; + } + return { + kind: 'logs', + source: 'tool', + rows: toolRows, + offset: input.offset ?? 0, + total: toolRows.length, + nextOffset: null, + } as OperationOutput<'usage.query'>; + }, + }; + + const stats = await loadDesktopUsageStats(client, 'all', 'host-a'); + + assert.equal(stats.summary.totalRequests, 101); + assert.equal(stats.summary.totalTokens, 1212); + assert.equal(stats.logs.length, 102); + assert.equal(stats.logs[0]?.status, 'aborted'); + assert.equal(stats.logs[0]?.sessionId, 'unknown'); + assert.equal(stats.logs[0]?.costUsd, undefined); + assert.equal(stats.logs[2]?.sessionId, JSON.stringify(['host-a', 'session-a'])); + const concreteRanges = calls + .filter((input) => input.kind === 'summary' || input.kind === 'buckets' || input.kind === 'logs') + .map((input) => JSON.stringify(input.query.range)); + assert.equal(new Set(concreteRanges).size, 1, 'all Host reads must use one concrete time window'); + assert.deepEqual(stats.byTool, [{ + tool: 'Bash', + calls: 1, + success: 0, + errors: 0, + aborted: 1, + avgDurationMs: 42, + }]); + assert.equal( + calls.filter((input) => input.kind === 'logs' && input.source === 'llm').length, + 2, + 'the adapter must fetch the second model-log page', + ); +}); + +function modelLog(id: string, ts: number) { + return { + source: 'llm' as const, + id, + ts, + providerId: 'provider-a', + modelId: 'model-a', + inputTokens: 10, + outputTokens: 2, + cacheMissTokens: 3, + cacheReadTokens: 4, + cacheWriteTokens: 5, + reasoningTokens: 6, + totalTokens: 12, + costUsd: 0.01, + latencyMs: 10, + status: 'success' as const, + sessionId: 'session-a', + turnId: `turn-${id}`, + }; +} diff --git a/apps/desktop/src/main/__tests__/usage-preload-contract.test.ts b/apps/desktop/src/main/__tests__/usage-preload-contract.test.ts new file mode 100644 index 0000000000..9db5163652 --- /dev/null +++ b/apps/desktop/src/main/__tests__/usage-preload-contract.test.ts @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const preloadSource = readFileSync( + fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url)), + 'utf8', +); + +test('Host usage stats unwrap the reconnectable read Result before reaching the renderer', () => { + assert.match( + preloadSource, + /unwrapRuntimeHostReadResult\(\s*await invokeSelectedRuntimeHost>\(host, 'usage:stats', range\),?\s*\)/u, + ); +}); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index bee614b763..05dc7b2fc7 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1183,6 +1183,7 @@ function registerHostClientIpc( registerRuntimeHostUsageIpc({ ipcMain: scopedIpc, client, + hostId: scope.hostId, sendToRenderer, }); registerRuntimeHostWorkspaceIpc({ diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index a950f033f2..a1287a351f 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -27,21 +27,35 @@ import type { UsageGroupBy, UsageQuery, } from "@maka/core/usage-stats/types"; +import type { UsageRange, UsageStats } from "@maka/core/settings"; +import { resolveUsageRange } from "@maka/core/model-call-usage-projection"; import { handleReconnectableRead, type ReconnectableReadIpcMain, tryReconnectableReadResult, } from "./ipc-reconnect-policy.js"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; +import type { OperationOutput } from "@maka/runtime-host/protocol"; +import { desktopSessionKey } from "../shared/runtime-host-identity.js"; interface RuntimeHostUsageIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; + readonly hostId: string; readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; } const PAGE_LIMIT = 100; +type LlmUsageLog = Extract< + OperationOutput<"usage.query">, + { kind: "logs"; source: "llm" } +>["rows"][number]; +type ToolUsageLog = Extract< + OperationOutput<"usage.query">, + { kind: "logs"; source: "tool" } +>["rows"][number]; + export function registerRuntimeHostUsageIpc( deps: RuntimeHostUsageIpcDeps, ): void { @@ -68,6 +82,15 @@ export function registerRuntimeHostUsageIpc( return { ...result.summary, provenance: result.provenance }; }, "USAGE_SUMMARY_FAILED"), ); + handleReconnectableRead( + deps.ipcMain, + "usage:stats", + (_event, range?: UsageRange) => + tryReconnectableReadResult( + () => loadDesktopUsageStats(deps.client, normalizeUsageRange(range), deps.hostId), + "USAGE_STATS_FAILED", + ), + ); handleReconnectableRead( deps.ipcMain, "usage:buckets", @@ -142,8 +165,182 @@ export function registerRuntimeHostUsageIpc( ); } +export async function loadDesktopUsageStats( + client: Pick, + range: UsageRange, + hostId?: string, +): Promise { + const query: UsageQuery = { range: resolveUsageRange(range, Date.now()) }; + const [summary, providers, models, tools, modelLogs, toolLogs] = await Promise.all([ + queryUsageSummary(client, query), + loadAllBuckets(client, { ...query, groupBy: "provider" }), + loadAllBuckets(client, { ...query, groupBy: "model" }), + loadAllBuckets(client, { ...query, groupBy: "tool" }), + loadAllLogs(client, "llm", query), + loadAllLogs(client, "tool", query), + ]); + const logs = [ + ...modelLogs.map((row) => toUsageModelLog(row, hostId)), + ...toolLogs.map((row) => toUsageToolLog(row, hostId)), + ].sort((left, right) => right.ts - left.ts); + return { + summary: { + totalRequests: summary.totalRequests, + totalCostUsd: summary.totalCostUsd, + totalTokens: summary.totalTokens.total, + inputTokens: summary.totalTokens.input, + outputTokens: summary.totalTokens.output, + cacheTokens: summary.totalTokens.cacheRead + summary.totalTokens.cacheWrite, + cacheMiss: summary.totalTokens.cacheMiss, + cacheRead: summary.totalTokens.cacheRead, + cacheCreation: summary.totalTokens.cacheWrite, + reasoning: summary.totalTokens.reasoning, + }, + logs, + byProvider: providers.map((bucket) => ({ + provider: bucket.key, + requests: bucket.requests, + tokens: bucket.totalTokens, + costUsd: bucket.costUsd, + })), + byModel: models.map((bucket) => ({ + model: bucket.key, + requests: bucket.requests, + tokens: bucket.totalTokens, + costUsd: bucket.costUsd, + })), + byTool: aggregateToolUsage(toolLogs), + pricing: [], + }; +} + +async function queryUsageSummary( + client: Pick, + query: UsageQuery, +) { + const result = await client.queryUsage({ kind: "summary", query: toLlmQuery(query) }); + if (result.kind !== "summary") throw invalidUsageProjection(); + return result.summary; +} + +async function loadAllLogs( + client: Pick, + source: "llm", + query: UsageQuery, +): Promise; +async function loadAllLogs( + client: Pick, + source: "tool", + query: UsageQuery, +): Promise; +async function loadAllLogs( + client: Pick, + source: "llm" | "tool", + query: UsageQuery, +): Promise { + const rows: Array = []; + let offset = 0; + while (true) { + const result = await client.queryUsage( + source === "llm" + ? { + kind: "logs", + source, + query: toLlmQuery(query), + offset, + limit: PAGE_LIMIT, + } + : { + kind: "logs", + source, + query: toToolQuery(query), + offset, + limit: PAGE_LIMIT, + }, + ); + if (result.kind !== "logs" || result.source !== source || result.offset !== offset) { + throw invalidUsageProjection(); + } + rows.push(...result.rows); + if (result.nextOffset === null) return rows; + if (result.nextOffset <= offset) throw invalidUsageProjection(); + offset = result.nextOffset; + } +} + +function normalizeUsageRange(range: UsageRange | undefined): UsageRange { + if (range === "24h" || range === "7d" || range === "30d" || range === "all") return range; + return "24h"; +} + +function toUsageModelLog(row: LlmUsageLog, hostId?: string): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "model", + sessionId: projectSessionId(hostId, row.sessionId), + turnId: row.turnId ?? "unknown", + provider: row.connectionSlug ?? row.providerId, + model: row.modelId, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + ...(row.cacheMissTokens ? { cacheMiss: row.cacheMissTokens } : {}), + ...(row.cacheReadTokens ? { cacheRead: row.cacheReadTokens } : {}), + ...(row.cacheWriteTokens ? { cacheCreation: row.cacheWriteTokens } : {}), + ...(row.reasoningTokens ? { reasoning: row.reasoningTokens } : {}), + ...(row.costUsd === undefined ? {} : { costUsd: row.costUsd }), + latencyMs: row.latencyMs, + status: row.status, + }; +} + +function toUsageToolLog(row: ToolUsageLog, hostId?: string): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "tool", + sessionId: projectSessionId(hostId, row.sessionId), + turnId: row.turnId ?? "unknown", + provider: row.providerId ?? "unknown", + model: row.modelId ?? "unknown", + toolName: row.toolName, + inputTokens: 0, + outputTokens: 0, + latencyMs: row.durationMs, + status: row.status, + }; +} + +function projectSessionId(hostId: string | undefined, sessionId: string | undefined): string { + if (!sessionId) return "unknown"; + return hostId ? desktopSessionKey({ hostId, sessionId }) : sessionId; +} + +function aggregateToolUsage(rows: readonly ToolUsageLog[]): UsageStats["byTool"] { + const grouped = new Map(); + for (const row of rows) { + const current = grouped.get(row.toolName) ?? { calls: 0, success: 0, errors: 0, aborted: 0, duration: 0 }; + current.calls += 1; + if (row.status === "success") current.success += 1; + else if (row.status === "error") current.errors += 1; + else current.aborted += 1; + current.duration += row.durationMs; + grouped.set(row.toolName, current); + } + return [...grouped.entries()] + .map(([tool, row]) => ({ + tool, + calls: row.calls, + success: row.success, + errors: row.errors, + ...(row.aborted > 0 ? { aborted: row.aborted } : {}), + avgDurationMs: row.calls === 0 ? 0 : Math.round(row.duration / row.calls), + })) + .sort((left, right) => right.calls - left.calls || left.tool.localeCompare(right.tool)); +} + async function loadAllBuckets( - client: DesktopRuntimeHostClient, + client: Pick, query: UsageQuery & { groupBy: UsageGroupBy }, ) { const buckets = []; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f2aa6d8817..76687716f9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1119,7 +1119,7 @@ export interface MakaBridge { subscribeExternalChanged(handler: () => void, host?: DesktopRuntimeHostRef): () => void; testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; testBotChannel(provider: BotProvider): Promise; - usageStats(range?: UsageRange): Promise; + usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise; bots: { listStatuses(): Promise>; restart(provider: BotProvider): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7fc166f717..3a6fa039eb 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -202,6 +202,7 @@ import { } from '@maka/core/settings/network-settings'; import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; +import { unwrapRuntimeHostReadResult } from '../shared/runtime-host-read-result.js'; import type { McpConfigAddResult, McpConfigImportResult, @@ -2731,7 +2732,12 @@ const makaBridge = { testBotChannel(provider: BotProvider): Promise { return ipcRenderer.invoke('settings:testBotChannel', provider); }, - usageStats(range?: UsageRange): Promise { + async usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise { + if (host) { + return unwrapRuntimeHostReadResult( + await invokeSelectedRuntimeHost>(host, 'usage:stats', range), + ); + } return ipcRenderer.invoke('settings:usageStats', range); }, bots: { diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts index 5d26a59edc..d3ec7f1c5f 100644 --- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts @@ -24,12 +24,12 @@ export type UsageSettingsCopy = { refreshingAria: string; refreshAria: string; summaryAria: string; totalRequests: string; totalCost: string; costHelp: string; totalTokens: string; tokenDetail(input: number, output: number): string; cacheTokens: string; cacheDetail(miss: number, read: number, creation: number): string; viewAria: string; tabs: readonly [string, string, string, string, string]; filtersAria: string; filterPlaceholder: string; filterAria: string; - statusAria: string; statuses: readonly [string, string, string]; details: string; detailsAria: string; recordCount(count: number): string; clearFilters: string; + statusAria: string; statuses: readonly [string, string, string, string]; details: string; detailsAria: string; recordCount(count: number): string; clearFilters: string; summaryOnly: string; showDetails: string; filteredEmpty: string; filteredEmptyHelp: string; requestEmpty: string; tables: { providersAria: string; modelsAria: string; toolsAria: string; pricingAria: string; requestsAria: string; providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; pricingHeaders: string[]; requestHeaders: string[]; - noPricing: string; modelKind: string; toolKind: string; openSession(label: string): string; success: string; error: string; + noPricing: string; modelKind: string; toolKind: string; openSession(label: string): string; unknownSession: string; success: string; error: string; aborted: string; providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string; toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string; }; @@ -38,18 +38,18 @@ export type UsageSettingsCopy = { const SETTINGS_USAGE_COPY = { zh: { saveFailed: '保存使用统计设置失败', toolbarAria: '使用统计范围与刷新', rangeAria: '使用统计时间范围', ranges: ['24h', '7天', '30天', '全部'], - refreshingAria: '正在刷新使用统计', refreshAria: '刷新使用统计', summaryAria: '使用统计汇总指标', totalRequests: '总请求', totalCost: '总费用', costHelp: '以模型供应商最终结算为准', + refreshingAria: '正在刷新使用统计', refreshAria: '刷新使用统计', summaryAria: '使用统计汇总指标', totalRequests: '模型请求', totalCost: '总费用', costHelp: '以模型供应商最终结算为准', totalTokens: '总 Token', tokenDetail: (input, output) => `输入 ${input} / 输出 ${output}`, cacheTokens: '缓存 Token', - cacheDetail: (miss, read, creation) => `新 ${miss} / 命中 ${read} / 创建 ${creation}`, viewAria: '使用统计视图', tabs: ['请求日志', '供应商统计', '模型统计', '工具统计', '定价配置'], - filtersAria: '请求记录筛选', filterPlaceholder: '按模型或工具筛选…', filterAria: '按模型或工具筛选请求记录', statusAria: '请求状态筛选', - statuses: ['全部状态', '成功', '错误'], details: '详情记录', detailsAria: '显示使用统计详情记录', recordCount: (count) => `共 ${count} 条记录`, clearFilters: '清除筛选', + cacheDetail: (miss, read, creation) => `新 ${miss} / 命中 ${read} / 创建 ${creation}`, viewAria: '使用统计视图', tabs: ['活动日志', '供应商统计', '模型统计', '工具统计', '定价配置'], + filtersAria: '活动筛选', filterPlaceholder: '按模型或工具筛选…', filterAria: '按模型或工具筛选活动记录', statusAria: '活动状态筛选', + statuses: ['全部状态', '成功', '错误', '中止'], details: '详情记录', detailsAria: '显示使用统计详情记录', recordCount: (count) => `共 ${count} 条记录`, clearFilters: '清除筛选', summaryOnly: '当前仅显示汇总指标。打开详情记录后,可以查看逐条模型请求和工具调用,按模型、工具或状态筛选,并用于排查费用与失败请求。', - showDetails: '显示明细', filteredEmpty: '没有符合筛选条件的请求记录', filteredEmptyHelp: '调整或清除筛选条件后可查看全部请求记录。', requestEmpty: '暂无请求记录', + showDetails: '显示明细', filteredEmpty: '没有符合筛选条件的活动记录', filteredEmptyHelp: '调整或清除筛选条件后可查看全部活动记录。', requestEmpty: '暂无活动记录', tables: { - providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计请求日志表', - providerHeaders: ['供应商', '请求', 'Token', '费用'], modelHeaders: ['模型', '请求', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'], + providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计活动日志表', + providerHeaders: ['供应商', '请求', 'Token', '费用'], modelHeaders: ['模型', '请求', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '中止', '平均耗时'], pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'], - noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', openSession: (label) => `打开 ${label}`, success: '成功', error: '错误', + noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', openSession: (label) => `打开 ${label}`, unknownSession: '未知会话', success: '成功', error: '错误', aborted: '中止', providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型请求后,这里会按供应商聚合请求数、Token 与费用。', modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型请求后,这里会按模型聚合请求数、Token 与费用。', toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。', @@ -58,18 +58,18 @@ const SETTINGS_USAGE_COPY = { }, en: { saveFailed: 'Failed to save usage settings', toolbarAria: 'Usage range and refresh', rangeAria: 'Usage time range', ranges: ['24h', '7 days', '30 days', 'All'], - refreshingAria: 'Refreshing usage', refreshAria: 'Refresh usage', summaryAria: 'Usage summary metrics', totalRequests: 'Total requests', totalCost: 'Total cost', costHelp: 'Final billing is determined by the model provider', + refreshingAria: 'Refreshing usage', refreshAria: 'Refresh usage', summaryAria: 'Usage summary metrics', totalRequests: 'Model requests', totalCost: 'Total cost', costHelp: 'Final billing is determined by the model provider', totalTokens: 'Total tokens', tokenDetail: (input, output) => `Input ${input} / output ${output}`, cacheTokens: 'Cache tokens', - cacheDetail: (miss, read, creation) => `New ${miss} / hit ${read} / created ${creation}`, viewAria: 'Usage view', tabs: ['Request log', 'Providers', 'Models', 'Tools', 'Pricing'], - filtersAria: 'Request filters', filterPlaceholder: 'Filter by model or tool…', filterAria: 'Filter requests by model or tool', statusAria: 'Filter by request status', - statuses: ['All statuses', 'Success', 'Error'], details: 'Detailed records', detailsAria: 'Show detailed usage records', recordCount: (count) => `${count} ${count === 1 ? 'record' : 'records'}`, clearFilters: 'Clear filters', + cacheDetail: (miss, read, creation) => `New ${miss} / hit ${read} / created ${creation}`, viewAria: 'Usage view', tabs: ['Activity log', 'Providers', 'Models', 'Tools', 'Pricing'], + filtersAria: 'Activity filters', filterPlaceholder: 'Filter by model or tool…', filterAria: 'Filter activity by model or tool', statusAria: 'Filter by activity status', + statuses: ['All statuses', 'Success', 'Error', 'Aborted'], details: 'Detailed records', detailsAria: 'Show detailed usage records', recordCount: (count) => `${count} ${count === 1 ? 'record' : 'records'}`, clearFilters: 'Clear filters', summaryOnly: 'Only summary metrics are shown. Enable detailed records to inspect individual model requests and tool calls, filter by model, tool, or status, and investigate costs or failures.', - showDetails: 'Show details', filteredEmpty: 'No requests match these filters', filteredEmptyHelp: 'Adjust or clear the filters to see all request records.', requestEmpty: 'No request records', + showDetails: 'Show details', filteredEmpty: 'No activity matches these filters', filteredEmptyHelp: 'Adjust or clear the filters to see all activity records.', requestEmpty: 'No activity records', tables: { - providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage request log', - providerHeaders: ['Provider', 'Requests', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Requests', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Average duration'], + providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage activity log', + providerHeaders: ['Provider', 'Requests', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Requests', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Aborted', 'Average duration'], pricingHeaders: ['Provider', 'Model', 'Input / 1M', 'Output / 1M'], requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'], - noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', openSession: (label) => `Open ${label}`, success: 'Success', error: 'Error', + noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', openSession: (label) => `Open ${label}`, unknownSession: 'Unknown session', success: 'Success', error: 'Error', aborted: 'Aborted', providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model request, provider request counts, tokens, and costs appear here.', modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model request, request counts, tokens, and costs appear here by model.', toolEmptyTitle: 'No tool calls', toolEmptyBody: 'After an agent calls a tool, calls, successes, errors, and average duration appear here by tool.', diff --git a/apps/desktop/src/renderer/settings/settings-nav.ts b/apps/desktop/src/renderer/settings/settings-nav.ts index 79dfee5a06..7c949a2a88 100644 --- a/apps/desktop/src/renderer/settings/settings-nav.ts +++ b/apps/desktop/src/renderer/settings/settings-nav.ts @@ -110,7 +110,7 @@ const SETTINGS_SECTION_SCOPES: Record< memory: 'runtime-host', 'bot-chat': 'client', search: 'runtime-host', - usage: 'client', + usage: 'runtime-host', 'archived-tasks': 'client', 'import-tasks': 'runtime-host', 'daily-review': 'runtime-host', diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index a9f3dd92f5..9514090b0e 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -302,6 +302,7 @@ export function SettingsSurface(props: { () => createSettingsRequestAuthority(initialRuntimeHostKey), ); const usageReloadTicketRef = useRef(0); + const selectedUsageHostKeyRef = useRef(undefined); const runtimeHostReloadTicketRef = useRef(0); const runtimeHostCatalogHydratedRef = useRef(false); const selectedProfileChangedByUserRef = useRef(false); @@ -335,6 +336,9 @@ export function SettingsSurface(props: { ), [runtimeHostLifecycleByProfile, runtimeHosts, selectedProfileId], ); + selectedUsageHostKeyRef.current = selectedRuntimeHost + ? `${selectedRuntimeHost.profileId}:${selectedRuntimeHost.hostId}` + : undefined; const connectionsBridge = useMemo( () => selectedRuntimeHost ? runtimeHostConnectionsBridge(selectedRuntimeHost) @@ -602,12 +606,25 @@ export function SettingsSurface(props: { } } - async function reloadUsage(range: UsageRange = settings.usage.range) { + async function reloadUsage( + range: UsageRange = settings.usage.range, + host = selectedRuntimeHost, + ) { + if (!host) { + usageReloadTicketRef.current += 1; + setUsageStats(null); + return; + } const ticket = usageReloadTicketRef.current + 1; usageReloadTicketRef.current = ticket; + const hostKey = `${host.profileId}:${host.hostId}`; try { - const next = await window.maka.settings.usageStats(range); - if (settingsModalMountedRef.current && ticket === usageReloadTicketRef.current) { + const next = await window.maka.settings.usageStats(range, host); + if ( + settingsModalMountedRef.current && + ticket === usageReloadTicketRef.current && + selectedUsageHostKeyRef.current === hostKey + ) { setUsageStats(next); } } catch (error) { @@ -747,18 +764,15 @@ export function SettingsSurface(props: { }, [connectionsBridge, selectedRuntimeHost]); useEffect(() => { - // Keyed on the EFFECTIVE range, not just the section: usage is - // client-owned (settings-ownership.ts), and the persisted range rides - // in with the async getClient() load — which lands after this effect - // first fires when a Settings window is restored directly onto - // 使用统计. The initial fetch then used the '24h' default while the - // chip showed the persisted range, and nothing refetched — every - // metric read 0 until a manual refresh or a tab round-trip. With the - // range in the deps, the truth's arrival (or any later range change, - // including the page's own persisted range clicks) is the trigger, and - // this effect is the single owner of range-driven fetches. - if (section === 'usage') void reloadUsage(settings.usage.range); - }, [section, settings.usage.range]); + // Usage belongs to the selected Runtime Host. Invalidate the previous + // request whenever the host changes so a slower response from the old + // Host cannot repopulate the new Host's page. + usageReloadTicketRef.current += 1; + setUsageStats(null); + if (section === 'usage' && selectedRuntimeHost) { + void reloadUsage(settings.usage.range, selectedRuntimeHost); + } + }, [section, selectedRuntimeHost, settings.usage.range]); // PR-SETTINGS-HEADER-COPY-MAP-0 (U1): the page header derives its title // and description from the section→copy map keyed by the active section, diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx index 89d8e8dd51..b3728dadde 100644 --- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx @@ -296,6 +296,7 @@ function UsageRequestsPanel(props: { { value: 'all', label: props.copy.statuses[0] }, { value: 'success', label: props.copy.statuses[1] }, { value: 'error', label: props.copy.statuses[2] }, + { value: 'aborted', label: props.copy.statuses[3] }, ]} width={320} onChange={(value) => props.onStatusChange(value as AppSettings['usage']['status'])} @@ -339,7 +340,9 @@ function UsageRequestsPanel(props: { usageRequestTarget(row), usageRequestSessionCell(row, props.copy, props.onOpenSession), row.inputTokens + row.outputTokens, - row.kind === 'model' ? `$${(row.costUsd ?? 0).toFixed(2)}` : '-', + row.kind === 'model' + ? row.costUsd === undefined ? '-' : `$${row.costUsd.toFixed(2)}` + : '-', row.latencyMs ? `${row.latencyMs}ms` : '-', usageRequestStatusLabel(row.status, props.copy), ])} @@ -405,8 +408,9 @@ function UsageToolsPanel(props: { stats: UsageStats | null; copy: UsageSettingsC { header: props.copy.tables.toolHeaders[2], numeric: true }, { header: props.copy.tables.toolHeaders[3], numeric: true }, { header: props.copy.tables.toolHeaders[4], numeric: true }, + { header: props.copy.tables.toolHeaders[5], numeric: true }, ]} - rows={(props.stats?.byTool ?? []).map((row) => [row.tool, row.calls, row.success, row.errors, `${row.avgDurationMs}ms`])} + rows={(props.stats?.byTool ?? []).map((row) => [row.tool, row.calls, row.success, row.errors, row.aborted ?? 0, `${row.avgDurationMs}ms`])} empty={{ Icon: Activity, title: props.copy.tables.toolEmptyTitle, body: props.copy.tables.toolEmptyBody }} /> ); @@ -443,6 +447,7 @@ function usageRequestTarget(row: UsageStats['logs'][number]) { function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSettingsCopy, onOpenSession?: (sessionId: string) => void) { const label = shortUsageSessionId(row.sessionId); + if (row.sessionId === 'unknown') return copy.tables.unknownSession; if (!onOpenSession) return label; return (