diff --git a/apps/desktop/src/main/__tests__/card-converge-contract.test.ts b/apps/desktop/src/main/__tests__/card-converge-contract.test.ts index 9ad0e171dd..e3f512604d 100644 --- a/apps/desktop/src/main/__tests__/card-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/card-converge-contract.test.ts @@ -14,8 +14,9 @@ * * The usage stats table is NOT on a public Table primitive: with only one HTML * consumer it was premature abstraction (PR9 review P3), so - * SimpleStatsTable keeps its styles inline in usage-settings-page. The table - * a11y semantics (aria-label + scope) are locked in settings-usage-contract. + * UsageStatsTable keeps its styles inline in usage-settings-page (shared across + * all five usage tabs). The table a11y semantics (aria-label + scope) and + * per-column alignment are locked in settings-usage-contract. */ import { strict as assert } from 'node:assert'; diff --git a/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts b/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts index d53edb5807..1bcea20325 100644 --- a/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts @@ -13,105 +13,156 @@ async function readRepo(path: string): Promise { return readFile(join(repoRoot, path), 'utf8'); } +// The usage page splits into an orchestrator (UsageSettingsPage) plus one +// component per tab, then a shared table primitive. Contract assertions scope +// to the block that owns each concern so a regression in one tab cannot be +// masked by a matching string in another. +const usagePageBlock = (src: string) => + src.match(/function UsageSettingsPage\([\s\S]*?function UsageRequestsPanel/)?.[0] ?? ''; +const requestsPanelBlock = (src: string) => + src.match(/function UsageRequestsPanel\([\s\S]*?function UsageProvidersPanel/)?.[0] ?? ''; +const statsTableBlock = (src: string) => + src.match(/function UsageStatsTable\([\s\S]*?function MetricCard/)?.[0] ?? ''; + describe('Settings usage dashboard contract', () => { it('keeps request filters scoped to the request log tab', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/); + const usagePage = usagePageBlock(src); + const requestsPanel = requestsPanelBlock(src); assert.ok(usagePage, 'Usage settings page block must exist'); - assert.match(usagePage![0], /usageDraft\.activeTab === 'requests'/); - assert.match(usagePage![0], /settingsUsageFilters/); - assert.match(usagePage![0], /清除筛选/); - assert.match(usagePage![0], /status: 'all', modelFilter: ''/); - assert.match( - usagePage![0], - /\{showRequestDetails && \([\s\S]*?
/, - 'Usage filters must render only when request details are visible', - ); + assert.ok(requestsPanel, 'Usage requests panel block must exist'); + // Only the request-log tab computes/derives detail rows; the aggregate + // tabs never see the request filters. + assert.match(usagePage, /const showRequestDetails = usageDraft\.activeTab === 'requests' && usageDraft\.showDetails/); + assert.match(usagePage, /status: 'all', modelFilter: ''/); + assert.match(usagePage, /log\.model\.toLowerCase\(\)\.includes\(normalizedModelFilter\)/); + assert.match(usagePage, /\(log\.toolName \?\? ''\)\.toLowerCase\(\)\.includes\(normalizedModelFilter\)/); + // The filter cluster lives in the requests panel, behind its own details + // guard — it can never render under an aggregate tab. + assert.match(requestsPanel, /if \(!props\.showDetails\)/); + assert.match(requestsPanel, /
/); + assert.match(requestsPanel, /清除筛选/); + assert.match(requestsPanel, /\s*\{usageDraft\.showDetails/, + requestsPanel, + /
/, 'Usage request filters must not regress to an anonymous control cluster', ); - assert.match(usagePage![0], / { const src = await readSettingsCombinedSource(); + const requestsPanel = requestsPanelBlock(src); + // The orchestrator decides the empty copy from the filter state; the panel + // routes it into the shared table's EmptyState title. assert.match(src, /requestEmpty=\{hasRequestFilters \? '没有符合筛选条件的请求记录' : '暂无请求记录'\}/); - assert.match(src, /empty=\{props\.requestEmpty\}/); + assert.match(requestsPanel, /title: props\.requestEmpty/); + assert.match( + requestsPanel, + /empty=\{\{ Icon: props\.hasRequestFilters \? Search : Activity, title: props\.requestEmpty \}\}/, + 'The empty request log must surface through the shared EmptyState (icon + copy), not a bare table row', + ); }); it('makes the detail-records toggle control request log rendering', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/); + const usagePage = usagePageBlock(src); + const requestsPanel = requestsPanelBlock(src); assert.ok(usagePage, 'Usage settings page block must exist'); - assert.match(usagePage![0], /const showRequestDetails = usageDraft\.activeTab === 'requests' && usageDraft\.showDetails/); - assert.match(usagePage![0], /usageDraft\.activeTab === 'requests' && !usageDraft\.showDetails/); - assert.match(usagePage![0], /当前仅显示汇总指标/); - assert.match(usagePage![0], /显示明细/); - assert.match(usagePage![0], /showDetails: true/); - assert.match(usagePage![0], /logs=\{showRequestDetails \? filteredLogs : \[\]\}/); + assert.match(usagePage, /const showRequestDetails = usageDraft\.activeTab === 'requests' && usageDraft\.showDetails/); + assert.match(usagePage, /logs=\{showRequestDetails \? filteredLogs : \[\]\}/); + assert.match(usagePage, /showDetails: true/); + // With details off the panel returns the summary-only prompt; the alert + + // 显示明细 CTA live in the requests panel now. + assert.match(requestsPanel, /if \(!props\.showDetails\)/); + assert.match(requestsPanel, /当前仅显示汇总指标/); + assert.match(requestsPanel, /显示明细/); + assert.match(requestsPanel, /onClick=\{props\.onEnableDetails\}/); }); - it('names usage segmented radiogroups for assistive technology', async () => { + it('names the usage range selector and tab views for assistive technology', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/); + const usagePage = usagePageBlock(src); assert.ok(usagePage, 'Usage settings page block must exist'); assert.match( - usagePage![0], + usagePage, /
/, 'Usage range selector and refresh action must expose a named control group', ); assert.doesNotMatch( - usagePage![0], - /
\s*\s*/, + 'the tab row must use the underline TabsList so it reads as tabs, not a toggle chip', + ); + assert.doesNotMatch( + // Bounded so it only fires when a single tag itself binds + // activeTab — not when the range Segmented merely precedes the TabsRoot. + usagePage, + /)[\s\S])*?value=\{usageDraft\.activeTab\}/, + 'the view switcher must not regress to a segmented toggle', + ); + for (const [value, label] of [ + ['requests', '请求日志'], + ['providers', '供应商统计'], + ['models', '模型统计'], + ['tools', '工具统计'], + ['pricing', '定价配置'], + ] as const) { + assert.match( + usagePage, + new RegExp(`${label} `), + `tab ${value} must render its ${label} label with a count pill`, + ); + } }); it('names the usage summary metrics group', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/); + const usagePage = usagePageBlock(src); assert.ok(usagePage, 'Usage settings page block must exist'); assert.match( - usagePage![0], + usagePage, /
/, 'Usage summary metric cards must expose a named group before the tabbed detail tables', ); assert.doesNotMatch( - usagePage![0], + usagePage, /
\s* { + it('names every usage stats table and boxes it in the shared table primitive', async () => { const src = await readSettingsCombinedSource(); - const usageTable = src.match(/function UsageTable\([\s\S]*?function usageRequestKindLabel/)?.[0] ?? ''; - const simpleStatsTable = src.match(/function SimpleStatsTable\([\s\S]*?function MetricCard/)?.[0] ?? ''; + const statsTable = statsTableBlock(src); for (const label of [ '使用统计请求日志表', @@ -120,70 +171,86 @@ describe('Settings usage dashboard contract', () => { '使用统计工具统计表', '使用统计定价配置表', ]) { - assert.match(usageTable, new RegExp(`ariaLabel="${label}"`), `UsageTable must name ${label}`); + assert.match(src, new RegExp(`ariaLabel="${label}"`), `A usage tab must name its ${label}`); } + // Every tab funnels through the one shared table so the column rhythm / + // hairline / tabular-nums recipe stays in a single place. assert.match( - simpleStatsTable, - /function SimpleStatsTable\(props: \{ ariaLabel: string; headers: string\[\]; rows: Array>; empty\?: string \}\)/, - 'SimpleStatsTable callers must provide a table-specific accessible name', + statsTable, + /function UsageStatsTable\(props: \{\s*ariaLabel: string;\s*columns: UsageColumn\[\];\s*rows: Array>;\s*empty: UsageEmpty;\s*\}\)/, + 'UsageStatsTable callers must provide a table-specific accessible name, typed columns, and an EmptyState config', ); assert.match( - simpleStatsTable, + statsTable, /\s*
/, - 'Usage stats tables must not regress to anonymous tables', + // Numeric columns right-align + tabular-nums; every non-grow column stays on + // one line and sizes to content, while the grow column absorbs slack and + // wraps — so numeric columns never float apart and headers never wrap. + assert.match( + statsTable, + /column\.numeric \? 'text-right \[font-variant-numeric:tabular-nums\]' : 'text-left'/, + 'Usage stats columns must right-align numeric data with tabular-nums', ); - assert.doesNotMatch( - simpleStatsTable, - /
\{header\}<\/th>/, - 'Usage stats table headers must not regress to unscoped header cells', + assert.match( + statsTable, + /column\.grow \? 'w-full' : 'whitespace-nowrap'/, + 'Usage stats tables must let one column absorb slack while the rest size to content on one line', + ); + // Empty tabs render the shared EmptyState rather than a header-only table. + assert.match( + statsTable, + /if \(props\.rows\.length === 0\) \{\s*return \(\s* \{cell\}<\/td>\)/, - 'Usage stats table rows must not regress to body-only data cells', + statsTable, + /\s*/, + 'Usage stats tables must not regress to anonymous tables', ); }); it('keeps usage filters responsive through a local draft while saves run in the background', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/); + const usagePage = usagePageBlock(src); + const requestsPanel = requestsPanelBlock(src); assert.ok(usagePage, 'Usage settings page block must exist'); - assert.match(usagePage![0], /const persistedUsage = props\.settings\.usage/); + assert.match(usagePage, /const persistedUsage = props\.settings\.usage/); assert.match( - usagePage![0], + usagePage, /useOptimisticSettingsDraft\([\s\S]*persistedUsage,[\s\S]*\(patch\) => props\.onUpdate\(\{ usage: patch \}\)\.then\(\(result\) => result\.settings\.usage\)/, 'Usage controls must drive their local draft through the shared optimistic draft hook instead of waiting for settings IPC', ); assert.match( - usagePage![0], + usagePage, /draft: usageDraft,[\s\S]*draftRef: usageDraftRef,[\s\S]*mountedRef: usagePageMountedRef,[\s\S]*update,/, 'Usage must read its rendered draft, synchronous draft ref, and mounted ref from the shared hook', ); assert.match( - usagePage![0], + usagePage, /\{ onError: \(error\) => toast\.error\('保存使用统计设置失败', settingsActionErrorMessage\(error\)\) \},[\s\S]*function updateUsage\(patch: Partial\): Promise \{[\s\S]*return update\(patch\);/, 'Usage settings saves must route through the shared draft update (latest-response sync + rollback owned by the hook)', ); - assert.match(usagePage![0], / { it('surfaces usage preference save failures instead of leaving filter controls silent', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/); + const usagePage = usagePageBlock(src); assert.ok(usagePage, 'Usage settings page block must exist'); - assert.match(usagePage![0], /function updateUsage\(patch: Partial\): Promise/); + assert.match(usagePage, /function updateUsage\(patch: Partial\): Promise/); assert.match( - usagePage![0], + usagePage, /\{ onError: \(error\) => toast\.error\('保存使用统计设置失败', settingsActionErrorMessage\(error\)\) \},[\s\S]*function updateUsage\(patch: Partial\): Promise \{[\s\S]*return update\(patch\);/, 'Usage settings updates must surface the save failure through the shared hook (which gates on the latest mounted save) and report failure to callers', ); assert.match( - usagePage![0], + usagePage, /const saved = await updateUsage\(\{ range \}\);[\s\S]*if \(!saved \|\| !usagePageMountedRef\.current\) return;[\s\S]*await props\.onReload\(range\)/, 'Changing the usage range must not reload stats after the preference save fails', ); assert.doesNotMatch( - usagePage![0], + usagePage, /void props\.onUpdate\(\{ usage:/, 'Usage filter controls must not fire-and-forget raw settings updates', ); @@ -214,7 +281,7 @@ describe('Settings usage dashboard contract', () => { it('drops late usage preference and refresh UI writes after Settings is closed', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/)?.[0] ?? ''; + const usagePage = usagePageBlock(src); assert.match( usagePage, @@ -226,9 +293,6 @@ describe('Settings usage dashboard contract', () => { /const usageRefreshGuard = useActionGuard<'refresh'>\(\)/, 'Usage settings must hold its manual refresh guard from the shared hook (which releases it on unmount and invalidates saves)', ); - // Save-response staleness + rollback after unmount are owned by the shared - // optimistic draft hook and covered by its controller unit test; the page - // only gates the stats reload on the boolean the shared update returns. assert.match( usagePage, /const saved = await updateUsage\(\{ range \}\);[\s\S]*if \(!saved \|\| !usagePageMountedRef\.current\) return;[\s\S]*await props\.onReload\(range\);/, @@ -265,60 +329,65 @@ describe('Settings usage dashboard contract', () => { it('gates manual usage refresh and reads the latest draft range', async () => { const src = await readSettingsCombinedSource(); - const usagePage = src.match(/function UsageSettingsPage\([\s\S]*?function UsageTable/); + const usagePage = usagePageBlock(src); assert.ok(usagePage, 'Usage settings page block must exist'); assert.match( - usagePage![0], + usagePage, /const usageRefreshGuard = useActionGuard<'refresh'>\(\)/, 'Manual usage refresh needs a synchronous guard so fast double-clicks cannot duplicate reloads before React disables the button', ); assert.match( - usagePage![0], + usagePage, /async function refresh\(\) \{\s*if \(!usageRefreshGuard\.begin\('refresh'\)\) return;[\s\S]*await props\.onReload\(usageDraftRef\.current\.range\)/, 'Manual usage refresh must lock synchronously and read the latest local draft range', ); assert.match( - usagePage![0], + usagePage, /finally \{[\s\S]*usageRefreshGuard\.finish\(\);[\s\S]*setRefreshing\(false\);[\s\S]*\}/, 'Manual usage refresh must release the guard after reload settles', ); assert.doesNotMatch( - usagePage![0], + usagePage, /props\.onReload\(usageDraft\.range\)/, 'Manual usage refresh must not read stale React state after a just-clicked range change', ); - assert.match(usagePage![0], /aria-busy=\{refreshing\}/, 'Usage refresh button must expose pending state to assistive tech'); - assert.match(usagePage![0], /data-pending=\{refreshing \? 'true' : undefined\}/, 'Usage refresh button must expose a stable pending hook'); - assert.match(usagePage![0], /onClick=\{\(\) => void refresh\(\)\}/, 'Usage refresh click handler must explicitly discard the async promise'); + assert.match(usagePage, /aria-busy=\{refreshing\}/, 'Usage refresh button must expose pending state to assistive tech'); + assert.match(usagePage, /data-pending=\{refreshing \? 'true' : undefined\}/, 'Usage refresh button must expose a stable pending hook'); + assert.match(usagePage, /onClick=\{\(\) => void refresh\(\)\}/, 'Usage refresh click handler must explicitly discard the async promise'); }); it('does not render raw request status enums in the usage table', async () => { const src = await readSettingsCombinedSource(); - const usageTable = src.match(/function UsageTable\([\s\S]*?function SimpleStatsTable/); + const requestsPanel = requestsPanelBlock(src); - assert.ok(usageTable, 'Usage table block must exist'); - assert.match(usageTable![0], /usageRequestStatusLabel\(row\.status\)/); + assert.ok(requestsPanel, 'Usage requests panel block must exist'); + assert.match(requestsPanel, /usageRequestStatusLabel\(row\.status\)/); assert.match(src, /function usageRequestStatusLabel/); assert.match(src, /case 'success': return '成功'/); assert.match(src, /case 'error': return '错误'/); assert.doesNotMatch( - usageTable![0], - /,\s*row\.status\]\)/, + requestsPanel, + /,\s*row\.status\]/, 'Usage request table must not render raw `success` / `error` enums directly', ); }); it('labels model and tool rows without rendering raw request kind enums', async () => { const src = await readSettingsCombinedSource(); - const usageTable = src.match(/function UsageTable\([\s\S]*?function usageRequestStatusLabel/); + const requestsPanel = requestsPanelBlock(src); - assert.ok(usageTable, 'Usage table block must exist'); - assert.match(usageTable![0], /headers=\{\['时间', '类型', '对象', '会话', 'Token', '费用', '延迟', '状态'\]\}/); - assert.match(usageTable![0], /usageRequestKindLabel\(row\.kind\)/); - assert.match(usageTable![0], /usageRequestTarget\(row\)/); - assert.match(usageTable![0], /usageRequestSessionCell\(row, props\.onOpenSession\)/); - assert.match(usageTable![0], /row\.kind === 'model' \? `\$\$\{\(row\.costUsd \?\? 0\)\.toFixed\(2\)\}` : '-'/); + assert.ok(requestsPanel, 'Usage requests panel block must exist'); + // Columns are objects now (per-column alignment); the request log keeps + // its full 时间→状态 shape. + for (const header of ['时间', '类型', '会话', 'Token', '费用', '延迟', '状态']) { + assert.match(requestsPanel, new RegExp(`header: '${header}'`), `request log must keep the ${header} column`); + } + assert.match(requestsPanel, /\{ header: '对象', grow: true \}/, 'the 对象 column must absorb slack so numeric columns size to content'); + assert.match(requestsPanel, /usageRequestKindLabel\(row\.kind\)/); + assert.match(requestsPanel, /usageRequestTarget\(row\)/); + assert.match(requestsPanel, /usageRequestSessionCell\(row, props\.onOpenSession\)/); + assert.match(requestsPanel, /row\.kind === 'model' \? `\$\$\{\(row\.costUsd \?\? 0\)\.toFixed\(2\)\}` : '-'/); assert.match(src, /case 'model': return '模型'/); assert.match(src, /case 'tool': return '工具'/); assert.match(src, /return row\.kind === 'tool' \? row\.toolName \?\? row\.model : row\.model/); @@ -327,7 +396,7 @@ describe('Settings usage dashboard contract', () => { assert.match(src, /打开 \{label\}/); assert.match(src, /function shortUsageSessionId/); assert.doesNotMatch( - usageTable![0], + requestsPanel, /,\s*row\.kind\s*,/, 'Usage request table must not render raw `model` / `tool` enums directly', ); diff --git a/apps/desktop/src/main/visual-smoke-fixture.ts b/apps/desktop/src/main/visual-smoke-fixture.ts index fac58d0a43..08e0b4128e 100644 --- a/apps/desktop/src/main/visual-smoke-fixture.ts +++ b/apps/desktop/src/main/visual-smoke-fixture.ts @@ -65,6 +65,7 @@ import { writePlanReminders, writeSettings, } from './visual-smoke/scenarios-settings.js'; +import { usageStatsSessions } from './visual-smoke/scenarios-usage.js'; const VISUAL_SMOKE_SCENARIOS = new Set([ 'all', @@ -606,7 +607,7 @@ export async function seedVisualSmokeFixture(input: { const now = input.now ?? VISUAL_SMOKE_NOW; await rm(input.workspaceRoot, { recursive: true, force: true }); await mkdir(input.workspaceRoot, { recursive: true }); - await writeSettings(input.workspaceRoot); + await writeSettings(input.workspaceRoot, input.fixture.scenario); if (input.fixture.scenario === 'first-run') return; await writeConnections(input.workspaceRoot, now, input.fixture.scenario); for (const slug of ['zai-live', 'relay-fallback', 'empty-fetched', 'needs-reauth', 'broken-provider']) { @@ -686,4 +687,12 @@ export async function seedVisualSmokeFixture(input: { if (input.fixture.scenario === 'module-mcp') { await seedMcpFixture(input.workspaceRoot); } + // Settings → 使用统计: seed extra model + tool traffic so the request log, + // provider / model / tool aggregates render real content in the capture. + // Scenario-gated so no other fixture's sidebar or usage totals shift. + if (input.fixture.scenario === 'settings-usage') { + for (const seed of usageStatsSessions(now)) { + await writeSession(input.workspaceRoot, seed.header, seed.messages); + } + } } diff --git a/apps/desktop/src/main/visual-smoke/scenarios-settings.ts b/apps/desktop/src/main/visual-smoke/scenarios-settings.ts index 582d842050..ca23effdd0 100644 --- a/apps/desktop/src/main/visual-smoke/scenarios-settings.ts +++ b/apps/desktop/src/main/visual-smoke/scenarios-settings.ts @@ -9,7 +9,10 @@ import type { import { createDefaultSettings } from '@maka/core/settings'; import { writeJson } from './seed-helpers.js'; -export async function writeSettings(workspaceRoot: string): Promise { +export async function writeSettings( + workspaceRoot: string, + scenario?: VisualSmokeScenario, +): Promise { // PR-SIDEBAR-IA-0 Phase 3 P0 fixup v2 (kenji `08be08d8` + WAWQAQ // `1886c41b`): the fixture previously seeded a placeholder // Chinese personal name for screenshot baselines, but a real @@ -26,6 +29,14 @@ export async function writeSettings(workspaceRoot: string): Promise { const settings = createDefaultSettings(); settings.personalization.displayName = ''; settings.appearance.theme = 'auto'; + // Settings → 使用统计: the seeded traffic uses the fixed visual-smoke clock, + // which sits outside the real-time 24h/7天/30天 windows the store derives + // from Date.now(). Default the usage view to 全部 + details-on so the + // capture shows the populated request log and stats tables deterministically. + if (scenario === 'settings-usage') { + settings.usage.range = 'all'; + settings.usage.showDetails = true; + } await writeJson(join(workspaceRoot, 'settings.json'), settings); } diff --git a/apps/desktop/src/main/visual-smoke/scenarios-usage.ts b/apps/desktop/src/main/visual-smoke/scenarios-usage.ts new file mode 100644 index 0000000000..95864c181c --- /dev/null +++ b/apps/desktop/src/main/visual-smoke/scenarios-usage.ts @@ -0,0 +1,198 @@ +import type { SessionHeader, StoredMessage } from '@maka/core'; +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. +// +// 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 → 请求日志 + +interface UsageTurnSpec { + turnId: string; + minutesAgo: number; + model: string; + usage: { + input: number; + output: number; + cacheRead?: number; + cacheMissInput?: number; + cacheCreation?: number; + reasoning?: number; + costUsd: number; + }; + tools: Array<{ + id: string; + toolName: string; + displayName: string; + durationMs: number; + isError?: boolean; + }>; +} + +function usageTurnMessages(now: number, spec: UsageTurnSpec): StoredMessage[] { + const turnTs = now - spec.minutesAgo * 60_000; + const messages: StoredMessage[] = [ + { + type: 'user', + id: `${spec.turnId}-user`, + turnId: spec.turnId, + ts: turnTs - 30_000, + text: '继续这轮工作,并汇总一次用量。', + }, + ]; + spec.tools.forEach((tool, index) => { + const callTs = turnTs - 24_000 + index * 3_000; + messages.push({ + type: 'tool_call', + id: tool.id, + turnId: spec.turnId, + ts: callTs, + toolName: tool.toolName, + displayName: tool.displayName, + args: {}, + }); + messages.push({ + type: 'tool_result', + id: `${tool.id}-result`, + turnId: spec.turnId, + ts: callTs + tool.durationMs, + toolUseId: tool.id, + isError: tool.isError ?? false, + durationMs: tool.durationMs, + content: { kind: 'text', text: tool.isError ? '调用失败(fixture)' : '调用完成(fixture)' }, + }); + }); + messages.push({ + type: 'assistant', + id: `${spec.turnId}-assistant`, + turnId: spec.turnId, + ts: turnTs, + text: '这一轮的模型请求与工具调用已完成,用量已并入统计。', + modelId: spec.model, + }); + messages.push({ + type: 'token_usage', + id: `${spec.turnId}-usage`, + turnId: spec.turnId, + ts: turnTs + 100, + input: spec.usage.input, + output: spec.usage.output, + ...(spec.usage.cacheRead !== undefined ? { cacheRead: spec.usage.cacheRead } : {}), + ...(spec.usage.cacheMissInput !== undefined ? { cacheMissInput: spec.usage.cacheMissInput } : {}), + ...(spec.usage.cacheCreation !== undefined ? { cacheCreation: spec.usage.cacheCreation } : {}), + ...(spec.usage.reasoning !== undefined ? { reasoning: spec.usage.reasoning } : {}), + costUsd: spec.usage.costUsd, + }); + return messages; +} + +function usageSession( + now: number, + input: { id: string; name: string; connection: string; model: string; minutesAgo: number }, +): SessionHeader { + return header({ + id: input.id, + name: input.name, + connection: input.connection, + model: input.model, + now, + lastMessageAt: now - input.minutesAgo * 60_000, + }); +} + +export function usageStatsSessions( + now: number, +): Array<{ header: SessionHeader; messages: StoredMessage[] }> { + return [ + { + header: usageSession(now, { + id: 'visual-smoke-usage-glm', + name: '用量样本 · GLM 工作区', + connection: 'zai-live', + model: 'glm-5.1', + minutesAgo: 40, + }), + messages: [ + ...usageTurnMessages(now, { + turnId: 'usage-glm-1', + minutesAgo: 45, + model: 'glm-5.1', + usage: { input: 4820, output: 1240, cacheRead: 3200, cacheMissInput: 1620, cacheCreation: 640, reasoning: 210, costUsd: 0.0186 }, + tools: [ + { id: 'usage-glm-1-bash', toolName: 'Bash', displayName: '运行测试', durationMs: 8_240 }, + { id: 'usage-glm-1-read', toolName: 'Read', displayName: '读取源码', durationMs: 1_120 }, + { id: 'usage-glm-1-grep', toolName: 'Grep', displayName: '检索用法', durationMs: 640 }, + ], + }), + ...usageTurnMessages(now, { + turnId: 'usage-glm-2', + minutesAgo: 38, + model: 'glm-5.1-air', + usage: { input: 2110, output: 560, cacheMissInput: 2110, costUsd: 0.0071 }, + tools: [ + { id: 'usage-glm-2-edit', toolName: 'Edit', displayName: '修改文件', durationMs: 980 }, + { id: 'usage-glm-2-write', toolName: 'Write', displayName: '写入文件', durationMs: 1_460, isError: true }, + ], + }), + ], + }, + { + header: usageSession(now, { + id: 'visual-smoke-usage-claude', + name: '用量样本 · Claude 中继', + connection: 'relay-fallback', + model: 'claude-sonnet-4.5', + minutesAgo: 28, + }), + messages: [ + ...usageTurnMessages(now, { + turnId: 'usage-claude-1', + minutesAgo: 30, + model: 'claude-sonnet-4.5', + usage: { input: 6400, output: 2050, cacheRead: 5100, cacheCreation: 1300, reasoning: 880, costUsd: 0.0642 }, + tools: [ + { id: 'usage-claude-1-search', toolName: 'WebSearch', displayName: '联网检索', durationMs: 3_050 }, + { id: 'usage-claude-1-read', toolName: 'Read', displayName: '读取文档', durationMs: 900 }, + ], + }), + ...usageTurnMessages(now, { + turnId: 'usage-claude-2', + minutesAgo: 24, + model: 'claude-haiku-4.5', + usage: { input: 1500, output: 300, costUsd: 0.0021 }, + tools: [ + { id: 'usage-claude-2-bash', toolName: 'Bash', displayName: '构建 renderer', durationMs: 5_200 }, + ], + }), + ], + }, + { + header: usageSession(now, { + id: 'visual-smoke-usage-gpt', + name: '用量样本 · GPT 备用', + connection: 'needs-reauth', + model: 'gpt-5.1-mini', + minutesAgo: 16, + }), + messages: [ + ...usageTurnMessages(now, { + turnId: 'usage-gpt-1', + minutesAgo: 18, + model: 'gpt-5.1-mini', + usage: { input: 3300, output: 900, cacheRead: 1200, costUsd: 0.0125 }, + tools: [ + { id: 'usage-gpt-1-bash', toolName: 'Bash', displayName: '生成截图', durationMs: 6_400 }, + { id: 'usage-gpt-1-grep', toolName: 'Grep', displayName: '扫描目录', durationMs: 720, isError: true }, + ], + }), + ], + }, + ]; +} diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx index b71969a311..5419cb3ee8 100644 --- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx @@ -1,12 +1,29 @@ import { useMemo, useState, type ReactNode } from 'react'; import type { AppSettings, UpdateAppSettingsResult, UsageRange, UsageStats } from '@maka/core'; -import { Alert, AlertAction, AlertDescription, Button, Input, Segmented, SettingsSelect, SettingsSwitch as Switch, useToast } from '@maka/ui'; -import { RefreshCcw } from '@maka/ui/icons'; +import { + Alert, + AlertAction, + AlertDescription, + Button, + EmptyState, + Input, + Segmented, + SettingsSelect, + SettingsSwitch as Switch, + TabsList, + TabsPanel, + TabsRoot, + TabsTrigger, + useToast, +} from '@maka/ui'; +import { Activity, BarChart3, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons'; import { MetricCard } from './settings-metric-card'; import { settingsActionErrorMessage } from './settings-error-copy'; import { useActionGuard } from './use-action-guard'; import { useOptimisticSettingsDraft } from './use-optimistic-settings-draft'; +type UsageActiveTab = AppSettings['usage']['activeTab']; + export function UsageSettingsPage(props: { settings: AppSettings; stats: UsageStats | null; @@ -44,6 +61,14 @@ export function UsageSettingsPage(props: { ); }, [stats, usageDraft.status, normalizedModelFilter]); + const tabCounts: Record = { + requests: stats?.logs.length ?? 0, + providers: stats?.byProvider.length ?? 0, + models: stats?.byModel.length ?? 0, + tools: stats?.byTool.length ?? 0, + pricing: stats?.pricing.length ?? 0, + }; + async function setRange(range: UsageRange) { const saved = await updateUsage({ range }); if (!saved || !usagePageMountedRef.current) return; @@ -88,7 +113,9 @@ export function UsageSettingsPage(props: { {/* Detail audit: 刷新 was a primary --action chip glued to the segmented — two control styles fighting in one row for a low-frequency utility. Same quiet icon form as the automations - page refresh (one action, one shape everywhere). */} + page refresh (one action, one shape everywhere); pinned to the + row's trailing edge so the time cluster reads as a single + left-aligned group. */} - - )} - - {usageDraft.activeTab === 'requests' && !usageDraft.showDetails ? ( - - 当前仅显示汇总指标。打开详情记录后,可以查看逐条模型请求和工具调用,按模型、工具或状态筛选,并用于排查费用与失败请求。 - - - - - ) : ( - - )} + + + + + + + + + + + + + + + + + + ); } -function UsageTable(props: { activeTab: AppSettings['usage']['activeTab']; stats: UsageStats | null; logs: UsageStats['logs']; requestEmpty: string; onOpenSession?(sessionId: string): void }) { - if (props.activeTab === 'providers') { - return [row.provider, row.requests, row.tokens, `$${row.costUsd.toFixed(2)}`])} />; - } - if (props.activeTab === 'models') { - return [row.model, row.requests, row.tokens, `$${row.costUsd.toFixed(2)}`])} />; - } - if (props.activeTab === 'tools') { - return [row.tool, row.calls, row.success, row.errors, `${row.avgDurationMs}ms`])} />; - } - if (props.activeTab === 'pricing') { - return [row.provider, row.model, `$${row.inputPerMTokUsd}`, `$${row.outputPerMTokUsd}`])} empty="暂无定价覆盖配置" />; +// ── Per-tab panels ───────────────────────────────────────────────────────── +// Each tab owns its own component so the panel structure (filters, tables, +// empty states) reads top-to-bottom instead of hiding inside one switch. +// They all funnel their rows through the shared UsageStatsTable so every tab +// inherits the same hairline / column-rhythm / tabular-nums recipe. + +function UsageRequestsPanel(props: { + stats: UsageStats | null; + logs: UsageStats['logs']; + showDetails: boolean; + modelFilter: string; + status: AppSettings['usage']['status']; + recordCount: number; + hasRequestFilters: boolean; + requestEmpty: string; + onOpenSession?(sessionId: string): void; + onEnableDetails(): void; + onModelFilterChange(value: string): void; + onStatusChange(status: AppSettings['usage']['status']): void; + onToggleDetails(showDetails: boolean): void; + onClearFilters(): void; +}) { + if (!props.showDetails) { + return ( + + 当前仅显示汇总指标。打开详情记录后,可以查看逐条模型请求和工具调用,按模型、工具或状态筛选,并用于排查费用与失败请求。 + + + + + ); } - return [new Date(row.ts).toLocaleString(), usageRequestKindLabel(row.kind), usageRequestTarget(row), usageRequestSessionCell(row, props.onOpenSession), row.inputTokens + row.outputTokens, row.kind === 'model' ? `$${(row.costUsd ?? 0).toFixed(2)}` : '-', row.latencyMs ? `${row.latencyMs}ms` : '-', usageRequestStatusLabel(row.status)])} empty={props.requestEmpty} />; + return ( + <> +
+ props.onModelFilterChange(event.currentTarget.value)} placeholder="按模型或工具筛选…" aria-label="按模型或工具筛选请求记录" /> + } + onChange={props.onStatusChange} + /> + + 共 {props.recordCount} 条记录 + +
+ [ + new Date(row.ts).toLocaleString(), + usageRequestKindLabel(row.kind), + usageRequestTarget(row), + usageRequestSessionCell(row, props.onOpenSession), + row.inputTokens + row.outputTokens, + row.kind === 'model' ? `$${(row.costUsd ?? 0).toFixed(2)}` : '-', + row.latencyMs ? `${row.latencyMs}ms` : '-', + usageRequestStatusLabel(row.status), + ])} + empty={{ Icon: props.hasRequestFilters ? Search : Activity, title: props.requestEmpty }} + /> + + ); +} + +function UsageProvidersPanel(props: { stats: UsageStats | null }) { + return ( + [row.provider, row.requests, row.tokens, `$${row.costUsd.toFixed(2)}`])} + empty={{ Icon: Database, title: '暂无供应商用量', body: '完成一次模型请求后,这里会按供应商聚合请求数、Token 与费用。' }} + /> + ); +} + +function UsageModelsPanel(props: { stats: UsageStats | null }) { + return ( + [row.model, row.requests, row.tokens, `$${row.costUsd.toFixed(2)}`])} + empty={{ Icon: Cpu, title: '暂无模型用量', body: '完成一次模型请求后,这里会按模型聚合请求数、Token 与费用。' }} + /> + ); +} + +function UsageToolsPanel(props: { stats: UsageStats | null }) { + return ( + [row.tool, row.calls, row.success, row.errors, `${row.avgDurationMs}ms`])} + empty={{ Icon: Activity, title: '暂无工具调用', body: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。' }} + /> + ); } +function UsagePricingPanel(props: { stats: UsageStats | null }) { + return ( + [row.provider, row.model, `$${row.inputPerMTokUsd}`, `$${row.outputPerMTokUsd}`])} + empty={{ Icon: BarChart3, title: '暂无定价覆盖配置', body: '未配置定价覆盖时,费用按内置模型定价表结算;在此可为特定模型登记自定义价格。' }} + /> + ); +} + +// ── Request-log cell helpers ──────────────────────────────────────────────── + function usageRequestKindLabel(kind: UsageStats['logs'][number]['kind']) { switch (kind) { case 'model': return '模型'; @@ -231,31 +388,77 @@ function usageRequestStatusLabel(status: UsageStats['logs'][number]['status']) { } } -function SimpleStatsTable(props: { ariaLabel: string; headers: string[]; rows: Array>; empty?: string }) { - // Local table styles reproduce the retired Table primitive (now removed — a - // single consumer did not justify a public primitive). Values are inline so - // the stats surface stays self-contained until a second HTML
consumer - // appears, at which point this can lift back to packages/ui. - const headClass = "border-b border-border px-[var(--space-2)] py-[var(--space-1)] text-left align-middle font-semibold text-foreground-secondary [font-variant-numeric:tabular-nums]"; - const cellClass = "border-b border-border px-[var(--space-2)] py-[var(--space-1)] text-left align-middle text-foreground-secondary [font-variant-numeric:tabular-nums]"; +// ── Shared table primitive ───────────────────────────────────────────────── +// The local table recipe reproduces the retired public Table primitive (a +// single HTML
surface did not justify one in packages/ui). All five +// usage tabs render through it so the column rhythm, hairline separators, +// muted+medium header row, and per-column alignment stay identical. +// +// Column model: the `grow` column absorbs the row's slack so numeric columns +// shrink to content (no floating giant gaps); numeric columns right-align and +// force tabular-nums; the first column is a scoped row header. + +interface UsageColumn { + header: string; + /** Numeric columns right-align and force tabular-nums. */ + numeric?: boolean; + /** The column that absorbs slack so the others size to content. */ + grow?: boolean; +} + +interface UsageEmpty { + /** A lucide icon (same shape EmptyState accepts). */ + Icon: typeof Search; + title: string; + body?: string; +} + +function UsageStatsTable(props: { + ariaLabel: string; + columns: UsageColumn[]; + rows: Array>; + empty: UsageEmpty; +}) { + if (props.rows.length === 0) { + return ( + + ); + } + const base = 'border-b border-border px-[var(--space-2)] py-[var(--space-1-5)] align-middle'; + // Only the grow column wraps; every other column stays on one line and sizes + // to its content (no per-character header wrapping, no floating giant gaps). + const shape = (column: UsageColumn) => + [ + column.numeric ? 'text-right [font-variant-numeric:tabular-nums]' : 'text-left', + column.grow ? 'w-full' : 'whitespace-nowrap', + ].join(' '); + const cellClass = (column: UsageColumn) => `${base} text-foreground-secondary ${shape(column)}`; + const headClass = (column: UsageColumn) => `${base} font-medium text-muted-foreground ${shape(column)}`; return (
- {props.headers.map((header) => )} + + {props.columns.map((column) => ( + + ))} + - {props.rows.length === 0 ? ( - - ) : props.rows.map((row, rowIndex) => ( + {props.rows.map((row, rowIndex) => ( {row.map((cell, cellIndex) => ( cellIndex === 0 ? ( - + ) : ( - + ) ))} diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index 7c7eb7baf8..4dbd8c983b 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -234,6 +234,71 @@ gap: var(--space-1-5); } +/* Detail audit: the range segmented reads as a single left-aligned time + cluster; the low-frequency refresh is pushed to the row's trailing edge + (mixed-type controls align on the shared centerline via align-items). */ +.settingsUsageToolbar { + justify-content: space-between; +} + +/* House tab language (skills / MCP precedent): an underline TabsList with + count pills, sitting on a hairline that spans the panel width. */ +.settingsUsageTabsBar { + display: flex; + align-items: center; + border-bottom: var(--border-width-hairline) solid var(--border); +} + +.settingsUsageTabs { + display: flex; + gap: var(--space-4); + border-bottom: 0; +} + +.settingsUsageTab { + position: relative; + height: 32px; + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0 1px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted-foreground); + font-size: var(--font-size-ui); + font-weight: var(--font-weight-medium); +} + +.settingsUsageTab span { + min-width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + padding-inline: var(--space-1); + border-radius: var(--radius-pill); + background: oklch(from var(--foreground) l c h / 0.06); + color: var(--muted-foreground); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-semibold); + font-variant-numeric: tabular-nums; +} + +.settingsUsageTabPanel { + display: grid; + align-content: start; + gap: var(--space-2); + min-height: 0; + margin-top: var(--space-2-5); +} + +/* Empty tab: the shared EmptyState card, nudged to the density of the + stats surface rather than the full page hero. */ +.settingsUsageEmpty { + padding: var(--space-6); +} + .settingsUsageFilters { display: grid; grid-template-columns: minmax(260px, 1fr) 148px 108px 92px 92px; diff --git a/scripts/audit-alignment.mjs b/scripts/audit-alignment.mjs index 033e5c805a..4e5ea3421f 100644 --- a/scripts/audit-alignment.mjs +++ b/scripts/audit-alignment.mjs @@ -24,6 +24,9 @@ const FIXTURES = [ 'fetched-empty', 'settings-data', 'settings-gateway', + // 使用统计 restyle: the range/refresh row, underline tab bar, and stats + // tables now sit under the alignment auditor's watch. + 'settings-usage', 'turn-narrative', 'settings-permissions', // #1233 deferral: bot QR-onboarding modal in its deterministic waiting state.
{header}
{column.header}
{props.empty ?? '暂无请求记录'}
{cell}{cell}{cell}{cell}