diff --git a/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts b/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts index b8fd943106..fa6c770ba0 100644 --- a/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts +++ b/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts @@ -19,26 +19,24 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { compactNumberFormatter } from '../../renderer/features/workbar/testing.js'; +import { formatCompactTokenCount } from '@maka/ui'; -test('formats context-window capacity with stable K/M units in every UI locale', () => { - for (const locale of ['en', 'zh'] as const) { - const format = compactNumberFormatter(locale); - - assert.equal(format(256_000), '256K'); - assert.equal(format(1_000_000), '1M'); - } +test('formats context-window capacity with locale-independent K/M units', () => { + assert.equal(formatCompactTokenCount(256_000), '256K'); + assert.equal(formatCompactTokenCount(1_000_000), '1M'); }); -test('formats context-window capacity with at most one decimal and promotes rounded K values to M', () => { - const format = compactNumberFormatter('en'); - - assert.equal(format(999), '999'); - assert.equal(format(1_000), '1K'); - assert.equal(format(8_192), '8.2K'); - assert.equal(format(69_000), '69K'); - assert.equal(format(69_194), '69.2K'); - assert.equal(format(990_000), '990K'); - assert.equal(format(999_950), '1M'); - assert.equal(format(1_250_000), '1.3M'); +test('formats token counts with at most one decimal and promotes rounded values', () => { + assert.equal(formatCompactTokenCount(999), '999'); + assert.equal(formatCompactTokenCount(1_000), '1K'); + assert.equal(formatCompactTokenCount(8_192), '8.2K'); + assert.equal(formatCompactTokenCount(69_000), '69K'); + assert.equal(formatCompactTokenCount(69_194), '69.2K'); + assert.equal(formatCompactTokenCount(990_000), '990K'); + assert.equal(formatCompactTokenCount(999_950), '1M'); + assert.equal(formatCompactTokenCount(1_250_000), '1.3M'); + assert.equal(formatCompactTokenCount(999_950_000), '1B'); + assert.equal(formatCompactTokenCount(1_250_000_000), '1.3B'); + assert.equal(formatCompactTokenCount(999_950_000_000), '1T'); + assert.equal(formatCompactTokenCount(1_250_000_000_000), '1.3T'); }); diff --git a/apps/desktop/src/renderer/features/usage/ui/metric-card.tsx b/apps/desktop/src/renderer/features/usage/ui/metric-card.tsx index 69f6ec1e4c..4c9896edc7 100644 --- a/apps/desktop/src/renderer/features/usage/ui/metric-card.tsx +++ b/apps/desktop/src/renderer/features/usage/ui/metric-card.tsx @@ -17,11 +17,15 @@ * under the License. */ -import { StatTile } from '@maka/ui'; +import { StatTile, type StatTileProps } from '@maka/ui'; /** Thin alias over the shared StatTile — feature-local copy of the settings * MetricCard so the Usage feature carries no legacy import (#4425). */ -export function MetricCard(props: { title: string; value: string; detail?: string }) { +export function MetricCard(props: { + title: string; + value: StatTileProps['value']; + detail?: string; +}) { return ( /* One tile language across every settings summary strip: this used to ask for a gray-plate variant while the Permission/Health summaries used the diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx index 61df3dcdd8..0dc7cc137b 100644 --- a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx +++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx @@ -29,7 +29,16 @@ import { uiLocaleToIntlLocale } from '@maka/core/ui-locale'; import { parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js'; import type { UsageRange, UsageSettings, UsageStats } from '@maka/core/settings'; import { estimatedUsageCost, hasUnavailableUsage } from '@maka/core/usage-ledger-merge'; -import { Button, TextInput, Selector, Switch, useToast, useUiLocale, Banner } from '@maka/ui'; +import { + Banner, + Button, + formatCompactTokenCount, + Selector, + Switch, + TextInput, + useToast, + useUiLocale, +} from '@maka/ui'; import { ICON_SIZE, Activity, BarChart3, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons'; import { getUsageSettingsCopy, @@ -43,6 +52,18 @@ import { useUsageServices, useUsageStats } from '../services-context.js'; type UsageActiveTab = UsageSettings['activeTab']; +function TokenTooltipContent(props: { + rows: ReadonlyArray; +}) { + return ( +
+ {props.rows.map(([label, value]) => ( +
{label}: {value}
+ ))} +
+ ); +} + /** * The Usage settings surface (issue #4425). A disposable view: it unmounts when * the user leaves the Usage section. The loaded stats snapshot lives in the @@ -60,6 +81,10 @@ export function UsageSettingsView(props: { const services = useUsageServices(); const locale = useUiLocale(); const copy = getUsageSettingsCopy(locale); + const exactTokenFormatter = useMemo( + () => new Intl.NumberFormat(uiLocaleToIntlLocale(locale)), + [locale], + ); const toast = useToast(); const persistedUsage = props.settings; // The stats snapshot lives in the persistent `UsageFeatureScope` (keyed by the @@ -186,8 +211,50 @@ export function UsageSettingsView(props: {
- - + + )} + hasHoverIndication={false} + > + {formatCompactTokenCount(stats.summary.totalTokens)} + + ) : '—'} + detail={stats ? copy.tokenDetail( + formatCompactTokenCount(stats.summary.inputTokens), + formatCompactTokenCount(stats.summary.outputTokens), + ) : undefined} + /> + + )} + hasHoverIndication={false} + > + {formatCompactTokenCount(stats.summary.cacheTokens)} + + ) : '—'} + detail={stats ? copy.cacheDetail( + formatCompactTokenCount(stats.summary.cacheMiss), + formatCompactTokenCount(stats.summary.cacheRead), + formatCompactTokenCount(stats.summary.cacheCreation), + ) : undefined} + />
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index f13752a75d..ec4566eecc 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -33,7 +33,6 @@ export * from './tools/artifacts/artifact-list-keyboard.js'; export * from './tools/artifacts/artifact-visibility.js'; export * from './tools/inspector/session-inspector-panel-model.js'; export { - compactNumberFormatter, InspectorCompositionSection, RING_ACTIVE_MIN_SWEEP, RING_MIN_SWEEP, diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx index ac29081afb..e8797a9829 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx @@ -27,7 +27,7 @@ import { Section } from '@astryxdesign/core/Section'; import { Text } from '@astryxdesign/core/Text'; import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; import { traceTurnIdentityKey } from '@maka/core/session-trace'; -import { useToast, useUiLocale } from '@maka/ui'; +import { formatCompactTokenCount, useToast, useUiLocale } from '@maka/ui'; import { ICON_SIZE, Activity, AlertTriangle, Copy } from '@maka/ui/icons'; import { getDesktopConversationCopy, @@ -269,7 +269,6 @@ function InspectorOverview(props: { }) { const { copy, overview } = props; const formatNumber = numberFormatter(props.locale); - const formatCompactNumber = compactNumberFormatter(props.locale); const context = overview.context; // Local bindings so the JSX guards narrow into the map callbacks below. const tokenUsage = overview.tokenUsage; @@ -301,7 +300,7 @@ function InspectorOverview(props: { kind: segment.kind, label: copy.tokenUsage.segment[segment.kind], swatch: `token-${segment.kind}`, - value: `${formatCompactNumber(segment.tokens)} · ${formatPercent( + value: `${formatCompactTokenCount(segment.tokens)} · ${formatPercent( segment.tokens / tokenUsage.total, )}`, }))} @@ -354,7 +353,7 @@ function InspectorOverview(props: { )} @@ -784,19 +783,6 @@ function numberFormatter(locale: UiLocale): (value: number) => string { return (value) => formatter.format(value); } -export function compactNumberFormatter(_locale: UiLocale): (value: number) => string { - return (value) => { - if (value < 1_000) return String(value); - - if (value < 1_000_000) { - const thousands = Math.round(value / 100) / 10; - return thousands >= 1_000 ? '1M' : `${thousands}K`; - } - - return `${Math.round(value / 100_000) / 10}M`; - }; -} - function formatPercent(ratio: number): string { return `${(ratio * 100).toFixed(1)}%`; } diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts index f5160b4e2c..1ca3c1e26d 100644 --- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts @@ -22,7 +22,8 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; export type UsageSettingsCopy = { saveFailed: string; toolbarAria: string; rangeAria: string; ranges: readonly [string, string, string, string]; 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; + totalTokens: string; tokenDetail(input: string, output: string): string; cacheTokens: string; cacheDetail(miss: string, read: string, creation: string): string; + tokenTooltip: { total: string; input: string; output: string; cached: string; new: string; hit: string; created: string }; viewAria: string; tabs: readonly [string, string, string, string, string]; filtersAria: string; filterPlaceholder: string; filterAria: 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; @@ -42,6 +43,7 @@ const SETTINGS_USAGE_COPY = { refreshingAria: '正在刷新使用统计', refreshAria: '刷新使用统计', summaryAria: '使用统计汇总指标', totalRequests: '模型调用', totalCost: '总费用', costHelp: '以模型供应商最终结算为准', totalTokens: '总 Token', tokenDetail: (input, output) => `输入 ${input} / 输出 ${output}`, cacheTokens: '缓存 Token', cacheDetail: (miss, read, creation) => `新 ${miss} / 命中 ${read} / 创建 ${creation}`, viewAria: '使用统计视图', tabs: ['活动记录', '供应商统计', '模型统计', '工具统计', '定价配置'], + tokenTooltip: { total: '总计', input: '输入', output: '输出', cached: '缓存', new: '新', hit: '命中', created: '创建' }, filtersAria: '活动记录筛选', filterPlaceholder: '按模型或工具筛选…', filterAria: '按模型或工具筛选活动记录', statusAria: '活动状态筛选', statuses: ['全部状态', '成功', '错误', '已中止'], details: '详情记录', detailsAria: '显示使用统计详情记录', recordCount: (count) => `共 ${count} 条记录`, clearFilters: '清除筛选', summaryOnly: '当前仅显示汇总指标。打开详情记录后,可以查看逐条模型调用和工具调用,按模型、工具或状态筛选,并用于排查费用与失败调用。', @@ -64,6 +66,7 @@ const SETTINGS_USAGE_COPY = { 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: ['Activity log', 'Providers', 'Models', 'Tools', 'Pricing'], + tokenTooltip: { total: 'Total', input: 'Input', output: 'Output', cached: 'Cached', new: 'New', hit: 'Hit', created: 'Created' }, 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.', diff --git a/apps/desktop/src/renderer/styles/settings/usage.css b/apps/desktop/src/renderer/styles/settings/usage.css index a4979eff6a..2c3be4be62 100644 --- a/apps/desktop/src/renderer/styles/settings/usage.css +++ b/apps/desktop/src/renderer/styles/settings/usage.css @@ -209,7 +209,6 @@ only the metric-strip density override. */ .settingsMetricCard { min-height: 52px; - justify-content: center; gap: var(--space-0-5); padding: var(--space-1-5) var(--space-2-5); } diff --git a/packages/ui/src/compact-token-count.ts b/packages/ui/src/compact-token-count.ts new file mode 100644 index 0000000000..d0a3aa6e62 --- /dev/null +++ b/packages/ui/src/compact-token-count.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +const COMPACT_TOKEN_UNITS = [ + { value: 1_000, suffix: 'K' }, + { value: 1_000_000, suffix: 'M' }, + { value: 1_000_000_000, suffix: 'B' }, + { value: 1_000_000_000_000, suffix: 'T' }, +] as const; + +export function formatCompactTokenCount(value: number): string { + if (value < 1_000) return String(value); + + let unitIndex = COMPACT_TOKEN_UNITS.length - 1; + while (unitIndex > 0 && value < COMPACT_TOKEN_UNITS[unitIndex].value) unitIndex -= 1; + + let compactValue = Math.round((value / COMPACT_TOKEN_UNITS[unitIndex].value) * 10) / 10; + if (compactValue >= 1_000 && unitIndex < COMPACT_TOKEN_UNITS.length - 1) { + unitIndex += 1; + compactValue = 1; + } + + return `${compactValue}${COMPACT_TOKEN_UNITS[unitIndex].suffix}`; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9a31cf091e..0db1f1c50a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -188,3 +188,4 @@ export { type SearchSource, type SearchableItem, } from '@astryxdesign/core'; +export { formatCompactTokenCount } from './compact-token-count.js';