From 3f1343c1de27783f4d83f11f24f3907e1957383a Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Mon, 24 Aug 2026 15:10:47 +0800 Subject: [PATCH 1/6] fix(desktop): align usage activity with model calls Read usage summary and activity from the Runtime Host canonical usage authority so Usage Statistics and Daily Review share the same model-call count. Keep tool calls visible as separately labelled activity and page both sources completely. Generated-by: OpenAI Codex --- .../desktop-session-projection.test.ts | 52 +++ .../runtime-host-usage-ipc-main.test.ts | 339 ++++++++++++++++++ apps/desktop/src/main/e2e-fixture.ts | 22 +- .../src/main/e2e-fixture/scenarios-usage.ts | 94 ++++- apps/desktop/src/main/runtime-host-boot.ts | 11 - .../src/main/runtime-host-usage-ipc-main.ts | 235 +++++++++++- apps/desktop/src/preload/bridge-contract.d.ts | 2 +- apps/desktop/src/preload/preload.ts | 7 +- .../renderer/locales/settings-usage-copy.ts | 48 +-- .../src/renderer/settings/settings-nav.ts | 2 +- .../renderer/settings/settings-surface.tsx | 62 +++- .../renderer/settings/usage-settings-page.tsx | 17 +- .../src/shared/desktop-session-projection.ts | 4 +- .../settings/settings-pages.stories.tsx | 16 +- packages/core/src/settings.ts | 10 +- packages/ui/src/daily-review-copy.ts | 8 +- 16 files changed, 842 insertions(+), 87 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index a2d24ccae1..45ae681eba 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -21,10 +21,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { SessionEvent } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; +import type { UsageStats } from '@maka/core/settings'; import { projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, + projectDesktopUsageStats, } from '../../shared/desktop-session-projection.js'; test('keeps equal raw Session ids distinct across Runtime Hosts', () => { @@ -145,6 +147,56 @@ test('projects queued Session attachments into the Desktop host namespace', () = ); }); +test('projects only present Usage Session ids into the Desktop host namespace', () => { + const stats: UsageStats = { + summary: { + totalRequests: 2, + totalCostUsd: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + cacheMiss: 0, + cacheRead: 0, + cacheCreation: 0, + reasoning: 0, + }, + logs: [ + { + id: 'with-session', + ts: 1, + kind: 'model', + sessionId: 'session-1', + turnId: 'turn-1', + provider: 'provider', + model: 'model', + inputTokens: 0, + outputTokens: 0, + status: 'success', + }, + { + id: 'without-session', + ts: 2, + kind: 'model', + provider: 'provider', + model: 'model', + inputTokens: 0, + outputTokens: 0, + status: 'aborted', + }, + ], + byProvider: [], + byModel: [], + byTool: [], + pricing: [], + }; + + const projected = projectDesktopUsageStats({ hostId: 'remote-root' }, stats); + + assert.equal(projected.logs[0]?.sessionId, JSON.stringify(['remote-root', 'session-1'])); + assert.equal(projected.logs[1]?.sessionId, undefined); +}); + function summary(id: string): SessionSummary { return { id, diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts new file mode 100644 index 0000000000..22a96627a5 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts @@ -0,0 +1,339 @@ +/* + * 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 type { UsageStats } from "@maka/core/settings"; +import type { UsageQueryInput, UsageQueryResult } from "@maka/runtime-host/protocol"; +import type { IpcHandler } from "../ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; +import { registerRuntimeHostUsageIpc } from "../runtime-host-usage-ipc-main.js"; + +test("settings usage stats use the canonical model-call total and load every activity page", async () => { + const handlers = new Map(); + const calls: Array<{ source?: "llm" | "tool"; offset?: number }> = []; + const ranges: UsageQueryInput["query"]["range"][] = []; + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + ranges.push(input.query.range); + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 151, + totalCostUsd: 12.5, + totalTokens: { + input: 3_000_000, + output: 500_000, + cacheMiss: 100_000, + cacheRead: 400_000, + cacheWrite: 43_090, + reasoning: 90, + total: 4_043_090, + }, + cacheHitRequests: 10, + cacheCreateRequests: 5, + errorRequests: 2, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + calls.push({ source: input.source, offset: input.offset }); + if (input.source === "llm") { + const offset = input.offset ?? 0; + const count = offset === 0 ? 100 : 51; + return { + kind: "logs", + source: "llm", + rows: Array.from({ length: count }, (_, index) => llmRow(offset + index)), + offset, + total: 151, + nextOffset: offset === 0 ? 100 : null, + provenance: provenance(), + } satisfies UsageQueryResult; + } + const offset = input.offset ?? 0; + const count = offset === 0 ? 100 : 71; + return { + kind: "logs", + source: "tool", + rows: Array.from({ length: count }, (_, index) => toolRow(offset + index)), + offset, + total: 171, + nextOffset: offset === 0 ? 100 : null, + } satisfies UsageQueryResult; + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 1, + entries: [ + { + source: "custom", + resetEffect: "become_unpriced", + pricing: { + modelKey: "provider-a:model-a", + inputUsdPer1M: 1, + outputUsdPer1M: 2, + }, + }, + ], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + const stats = await handler({} as never, "24h") as UsageStats; + + assert.equal(stats.summary.totalRequests, 151); + assert.equal(stats.summary.totalTokens, 4_043_090); + assert.equal(stats.logs.length, 322); + assert.equal(stats.logs.filter((row) => row.kind === "model").length, 151); + assert.equal(stats.logs.filter((row) => row.kind === "tool").length, 171); + const expectedCalls: Array<{ source?: "llm" | "tool"; offset?: number }> = [ + { source: "llm", offset: 0 }, + { source: "llm", offset: 100 }, + { source: "tool", offset: 0 }, + { source: "tool", offset: 100 }, + ]; + assert.deepEqual(calls.sort(compareCall), expectedCalls.sort(compareCall)); + assert.ok(ranges.every((range) => typeof range === "object")); + assert.ok(ranges.every((range) => JSON.stringify(range) === JSON.stringify(ranges[0]))); + assert.equal(stats.logs.find((row) => row.id === "llm-150")?.status, "aborted"); + assert.equal(stats.logs.find((row) => row.id === "llm-150")?.sessionId, undefined); + assert.equal(stats.logs.find((row) => row.id === "llm-150")?.costUsd, undefined); + assert.equal(stats.logs.find((row) => row.id === "tool-170")?.status, "aborted"); + assert.deepEqual(stats.byProvider, [ + { provider: "provider-a", requests: 151, tokens: 604, costUsd: 150 }, + ]); + assert.deepEqual(stats.byTool, [ + { tool: "Read", calls: 171, success: 170, errors: 0, avgDurationMs: 25 }, + ]); + assert.deepEqual(stats.pricing, [ + { + provider: "provider-a", + model: "model-a", + inputPerMTokUsd: 1, + outputPerMTokUsd: 2, + }, + ]); +}); + +test("settings usage stats reject a non-advancing activity page", async () => { + const handlers = new Map(); + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 0, + totalCostUsd: 0, + totalTokens: { + input: 0, + output: 0, + cacheMiss: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 0, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + return input.source === "llm" + ? ({ + kind: "logs", + source: "llm", + rows: [], + offset: 0, + total: 1, + nextOffset: 0, + provenance: provenance(), + } satisfies UsageQueryResult) + : ({ + kind: "logs", + source: "tool", + rows: [], + offset: 0, + total: 0, + nextOffset: null, + } satisfies UsageQueryResult); + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 0, + entries: [], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + await assert.rejects(() => handler({} as never, "24h"), /invalid Usage projection/); +}); + +test("settings usage stats reject model logs that disagree with the canonical summary", async () => { + const handlers = new Map(); + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 2, + totalCostUsd: 0, + totalTokens: { + input: 0, + output: 0, + cacheMiss: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 0, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + return input.source === "llm" + ? ({ + kind: "logs", + source: "llm", + rows: [llmRow(0)], + offset: 0, + total: 1, + nextOffset: null, + provenance: provenance(), + } satisfies UsageQueryResult) + : ({ + kind: "logs", + source: "tool", + rows: [], + offset: 0, + total: 0, + nextOffset: null, + } satisfies UsageQueryResult); + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 0, + entries: [], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + await assert.rejects(() => handler({} as never, "all"), /invalid Usage projection/); +}); + +function llmRow(index: number) { + return { + source: "llm" as const, + id: `llm-${index}`, + ts: 1_000 + index, + providerId: "provider-a", + modelId: "model-a", + inputTokens: 3, + outputTokens: 1, + cacheMissTokens: 1, + cacheReadTokens: 2, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 7, + ...(index === 150 ? { costBasis: "unpriced" as const } : { costUsd: 1 }), + latencyMs: 10, + status: index === 150 ? ("aborted" as const) : ("success" as const), + ...(index === 150 ? {} : { sessionId: "session-a", turnId: `turn-${index}` }), + }; +} + +function toolRow(index: number) { + return { + source: "tool" as const, + id: `tool-${index}`, + ts: 2_000 + index, + toolName: "Read", + durationMs: 25, + status: index === 170 ? ("aborted" as const) : ("success" as const), + bytesIn: 0, + bytesOut: 0, + startedAt: 1_975 + index, + sessionId: "session-a", + turnId: `turn-${index}`, + }; +} + +function provenance() { + return { + coverage: { + attempts: 148, + pricedAttempts: 147, + unpricedAttempts: 1, + usageReportedAttempts: 148, + usagePartialAttempts: 0, + usageMissingAttempts: 0, + }, + legacyRecords: 3, + unreadableRecords: 0, + pendingRepairs: 0, + }; +} + +function compareCall( + left: { source?: "llm" | "tool"; offset?: number }, + right: { source?: "llm" | "tool"; offset?: number }, +): number { + return `${left.source}:${left.offset}`.localeCompare(`${right.source}:${right.offset}`); +} diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 25360bffd0..7efbd1eb69 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -22,7 +22,11 @@ import { join } from 'node:path'; import type { E2eFixtureScenario, E2eFixtureState } from '@maka/core/e2e-fixture'; import type { UiLocale } from '@maka/core/ui-locale'; import { createProjectCatalog } from '@maka/storage/project-catalog'; -import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { E2E_FIXTURE_NOW, LONG_SIDEBAR_PROJECT_ID, @@ -46,7 +50,7 @@ import { writeScheduledTasks, writeSettings, } from './e2e-fixture/scenarios-settings.js'; -import { usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; +import { usageStatsRecords, usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; const E2E_FIXTURE_SCENARIOS = new Set([ 'settings-models', @@ -211,7 +215,7 @@ export async function seedE2eFixture(input: { const scenario = input.fixture.scenario; await rm(input.workspaceRoot, { recursive: true, force: true }); await mkdir(input.workspaceRoot, { recursive: true }); - await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); + const storageRoot = await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); await writeSettings(input.workspaceRoot, scenario); await writeConnections(input.workspaceRoot, now, scenario); await writeSession(input.workspaceRoot, turnSession(now), turnMessages(now)); @@ -242,5 +246,17 @@ export async function seedE2eFixture(input: { for (const seed of usageStatsSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); } + const owner = await tryAcquireInteractiveRootOwner(storageRoot); + if (!owner) throw new Error('Unable to acquire the E2E fixture storage root'); + const usage = await openInteractiveUsageStoresForWrite(owner.lease); + try { + const records = usageStatsRecords(now); + for (const record of records.llm) await usage.telemetry.recordLlmCall(record); + for (const record of records.tools) await usage.telemetry.recordToolInvocation(record); + await usage.flush(); + } finally { + await usage.close(); + await owner.close(); + } } } diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts index 7ed957643e..0cf11b987d 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts @@ -18,20 +18,24 @@ */ import type { SessionHeader, StoredMessage } from '@maka/core/session'; +import type { TelemetryIndexWriter } from '@maka/storage/usage-stores'; import { header } from './seed-helpers.js'; -// Settings → 使用统计 fixture. `usageStats` aggregates `token_usage` + tool -// messages across ALL sessions in the workspace, so the settings-usage capture -// only shows real tables if the seed contains enough varied traffic. These -// sessions are gated to the `settings-usage` scenario so no other capture is -// disturbed; every value is a literal keyed off the fixed `now`, so the tables -// render deterministically. +type PersistedLlmCallRecord = Parameters[0]; +type PersistedToolInvocationRecord = Parameters< + TelemetryIndexWriter['recordToolInvocation'] +>[0]; + +// Settings → 使用统计 fixture. Session messages keep the task links realistic, +// while `usageStatsRecords` seeds the Runtime Host's canonical usage surface. +// These records are gated to `settings-usage` so no other capture is disturbed; +// every value is derived from the fixed fixture clock for deterministic tables. // // The shape below intentionally spreads across: // - 3 providers (zai-live / relay-fallback / needs-reauth) → 供应商统计 // - 5 models (glm / claude / gpt families) → 模型统计 // - 6 tools with 2 failures → 工具统计 (exercises the error column) -// - a dozen request-log rows mixing model + tool + success/error → 请求日志 +// - a dozen activity rows mixing model + tool + success/error → 活动记录 interface UsageTurnSpec { turnId: string; @@ -215,3 +219,79 @@ export function usageStatsSessions( }, ]; } + +export function usageStatsRecords(now: number): { + llm: PersistedLlmCallRecord[]; + tools: PersistedToolInvocationRecord[]; +} { + const sessions = usageStatsSessions(now); + const llm: PersistedLlmCallRecord[] = []; + const tools: PersistedToolInvocationRecord[] = []; + for (const { header: session, messages } of sessions) { + const modelByTurn = new Map( + messages + .filter((message) => message.type === 'assistant') + .map((message) => [message.turnId, message.modelId]), + ); + const toolResults = new Map( + messages + .filter((message) => message.type === 'tool_result') + .map((message) => [message.toolUseId, message]), + ); + for (const message of messages) { + if (message.type === 'token_usage') { + const inputTokens = message.input; + const outputTokens = message.output; + const cacheRead = message.cacheRead ?? 0; + const cacheMiss = message.cacheMissInput ?? Math.max(0, inputTokens - cacheRead); + const cacheWrite = message.cacheCreation ?? 0; + llm.push({ + id: message.id, + sessionId: session.id, + turnId: message.turnId, + callKind: 'main', + callId: message.id, + connectionSlug: session.llmConnectionSlug, + providerId: session.llmConnectionSlug, + modelId: modelByTurn.get(message.turnId) ?? session.model, + inputTokens, + outputTokens, + cacheHitInputTokens: cacheRead, + cacheMissInputTokens: cacheMiss, + cachedInputTokens: cacheRead, + cacheWriteInputTokens: cacheWrite, + reasoningTokens: message.reasoning ?? 0, + totalTokens: inputTokens + outputTokens, + costUsd: message.costUsd ?? 0, + latencyMs: 2_000, + status: 'success', + startedAt: message.ts - 2_000, + date: new Date(message.ts).toISOString().slice(0, 10), + ts: message.ts, + }); + } + if (message.type === 'tool_call') { + const result = toolResults.get(message.id); + const durationMs = result?.durationMs ?? 0; + const ts = result?.ts ?? message.ts; + tools.push({ + id: `tool:${message.id}`, + sessionId: session.id, + turnId: message.turnId, + toolCallId: message.id, + toolName: message.displayName ?? message.toolName, + providerId: session.llmConnectionSlug, + modelId: modelByTurn.get(message.turnId) ?? session.model, + durationMs, + status: result?.isError ? 'error' : 'success', + bytesIn: 0, + bytesOut: 0, + startedAt: message.ts, + date: new Date(ts).toISOString().slice(0, 10), + ts, + }); + } + } + } + return { llm, tools }; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index b9a3771e17..2ff8b49ffd 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -31,7 +31,6 @@ import { import { randomUUID } from "node:crypto"; import { basename, join } from "node:path"; import { type ConnectionEvent } from '@maka/core/connections'; -import type { UsageRange } from '@maka/core/settings'; import { type SessionChangedEvent, type SessionChangedReason } from '@maka/core/session'; import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; import { resolveSystemUiLocale } from '@maka/core/ui-locale'; @@ -200,9 +199,6 @@ import { import { resolveDesktopStorageRoot } from "./storage-root-startup.js"; import { startupStep } from "./startup-step.js"; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; -import { - projectDesktopUsageStats, -} from "../shared/desktop-session-projection.js"; import { parseDesktopSessionResourceKey, requireDesktopTargetScope, @@ -301,7 +297,6 @@ if (!startupLocalStorageRoot) { await new Promise(() => {}); throw new Error("Desktop storage root resolution did not complete"); } -const localRuntimeHostId = startupLocalStorageRoot.rootId; const settingsStore = createSettingsStore(workspaceRoot); const desktopLocale = createDesktopLocaleAuthority({ readSettings: () => settingsStore.get(), @@ -1369,12 +1364,6 @@ function registerPersistentClientIpc(): void { } : {}), }); - ipcMain.handle("settings:usageStats", async (_event, range?: UsageRange) => - projectDesktopUsageStats( - { hostId: localRuntimeHostId }, - await settingsStore.usageStats(range), - ), - ); ipcMain.handle("sessions:unobserve", async (_event, observerId: unknown) => { if (typeof observerId !== "string" || observerId.length === 0 || observerId.length > 256) { throw new Error("Invalid Session observer identity"); 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..69353f7617 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -17,16 +17,24 @@ * under the License. */ +import { resolveUsageRange } from "@maka/core/model-call-usage-projection"; import { tryResult } from "@maka/core/result"; +import type { UsageRange, UsageStats } from "@maka/core/settings"; import { normalizePricingConfig, normalizePricingModelKey, } from "@maka/core/usage-stats/pricing"; import type { PricingConfig, + TimeRange, UsageGroupBy, UsageQuery, } from "@maka/core/usage-stats/types"; +import { + USAGE_PAGE_MAX_ITEMS, + type LlmUsageLogProjection, + type ToolUsageLogProjection, +} from "@maka/runtime-host/protocol"; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -40,7 +48,7 @@ interface RuntimeHostUsageIpcDeps { readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; } -const PAGE_LIMIT = 100; +const MAX_ACTIVITY_RECORDS = 50_000; export function registerRuntimeHostUsageIpc( deps: RuntimeHostUsageIpcDeps, @@ -55,6 +63,12 @@ export function registerRuntimeHostUsageIpc( return result; }; + handleReconnectableRead( + deps.ipcMain, + "settings:usageStats", + (_event, range: UsageRange = "24h") => + loadUsageStats(deps.client, normalizeUsageRange(range)), + ); handleReconnectableRead( deps.ipcMain, "usage:summary", @@ -142,6 +156,215 @@ export function registerRuntimeHostUsageIpc( ); } +async function loadUsageStats( + client: DesktopRuntimeHostClient, + range: UsageRange, +): Promise { + const query = { range: resolveUsageRange(range, Date.now()) } satisfies UsageQuery; + const [summaryResult, llmLogs, toolLogs, pricing] = await Promise.all([ + client.queryUsage({ kind: "summary", query }), + loadAllLogs(client, "llm", query), + loadAllLogs(client, "tool", query), + client.loadPricingSnapshot(), + ]); + if (summaryResult.kind !== "summary") throw invalidUsageProjection(); + if ( + summaryResult.summary.totalRequests !== llmLogs.length || + llmLogs.length + toolLogs.length > MAX_ACTIVITY_RECORDS + ) { + throw invalidUsageProjection(); + } + + return { + summary: { + totalRequests: summaryResult.summary.totalRequests, + totalCostUsd: summaryResult.summary.totalCostUsd, + totalTokens: summaryResult.summary.totalTokens.total, + inputTokens: summaryResult.summary.totalTokens.input, + outputTokens: summaryResult.summary.totalTokens.output, + cacheTokens: + summaryResult.summary.totalTokens.cacheRead + + summaryResult.summary.totalTokens.cacheWrite, + cacheMiss: summaryResult.summary.totalTokens.cacheMiss, + cacheRead: summaryResult.summary.totalTokens.cacheRead, + cacheCreation: summaryResult.summary.totalTokens.cacheWrite, + reasoning: summaryResult.summary.totalTokens.reasoning, + }, + logs: [...llmLogs.map(projectLlmLog), ...toolLogs.map(projectToolLog)].sort( + (left, right) => right.ts - left.ts, + ), + byProvider: aggregateModelLogs(llmLogs, "provider"), + byModel: aggregateModelLogs(llmLogs, "model"), + byTool: aggregateToolLogs(toolLogs), + pricing: pricing.entries + .filter((entry) => entry.source === "custom") + .map(({ pricing: entry }) => projectPricing(entry)) + .sort( + (left, right) => + left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model), + ), + }; +} + +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm", + query: UsageQuery & { range: TimeRange }, +): Promise; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "tool", + query: UsageQuery & { range: TimeRange }, +): Promise; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm" | "tool", + query: UsageQuery & { range: TimeRange }, +): Promise> { + const rows: Array = []; + let offset = 0; + let total: number | undefined; + while (true) { + const result = await client.queryUsage( + source === "llm" + ? { + kind: "logs", + source, + query: toLlmQuery(query), + offset, + limit: USAGE_PAGE_MAX_ITEMS, + } + : { + kind: "logs", + source, + query: toToolQuery(query), + offset, + limit: USAGE_PAGE_MAX_ITEMS, + }, + ); + if (result.kind !== "logs" || result.source !== source || result.offset !== offset) { + throw invalidUsageProjection(); + } + total ??= result.total; + if (result.total !== total) throw invalidUsageProjection(); + rows.push(...result.rows); + if (rows.length > MAX_ACTIVITY_RECORDS || rows.length > result.total) { + throw invalidUsageProjection(); + } + if (result.nextOffset === null) { + if (rows.length !== total) throw invalidUsageProjection(); + return rows; + } + if (result.nextOffset <= offset) throw invalidUsageProjection(); + offset = result.nextOffset; + } +} + +function projectLlmLog(row: LlmUsageLogProjection): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "model", + ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.turnId === undefined ? {} : { turnId: row.turnId }), + provider: row.providerId, + model: row.modelId, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheMiss: row.cacheMissTokens, + cacheRead: row.cacheReadTokens, + cacheCreation: row.cacheWriteTokens, + reasoning: row.reasoningTokens, + ...(row.costUsd === undefined ? {} : { costUsd: row.costUsd }), + latencyMs: row.latencyMs, + status: row.status, + }; +} + +function projectToolLog(row: ToolUsageLogProjection): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "tool", + ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.turnId === undefined ? {} : { turnId: row.turnId }), + provider: row.providerId ?? "", + model: row.modelId ?? "", + toolName: row.toolName, + inputTokens: 0, + outputTokens: 0, + latencyMs: row.durationMs, + status: row.status, + }; +} + +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "provider", +): UsageStats["byProvider"]; +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "model", +): UsageStats["byModel"]; +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "provider" | "model", +): UsageStats["byProvider"] | UsageStats["byModel"] { + const rows = new Map(); + for (const log of logs) { + const id = key === "provider" ? log.providerId : log.modelId; + const current = rows.get(id) ?? { requests: 0, tokens: 0, costUsd: 0 }; + current.requests += 1; + current.tokens += log.inputTokens + log.outputTokens; + current.costUsd += log.costUsd ?? 0; + rows.set(id, current); + } + return [...rows.entries()] + .map(([id, row]) => ({ [key]: id, ...row })) + .sort((left, right) => right.requests - left.requests) as + | UsageStats["byProvider"] + | UsageStats["byModel"]; +} + +function aggregateToolLogs(logs: readonly ToolUsageLogProjection[]): UsageStats["byTool"] { + const rows = new Map< + string, + { calls: number; success: number; errors: number; totalDurationMs: number } + >(); + for (const log of logs) { + const current = rows.get(log.toolName) ?? { + calls: 0, + success: 0, + errors: 0, + totalDurationMs: 0, + }; + current.calls += 1; + if (log.status === "success") current.success += 1; + if (log.status === "error") current.errors += 1; + current.totalDurationMs += log.durationMs; + rows.set(log.toolName, current); + } + return [...rows.entries()] + .map(([tool, row]) => ({ + tool, + calls: row.calls, + success: row.success, + errors: row.errors, + avgDurationMs: row.calls === 0 ? 0 : Math.round(row.totalDurationMs / row.calls), + })) + .sort((left, right) => right.calls - left.calls || left.tool.localeCompare(right.tool)); +} + +function projectPricing(pricing: PricingConfig): UsageStats["pricing"][number] { + const separator = pricing.modelKey.indexOf(":"); + return { + provider: separator < 0 ? "" : pricing.modelKey.slice(0, separator), + model: separator < 0 ? pricing.modelKey : pricing.modelKey.slice(separator + 1), + inputPerMTokUsd: pricing.inputUsdPer1M, + outputPerMTokUsd: pricing.outputUsdPer1M, + }; +} + async function loadAllBuckets( client: DesktopRuntimeHostClient, query: UsageQuery & { groupBy: UsageGroupBy }, @@ -156,14 +379,14 @@ async function loadAllBuckets( query: toToolQuery(query), groupBy: "tool", offset, - limit: PAGE_LIMIT, + limit: USAGE_PAGE_MAX_ITEMS, } : { kind: "buckets", query: toLlmQuery(query), groupBy: query.groupBy, offset, - limit: PAGE_LIMIT, + limit: USAGE_PAGE_MAX_ITEMS, }, ); if (result.kind !== "buckets" || result.offset !== offset) @@ -180,6 +403,12 @@ function toLlmQuery(query: UsageQuery) { return llmQuery; } +function normalizeUsageRange(range: unknown): UsageRange { + return range === "24h" || range === "7d" || range === "30d" || range === "all" + ? range + : "24h"; +} + function toToolQuery(query: UsageQuery) { return { range: query.range, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1eb3a0e72a..8703523bf1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1160,7 +1160,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 45a6fc19ca..b321fc035d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -236,6 +236,7 @@ import { projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, + projectDesktopUsageStats, type DesktopSessionSummary, } from '../shared/desktop-session-projection.js'; @@ -2713,8 +2714,10 @@ const makaBridge = { testBotChannel(provider: BotProvider): Promise { return ipcRenderer.invoke('settings:testBotChannel', provider); }, - usageStats(range?: UsageRange): Promise { - return ipcRenderer.invoke('settings:usageStats', range); + async usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise { + const scope = await selectedRuntimeHostScope(host); + const stats = await ipcRenderer.invoke('settings:usageStats', scope, range) as UsageStats; + return projectDesktopUsageStats(scope, stats); }, bots: { listStatuses(): Promise> { diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts index 303a86963a..20ed450324 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; untitledSession: string; success: string; error: string; + noPricing: string; modelKind: string; toolKind: string; unknown: string; untitledSession: string; openSession(label: string): string; success: string; error: string; aborted: string; providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string; toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string; }; @@ -38,40 +38,40 @@ 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: '清除筛选', - summaryOnly: '当前仅显示汇总指标。打开详情记录后,可以查看逐条模型请求和工具调用,按模型、工具或状态筛选,并用于排查费用与失败请求。', - showDetails: '显示明细', filteredEmpty: '没有符合筛选条件的请求记录', filteredEmptyHelp: '调整或清除筛选条件后可查看全部请求记录。', requestEmpty: '暂无请求记录', + 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: '暂无活动记录', 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}」`, untitledSession: '未命名会话', success: '成功', error: '错误', - providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型请求后,这里会按供应商聚合请求数、Token 与费用。', - modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型请求后,这里会按模型聚合请求数、Token 与费用。', + noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', openSession: (label) => `打开会话「${label}」`, success: '成功', error: '错误', aborted: '已中止', + providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型调用后,这里会按供应商聚合调用数、Token 与费用。', + modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型调用后,这里会按模型聚合调用数、Token 与费用。', toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。', pricingEmptyBody: '未配置定价覆盖时,费用按内置模型定价表结算;在此可为特定模型登记自定义价格。', }, }, 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 calls', 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', - 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', + 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 calls and tool calls, filter by model, tool, or status, and investigate costs or failures.', + 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', 'Calls', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Calls', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', '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 session "${label}"`, untitledSession: 'Untitled session', success: 'Success', error: 'Error', - 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.', + noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', untitledSession: 'Untitled session', openSession: (label) => `Open session "${label}"`, success: 'Success', error: 'Error', aborted: 'Aborted', + providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model call, provider call counts, tokens, and costs appear here.', + modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model call, call 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.', pricingEmptyBody: 'Without pricing overrides, costs use the built-in model pricing table. Add custom prices here for specific models.', }, 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 9d261aea79..1f8facf9ec 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -294,7 +294,11 @@ export function SettingsSurface(props: { const defaultRuntimeHostProfileIdRef = useRef( initialRuntimeHostCatalog?.defaultProfileId, ); - const [usageStats, setUsageStats] = useState(null); + const [usageStats, setUsageStats] = useState<{ + hostKey: string; + range: UsageRange; + value: UsageStats; + } | null>(null); const [clientLoading, setClientLoading] = useState(initialClientSettings === undefined); const settingsModalMountedRef = useMountedRef(); const clientSettingsTicketRef = useRef(0); @@ -344,6 +348,8 @@ export function SettingsSurface(props: { const selectedRuntimeHostKey = selectedRuntimeHost ? runtimeHostSettingsKey(selectedRuntimeHost) : undefined; + const selectedRuntimeHostKeyRef = useRef(selectedRuntimeHostKey); + selectedRuntimeHostKeyRef.current = selectedRuntimeHostKey; function commitSelectedRuntimeHostProfile( profileId: string, snapshot = runtimeHosts, @@ -353,10 +359,14 @@ export function SettingsSurface(props: { const nextKey = nextHost ? runtimeHostSettingsKey(nextHost) : undefined; // Reject old-Host reads and writes synchronously with the authority // change, before React renders the newly selected profile. - runtimeHostRequestAuthority.selectTarget( + const targetChanged = runtimeHostRequestAuthority.selectTarget( nextKey, lifecycle?.epoch, ); + if (targetChanged) { + usageReloadTicketRef.current += 1; + setUsageStats(null); + } selectedProfileIdRef.current = profileId; setSelectedProfileId(profileId); } @@ -603,15 +613,30 @@ export function SettingsSurface(props: { } async function reloadUsage(range: UsageRange = settings.usage.range) { + const host = selectedRuntimeHost; + if (!host) { + usageReloadTicketRef.current += 1; + setUsageStats(null); + return; + } + const hostKey = runtimeHostSettingsKey(host); const ticket = usageReloadTicketRef.current + 1; usageReloadTicketRef.current = ticket; try { - const next = await window.maka.settings.usageStats(range); - if (settingsModalMountedRef.current && ticket === usageReloadTicketRef.current) { - setUsageStats(next); + const next = await window.maka.settings.usageStats(range, host); + if ( + settingsModalMountedRef.current && + ticket === usageReloadTicketRef.current && + selectedRuntimeHostKeyRef.current === hostKey + ) { + setUsageStats({ hostKey, range, value: next }); } } catch (error) { - if (settingsModalMountedRef.current && ticket === usageReloadTicketRef.current) { + if ( + settingsModalMountedRef.current && + ticket === usageReloadTicketRef.current && + selectedRuntimeHostKeyRef.current === hostKey + ) { toast.error(copy.usageLoadFailed, settingsActionErrorMessage(error, locale)); } } @@ -682,6 +707,8 @@ export function SettingsSurface(props: { // Fence synchronously, before the catalog refresh can resolve. The // previous generation's snapshots stay visible but no Host-backed // control may treat them as current write authority. + usageReloadTicketRef.current += 1; + setUsageStats(null); setRuntimeHostCatalog(invalidateSettingsResourceGeneration); setRuntimeHostSettings(invalidateSettingsResourceGeneration); setRuntimeHostConnections(invalidateSettingsResourceGeneration); @@ -747,18 +774,11 @@ 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. + // Usage records are Host-owned while the display preferences remain + // client-owned. Refetch when either the persisted range arrives or the + // selected Host changes so labels and numbers always describe one Host. if (section === 'usage') void reloadUsage(settings.usage.range); - }, [section, settings.usage.range]); + }, [section, settings.usage.range, selectedRuntimeHostKey]); // 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, @@ -964,7 +984,13 @@ export function SettingsSurface(props: { normalizedModelFilter.length === 0 || log.model.toLowerCase().includes(normalizedModelFilter) || + log.provider.toLowerCase().includes(normalizedModelFilter) || (log.toolName ?? '').toLowerCase().includes(normalizedModelFilter) ); }, [stats, usageDraft.status, normalizedModelFilter]); @@ -297,6 +298,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'])} @@ -340,8 +342,8 @@ function UsageRequestsPanel(props: { usageRequestTarget(row), usageRequestSessionCell(row, props.copy, props.onOpenSession), row.inputTokens + row.outputTokens, - row.kind === 'model' ? `$${(row.costUsd ?? 0).toFixed(2)}` : '-', - row.latencyMs ? `${row.latencyMs}ms` : '-', + row.kind === 'model' && row.costUsd !== undefined ? `$${row.costUsd.toFixed(2)}` : '-', + row.latencyMs !== undefined ? `${row.latencyMs}ms` : '-', usageRequestStatusLabel(row.status, props.copy), ])} empty={{ @@ -439,10 +441,14 @@ function usageRequestKindLabel(kind: UsageStats['logs'][number]['kind'], copy: U } function usageRequestTarget(row: UsageStats['logs'][number]) { - return row.kind === 'tool' ? row.toolName ?? row.model : row.model; + return row.kind === 'tool' ? row.toolName || row.model || row.provider || '-' : row.model || row.provider || '-'; } function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSettingsCopy, onOpenSession?: (sessionId: string) => void) { + // Canonical activity rows can lack a session (e.g. an aborted call before a + // session was attached); show it as unknown rather than a blank link. + if (!row.sessionId) return copy.tables.unknown; + const sessionId = row.sessionId; const label = usageSessionDisplayLabel(row, copy); if (!onOpenSession) return label; return ( @@ -450,7 +456,7 @@ function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSet className="settingsUsageSessionCell" variant="ghost" size="sm" - onClick={() => onOpenSession(row.sessionId)} + onClick={() => onOpenSession(sessionId)} label={label} tooltip={copy.tables.openSession(label)} /> @@ -464,7 +470,7 @@ function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSet function usageSessionDisplayLabel(row: UsageStats['logs'][number], copy: UsageSettingsCopy) { const name = row.sessionName?.trim(); if (name) return name; - return `${copy.tables.untitledSession} · ${shortRealSessionId(row.sessionId)}`; + return `${copy.tables.untitledSession} · ${shortRealSessionId(row.sessionId ?? '')}`; } function shortRealSessionId(sessionKey: string) { @@ -483,6 +489,7 @@ function usageRequestStatusLabel(status: UsageStats['logs'][number]['status'], c switch (status) { case 'success': return copy.tables.success; case 'error': return copy.tables.error; + case 'aborted': return copy.tables.aborted; } } diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index c2c4b0a0a6..daf09d2b1e 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -240,7 +240,9 @@ export function projectDesktopUsageStats( ...stats, logs: stats.logs.map((log) => ({ ...log, - sessionId: projectSessionId(host, log.sessionId), + ...(log.sessionId === undefined + ? {} + : { sessionId: projectSessionId(host, log.sessionId) }), })), }; } diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index b3609bdd00..e9b6d9ee9e 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -203,7 +203,7 @@ function makeUsageLog(input: { kind: 'model' | 'tool'; model: string; toolName?: string; - status?: 'success' | 'error'; + status?: 'success' | 'error' | 'aborted'; minutesAgo: number; sessionName?: string; }): UsageStats['logs'][number] { @@ -245,6 +245,12 @@ const usageLogs: UsageStats['logs'] = [ // No sessionName → renders the "未命名会话 · " fallback. makeUsageLog({ id: '3', kind: 'model', model: 'glm-4.7', status: 'error', minutesAgo: 16 }), makeUsageLog({ id: '4', kind: 'tool', model: 'glm-4.7', toolName: 'Bash', sessionName: 'Bash 环境探查', minutesAgo: 25 }), + { + ...makeUsageLog({ id: '5', kind: 'model', model: 'gpt-5', status: 'aborted', minutesAgo: 31 }), + sessionId: undefined, + turnId: undefined, + costUsd: undefined, + }, ]; const usageStats: UsageStats = { @@ -1954,13 +1960,19 @@ export const UsageMultiModel: Story = { decorators: [withUsageMultiModelBridge], render: () => , }; -// Real path: 设置 → 使用统计 → 详情记录 on → 请求日志, with long model and tool names. +// Real path: 设置 → 使用统计 → 详情记录 on → 活动记录, with long model and tool names. export const UsageLongTail: Story = { decorators: [withUsageLongTailBridge], render: () => , play: async ({ canvasElement, globals }) => { const canvas = within(canvasElement); const usageCopy = getUsageSettingsCopy(globals.locale === 'en' ? 'en' : 'zh'); + expect( + await canvas.findByText(usageCopy.totalRequests, { + selector: '[data-slot="stat-tile-label"]', + }), + ).toBeInTheDocument(); + expect(await canvas.findByRole('tab', { name: usageCopy.tabs[0] })).toBeInTheDocument(); await waitForStoryCondition( () => canvas.queryByRole('table', { name: usageCopy.tables.requestsAria }) !== null || canvas.queryByRole('button', { name: usageCopy.showDetails }) !== null, diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 9cff97ca75..f20e3103aa 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -112,7 +112,7 @@ export interface AppNetworkSettings { } export type UsageRange = '24h' | '7d' | '30d' | 'all'; -export type UsageStatus = 'all' | 'success' | 'error'; +export type UsageStatus = 'all' | 'success' | 'error' | 'aborted'; export type UsageTab = 'requests' | 'providers' | 'models' | 'tools' | 'pricing'; export interface UsageSettings { @@ -510,10 +510,10 @@ export interface UsageRequestLog { id: string; ts: number; kind: 'model' | 'tool'; - sessionId: string; + sessionId?: string; /** Human-readable session title (SessionHeader.name); may be empty for untitled sessions. */ - sessionName: string; - turnId: string; + sessionName?: string; + turnId?: string; provider: string; model: string; toolName?: string; @@ -525,7 +525,7 @@ export interface UsageRequestLog { reasoning?: number; costUsd?: number; latencyMs?: number; - status: 'success' | 'error'; + status: 'success' | 'error' | 'aborted'; } export interface UsageSummary { diff --git a/packages/ui/src/daily-review-copy.ts b/packages/ui/src/daily-review-copy.ts index e673d7df01..3afe0196f8 100644 --- a/packages/ui/src/daily-review-copy.ts +++ b/packages/ui/src/daily-review-copy.ts @@ -132,11 +132,11 @@ const DAILY_REVIEW_COPY = { title: '每日回顾', generateAnalysis: '生成分析', retryAnalysis: '重新生成', viewAnalysis: '查看分析', backToActivity: '返回活动', timeRange: '时间范围', rangeOptions: [['1', '今日'], ['7', '最近 7 天'], ['30', '最近 30 天']], rangeSwitch: '时间范围切换', }, overview: { - ariaLabel: (label) => `${label}概览`, refreshFailed: (error) => `每日回顾刷新失败:${error}`, retry: '重试', conversations: '任务', requests: '请求', tokens: 'Token', cost: '费用', activeConversations: '活跃任务', + ariaLabel: (label) => `${label}概览`, refreshFailed: (error) => `每日回顾刷新失败:${error}`, retry: '重试', conversations: '任务', requests: '模型调用', tokens: 'Token', cost: '费用', activeConversations: '活跃任务', }, errorFallback: '每日回顾暂时不可用,请稍后重试。', markdown: { - separator: ':', title: (dayLabel) => `# Maka · 每日回顾 · ${dayLabel}`, conversations: '任务', requests: '请求', tokens: 'Token', cost: '费用', errors: '错误', activeConversations: '活跃任务', modelUsage: '模型使用', toolCalls: '工具调用', requestCount: (count) => `${count} 次`, + separator: ':', title: (dayLabel) => `# Maka · 每日回顾 · ${dayLabel}`, conversations: '任务', requests: '模型调用', tokens: 'Token', cost: '费用', errors: '错误', activeConversations: '活跃任务', modelUsage: '模型使用', toolCalls: '工具调用', requestCount: (count) => `${count} 次`, }, }, en: { @@ -167,11 +167,11 @@ const DAILY_REVIEW_COPY = { title: 'Daily review', generateAnalysis: 'Generate analysis', retryAnalysis: 'Generate again', viewAnalysis: 'View analysis', backToActivity: 'Back to activity', timeRange: 'Time range', rangeOptions: [['1', 'Today'], ['7', 'Last 7 days'], ['30', 'Last 30 days']], rangeSwitch: 'Change time range', }, overview: { - ariaLabel: (label) => `${label} overview`, refreshFailed: (error) => `Failed to refresh daily review: ${error}`, retry: 'Retry', conversations: 'Tasks', requests: 'Requests', tokens: 'Tokens', cost: 'Cost', activeConversations: 'Active tasks', + ariaLabel: (label) => `${label} overview`, refreshFailed: (error) => `Failed to refresh daily review: ${error}`, retry: 'Retry', conversations: 'Tasks', requests: 'Model calls', tokens: 'Tokens', cost: 'Cost', activeConversations: 'Active tasks', }, errorFallback: 'Daily review is temporarily unavailable. Try again later.', markdown: { - separator: ':', title: (dayLabel) => `# Maka · Daily review · ${dayLabel}`, conversations: 'Tasks', requests: 'Requests', tokens: 'Tokens', cost: 'Cost', errors: 'Errors', activeConversations: 'Active tasks', modelUsage: 'Model usage', toolCalls: 'Tool calls', requestCount: (count) => `${count} ${count === 1 ? 'request' : 'requests'}`, + separator: ':', title: (dayLabel) => `# Maka · Daily review · ${dayLabel}`, conversations: 'Tasks', requests: 'Model calls', tokens: 'Tokens', cost: 'Cost', errors: 'Errors', activeConversations: 'Active tasks', modelUsage: 'Model usage', toolCalls: 'Tool calls', requestCount: (count) => `${count} ${count === 1 ? 'call' : 'calls'}`, }, }, } satisfies UiCatalog; From b5e6507b0994a84a708b366db4aea72f81b1d28f Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Mon, 24 Aug 2026 22:59:19 +0800 Subject: [PATCH 2/6] ci: rerun checks From a8e746bcecbd326db90acebfee9e4d59e8b30878 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Tue, 25 Aug 2026 15:28:19 +0800 Subject: [PATCH 3/6] fix(desktop): match usage activity tab by prefix in storybook smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UsageLongTail/UsageNarrow play asserted findByRole('tab', { name: usageCopy.tabs[0] }) with an exact string. The tab renders a count badge via endContent, which Astryx folds into the accessible name (e.g. '活动记录 5'), so the exact match never resolved and the storybook render smoke failed both stories. Match the label as a prefix RegExp, consistent with how accessibility-coverage queries workbar tabs. --- apps/desktop/stories/settings/settings-pages.stories.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index e9b6d9ee9e..e79e9a5a67 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1972,7 +1972,13 @@ export const UsageLongTail: Story = { selector: '[data-slot="stat-tile-label"]', }), ).toBeInTheDocument(); - expect(await canvas.findByRole('tab', { name: usageCopy.tabs[0] })).toBeInTheDocument(); + // The tab carries a count badge in its `endContent`, which Astryx folds + // into the accessible name (e.g. '活动记录 5'), so match the label as a + // prefix rather than the whole name — same reason accessibility-coverage + // queries workbar tabs with a RegExp. + expect( + await canvas.findByRole('tab', { name: new RegExp(`^${usageCopy.tabs[0]}`) }), + ).toBeInTheDocument(); await waitForStoryCondition( () => canvas.queryByRole('table', { name: usageCopy.tables.requestsAria }) !== null || canvas.queryByRole('button', { name: usageCopy.showDetails }) !== null, From bde3dff09d8c1ca38b4db714d6ed1c26c7b12842 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Tue, 25 Aug 2026 17:40:02 +0800 Subject: [PATCH 4/6] fix(desktop): query usage activity tab by button role in storybook smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UsageLongTail/UsageNarrow play asserted findByRole('tab', ...), but Astryx's TabList renders a