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 (