From aaca2fa52fcba175937682c36bdc34d0b5d01eb7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:21:34 +0800 Subject: [PATCH] fix(web): localize stats and close stale context meter --- ...4-web-latency-throughput-metrics.i18n.yaml | 4 +-- ...26-08-04-web-latency-throughput-metrics.md | 4 +-- ...08-04-web-latency-throughput-metrics.zh.md | 4 +-- apps/web/tests/math-rendering.e2e.ts | 6 +++- .../snapshots/math-rendering/ui.expected.md | 4 +-- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/chat/StatsLine.tsx | 32 ++++++++++++------- .../ui-conversation/src/client/locales.ts | 14 ++++++++ .../src/client/skeleton/ContextMeter.tsx | 13 ++++++-- .../tests/chat-branch-tails.spec.tsx | 3 +- .../tests/chat-stats-bash-sample.spec.tsx | 23 +++++++++++-- .../tests/context-meter.spec.tsx | 22 +++++++++++++ .../tests/gate-branch-tails.spec.tsx | 3 +- 16 files changed, 110 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml index f8b22ddc13..16ccf1cff3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md index d7c816ba1d..4d7627a9a3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md +++ b/.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. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md index c9e5f63da5..09e59242b7 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md +++ b/.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}}` 失去它赖以匹配的词边界。 diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index 2c908bffae..b183fe7df5 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/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') } diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index f8503988b9..be1bbb7069 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/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 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d260b78624..e040af0c90 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a127661ab5..f2b0d9bde6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -46,7 +46,7 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. -The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a `TTFT avg … · … tok/s` group; a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. +The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 937ad5fbec..3cb344938f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -46,7 +46,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到 `TTFT avg … · … tok/s` 分组;缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 +聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 4f662f3ab6..a10b2b4f87 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/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. diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index ee80692e8e..8e672740cf 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/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 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() } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 09cf15a779..62a112cc42 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/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', diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx index df98cc93d3..1f0bedded7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx +++ b/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(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}%` diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 279377011f..64081609de 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/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( 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') }) }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index dbc01fa36d..7187851420 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/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[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 = { 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() + 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()).not.toThrow() + }) + it('keeps durable token groups after the visible step window is empty', () => { const { source } = makeSource() const view = render( { expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1) }) + it('closes when capacity disappears and stays closed when it returns', () => { + let values: Record = { + contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + } + const view = render( 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( values[key]} t={t} />) + expect(view.container.textContent).toBe('') + + values = { + contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + contextBreakdown: BREAKDOWN, + } + view.rerender( 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 }, diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 8d77ed9419..5adb4d817e 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/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( } 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', () => {