소스 검색

feat(web): preserve near-full cache-hit precision

pku-xht 4 주 전
부모
커밋
a4da0f40d5

+ 6 - 0
.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md
+2026-08-19-high-cache-hit-decimal-display.md: 29e0ebc11ae690b918c7e9364f29635de1e50f2a
+2026-08-19-high-cache-hit-decimal-display.zh.md: 28cd99a4c321cc4da0ce03142e2fbf438539b0ff

+ 50 - 0
.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md

@@ -0,0 +1,50 @@
+# Agent Note: High cache-hit decimal display
+
+Status: implemented
+
+English | [中文](2026-08-19-high-cache-hit-decimal-display.zh.md)
+
+## Problem
+
+The Web conversation stats line rounded every non-empty cache-hit ratio to an integer. Once the actual ratio passed 99%, the display hid further progress, and a ratio of at least 99.5% appeared as 100% even while uncached input or cache writes remained.
+
+Users therefore could not distinguish a nearly complete cache hit from a true full hit.
+
+## Decision
+
+`StatsLine` continues to derive the ratio from the whole-session `tokenUsage` projection owned by `@deepseek-ai/dsh-token-meter`; the projection remains the only owner of the uncached-input, cache-read, cache-write, and output counts ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). The presentation layer changes only the text inserted into the existing `stats.cacheHit` locale template.
+
+| Actual ratio | Display |
+|---|---|
+| No billed input | Cache-hit group omitted |
+| Integer rounding is below 100% | Rounded integer |
+| Non-full ratio whose current rounding is 100% | Minimum decimal precision whose rounded result is below 100% |
+| 100% | `100%` |
+
+Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` scales and rounds the integer token counts with `bigint`, which avoids floating-point formatting limits and imposes no precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip.
+
+## Ownership and lifecycle
+
+Token-meter continues to fold usage from the complete durable session log. `StatsLine` performs a synchronous display derivation whenever the standard projection value changes. It introduces no setting, stored percentage, event, wire field, client state, or recovery path.
+
+Live updates, reload replay, and reconnect recovery all restore the same `tokenUsage` counts and run the same display function. A missing projection still omits every token group, and a zero input denominator still omits only the cache-hit group.
+
+## Verification
+
+The component spec pins the zero denominator, ordinary integer rounding, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call.
+
+## Alternatives considered
+
+**Keep integer rounding for every ratio.** Rejected because it hides all movement above 99% and still reports some non-full hits as 100%.
+
+**Truncate the high band to one decimal.** Rejected because `99.95%`, `99.995%`, and still closer ratios all collapse to `99.9%` instead of retaining the minimum precision that distinguishes them from a full hit.
+
+**Cap precision and use a substitute such as `<100%`.** Rejected because the exact cumulative counts can produce the required numeric result, and a cap would make display behavior depend on an arbitrary presentation limit.
+
+**Show one decimal at every ratio.** Rejected because the additional low-band motion adds noise and changes the established display where integer precision is sufficient.
+
+**Persist a display percentage in token-meter.** Rejected because the projection already carries the exact counts, while presentation precision belongs to the Web stats line. A second stored value would duplicate derivable state and expand replay and wire responsibilities.
+
+## Consequences
+
+High cache-hit sessions remain visually stable until integer rounding would falsely report a full hit, then expose only the decimal places needed to preserve that distinction. Extremely close non-full ratios can therefore produce long decimal strings; this is the accepted cost of having no arbitrary precision cap or nonnumeric fallback. Every delivery and recovery path stays on the existing durable projection lifecycle.

+ 50 - 0
.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md

@@ -0,0 +1,50 @@
+# Agent Note: 高缓存命中率的小数显示
+
+Status: implemented
+
+[English](2026-08-19-high-cache-hit-decimal-display.md) | 中文
+
+## 问题
+
+Web 会话统计行会把所有非空缓存命中率舍入为整数。真实比率超过 99% 后,显示会隐藏后续提升;比率达到 99.5% 时,即使仍有未缓存输入或缓存写入,也会显示为 100%。
+
+用户因此无法区分接近完整的缓存命中与真实满命中。
+
+## 决策
+
+`StatsLine` 继续从 `@deepseek-ai/dsh-token-meter` 所拥有的完整会话 `tokenUsage` 投影派生比率;该投影仍是未缓存输入、缓存读取、缓存写入与输出计数的唯一所有方([投影决策](../architecture/2026-07-29-projected-token-usage-and-request-context.md))。展示层只改变插入现有 `stats.cacheHit` locale 模板的文本。
+
+| 真实比率 | 显示结果 |
+|---|---|
+| 没有计费输入 | 省略缓存命中分组 |
+| 整数舍入结果低于 100% | 舍入后的整数 |
+| 当前舍入结果为 100% 的非满命中 | 舍入结果低于 100% 所需的最少小数位 |
+| 100% | `100%` |
+
+所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%` 与 `99.49%` 仍显示为 `99%`,而 `99.5%`、`99.95%` 与 `99.995%` 分别保留一位、两位与三位小数。`StatsLine` 使用 `bigint` 缩放并舍入整数 token 计数,从而避开浮点格式化限制,且不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。
+
+## 归属与生命周期
+
+token-meter 继续从完整持久会话日志折叠用量。标准投影值变化时,`StatsLine` 同步派生显示文本。本决策不引入设置、持久百分比、事件、协议字段、客户端状态或恢复路径。
+
+实时更新、刷新回放与重连恢复都会还原同一组 `tokenUsage` 计数,并运行同一个显示函数。投影缺失时仍会省略全部 token 分组;输入分母为零时仍只省略缓存命中分组。
+
+## 验证
+
+组件测试固定了零分母、普通整数舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。
+
+## 备选方案
+
+**对所有比率继续使用整数舍入。** 不予采纳,因为它会隐藏 99% 以上的全部变化,并继续把部分非满命中显示为 100%。
+
+**把高位区间向下截取到一位小数。** 不予采纳,因为 `99.95%`、`99.995%` 以及更接近满命中的比率都会坍缩为 `99.9%`,无法保留区分真实满命中所需的最少精度。
+
+**限制精度并使用 `<100%` 等替代文案。** 不予采纳,因为精确累计计数能够产生所需的数值结果,而精度上限会让显示行为依赖任意的展示限制。
+
+**所有比率都显示一位小数。** 不予采纳,因为低位区间的额外变化会增加无效抖动,并改变整数精度已经足够的既有显示。
+
+**在 token-meter 中持久化显示百分比。** 不予采纳,因为投影已经携带精确计数,而展示精度属于 Web 统计行。第二个持久值会复制可派生状态,并扩大回放与协议职责。
+
+## 后果
+
+高缓存命中率会保持稳定的整数显示,直到整数舍入会错误地报告满命中;此时界面只展示维持区分所需的小数位。极接近满命中的非满比率可能因此产生较长的小数字符串,这是不设置任意精度上限或非数值回退所接受的代价。所有交付与恢复路径继续沿用既有持久投影生命周期。

+ 6 - 2
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
 const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
 const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
@@ -45,7 +46,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   const sessionEvents: SessionEvent[] = []
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
+    scaffold = await launchWebScaffold(MODE === 'record'
+      ? {}
+      : { replayFixture: FIXTURE, replayOverride: REPLAY_OVERRIDE, paceMs: REPLAY_PACE_MS })
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
@@ -206,6 +209,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     ).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+    await expect.poll(() => page.getByText('Cache hit 99.5%', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
     // Host: the session's durable header cwd is the folder the workspace
     // flow created and adopted (<workspaceCwd>/workspace) — the proof the
     // send went through workspace materialization rather than a bare
@@ -271,7 +275,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
+      'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
     ])
   })
 })

+ 2 - 2
apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md

@@ -37,6 +37,6 @@
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
-- button "6% of context used"
+- button "8% of context used"
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99.5% Input 10K tok · Output 21 tok

+ 153 - 0
apps/web/tests/snapshots/lifecycle-chrome/replay.override.json

@@ -0,0 +1,153 @@
+{
+  "patches": [
+    {
+      "at": 0,
+      "entry": {
+        "kind": "chunks",
+        "chunks": [
+          {
+            "type": "block-start",
+            "index": 0,
+            "blockType": "reasoning"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": "The"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " user"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " wants"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " me"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " to"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " reply"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " with"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " a"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " single"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " word"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": "."
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " Let"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " me"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": " comply"
+          },
+          {
+            "type": "reasoning-delta",
+            "index": 0,
+            "text": "."
+          },
+          {
+            "type": "block-start",
+            "index": 1,
+            "blockType": "text"
+          },
+          {
+            "type": "text-delta",
+            "index": 1,
+            "text": "L"
+          },
+          {
+            "type": "text-delta",
+            "index": 1,
+            "text": "IGH"
+          },
+          {
+            "type": "text-delta",
+            "index": 1,
+            "text": "TH"
+          },
+          {
+            "type": "text-delta",
+            "index": 1,
+            "text": "O"
+          },
+          {
+            "type": "text-delta",
+            "index": 1,
+            "text": "USE"
+          },
+          {
+            "type": "block-end",
+            "index": 0,
+            "block": {
+              "type": "reasoning",
+              "text": "The user wants me to reply with a single word. Let me comply."
+            }
+          },
+          {
+            "type": "block-end",
+            "index": 1,
+            "block": {
+              "type": "text",
+              "text": "LIGHTHOUSE"
+            }
+          },
+          {
+            "type": "usage",
+            "usage": {
+              "inputTokens": 50,
+              "outputTokens": 21,
+              "cacheReadTokens": 9950,
+              "reasoningTokens": 15
+            }
+          },
+          {
+            "type": "finish",
+            "reason": {
+              "kind": "stop"
+            }
+          }
+        ]
+      }
+    }
+  ]
+}

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 7793273e053142b232cf6e810cd915ddbdeb90b0
-README.zh.md: f45dfa6d72ecf5b49a12c9550d41dbcbc35f9be0
+README.md: 2e3b99fbd4fca6bbe7b12d8408edb30597f8259e
+README.zh.md: 6f3aaa313c0f9372a6191e50dd34916fadffa8f3

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/client/ui-conversation/README.md


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/client/ui-conversation/README.zh.md


+ 25 - 7
packages/client/ui-conversation/src/client/chat/StatsLine.tsx

@@ -102,15 +102,33 @@ export function formatDuration(ms: number): string {
 }
 
 /**
- * Cache-hit share of prompt-side input over the whole durable log.
+ * Display-ready cache-hit share of prompt-side input over the whole durable log.
  * @param usage - the session's token-usage projection value.
- * @returns rounded integer percent, or null when no input was billed.
+ * @returns integer text when integer rounding stays below 100, otherwise the
+ * minimum decimal precision that still rounds below 100; a full hit returns
+ * 100, and no billed input returns null.
  */
-export function cacheHitPercent(usage: TokenUsageProjection): number | null {
-  const denominator = billedInputTokens(usage)
-  return denominator === 0
-    ? null
-    : Math.round(usage.cacheReadTokens / denominator * 100)
+export function cacheHitPercent(usage: TokenUsageProjection): string | null {
+  const cacheReadTokens = BigInt(usage.cacheReadTokens)
+  const denominator = BigInt(usage.uncachedInputTokens)
+    + cacheReadTokens
+    + BigInt(usage.cacheWriteTokens)
+  if (denominator === 0n) return null
+  if (cacheReadTokens === denominator) return '100'
+
+  let decimalPlaces = 0
+  let decimalScale = 1n
+  while (true) {
+    const fullHit = 100n * decimalScale
+    const rounded = (2n * cacheReadTokens * fullHit + denominator) / (2n * denominator)
+    if (rounded < fullHit) {
+      if (decimalPlaces === 0) return rounded.toString()
+      const digits = rounded.toString().padStart(decimalPlaces + 1, '0')
+      return `${digits.slice(0, -decimalPlaces)}.${digits.slice(-decimalPlaces)}`
+    }
+    decimalPlaces += 1
+    decimalScale *= 10n
+  }
 }
 
 /**

+ 29 - 4
packages/client/ui-conversation/tests/chat-stats.client.spec.tsx

@@ -191,6 +191,10 @@ describe('StatsLine', () => {
     return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
   }
 
+  function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) {
+    return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 }
+  }
+
   it('renders the grouped stats row and hides a brand-new empty session', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsLine {...props(source)} />)
@@ -205,19 +209,40 @@ describe('StatsLine', () => {
     expect(emptyView.container.textContent).toBe('')
   })
 
+  it.each([
+    { actual: '98.6%', tokenUsageValue: tokenUsage(986, 14), expected: 'Cache hit 99%' },
+    { actual: '99.1%', tokenUsageValue: tokenUsage(991, 9), expected: 'Cache hit 99%' },
+    { actual: '99.49%', tokenUsageValue: tokenUsage(9_949, 51), expected: 'Cache hit 99%' },
+    { actual: '99.5%', tokenUsageValue: tokenUsage(995, 5), expected: 'Cache hit 99.5%' },
+    { actual: '99.94%', tokenUsageValue: tokenUsage(9_994, 6), expected: 'Cache hit 99.9%' },
+    { actual: '99.95%', tokenUsageValue: tokenUsage(9_995, 5), expected: 'Cache hit 99.95%' },
+    { actual: '99.995%', tokenUsageValue: tokenUsage(19_999, 1), expected: 'Cache hit 99.995%' },
+    {
+      actual: 'the closest non-full ratio available from safe integer cumulative counts',
+      tokenUsageValue: tokenUsage(Number.MAX_SAFE_INTEGER - 1, 1),
+      expected: 'Cache hit 99.99999999999999%',
+    },
+    { actual: '100%', tokenUsageValue: tokenUsage(10_000, 0), expected: 'Cache hit 100%' },
+  ])('formats an actual $actual cache-hit ratio as $expected', ({ tokenUsageValue, expected }) => {
+    const { source } = makeSource({ nodes: [assistant(1, 1)] })
+    const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsageValue })} />)
+    expect(view.container.textContent).toContain(expected)
+  })
+
   it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
     vi.useFakeTimers()
     // jsdom lays nothing out; fake a row narrower than its content.
     vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
     vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
-    const view = render(<StatsLine {...props(source)} />)
+    const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} />)
+    expect(view.container.textContent).toContain('Cache hit 99.95%')
     fireEvent.mouseEnter(view.container.firstElementChild!)
     act(() => { vi.advanceTimersByTime(499) })
     expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
     act(() => { vi.advanceTimersByTime(1) })
     expect(view.container.querySelector('[role="tooltip"]')?.textContent)
-      .toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok')
+      .toBe('1 turns · 1 steps | Cache hit 99.95% | Input 10K tok · Output 1 tok')
   })
 
   it('suppresses the tooltip while the row fits without truncation', () => {
@@ -245,9 +270,9 @@ describe('StatsLine', () => {
       timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
     }
     const { source } = makeSource({ nodes: [timed] })
-    const view = render(<StatsLine {...props(source)} t={t} />)
+    const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
     expect(view.container.textContent)
-      .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok')
+      .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
   })
 
   it('renders without ResizeObserver support', () => {

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.