Quellcode durchsuchen

fix(web): localize stats and close stale context meter

imccyu vor 1 Monat
Ursprung
Commit
aaca2fa52f

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.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 .agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md
-2026-08-04-web-latency-throughput-metrics.md: d7c816ba1d6380bd257fe358d26e3c2e72c3fbe1
-2026-08-04-web-latency-throughput-metrics.zh.md: c9e5f63da576b6b850a55dc2a741b59fd729705c
+2026-08-04-web-latency-throughput-metrics.md: 4d7627a9a38127259f1ba07121cea7282f794f2e
+2026-08-04-web-latency-throughput-metrics.zh.md: 09e59242b799a3f4d03b9e0259f1a5a0d85f6a53

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md

@@ -14,7 +14,7 @@ A package-local fold, `ui-conversation`'s `chat/turn-metrics.ts`, is the single
 
 The assistant footer appends the readings to the existing hover-revealed time chrome after `Ran for`, as `TTFT {s}s · {tps} tok/s`, each omitted independently when unrecorded. ChatView shows a turn's readings only when that turn's `turnTimings` entry has an `endTime`: the loaded window is a contiguous log suffix, so an in-window settled turn carries every one of its steps and the first-step TTFT is genuine rather than a window artifact. `formatLatencySeconds` is unit-less so each locale template owns its second suffix (`TTFT {seconds}s` / `首 token {seconds}秒`).
 
-The stats line reuses the same step reading in its window fold: `deriveStats` accumulates TTFT sum/count and decode span/tokens, rendering a `TTFT avg … · … tok/s` group beside the LLM/tool wall times. Like those wall times the group is window-scoped and folds no billing; token accounting stays on the token-meter projections.
+The stats line reuses the same step reading in its window fold: `deriveStats` accumulates TTFT sum/count and decode span/tokens, rendering a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English) beside the LLM/tool wall times. The turn-count, step-count, duration, cache, and token labels use the same namespace. Like those wall times the group is window-scoped and folds no billing; token accounting stays on the token-meter projections.
 
 ## Alternatives considered
 
@@ -26,6 +26,6 @@ The stats line reuses the same step reading in its window fold: `deriveStats` ac
 
 ## Consequences
 
-A settled in-window turn's footer reveals `TTFT`/`tok/s` on hover after the wall time, and the stats line shows window-average latency and throughput beside its wall times, all without new session events or host changes. Metrics degrade by omission: providers or steps without timing or usage samples drop individual figures rather than rendering zeros. Older history outside the loaded window stays uncounted, recorded in the package README's stats-line limitation.
+A settled in-window turn's footer reveals `TTFT`/`tok/s` on hover after the wall time, and the stats line shows window-average latency and throughput with localized labels beside its wall times, all without new session events or host changes. Metrics degrade by omission: providers or steps without timing or usage samples drop individual figures rather than rendering zeros. Older history outside the loaded window stays uncounted, recorded in the package README's stats-line limitation.
 
 Both readings divide by measured wall time, so neither is reproducible: the same replayed scenario yielded 69 and 70 tok/s on consecutive local runs, and a 3 ms replayed stream reads 26333 tok/s. The Web aria goldens therefore normalize throughput to `{{throughput}}` beside the existing `{{duration}}`, and the footer's decorative separators gained flanking spaces — without them the readings concatenate into one accessible string (`Ran for 13sTTFT 0.2s12 tok/s`), which both loses the reading boundaries a screen reader needs and denies `{{duration}}` the word boundary it matches on.

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md

@@ -14,7 +14,7 @@ Web 聊天已经记录了逐步骤的 LLM 计时(`stepStartTime`/`firstToken
 
 assistant 页脚把读数追加到既有 hover 显示的时间附属元素中、`用时` 之后,形如 `首 token {s}秒 · {tps} tok/s`,未记录的数字各自省略。ChatView 仅在该轮次的 `turnTimings` 条目带有 `endTime` 时才显示读数:已加载窗口是日志的连续后缀,因此窗口内已结算的轮次必然带着它的全部步骤,首步 TTFT 是真实值而非窗口截断的产物。`formatLatencySeconds` 不带单位,各语言模板各自拥有秒后缀(`TTFT {seconds}s`/`首 token {seconds}秒`)。
 
-统计行在其窗口折算中复用同一份步骤读数:`deriveStats` 累计 TTFT 总和/计数与解码时长/token 数,在 LLM/工具墙钟时间旁渲染 `TTFT avg … · … tok/s` 分组。与那些墙钟时间一样,该分组是窗口作用域的,不折算任何计费;token 账目仍归 token-meter 投影。
+统计行在其窗口折算中复用同一份步骤读数:`deriveStats` 累计 TTFT 总和/计数与解码时长/token 数,在 LLM/工具墙钟时间旁渲染经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`)。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。与那些墙钟时间一样,该分组是窗口作用域的,不折算任何计费;token 账目仍归 token-meter 投影。
 
 ## 考虑过的替代方案
 
@@ -26,6 +26,6 @@ assistant 页脚把读数追加到既有 hover 显示的时间附属元素中、
 
 ## 后果
 
-窗口内已结算轮次的页脚在 hover 时于实际耗时之后显示 `首 token`/`tok/s`,统计行在墙钟时间旁显示窗口平均延迟与吞吐,全程不新增会话事件、不改 host。指标以省略的方式退化:没有计时或 usage 采样的提供方或步骤只是丢掉对应数字,而不会渲染成零。已加载窗口之外的更早历史仍不计入,已记录在包 README 的统计行限制中。
+窗口内已结算轮次的页脚在 hover 时于实际耗时之后显示 `首 token`/`tok/s`,统计行在墙钟时间旁以本地化标签显示窗口平均延迟与吞吐,全程不新增会话事件、不改 host。指标以省略的方式退化:没有计时或 usage 采样的提供方或步骤只是丢掉对应数字,而不会渲染成零。已加载窗口之外的更早历史仍不计入,已记录在包 README 的统计行限制中。
 
 两个读数都以实测墙钟时间作分母,因此都不可复现:同一个回放场景在本机连续两次跑出 69 与 70 tok/s,而一段 3 毫秒的回放流会读成 26333 tok/s。因此 Web aria golden 在既有的 `{{duration}}` 之外,把吞吐归一化为 `{{throughput}}`;页脚的装饰性分隔符也补上了两侧空格——没有它们,这些读数会连成一整串无障碍文本(`Ran for 13sTTFT 0.2s12 tok/s`),既让屏幕阅读器失去读数之间的边界,也让 `{{duration}}` 失去它赖以匹配的词边界。

+ 5 - 1
apps/web/tests/math-rendering.e2e.ts

@@ -26,6 +26,7 @@ const DONE = 'MATH_RENDERING_DONE'
 /** Build a settled assistant reply that exercises every supported math delimiter. */
 function mathFixture(): string {
   const session = Session.create(SessionId('math-rendering-source'))
+  const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
   session.append('turn/start', {
     turn: 1,
   })
@@ -76,7 +77,10 @@ function mathFixture(): string {
       createdAt: 0,
       cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify(event)),
+    ...session.events.map(event => JSON.stringify({
+      ...event,
+      time: eventTimeOrigin + event.seq * 1_000,
+    })),
     '',
   ].join('\n')
 }

+ 2 - 2
apps/web/tests/snapshots/math-rendering/ui.expected.md

@@ -35,7 +35,7 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}}Ran for {{duration}}
+- text: {{clock}} Ran for {{duration}}
 - textbox "Message the agent"
 - button "Commands":
   - img
@@ -44,4 +44,4 @@
   - text: Select model
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

+ 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: a127661ab55e9b57dbd61515b0c2831357e50a90
-README.zh.md: 937ad5fbeca618826279ea2b9bba76fdbd0a7188
+README.md: f2b0d9bde6ec0ba0066a61338ba2da2a333801b7
+README.zh.md: 3cb344938f703f920624cfccf205af320e970786

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
packages/client/ui-conversation/README.md


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
packages/client/ui-conversation/README.zh.md


+ 1 - 1
packages/client/ui-conversation/src/client/apply.ts

@@ -334,7 +334,7 @@ export function apply(ctx: Context): void {
   }, ChatView)
 
   // Session stats stick with the composer (composer.dock = stats-line family).
-  slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
+  slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine)
 
   // Class-plugin mount (packages/AGENTS.md service form): the service
   // registers itself as `conversation` and lives on its own child fiber.

+ 21 - 11
packages/client/ui-conversation/src/client/chat/StatsLine.tsx

@@ -7,6 +7,7 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
+import type { ComposerBarProps } from '../contract/slots.ts'
 import { formatTokensPerSecond } from './message-chrome.ts'
 import { assistantStepReading } from './turn-metrics.ts'
 import css from './StatsLine.module.css'
@@ -151,24 +152,32 @@ export function contextOccupancy(
 export interface StatsLineProps {
   useSession: SnapshotSelectorHook<ConversationSnapshot>
   useProjection: UseProjection
+  /** The owning dock's locale seat. */
+  t: ComposerBarProps['t']
 }
 
-export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
+export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
   const nodes = useSession(s => s.nodes)
   const usage = useProjection('tokenUsage')
   const stats = useMemo(() => deriveStats(nodes), [nodes])
   // Pipe-separated groups (figma stats strip); a group with no data drops out whole.
   const groups: string[] = []
   if (stats.steps > 0) {
-    groups.push(`${stats.turns} turns · ${stats.steps} steps`)
+    groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
     const durations: string[] = []
-    if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
-    if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
+    if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
+    if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) }))
     if (durations.length > 0) groups.push(durations.join(' · '))
     // Window-scoped like the wall times above: averages describe loaded steps.
     const speeds: string[] = []
-    if (stats.ttftSteps > 0) speeds.push(`TTFT avg ${formatDuration(stats.ttftMs / stats.ttftSteps)}`)
-    if (stats.decodeMs > 0) speeds.push(`${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`)
+    if (stats.ttftSteps > 0) {
+      speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
+    }
+    if (stats.decodeMs > 0) {
+      speeds.push(t('stats.tokensPerSecond', {
+        throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
+      }))
+    }
     if (speeds.length > 0) groups.push(speeds.join(' · '))
   }
   // Context occupancy deliberately lives on the composer's ContextMeter ring,
@@ -178,11 +187,11 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
   if (usage !== undefined
     && (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
     const cacheHit = cacheHitPercent(usage)
-    if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
-    groups.push(
-      `Input ${formatTokens(billedInputTokens(usage))} tok`
-      + ` · Output ${formatTokens(usage.outputTokens)} tok`,
-    )
+    if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
+    groups.push(t('stats.tokens', {
+      input: formatTokens(billedInputTokens(usage)),
+      output: formatTokens(usage.outputTokens),
+    }))
   }
   const line = groups.join(' | ')
   // The row elides with ellipsis when overlong; a delayed hover tooltip carries
@@ -194,6 +203,7 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
     if (el === null) return
     const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) }
     measure()
+    if (typeof ResizeObserver === 'undefined') return
     const observer = new ResizeObserver(measure)
     observer.observe(el)
     return () => { observer.disconnect() }

+ 14 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -28,6 +28,13 @@ export const zh = {
   'context.system': '系统提示词',
   'context.tools': '工具',
   'context.messages': '对话消息',
+  'stats.counts': '{turns} 轮 · {steps} 步',
+  'stats.llm': 'LLM {duration}',
+  'stats.toolCall': '工具调用 {duration}',
+  'stats.ttftAverage': '首 token 平均 {duration}',
+  'stats.tokensPerSecond': '{throughput} tok/s',
+  'stats.cacheHit': '缓存命中 {percent}%',
+  'stats.tokens': '输入 {input} tok · 输出 {output} tok',
   'settings.enter.title': '繁忙时 Enter 键行为',
   'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
   'settings.enter.queue': '排队发送',
@@ -148,6 +155,13 @@ export const en = {
   'context.system': 'System prompt',
   'context.tools': 'Tools',
   'context.messages': 'Messages',
+  'stats.counts': '{turns} turns · {steps} steps',
+  'stats.llm': 'LLM {duration}',
+  'stats.toolCall': 'Tool call {duration}',
+  'stats.ttftAverage': 'TTFT avg {duration}',
+  'stats.tokensPerSecond': '{throughput} tok/s',
+  'stats.cacheHit': 'Cache hit {percent}%',
+  'stats.tokens': 'Input {input} tok · Output {output} tok',
   'settings.enter.title': 'Enter behavior while busy',
   'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
   'settings.enter.queue': 'Queue',

+ 10 - 3
packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx

@@ -42,10 +42,18 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
   const breakdown = useProjection('contextBreakdown')
   const [open, setOpen] = useState(false)
   const rootRef = useRef<HTMLSpanElement | null>(null)
+  const context = contextOccupancy(pressure)
+  const available = context !== null
+
+  // A model switch can temporarily remove capacity while this component stays
+  // mounted. Close the now-unavailable panel instead of preserving stale UI.
+  useEffect(() => {
+    if (!available && open) setOpen(false)
+  }, [available, open])
 
   // Outside click / Escape close, one document listener while open (Menu's pattern).
   useEffect(() => {
-    if (!open) return
+    if (!open || !available) return
     const onPointerDown = (e: PointerEvent): void => {
       if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
       setOpen(false)
@@ -59,9 +67,8 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
       document.removeEventListener('pointerdown', onPointerDown)
       document.removeEventListener('keydown', onKeyDown)
     }
-  }, [open])
+  }, [available, open])
 
-  const context = contextOccupancy(pressure)
   if (context === null) return null
   const percent = context.percent
   const reading = `${percent}%`

+ 2 - 1
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -554,12 +554,13 @@ describe('small branch tails', () => {
     const source = { getSnapshot: () => snap, subscribe: () => () => {} }
     const view = render(
       <StatsLine
+        t={t}
         useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
         useProjection={(key: string) => key === 'tokenUsage'
           ? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
           : undefined}
       />,
     )
-    expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 10 tok')
+    expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok')
   })
 })

+ 21 - 2
packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx

@@ -11,15 +11,17 @@ import type {
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
 import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
-import { zh } from '../src/client/locales.ts'
+import { en, zh } from '../src/client/locales.ts'
 
 type BashRowProps = Parameters<typeof BashRow>[0]
 
 // Mirrors the real lookup chain (conversation namespace, then common).
 const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
+const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
 
 /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
 class ResizeObserverStub {
@@ -158,7 +160,7 @@ describe('StatsLine', () => {
     source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
     values: Record<string, unknown> = { tokenUsage: USAGE },
   ): StatsLineProps {
-    return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
+    return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
   }
 
   it('renders the grouped stats row and hides a brand-new empty session', () => {
@@ -209,6 +211,23 @@ describe('StatsLine', () => {
     expect(view.container.textContent).toContain('LLM 3.8s| TTFT avg 0.8s · 20 tok/s')
   })
 
+  it('takes every stats label from the active locale', () => {
+    const timed: AssistantMessageNode = {
+      ...assistant(1, 1, { outputTokens: 60 }),
+      timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
+    }
+    const { source } = makeSource({ nodes: [timed] })
+    const view = render(<StatsLine {...props(source)} t={t} />)
+    expect(view.container.textContent)
+      .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok')
+  })
+
+  it('renders without ResizeObserver support', () => {
+    vi.unstubAllGlobals()
+    const { source } = makeSource({ nodes: [assistant(1, 1)] })
+    expect(() => render(<StatsLine {...props(source)} />)).not.toThrow()
+  })
+
   it('keeps durable token groups after the visible step window is empty', () => {
     const { source } = makeSource()
     const view = render(<StatsLine {...props(source, {

+ 22 - 0
packages/client/ui-conversation/tests/context-meter.spec.tsx

@@ -112,6 +112,28 @@ describe('ContextMeter', () => {
     expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1)
   })
 
+  it('closes when capacity disappears and stays closed when it returns', () => {
+    let values: Record<string, unknown> = {
+      contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
+      contextBreakdown: BREAKDOWN,
+    }
+    const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
+    fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
+    expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
+
+    values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN }
+    view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
+    expect(view.container.textContent).toBe('')
+
+    values = {
+      contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
+      contextBreakdown: BREAKDOWN,
+    }
+    view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
+    expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false')
+    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+  })
+
   it('closes on outside pointerdown and Escape — but not inside clicks', () => {
     const view = meter({
       contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },

+ 2 - 1
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx

@@ -67,11 +67,12 @@ describe('render branch tails', () => {
     const source = { getSnapshot: () => snap, subscribe: () => () => {} }
     const view = render(
       <StatsLine
+        t={t}
         useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
         useProjection={() => undefined}
       />,
     )
-    expect(view.container.textContent).toBe('2 turns · 3 steps')
+    expect(view.container.textContent).toBe('2 轮 · 3 步')
   })
 
   it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.