diff --git a/src/client/components/statsTiming.tsx b/src/client/components/statsTiming.tsx index 4a1cfa8..9da56cb 100644 --- a/src/client/components/statsTiming.tsx +++ b/src/client/components/statsTiming.tsx @@ -70,10 +70,10 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac const [hoverKey, setHoverKey] = useState(null) const timing = props.timing const wall = timing !== null && Number.isFinite(timing.wallMs) && timing.wallMs > 0 ? timing.wallMs : 0 - // The throughput chip: the host paired provider output tokens with decode - // windows exactly as the harness session-stats fold does, so the two - // figures can never disagree. An unreadable or absent pairing (an older - // cached row) renders no chip. + // The throughput figure: the host paired provider output tokens with + // decode windows exactly as the harness session-stats fold does, so the + // two figures can never disagree. An unreadable or absent pairing (an + // older cached row) renders no figure. const tps = timing !== null && typeof timing.speedMs === 'number' && Number.isFinite(timing.speedMs) && timing.speedMs > 0 && typeof timing.speedTokens === 'number' && Number.isFinite(timing.speedTokens) && timing.speedTokens >= 0 @@ -187,7 +187,7 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac
{t('timing.title')} {tps !== null && ( - {t('timing.tps', { tps })} + {t('timing.tps', { tps })} )}
{rows.length === 0 diff --git a/src/client/components/statsTokens.tsx b/src/client/components/statsTokens.tsx index 0144488..87a07f2 100644 --- a/src/client/components/statsTokens.tsx +++ b/src/client/components/statsTokens.tsx @@ -10,12 +10,15 @@ * construction. The estimated category counts carry the ≈ marker (the * composition card's convention); the center total and the output count are * exact. Zero parts stay hidden once any usage is reported; the empty state - * (no provider report yet) keeps all seven rows behind a dash center. + * (no provider report yet) keeps all seven rows behind a dash center. The + * title row's right corner carries the session's cache-hit share — the harness + * chat line's own reads-over-billed formula — hidden until something was billed. */ import { useState, type ReactElement } from 'react' import type { ContextBreakdown, Snapshot, TokenUsage } from '../../shared/types' import { billedParts } from '../categories' +import { cacheHitPercent } from '../format' import { numOf } from '../services' import type { ViewKit } from '../viewkit' @@ -44,6 +47,11 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => Reac : 0 const output = props.usage !== null ? numOf(props.usage.outputTokens) : 0 const total = input + output + // The title row's corner figure: cache reads over billed prompt-side input, + // the harness chat line's own formula (statsContext's cell shares it). + const hit = props.usage === null + ? null + : cacheHitPercent(numOf(props.usage.cacheReadTokens), input) // Null usage (no provider report — the chat line shows no pill either) // takes the same zeroed split billedParts produces for a zero report. const parts = billedParts(props.current, props.breakdown, props.usage ?? NO_USAGE) @@ -61,6 +69,9 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => Reac
{t('tokens.title')} + {hit !== null && ( + {t('stats.cacheHit')} {hit}% + )}
{/* donut + legend row: the gap folds at a 320px card, below 240px the row wraps and the ring centers over the full-width legend (all keyed to the lc-card container). */} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 8b6f768..fe2d774 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -55,7 +55,7 @@ export const DICT_ZH: Record = { 'timing.toolTimes': '{n}次', 'timing.blockTimes': '{n}次', 'timing.empty': '暂无耗时数据 · 对话开始后这里会显示时长分布', - 'timing.tps': '{tps} tok/s', + 'timing.tps': '输出速度 {tps} tok/s', 'timing.tpsTip': '输出速度(TPS):累计解码输出 tokens ÷ 累计解码时长,与 DeepSeek Harness 会话统计同口径。', 'timing.strip': '耗时时间轴:各段按发生次序紧凑铺排,宽度为耗时占比,刻度为零、四分位与全程', 'tokens.title': 'Token 统计', @@ -458,7 +458,7 @@ export const DICT_EN: Record = { 'timing.toolTimes': '{n} runs', 'timing.blockTimes': '{n} blocks', 'timing.empty': 'No timing data yet — durations appear as the conversation runs', - 'timing.tps': '{tps} tok/s', + 'timing.tps': 'TPS {tps} tok/s', 'timing.tpsTip': 'Output speed (TPS): summed decoded output tokens over summed decode time — the same pairing as the harness session stats.', 'timing.strip': 'Session-time strip: slices packed in occurrence order, width as share of the active time; ticks at zero, the quartiles, and the full span', 'tokens.title': 'Token Stats', diff --git a/src/client/styles/stats.css b/src/client/styles/stats.css index 96972d3..754a4e4 100644 --- a/src/client/styles/stats.css +++ b/src/client/styles/stats.css @@ -24,11 +24,13 @@ a.lc-stat:hover { border-color: var(--dsw-alias-label-secondary); } .lc-stat-tip { top: calc(100% + 6px); left: 0; max-width: 280px; line-height: 1.5; font-weight: 400; } .lc-stat-tip-prices { display: block; margin-top: 5px; border-top: 1px dashed var(--dsw-alias-border-l1); padding-top: 5px; } .lc-stat-tip-head { display: block; font-weight: 600; } -/* The timing card's throughput chip (statsTiming.tsx): a quiet figure pinned - to the title row's right end — the same harness session-stats pairing, so - the two figures never disagree. `margin-left: auto` parks it at the row's - end; the title row's own wrap carries it below the title under pressure. */ -.lc-timing-tps { flex: none; margin-left: auto; padding: 1px 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); font-size: 12px; line-height: 18px; font-variant-numeric: tabular-nums; cursor: default; } +/* The donut cards' right-corner rate figure (statsTiming.tsx's decode TPS, + statsTokens.tsx's session cache hit): plain text pinned to the title row's + right end, regular weight against the 600 title — no pill chrome; the + native `title` tooltip explains it on hover. `margin-left: auto` parks it + at the row's end; the title row's own wrap carries it below the title + under pressure. */ +.lc-card-rate { flex: none; margin-left: auto; font-weight: 400; font-size: 12px; line-height: 18px; font-variant-numeric: tabular-nums; cursor: default; } .lc-stat-tip-row { display: block; margin-top: 2px; } /* One billed model's block: the four bucket rates as label/price rows, the listing line beneath naming the registry face the rates resolved from. */ diff --git a/tests/client/components/statsTiming.spec.ts b/tests/client/components/statsTiming.spec.ts index 1967143..be3c936 100644 --- a/tests/client/components/statsTiming.spec.ts +++ b/tests/client/components/statsTiming.spec.ts @@ -215,32 +215,32 @@ describe('StatsTiming — the generation split', () => { }) }) -describe('StatsTiming — the throughput chip', () => { +describe('StatsTiming — the throughput figure', () => { const paired: TimingTotals = { ...TIMING, speedTokens: 12_345, speedMs: 42_720 } - test('the paired seat renders the harness-formatted figure in the title row', async () => { + test('the paired seat renders the output-speed figure in the title row', async () => { const m = await mount(h(StatsTiming, { timing: paired })) - const chip = query(m.container, '.lc-timing-tps') - assert.equal(chip?.textContent, '289 tok/s', '12345 tokens over 42.72s') - assert.ok(chip?.getAttribute('title')?.includes('Output speed (TPS)')) + const rate = query(m.container, '.lc-card-rate') + assert.equal(rate?.textContent, 'TPS 289 tok/s', '12345 tokens over 42.72s') + assert.ok(rate?.getAttribute('title')?.includes('Output speed (TPS)')) await m.unmount() }) - test('the chip localizes its tooltip and stays in the title row', async () => { + test('the figure localizes its label and tooltip in the title row', async () => { const m = await mount(h(StatsTimingZh, { timing: paired })) - const chip = query(m.container, '.lc-timing-tps') - assert.equal(chip?.textContent, '289 tok/s') - assert.ok(chip?.getAttribute('title')?.includes('输出速度(TPS)')) + const rate = query(m.container, '.lc-card-rate') + assert.equal(rate?.textContent, '输出速度 289 tok/s') + assert.ok(rate?.getAttribute('title')?.includes('输出速度(TPS)')) await m.unmount() }) test('a sub-10 figure keeps one decimal', async () => { const m = await mount(h(StatsTiming, { timing: { ...TIMING, speedTokens: 26, speedMs: 10_000 } })) - assert.equal(query(m.container, '.lc-timing-tps')?.textContent, '2.6 tok/s') + assert.equal(query(m.container, '.lc-card-rate')?.textContent, 'TPS 2.6 tok/s') await m.unmount() }) - test('an unpaired, zero-window, or hostile seat renders no chip', async () => { + test('an unpaired, zero-window, or hostile seat renders no figure', async () => { for (const timing of [ TIMING, { ...TIMING, speedTokens: 500, speedMs: 0 }, @@ -248,7 +248,7 @@ describe('StatsTiming — the throughput chip', () => { { ...TIMING, speedTokens: -5, speedMs: 1_000 }, ] as TimingTotals[]) { const m = await mount(h(StatsTiming, { timing })) - assert.equal(queryAll(m.container, '.lc-timing-tps').length, 0, JSON.stringify(timing.speedTokens)) + assert.equal(queryAll(m.container, '.lc-card-rate').length, 0, JSON.stringify(timing.speedTokens)) await m.unmount() } }) diff --git a/tests/client/components/statsTokens.spec.ts b/tests/client/components/statsTokens.spec.ts index 4a15838..4ada8b7 100644 --- a/tests/client/components/statsTokens.spec.ts +++ b/tests/client/components/statsTokens.spec.ts @@ -112,3 +112,32 @@ describe('StatsTokens', () => { await m.unmount() }) }) + +describe('StatsTokens — the cache-hit corner figure', () => { + test('a billed session shows the chat line\'s cache-hit share in the title row', async () => { + const m = await mount(h(StatsTokens, { usage: USAGE, current: CURRENT, breakdown: null })) + const rate = query(m.container, '.lc-card-rate') + assert.equal(rate?.textContent, 'Cache Hit 25.00%', '100 reads over the 400 billed') + assert.ok(rate?.getAttribute('title')?.includes('Cumulative cache-read share')) + await m.unmount() + }) + + test('the figure localizes its label and tooltip', async () => { + const kitZh = makeKit('zh') + const StatsTokensZh = makeStatsTokens(kitZh, makeDonut(kitZh)) + const m = await mount(h(StatsTokensZh, { usage: USAGE, current: CURRENT, breakdown: null })) + const rate = query(m.container, '.lc-card-rate') + assert.equal(rate?.textContent, '缓存命中 25.00%') + assert.ok(rate?.getAttribute('title')?.includes('整个会话累计的缓存读取')) + await m.unmount() + }) + + test('an unbilled or unreported session renders no figure', async () => { + const zero: TokenUsage = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } + for (const usage of [null, zero] as (TokenUsage | null)[]) { + const m = await mount(h(StatsTokens, { usage, current: CURRENT, breakdown: null })) + assert.equal(queryAll(m.container, '.lc-card-rate').length, 0, JSON.stringify(usage)) + await m.unmount() + } + }) +})