Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
8 changes: 6 additions & 2 deletions apps/desktop/src/renderer/features/usage/ui/metric-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +52,18 @@ import { useUsageServices, useUsageStats } from '../services-context.js';

type UsageActiveTab = UsageSettings['activeTab'];

function TokenTooltipContent(props: {
rows: ReadonlyArray<readonly [label: string, value: string]>;
}) {
return (
<div>
{props.rows.map(([label, value]) => (
<div key={label}>{label}: {value}</div>
))}
</div>
);
}

/**
* 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
Expand All @@ -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
Expand Down Expand Up @@ -186,8 +211,50 @@ export function UsageSettingsView(props: {
<div className="settingsUsageSummary" role="group" aria-label={copy.summaryAria}>
<MetricCard title={copy.totalRequests} value={stats ? String(stats.summary.totalRequests) : '—'} />
<MetricCard title={copy.totalCost} value={totalCostDisplay} detail={copy.costHelp} />
<MetricCard title={copy.totalTokens} value={stats ? String(stats.summary.totalTokens) : '—'} detail={stats ? copy.tokenDetail(stats.summary.inputTokens, stats.summary.outputTokens) : undefined} />
<MetricCard title={copy.cacheTokens} value={stats ? String(stats.summary.cacheTokens) : '—'} detail={stats ? copy.cacheDetail(stats.summary.cacheMiss, stats.summary.cacheRead, stats.summary.cacheCreation) : undefined} />
<MetricCard
title={copy.totalTokens}
value={stats ? (
<Tooltip
content={(
<TokenTooltipContent rows={[
[copy.tokenTooltip.total, exactTokenFormatter.format(stats.summary.totalTokens)],
[copy.tokenTooltip.input, exactTokenFormatter.format(stats.summary.inputTokens)],
[copy.tokenTooltip.output, exactTokenFormatter.format(stats.summary.outputTokens)],
]} />
)}
hasHoverIndication={false}
>
{formatCompactTokenCount(stats.summary.totalTokens)}
</Tooltip>
) : '—'}
detail={stats ? copy.tokenDetail(
formatCompactTokenCount(stats.summary.inputTokens),
formatCompactTokenCount(stats.summary.outputTokens),
) : undefined}
/>
<MetricCard
title={copy.cacheTokens}
value={stats ? (
<Tooltip
content={(
<TokenTooltipContent rows={[
[copy.tokenTooltip.cached, exactTokenFormatter.format(stats.summary.cacheTokens)],
[copy.tokenTooltip.new, exactTokenFormatter.format(stats.summary.cacheMiss)],
[copy.tokenTooltip.hit, exactTokenFormatter.format(stats.summary.cacheRead)],
[copy.tokenTooltip.created, exactTokenFormatter.format(stats.summary.cacheCreation)],
]} />
)}
hasHoverIndication={false}
>
{formatCompactTokenCount(stats.summary.cacheTokens)}
</Tooltip>
) : '—'}
detail={stats ? copy.cacheDetail(
formatCompactTokenCount(stats.summary.cacheMiss),
formatCompactTokenCount(stats.summary.cacheRead),
formatCompactTokenCount(stats.summary.cacheCreation),
) : undefined}
/>
</div>
</div>

Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/features/workbar/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
)}`,
}))}
Expand Down Expand Up @@ -354,7 +353,7 @@ function InspectorOverview(props: {
<InspectorContextSection
copy={copy}
context={context}
formatCompactNumber={formatCompactNumber}
formatCompactNumber={formatCompactTokenCount}
formatNumber={formatNumber}
/>
)}
Expand Down Expand Up @@ -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)}%`;
}
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/renderer/locales/settings-usage-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: '当前仅显示汇总指标。打开详情记录后,可以查看逐条模型调用和工具调用,按模型、工具或状态筛选,并用于排查费用与失败调用。',
Expand All @@ -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.',
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/styles/settings/usage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
40 changes: 40 additions & 0 deletions packages/ui/src/compact-token-count.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,4 @@ export {
type SearchSource,
type SearchableItem,
} from '@astryxdesign/core';
export { formatCompactTokenCount } from './compact-token-count.js';