dsh-recent-usage-metrics 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,363 +1,409 @@
1
- # dsh-recent-usage-metrics
2
-
3
- DSH 的极客级请求指标。它在 **设置 → 通用 → 性能与用量** 里加入第三档 **极客**,
4
- 选中后,输入区状态行会常驻显示最新一次请求的实时读数,并可用一个磨砂玻璃抽屉
5
- 查看最近 10 次请求的完整指标。
6
-
7
- **简体中文** · [English](README.en.md)
8
-
9
- ![抽屉:最近请求指标(中文)](assets/screenshot-1.png)
10
-
11
- 上图是抽屉展开的样子:顶部是正在生成的那一条(首字在增长,速度列显示 `—`),
12
- 下面按新到旧列出最近 10 次请求,底部一行是它们的平均值。
13
- 最下方是状态栏里的气泡,与抽屉各列一一对齐。
14
-
15
- 界面为中英双语,跟随 DSH 的语言设置。英文界面下的同一抽屉:
16
-
17
- ![抽屉:最近请求指标(英文)](assets/screenshot-2.png)
18
-
19
- ## 你会得到什么
20
-
21
- **设置 → 通用 → 性能与用量** 现在有三档:
22
-
23
- | 档位 | 行为 |
24
- | --- | --- |
25
- | 简洁 | 原生行为,不变 |
26
- | 详细 | 原生行为,不变 |
27
- | **极客** | 本文下面描述的全部内容 |
28
-
29
- 简洁与详细仍写入原生的 `ui-chat` → `performanceUsage` 字段,因此行为与原来完全一致,
30
- 也和原生行的数据保持同步。极客是一个独立的持久标记,因为原生 Chat 的 schema 只接受两个值。
31
-
32
- **输入区状态行**(位于原生 `55 轮 2307 步` 气泡左侧):
33
-
34
- ```
35
- ⚡ 2.3 s 445 tok/s 1024 tok 4.6 s 223 tok/s
36
- ```
37
-
38
- 依次为:首字时间 · 实时生成速度 · 输出 Token · 总用时 · 折算速度(输出 ÷ 总用时),
39
- 各项之间只用空白分隔。该气泡复刻原生气泡:文字与图标同样使用
40
- `--dsw-alias-label-tertiary`(原生气泡从不给图标上色),字号体系也相同。
41
- 高度由 padding + line-height 自然撑开,因此与原生的那一行齐平。
42
-
43
- 每个数字都以**固定宽度写在数字元素自身上**——绝不写在外层格子上,因为格子里还有单位,
44
- 写窄了会把单位裁掉。估算标记是一个常驻占位的 1ch 元素,只切换可见性。
45
- 于是「`3.1` 长成 `57.8`」「速度从 `328` 掉到 `9`」「`--` 占位」「`~` 出现或消失」
46
- 这些变化都让气泡宽度逐字节不变——居中的输入区永远不会位移。
47
-
48
- 这些宽度来自一份 433 条读数的真实会话日志的分布,每个格式化函数都写得不会超出自己的预算:
49
-
50
- | 槽位 | 位数 | 为什么够 |
51
- | --- | --- | --- |
52
- | 首字 / 总用时 | `4.5ch` | 通常 `57.8`;超过 100 秒就打印整秒(`126`) |
53
- | 速度 / 折算速度 | `4.5ch` | 日志中 p50 `85`,最大 `328` |
54
- | 输出 Token | `5.5ch` | p50 `219`;`123456` 折叠为 `123k` |
55
-
56
- 气泡与抽屉使用**互不相交的类名**(`dsh-rum-part` vs `dsh-rum-col`)。
57
- 曾因二者共用同一个单元格类名,抽屉的省略号规则把气泡的单位也裁掉了——
58
- 这正是「数字后面跟着被截断的 ` s` / ` tok/s`」那次回归的成因。
59
-
60
- 抽屉的列宽是**在运行时从气泡实测得到的**,不是用 `ch` 推算的。`ch` 是字符 `0` 的推进宽度,
61
- 而 `tok/s` 和细窄的 `s` 实际都比它窄,所以算术推算出来的列永远无法精确落在渲染出的格子下面。
62
- 打开抽屉时,插件读取每个 `[data-metric]` 格子的 `getBoundingClientRect().width`,
63
- 按指标名各发布一个自定义属性(`--dsh-rum-col-ttft`、`--dsh-rum-col-speed`、
64
- `--dsh-rum-col-tokens`、`--dsh-rum-col-total`、`--dsh-rum-col-amortized`),由行的 grid 消费。
65
- 气泡上挂一个 `ResizeObserver`,在字体缩放或指标变化后重新测量;
66
- 测量落地前的那一帧由 `ch` 表达式兜底。
67
-
68
- | 抽屉列 | 消费的变量 | 对齐到 |
69
- | --- | --- | --- |
70
- | 首字 | `--dsh-rum-col-ttft` | 气泡的首字格 |
71
- | 速度 | `--dsh-rum-col-speed` | 气泡的速度格 |
72
- | 输出 Token | `--dsh-rum-col-tokens` | 气泡的 Token 格 |
73
- | 总用时 | `--dsh-rum-col-total` | 气泡的总用时格 |
74
- | 折算速度 | `--dsh-rum-col-amortized` | 气泡的折算速度格 |
75
-
76
- 还有两个细节才让边缘真正重合:
77
-
78
- - **对齐内容边缘,不是容器边缘。** 气泡的内容右内边距是 8px,而抽屉是自身 16px
79
- 加上一行的 6px。因此对齐两个*容器*会让数字明显内缩。两边的内边距都被实测
80
- (`drawerContentInset` / `pillContentInset`)并相减,抽屉里最后一个数字才与气泡里
81
- 最后一个数字齐平。
82
- - **富余宽度归到左侧。** 抽屉保留 `min-width` 以免自己的表头被截断;每一行用
83
- `justify-content: end`,把多余的空间推向左方,让最后一列保持贴右。
84
- 抽屉还会在下一个动画帧重新定位一次,因为首次定位可能跑在实测列宽生效之前。
85
-
86
- 点击气泡打开抽屉;再次点击、按 Esc、或点击外部即可关闭。没有悬停打开,也没有悬停提示。
87
-
88
- ## 三种状态
89
-
90
- 插件只该有两种「忙」和一种「闲」,三者的判定与呈现都不同:
91
-
92
- | 状态 | 判定 | 状态栏图标 | 抽屉右上角 | 实时行 |
93
- | --- | --- | --- | --- | --- |
94
- | **模型输出中** | `model` 为 `null` | ⚡ 闪电闪动 | `生成中` | 有(共 11 行) |
95
- | **工具调用中** | `model` 有值、`endTime` 为 `null` | **T 闪动** | **`调用工具中`** | 有(共 11 行) |
96
- | **空闲** | `endTime` 有值,或没有运行中的步骤 | 静止 | `最近 N 条` | 无(共 10 行) |
97
-
98
- ### 为什么要分「工具调用中」
99
-
100
- 本插件统计的是**模型输出性能**,而 `step/end` 要等这一步的工具调用全部跑完才到。
101
- 若只看 `running`(它由 `step/end` 清零),模型回复早已结束、工具还在跑的整段时间里,
102
- 插件都会停在「生成中」:闪电一直闪、实时行一直占着第 11 行。
103
-
104
- 分隔前两者的字段是 `model`,也就是 `messageTime − startTime`:
105
-
106
- - 模型还在写 → `null`
107
- - `assistant/message` 落地 → 定值,**即使工具还在跑**
108
-
109
- 再用 `endTime` 把工具阶段与空闲分开。这两个字段都来自宿主的 `sessionStats` 折叠,
110
- 与统计页显示的是同一份数据。
111
-
112
- ### 工具阶段仍保留实时行
113
-
114
- 这一阶段请求确实还在飞行,所以它继续占一行;只是标记从闪电换成 T,
115
- 抽屉标题也从「生成中」改成「调用工具中」。三个状态因此一眼可分。
116
-
117
- 两种图标共用**同一个 14×14 的 svg 盒**(`.dsh-rum-trigger svg`)与同一段 `pulse` 动画。
118
- 切换时改变的是盒内的字形与颜色,盒子本身不动,所以状态栏不会位移。
119
- T 的字形特意收窄,避免比闪电看起来更重。
120
-
121
- ### 降级
122
-
123
- 拿不到统计折叠时的组成(`uiConversation` 缺失)没有 `model` 可读,此时 `partial`
124
- 与轨迹域的请求都只可能表示模型正在输出,因此一律按「模型输出中」处理——
125
- 不会因为缺少折叠而误判为空闲。
126
-
127
- **抽屉**——从状态行向上滑出,右边缘与该气泡对齐,`position: fixed` 挂在
128
- `document.body` 上,因此不会被任何滚动容器裁剪。它的材质逐字复刻了原生统计弹窗:
129
- `--dsw-specific-menu` 底色(深色模式下是 `#303136f0`,即近乎不透明,正是原生数字背后的那层)、
130
- `--dsw-menu-backdrop-filter` 的 40px/150% 模糊、`--dsw-elevation-prominent` 描边与阴影、
131
- `--dsw-radius-lg`、以及 16px 内边距。它按最新在上的顺序列出最近十次请求,底部不加说明文字:
132
-
133
- | # | 时间 | 首字 | 速度 | 输出 Token | 总用时 | 折算速度 |
134
- | --- | --- | --- | --- | --- | --- | --- |
135
- | 1 | 18:42:07 | 2.3 s | 445 tok/s | 1024 tok | 4.6 s | 223 tok/s |
136
-
137
- 「时间」列取请求**发起**的时刻(`step/start`),与统计页一致。它不取结束时刻:
138
- 否则一行写着 18:42:07、总用时 4.6 秒,实际却是 18:42:12 才完成,读起来会误解成「那一刻就结束了」;
139
- 而且列表会变成按完成时间排序,而不是按发起时间。
140
-
141
- 新请求从顶部滑入;被十行上限挤掉的那一行在**同一帧**内移除,而不是自己淡出。
142
- 离场动画会让第 11 行在整个动画期间参与布局,使抽屉先长高一行再缩回——
143
- 每次插入都会看到一次高度跳动。打开抽屉时也只是建立基线,不播放动画,
144
- 所以整个列表不会在打开的瞬间一起滑入。
145
-
146
- 某个步骤正在流式生成时,抽屉把它显示为历史上方的一条 live 行,状态读数也切换为 live 样式。
147
- 历史下方有一条分隔线,线下面是汇总行——对当前可见请求取平均值,复用同一套 grid,
148
- 因此每个均值都落在它所汇总的那一列下面。完全没有样本的列(比如中断请求没有首字时间)
149
- 打印破折号,而不是容易误解的 0。
150
-
151
- ## 数字是怎么算出来的
152
-
153
- 一切都在浏览器内、从 Conversation 层本就维护的 Chat 快照推导得出——
154
- 插件没有新增任何 Host 服务、会话事件或投影。
155
-
156
- - **首字时间**——`timing.firstTokenTime − timing.stepStartTime`,即请求发出的时刻
157
- 与第一个可见增量到达的时刻。
158
- - **总用时**——`timing.completedTime − timing.stepStartTime`。
159
- - **实时速度**——输出 Token ÷ 解码时长(`completedTime − firstTokenTime`)。
160
- - **输出 Token**——上游自己的 `usage.outputTokens`,这是权威值。它读自
161
- `assistant/message.data.usage`(已用真实会话日志核对:262 个已结算步骤全部携带它)。
162
- `reasoningTokens` 已经包含在这个计数里,绝不再叠加。对于原样转发自身 usage 的适配器,
163
- 也接受 OpenAI 风格的 `completionTokens` 拼写。
164
- - **折算速度**——输出 Token ÷ 总用时。
165
-
166
- 字符估算是真正的兜底,不是常规路径:只有当某个步骤的上游完全没有返回 usage 时才会用到
167
- (中断或失败的请求),这样的行会被标记 `~`。
168
-
169
- 它的系数是标定出来的,不是猜的。内容并不同质:正文与推理大约 4 个 ASCII 字符/token,
170
- 而工具调用参数是 JSON——短键名、标点、带引号的值——分词效率明显更差,约 2.5。
171
- 以真实会话日志 729 个已结算步骤为样本,与上游自己的 `outputTokens` 对比,
172
- 取中位绝对对数误差:
173
-
174
- | 规则 | 中位误差 | 估计 ÷ 实际(p50) |
175
- | --- | --- | --- |
176
- | 全部共用 `/4` | 0.361 | 0.70 |
177
- | 正文 `/4`、工具参数 `/2.5`(现行) | **0.128** | **1.00** |
178
-
179
- 把两者拆开才是消除偏差的关键。旧的共用除数会系统性低估约 30%,
180
- 这正是生成过程中实时折算速度读数偏低的原因;细网格搜索给出的最优值是 0.125,
181
- 现行规则与之相差三千分之一。CJK 保留惯例的 1 token/字符:搜索建议了更低的代价,
182
- 但样本中只有 45% 的步骤含任何 CJK,该参数在这里辨识度很差,拟合出来是噪声。
183
-
184
- ### 工具参数在流式过程中
185
-
186
- `legacy.partial.blocks` 在一个调用进行期间**不**携带参数文本。存储层的
187
- `tool-call-delta` 分支是原样复制 `argsRaw`,所以它一直是空字符串,直到 `block-end`
188
- 一次性填入。因此只统计 `blocks` 会让实时读数在整个写入阶段冻结——
189
- 而这一阶段占据了真实输出的绝大部分:在一份会话日志的 874 个已结算步骤里,
190
- 工具参数占估算 token 的**中位数 93%**,且 98% 的步骤都带有它们。
191
-
192
- 分片在调用期间确实存在。它们累积在调用自身的 `args.chunks` 视图上,
193
- 对于仍处于 `preparing` 阶段的调用,`legacy.runningCalls` 会把它暴露出来,
194
- 因此实时计数从那里读取,并按工具参数系数加权。这就是为什么写文件期间数字会持续攀升,
195
- 而不是等到结束时才跳一下。
196
-
197
- 流式行本质上是近似的——Chat 快照只把正在运行的步骤暴露为
198
- `legacy.partial`(turn、step、blocks),没有每步的起始时间。有两点让这个近似保持诚实:
199
-
200
- - **步骤级的起点。** 结算后的数值是 `firstToken − stepStart`,所以实时值必须用同一个起点。
201
- 若改用*轮次*起点,就会把该轮中更早的一切都算进首字时间里:在一份真实会话日志中,
202
- 从轮次开始到后序步骤开始的间隔**中位数 174 秒**,最大达到 **2720 秒**。
203
- 这就是为什么实时读数可能在推理时显示几十秒,而步骤一结算却只剩几秒。
204
- - **用工具结果,而不是 `completedTime`。** `timing.completedTime` 取自 `assistant/message`
205
- 事件,落在该步**工具执行之前**。把它当下一步的起点,就会把整个工具执行时间算进去:
206
- 实测该间隔中位数 243 毫秒,最大 **120 秒**——这正是「生成中显示 6 秒、结算后变成 2 秒」
207
- 的成因。而一步的最后一个 `tool/result` 距它自己的 `step/end` 不超过 1 毫秒,
208
- 距下一步 `step/start` 中位数只有 9 毫秒,是目前浏览器侧能拿到的最紧起点。
209
- 归属用 **seq 区间**而不是时间戳:续写的日志会重放步骤,时间戳会重复。
210
- 实测 1113 个步骤,起点误差从 p50 100 ms / p99 20.8 s 降到 **p50 9 ms / p99 486 ms**,
211
- 超过一秒的步骤从 114 个降到 **1 个**。
212
- 该步没有调用工具时,退回它自己的 `completedTime`(此时它就是这一轮的最终回答,
213
- 后面不再有工具);一轮的第一步退回轮次起点(实测晚 26 ms)。
214
- - **首个增量,而不是首段可见文本。** 结算后的 `firstTokenTime` 也把推理与工具调用增量计入,
215
- 而 `legacy.partial` 只在步骤有了可见内容后才出现——所以新的 partial key 出现的瞬间
216
- *就是*首字时刻。
217
- - **从 `step/start` 就开始计时。** Chat 的 `legacy.partial` 要等第一个可见块,
218
- 实测距步骤开始**中位数 1.9 秒**(p90 2.9 秒)——而这段时间正是首字的主体。
219
- 所以只看 Chat 的话,数字会「一出现就是终值」,看不到增长。
220
- 轨迹域在 `step/start` 那一刻就建立了该步的 assistant 记录,其 `requests[]` 里
221
- 带 `status: "running"` 与精确的 `startedAt`(`step/start` 属于 location boundary,
222
- 会话装配层会把发布优先级从定义声明的 `none` 提升为 `immediate`)。
223
- 插件据此判断「本步正在进行」并取起点,于是首字在生成中由 `now − startedAt` 持续增长,
224
- 首个 token 到达后改用 `firstTokenTime − startedAt` 锁定——与结算值是同一个减法。
225
-
226
- ## 回放历史的首字时间
227
-
228
- 从已有会话给抽屉回填数据时,首字列曾经是空的,尽管轨迹面板对同样的步骤能显示出数值。
229
- 原因是数据流事实,而不是计算缺失:`timing.firstTokenTime` 是*流式累积*状态。
230
- Chat 存储从实时 token 增量中累积它,而会话日志不存 live chunk——
231
- 一份真实日志里有 787 个已结算消息,`assistant/live-chunk` 事件是**零**个。
232
- 因此从该日志回放的步骤没有这个字段。
233
-
234
- 但这个值仍可恢复。每条已结算消息都携带 `data.stream`,即完整的 chunk 时间线,
235
- 每个分片都有自己的时间戳;轨迹域正是从这里重建该时刻
236
- (`firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream)`),
237
- 对每个打包的 run 用 `time0 + dt` 做前缀和。这就是为什么它的面板能给 Chat 快照
238
- 无法定时的历史标上时间。
239
-
240
- 插件从轨迹快照读取这些时间——槽位条目通过标准 `useTrajectory` hook 访问它——
241
- 并据此给自己的行定时。有两点后果值得知道:
242
-
243
- - 轨迹域是另一个插件的投影,所以它的时间可能在插件完成回填*之后*才到达。
244
- 那些已存为无时间的行会在此时就地修复(`backfill`),而不是整个会话都停在 `--`。
245
- - 曾经流式实时记录下来的值绝不会被覆盖:修复只触碰首字时间仍然缺失的行。
246
-
247
- 如果某个组装里没有轨迹域,`useTrajectory` 就是缺席的,插件不建立索引,
248
- 无时间的行继续显示 `--`——即之前的行为,且不报错。
249
-
250
- ## 安装
251
-
252
- ```powershell
253
- # 在 dsh GUI 的插件管理器中安装,或:
254
- # plugin_manager install_bundle,target 指向本目录
255
- ```
256
-
257
- 该 bundle 把本目录链接进 profile,因此改完 `client/client.js` 刷新页面即可生效。
258
- 宿主侧代码永远不需要重启,因为它本身就存在。
259
-
260
- ## 目录结构
261
-
262
- | 路径 | 作用 |
263
- | --- | --- |
264
- | `client/client.js` | 全部功能:设置行、状态读数、抽屉 |
265
- | `lib/index.js` | 空的 Host 半——让包成为一个 Loader 条目,从而发布 `./client` |
266
- | `cordis.patch.yml` | 唯一插入的那一行 Loader 条目 |
267
- | `locale/en.json`、`locale/zh.json` | 插件管理器里显示的标题与说明 |
268
- | `icon.svg` | 插件图标 |
269
- | `test/lifecycle.cjs` | 在 DOM/hook shim 上跑真实的客户端:指标、十行上限、插入顺序、点击开合、清理 |
270
- | `test/repro-coldstart.cjs` | 回归:冷页面必须首屏中文,且双向可切换 |
271
- | `test/repro-hmr.cjs` | 回归:重载不得与存活的字典占用冲突 |
272
- | `test/repro-log.cjs` | 用本机真实会话日志复核(见下节),无日志时自动跳过 |
273
-
274
- ```powershell
275
- node test/lifecycle.cjs
276
- node test/repro-coldstart.cjs
277
- node test/repro-hmr.cjs
278
- $env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
279
- ```
280
-
281
- ## 用真实会话日志复核
282
-
283
- 统计页的数字来自磁盘上的会话日志:
284
-
285
- ```
286
- ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
287
- ```
288
-
289
- `test/repro-log.cjs` 会找到最新的那份日志,按统计页自己的 `foldEvent` / `stepMetrics`
290
- 算法重新折一遍,再检查抽屉依赖的那些性质:每步都有起始时刻、首字延迟非负且不超过总用时、
291
- 已结算的步骤有总用时、运行中的步骤还没有总用时、抽屉选出的是最新十行且按新到旧排列。
292
-
293
- 这比对着屏幕核对可靠得多:一次能覆盖上千步,而不是几行。
294
-
295
- 两个容易踩的坑,脚本里都处理了:
296
-
297
- - **日志是多帧拼接的 zstd。** 单次 `zstdDecompressSync` 只解出第一帧(几百字节),
298
- 必须按帧魔数 `28 B5 2F FD` 切开逐个解压,否则只能拿到 `session` 头。
299
- - **首字时间藏在打包记录里。** 日志中没有 `assistant/live-chunk`(那是仅客户端事件,
300
- 不落盘),首字时刻在 `assistant/message.data.stream` 的
301
- `reasoning-chunks` / `tool-call-chunks` 记录内:`time0` 加上 `dt` 逐段累加。
302
- 只认松散的 `chunk` 记录会让首字时间几乎全为 `--`(实测从 0% 提升到 96%)。
303
-
304
- ## 重启之后的语言
305
-
306
- `locale` 被声明为**硬依赖**(`inject: ['slots', 'locale']`),`apply` 读取 `ctx.locale`。
307
- 用 `ctx.get('locale')` 去探测是一个真实缺陷,症状相当令人困惑:locale 插件本身要等待
308
- connection、settings、remotes 这几个服务,因此冷页面时本插件可能先执行 `apply`,
309
- 读到 `undefined`,跳过字典注册,然后经由一个没有字典的 `t` 渲染——
310
- 表现为英文或原始 key,而且即便把语言切回去也仍然是英文。
311
-
312
- `configForms` 是可选的(某个 profile 可能在不带设置域的情况下组装本 bundle),
313
- 所以用 `ctx.inject(['configForms'], …)` 等待它,而不是探测一次。若只探测一次,
314
- 一旦该域晚到,设置行就会整个会话被锁死在兜底偏好上。两种选择都遵循插件规范:
315
- 必需的服务放进 `inject`,可选的放进 `ctx.inject`。
316
-
317
- 字典占用被挂在 `globalThis` 上,好让热重载能在注册前释放上一代的占用;
318
- 否则第二次 `register` 同一命名空间会抛错,让界面失去字典——同样的可见症状,另一条路径。
319
-
320
- ## 备注
321
-
322
- - 该行以 `priority: -1` 和原生的 `order: 30` 注册进原生的 `performance-usage` 单元格,
323
- 因此它渲染在同一位置,原生单元格是被遮蔽而不是重复。
324
- - 所有比一次渲染活得更久的东西都创建在 `ctx.effect` 里,因此禁用插件会恢复原生行,
325
- 并移除每一个监听器与样式。
326
- - **只有极客标记存放在 `localStorage`**(`dsh.recentUsageMetrics.mode.v1`)。
327
- 抽屉里的记录不再持久化:它们从当前会话的统计数据派生,见下面的说明。
328
-
329
- ## 抽屉的数据从哪来
330
-
331
- 抽屉的每一行读自 **DSH 自带「统计」页所用的同一份数据**(`stats` 视图目标),
332
- 而不是插件自己再算一遍:
333
-
334
- ```js
335
- const target = uiConversation.binding(sessionId).target('stats')
336
- ```
337
-
338
- 那份数据由宿主侧的 `sessionStats` fold 从会话日志算出,每步都带着 `ttft`、`model`、
339
- `decode`、`tps`、`output`、`wall`。统计页自己显示的首 token 就是
340
- `firstToken − step/start`,因此两边逐行一致,不再存在两套口径。
341
-
342
- **为什么不再本地保存:**
343
-
344
- 以前抽屉把历史写进 `localStorage`,用全局单一 key,行键是 `${turn}:${step}`。
345
- 而**每个会话的轮次和步骤都从 1 重新编号**,所以两个会话的 `1:1` 会撞在一起——
346
- 切换会话后看到的其实是另一个会话的数字,切回来又变。这正是「切换回来就不准」的成因。
347
-
348
- 现在:
349
-
350
- - 抽屉的行在每次渲染时从该会话自己的统计数据重建,没有本地副本可以过期
351
- - 收到会话切换的消息时立刻清空,避免新数据到达前短暂显示上一个会话的行
352
- - 页面刷新后自动从日志重建,不需要额外的持久化
353
-
354
- **视图目标需要先订阅才会存在**(`getSnapshot` 在订阅前返回 `undefined`)。插件在
355
- `apply` 时按会话订阅一次;激活是永久且共享的,所以不会在统计页之外再跑一份 fold。
356
-
357
- **降级路径**:若某个组装没有统计页数据(`uiConversation` 不存在),抽屉退回原先的
358
- Chat 快照 + 轨迹域算法,并按估算标记 `~`。这条路径只在缺少统计数据时生效。
359
-
360
- **流式期间不读统计数据**:那套 fold 每来一个 chunk 就会重建整张表。状态栏的实时读数
361
- 走另一条更轻的路径(轨迹域的 `step/start` 时刻 + 每 150 ms 的本地 tick),
362
- 只在步骤结算后读一次统计数据定稿。这样既保住了「数字从 0 开始爬」的观感,
363
- 也不会在流式期间额外增加 fold 的负担。
1
+ # dsh-recent-usage-metrics
2
+
3
+ DSH 的极客级请求指标。它在 **设置 → 通用 → 性能与用量** 里加入第三档 **极客**,
4
+ 选中后,输入区状态行会常驻显示最新一次请求的实时读数,并可用一个磨砂玻璃抽屉
5
+ 查看最近 10 次请求的完整指标。
6
+
7
+ **简体中文** · [English](README.en.md)
8
+
9
+ ![抽屉:最近请求指标(中文)](assets/screenshot-1.png)
10
+
11
+ 上图是抽屉展开的样子:顶部是正在生成的那一条(首字在增长,速度列显示 `—`),
12
+ 下面按新到旧列出最近 10 次请求,底部一行是它们的平均值。
13
+ 最下方是状态栏里的气泡,与抽屉各列一一对齐。
14
+
15
+ 界面为中英双语,跟随 DSH 的语言设置。英文界面下的同一抽屉:
16
+
17
+ ![抽屉:最近请求指标(英文)](assets/screenshot-2.png)
18
+
19
+ ## 你会得到什么
20
+
21
+ **设置 → 通用 → 性能与用量** 现在有三档:
22
+
23
+ | 档位 | 行为 |
24
+ | --- | --- |
25
+ | 简洁 | 原生行为,不变 |
26
+ | 详细 | 原生行为,不变 |
27
+ | **极客** | 本文下面描述的全部内容 |
28
+
29
+ 简洁与详细仍写入原生的 `ui-chat` → `performanceUsage` 字段,因此行为与原来完全一致,
30
+ 也和原生行的数据保持同步。极客是一个独立的持久标记,因为原生 Chat 的 schema 只接受两个值。
31
+
32
+ **输入区状态行**(位于原生 `55 轮 2307 步` 气泡左侧):
33
+
34
+ ```
35
+ ⚡ 2.3 s 445 tok/s 1024 tok 4.6 s 223 tok/s
36
+ ```
37
+
38
+ 依次为:首字时间 · 实时生成速度 · 输出 Token · 总用时 · 折算速度(输出 ÷ 总用时),
39
+ 各项之间只用空白分隔。该气泡复刻原生气泡:文字与图标同样使用
40
+ `--dsw-alias-label-tertiary`(原生气泡从不给图标上色),字号体系也相同。
41
+ 高度由 padding + line-height 自然撑开,因此与原生的那一行齐平。
42
+
43
+ 每个数字都以**固定宽度写在数字元素自身上**——绝不写在外层格子上,因为格子里还有单位,
44
+ 写窄了会把单位裁掉。估算标记是一个常驻占位的 1ch 元素,只切换可见性。
45
+ 于是「`3.1` 长成 `57.8`」「速度从 `328` 掉到 `9`」「`--` 占位」「`~` 出现或消失」
46
+ 这些变化都让气泡宽度逐字节不变——居中的输入区永远不会位移。
47
+
48
+ 这些宽度来自一份 433 条读数的真实会话日志的分布,每个格式化函数都写得不会超出自己的预算:
49
+
50
+ | 槽位 | 位数 | 为什么够 |
51
+ | --- | --- | --- |
52
+ | 首字 / 总用时 | `4.5ch` | 通常 `57.8`;超过 100 秒就打印整秒(`126`) |
53
+ | 速度 / 折算速度 | `4.5ch` | 日志中 p50 `85`,最大 `328` |
54
+ | 输出 Token | `5.5ch` | p50 `219`;`123456` 折叠为 `123k` |
55
+
56
+ 气泡与抽屉使用**互不相交的类名**(`dsh-rum-part` vs `dsh-rum-col`)。
57
+ 曾因二者共用同一个单元格类名,抽屉的省略号规则把气泡的单位也裁掉了——
58
+ 这正是「数字后面跟着被截断的 ` s` / ` tok/s`」那次回归的成因。
59
+
60
+ 抽屉的列宽是**在运行时从气泡实测得到的**,不是用 `ch` 推算的。`ch` 是字符 `0` 的推进宽度,
61
+ 而 `tok/s` 和细窄的 `s` 实际都比它窄,所以算术推算出来的列永远无法精确落在渲染出的格子下面。
62
+ 打开抽屉时,插件读取每个 `[data-metric]` 格子的 `getBoundingClientRect().width`,
63
+ 按指标名各发布一个自定义属性(`--dsh-rum-col-ttft`、`--dsh-rum-col-speed`、
64
+ `--dsh-rum-col-tokens`、`--dsh-rum-col-total`、`--dsh-rum-col-amortized`),由行的 grid 消费。
65
+ 气泡上挂一个 `ResizeObserver`,在字体缩放或指标变化后重新测量;
66
+ 测量落地前的那一帧由 `ch` 表达式兜底。
67
+
68
+ | 抽屉列 | 消费的变量 | 对齐到 |
69
+ | --- | --- | --- |
70
+ | 首字 | `--dsh-rum-col-ttft` | 气泡的首字格 |
71
+ | 速度 | `--dsh-rum-col-speed` | 气泡的速度格 |
72
+ | 输出 Token | `--dsh-rum-col-tokens` | 气泡的 Token 格 |
73
+ | 总用时 | `--dsh-rum-col-total` | 气泡的总用时格 |
74
+ | 折算速度 | `--dsh-rum-col-amortized` | 气泡的折算速度格 |
75
+
76
+ 还有两个细节才让边缘真正重合:
77
+
78
+ - **对齐内容边缘,不是容器边缘。** 气泡的内容右内边距是 8px,而抽屉是自身 16px
79
+ 加上一行的 6px。因此对齐两个*容器*会让数字明显内缩。两边的内边距都被实测
80
+ (`drawerContentInset` / `pillContentInset`)并相减,抽屉里最后一个数字才与气泡里
81
+ 最后一个数字齐平。
82
+ - **富余宽度归到左侧。** 抽屉保留 `min-width` 以免自己的表头被截断;每一行用
83
+ `justify-content: end`,把多余的空间推向左方,让最后一列保持贴右。
84
+ 抽屉还会在下一个动画帧重新定位一次,因为首次定位可能跑在实测列宽生效之前。
85
+
86
+ 点击气泡打开抽屉;再次点击、按 Esc、或点击外部即可关闭。没有悬停打开,也没有悬停提示。
87
+
88
+ ## 三种状态
89
+
90
+ 插件只该有两种「忙」和一种「闲」,三者的判定与呈现都不同:
91
+
92
+ | 状态 | 判定 | 状态栏图标 | 抽屉右上角 | 实时行 |
93
+ | --- | --- | --- | --- | --- |
94
+ | **模型输出中** | `model` 为 `null` | ⚡ 闪电闪动 | `生成中` | 有(共 11 行) |
95
+ | **工具调用中** | `model` 有值、`endTime` 为 `null` | **T 闪动** | **`调用工具中`** | 有(共 11 行) |
96
+ | **空闲** | `endTime` 有值,或没有运行中的步骤 | 静止 | `最近 N 条` | 无(共 10 行) |
97
+
98
+ ### 为什么要分「工具调用中」
99
+
100
+ 本插件统计的是**模型输出性能**,而 `step/end` 要等这一步的工具调用全部跑完才到。
101
+ 若只看 `running`(它由 `step/end` 清零),模型回复早已结束、工具还在跑的整段时间里,
102
+ 插件都会停在「生成中」:闪电一直闪、实时行一直占着第 11 行。
103
+
104
+ 分隔前两者的字段是 `model`,也就是 `messageTime − startTime`:
105
+
106
+ - 模型还在写 → `null`
107
+ - `assistant/message` 落地 → 定值,**即使工具还在跑**
108
+
109
+ 再用 `endTime` 把工具阶段与空闲分开。这两个字段都来自宿主的 `sessionStats` 折叠,
110
+ 与统计页显示的是同一份数据。
111
+
112
+ ### 工具阶段仍保留实时行
113
+
114
+ 这一阶段请求确实还在飞行,所以它继续占一行;只是标记从闪电换成 T,
115
+ 抽屉标题也从「生成中」改成「调用工具中」。三个状态因此一眼可分。
116
+
117
+ 两种图标共用**同一个 14×14 的 svg 盒**(`.dsh-rum-trigger svg`)与同一段 `pulse` 动画。
118
+ 切换时改变的是盒内的字形与颜色,盒子本身不动,所以状态栏不会位移。
119
+ T 的字形特意收窄,避免比闪电看起来更重。
120
+
121
+ ### 降级
122
+
123
+ 拿不到统计折叠时的组成(`uiConversation` 缺失)没有 `model` 可读,此时 `partial`
124
+ 与轨迹域的请求都只可能表示模型正在输出,因此一律按「模型输出中」处理——
125
+ 不会因为缺少折叠而误判为空闲。
126
+
127
+ **抽屉**——从状态行向上滑出,右边缘与该气泡对齐,`position: fixed` 挂在
128
+ `document.body` 上,因此不会被任何滚动容器裁剪。它的材质逐字复刻了原生统计弹窗:
129
+ `--dsw-specific-menu` 底色(深色模式下是 `#303136f0`,即近乎不透明,正是原生数字背后的那层)、
130
+ `--dsw-menu-backdrop-filter` 的 40px/150% 模糊、`--dsw-elevation-prominent` 描边与阴影、
131
+ `--dsw-radius-lg`、以及 16px 内边距。它按最新在上的顺序列出最近十次请求,底部不加说明文字:
132
+
133
+ | # | 时间 | 首字 | 速度 | 输出 Token | 总用时 | 折算速度 |
134
+ | --- | --- | --- | --- | --- | --- | --- |
135
+ | 1 | 18:42:07 | 2.3 s | 445 tok/s | 1024 tok | 4.6 s | 223 tok/s |
136
+
137
+ 「时间」列取请求**发起**的时刻(`step/start`),与统计页一致。它不取结束时刻:
138
+ 否则一行写着 18:42:07、总用时 4.6 秒,实际却是 18:42:12 才完成,读起来会误解成「那一刻就结束了」;
139
+ 而且列表会变成按完成时间排序,而不是按发起时间。
140
+
141
+ 新请求从顶部滑入;被十行上限挤掉的那一行在**同一帧**内移除,而不是自己淡出。
142
+ 离场动画会让第 11 行在整个动画期间参与布局,使抽屉先长高一行再缩回——
143
+ 每次插入都会看到一次高度跳动。打开抽屉时也只是建立基线,不播放动画,
144
+ 所以整个列表不会在打开的瞬间一起滑入。
145
+
146
+ 某个步骤正在流式生成时,抽屉把它显示为历史上方的一条 live 行,状态读数也切换为 live 样式。
147
+ 历史下方有一条分隔线,线下面是汇总行——对当前可见请求取平均值,复用同一套 grid,
148
+ 因此每个均值都落在它所汇总的那一列下面。完全没有样本的列(比如中断请求没有首字时间)
149
+ 打印破折号,而不是容易误解的 0。
150
+
151
+ ## 数字是怎么算出来的
152
+
153
+ 一切都在浏览器内、从 Conversation 层本就维护的 Chat 快照推导得出——
154
+ 插件没有新增任何 Host 服务、会话事件或投影。
155
+
156
+ - **首字时间**——`timing.firstTokenTime − timing.stepStartTime`,即请求发出的时刻
157
+ 与第一个可见增量到达的时刻。
158
+ - **总用时**——`timing.completedTime − timing.stepStartTime`。
159
+ - **实时速度**——输出 Token ÷ 解码时长(`completedTime − firstTokenTime`)。
160
+ - **输出 Token**——上游自己的 `usage.outputTokens`,这是权威值。它读自
161
+ `assistant/message.data.usage`(已用真实会话日志核对:262 个已结算步骤全部携带它)。
162
+ `reasoningTokens` 已经包含在这个计数里,绝不再叠加。对于原样转发自身 usage 的适配器,
163
+ 也接受 OpenAI 风格的 `completionTokens` 拼写。
164
+ - **折算速度**——输出 Token ÷ 总用时。
165
+
166
+ 字符估算是真正的兜底,不是常规路径:只有当某个步骤的上游完全没有返回 usage 时才会用到
167
+ (中断或失败的请求),这样的行会被标记 `~`。
168
+
169
+ 它的系数是标定出来的,不是猜的。内容并不同质:正文与推理大约 4 个 ASCII 字符/token,
170
+ 而工具调用参数是 JSON——短键名、标点、带引号的值——分词效率明显更差,约 2.5。
171
+ 以真实会话日志 729 个已结算步骤为样本,与上游自己的 `outputTokens` 对比,
172
+ 取中位绝对对数误差:
173
+
174
+ | 规则 | 中位误差 | 估计 ÷ 实际(p50) |
175
+ | --- | --- | --- |
176
+ | 全部共用 `/4` | 0.361 | 0.70 |
177
+ | 正文 `/4`、工具参数 `/2.5`(现行) | **0.128** | **1.00** |
178
+
179
+ 把两者拆开才是消除偏差的关键。旧的共用除数会系统性低估约 30%,
180
+ 这正是生成过程中实时折算速度读数偏低的原因;细网格搜索给出的最优值是 0.125,
181
+ 现行规则与之相差三千分之一。CJK 保留惯例的 1 token/字符:搜索建议了更低的代价,
182
+ 但样本中只有 45% 的步骤含任何 CJK,该参数在这里辨识度很差,拟合出来是噪声。
183
+
184
+ ### 工具参数在流式过程中
185
+
186
+ `legacy.partial.blocks` 在一个调用进行期间**不**携带参数文本。存储层的
187
+ `tool-call-delta` 分支是原样复制 `argsRaw`,所以它一直是空字符串,直到 `block-end`
188
+ 一次性填入。因此只统计 `blocks` 会让实时读数在整个写入阶段冻结——
189
+ 而这一阶段占据了真实输出的绝大部分:在一份会话日志的 874 个已结算步骤里,
190
+ 工具参数占估算 token 的**中位数 93%**,且 98% 的步骤都带有它们。
191
+
192
+ 分片在调用期间确实存在。它们累积在调用自身的 `args.chunks` 视图上,
193
+ 对于仍处于 `preparing` 阶段的调用,`legacy.runningCalls` 会把它暴露出来,
194
+ 因此实时计数从那里读取,并按工具参数系数加权。这就是为什么写文件期间数字会持续攀升,
195
+ 而不是等到结束时才跳一下。
196
+
197
+ 流式行本质上是近似的——Chat 快照只把正在运行的步骤暴露为
198
+ `legacy.partial`(turn、step、blocks),没有每步的起始时间。有两点让这个近似保持诚实:
199
+
200
+ - **步骤级的起点。** 结算后的数值是 `firstToken − stepStart`,所以实时值必须用同一个起点。
201
+ 若改用*轮次*起点,就会把该轮中更早的一切都算进首字时间里:在一份真实会话日志中,
202
+ 从轮次开始到后序步骤开始的间隔**中位数 174 秒**,最大达到 **2720 秒**。
203
+ 这就是为什么实时读数可能在推理时显示几十秒,而步骤一结算却只剩几秒。
204
+ - **用工具结果,而不是 `completedTime`。** `timing.completedTime` 取自 `assistant/message`
205
+ 事件,落在该步**工具执行之前**。把它当下一步的起点,就会把整个工具执行时间算进去:
206
+ 实测该间隔中位数 243 毫秒,最大 **120 秒**——这正是「生成中显示 6 秒、结算后变成 2 秒」
207
+ 的成因。而一步的最后一个 `tool/result` 距它自己的 `step/end` 不超过 1 毫秒,
208
+ 距下一步 `step/start` 中位数只有 9 毫秒,是目前浏览器侧能拿到的最紧起点。
209
+ 归属用 **seq 区间**而不是时间戳:续写的日志会重放步骤,时间戳会重复。
210
+ 实测 1113 个步骤,起点误差从 p50 100 ms / p99 20.8 s 降到 **p50 9 ms / p99 486 ms**,
211
+ 超过一秒的步骤从 114 个降到 **1 个**。
212
+ 该步没有调用工具时,退回它自己的 `completedTime`(此时它就是这一轮的最终回答,
213
+ 后面不再有工具);一轮的第一步退回轮次起点(实测晚 26 ms)。
214
+ - **首个增量,而不是首段可见文本。** 结算后的 `firstTokenTime` 也把推理与工具调用增量计入,
215
+ 而 `legacy.partial` 只在步骤有了可见内容后才出现——所以新的 partial key 出现的瞬间
216
+ *就是*首字时刻。
217
+ - **从 `step/start` 就开始计时。** Chat 的 `legacy.partial` 要等第一个可见块,
218
+ 实测距步骤开始**中位数 1.9 秒**(p90 2.9 秒)——而这段时间正是首字的主体。
219
+ 所以只看 Chat 的话,数字会「一出现就是终值」,看不到增长。
220
+ 轨迹域在 `step/start` 那一刻就建立了该步的 assistant 记录,其 `requests[]` 里
221
+ 带 `status: "running"` 与精确的 `startedAt`(`step/start` 属于 location boundary,
222
+ 会话装配层会把发布优先级从定义声明的 `none` 提升为 `immediate`)。
223
+ 插件据此判断「本步正在进行」并取起点,于是首字在生成中由 `now − startedAt` 持续增长,
224
+ 首个 token 到达后改用 `firstTokenTime − startedAt` 锁定——与结算值是同一个减法。
225
+
226
+ ## 回放历史的首字时间
227
+
228
+ 从已有会话给抽屉回填数据时,首字列曾经是空的,尽管轨迹面板对同样的步骤能显示出数值。
229
+ 原因是数据流事实,而不是计算缺失:`timing.firstTokenTime` 是*流式累积*状态。
230
+ Chat 存储从实时 token 增量中累积它,而会话日志不存 live chunk——
231
+ 一份真实日志里有 787 个已结算消息,`assistant/live-chunk` 事件是**零**个。
232
+ 因此从该日志回放的步骤没有这个字段。
233
+
234
+ 但这个值仍可恢复。每条已结算消息都携带 `data.stream`,即完整的 chunk 时间线,
235
+ 每个分片都有自己的时间戳;轨迹域正是从这里重建该时刻
236
+ (`firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream)`),
237
+ 对每个打包的 run 用 `time0 + dt` 做前缀和。这就是为什么它的面板能给 Chat 快照
238
+ 无法定时的历史标上时间。
239
+
240
+ 插件从轨迹快照读取这些时间——槽位条目通过标准 `useTrajectory` hook 访问它——
241
+ 并据此给自己的行定时。有两点后果值得知道:
242
+
243
+ - 轨迹域是另一个插件的投影,所以它的时间可能在插件完成回填*之后*才到达。
244
+ 那些已存为无时间的行会在此时就地修复(`backfill`),而不是整个会话都停在 `--`。
245
+ - 曾经流式实时记录下来的值绝不会被覆盖:修复只触碰首字时间仍然缺失的行。
246
+
247
+ 如果某个组装里没有轨迹域,`useTrajectory` 就是缺席的,插件不建立索引,
248
+ 无时间的行继续显示 `--`——即之前的行为,且不报错。
249
+
250
+ ## 安装
251
+
252
+ 已发布到 npm,包名 `dsh-recent-usage-metrics`:
253
+
254
+ ```powershell
255
+ # 在 dsh GUI 的插件管理器里搜索并安装,或直接:
256
+ npm install dsh-recent-usage-metrics
257
+ ```
258
+
259
+ 也可以把它加进 profile 的 `package.json` 依赖,再由插件管理器安装。
260
+
261
+ **开发模式**:把 profile 里的依赖改成 `link:` 指向本目录,这样改完 `client/client.js`
262
+ 刷新页面即可生效。宿主侧代码永远不需要重启,因为它本身就存在。
263
+
264
+ ## 发布
265
+
266
+ 推 `v*` 标签即由 GitHub Actions 自动发布,认证走 OIDC trusted publishing,
267
+ **不需要任何 npm token**。
268
+
269
+ ```powershell
270
+ npm version patch # 改版本号并打 tag
271
+ git push --follow-tags # 推送后自动发布
272
+ ```
273
+
274
+ 工作流会先校验标签与 `package.json` 的版本一致,不一致直接失败,避免发错版本。
275
+ 发布产物带 provenance 签名,npm 上可在包页面看到来源证明。
276
+
277
+ ## 目录结构
278
+
279
+ | 路径 | 作用 |
280
+ | --- | --- |
281
+ | `client/client.js` | 全部功能:设置行、状态读数、抽屉 |
282
+ | `lib/index.js` | 空的 Host 半——让包成为一个 Loader 条目,从而发布 `./client` |
283
+ | `cordis.patch.yml` | 唯一插入的那一行 Loader 条目 |
284
+ | `locale/en.json`、`locale/zh.json` | 插件管理器里显示的标题与说明 |
285
+ | `icon.svg` | 插件图标 |
286
+ | `CHANGELOG.md` | 各版本改动 |
287
+ | `.github/workflows/publish.yml` | 推标签时自动发布到 npm |
288
+ | `test/lifecycle.cjs` | 在 DOM/hook shim 上跑真实的客户端:指标、十行上限、插入顺序、点击开合、清理 |
289
+ | `test/repro-coldstart.cjs` | 回归:冷页面必须首屏中文,且双向可切换 |
290
+ | `test/repro-hmr.cjs` | 回归:重载不得与存活的字典占用冲突 |
291
+ | `test/repro-log.cjs` | 用本机真实会话日志复核(见下节),无日志时自动跳过 |
292
+
293
+ ```powershell
294
+ node test/lifecycle.cjs
295
+ node test/repro-coldstart.cjs
296
+ node test/repro-hmr.cjs
297
+ $env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
298
+ ```
299
+
300
+ 四套共 227 项检查。
301
+
302
+ ## 用真实会话日志复核
303
+
304
+ 统计页的数字来自磁盘上的会话日志:
305
+
306
+ ```
307
+ ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
308
+ ```
309
+
310
+ `test/repro-log.cjs` 会找到最新的那份日志,按统计页自己的 `foldEvent` / `stepMetrics`
311
+ 算法重新折一遍,再检查抽屉依赖的那些性质:每步都有起始时刻、首字延迟非负且不超过总用时、
312
+ 已结算的步骤有总用时、运行中的步骤还没有总用时、抽屉选出的是最新十行且按新到旧排列。
313
+
314
+ 这比对着屏幕核对可靠得多:一次能覆盖上千步,而不是几行。
315
+
316
+ 两个容易踩的坑,脚本里都处理了:
317
+
318
+ - **日志是多帧拼接的 zstd。** 单次 `zstdDecompressSync` 只解出第一帧(几百字节),
319
+ 必须按帧魔数 `28 B5 2F FD` 切开逐个解压,否则只能拿到 `session` 头。
320
+ - **首字时间藏在打包记录里。** 日志中没有 `assistant/live-chunk`(那是仅客户端事件,
321
+ 不落盘),首字时刻在 `assistant/message.data.stream` 的
322
+ `reasoning-chunks` / `tool-call-chunks` 记录内:`time0` 加上 `dt` 逐段累加。
323
+ 只认松散的 `chunk` 记录会让首字时间几乎全为 `--`(实测从 0% 提升到 96%)。
324
+
325
+ ## 重启之后的语言
326
+
327
+ `locale` 被声明为**硬依赖**(`inject: ['slots', 'locale']`),`apply` 读取 `ctx.locale`。
328
+ 用 `ctx.get('locale')` 去探测是一个真实缺陷,症状相当令人困惑:locale 插件本身要等待
329
+ connection、settings、remotes 这几个服务,因此冷页面时本插件可能先执行 `apply`,
330
+ 读到 `undefined`,跳过字典注册,然后经由一个没有字典的 `t` 渲染——
331
+ 表现为英文或原始 key,而且即便把语言切回去也仍然是英文。
332
+
333
+ `configForms` 是可选的(某个 profile 可能在不带设置域的情况下组装本 bundle),
334
+ 所以用 `ctx.inject(['configForms'], …)` 等待它,而不是探测一次。若只探测一次,
335
+ 一旦该域晚到,设置行就会整个会话被锁死在兜底偏好上。两种选择都遵循插件规范:
336
+ 必需的服务放进 `inject`,可选的放进 `ctx.inject`。
337
+
338
+ 字典占用被挂在 `globalThis` 上,好让热重载能在注册前释放上一代的占用;
339
+ 否则第二次 `register` 同一命名空间会抛错,让界面失去字典——同样的可见症状,另一条路径。
340
+
341
+ ## 备注
342
+
343
+ - 该行以 `priority: -1` 和原生的 `order: 30` 注册进原生的 `performance-usage` 单元格,
344
+ 因此它渲染在同一位置,原生单元格是被遮蔽而不是重复。
345
+ - 所有比一次渲染活得更久的东西都创建在 `ctx.effect` 里,因此禁用插件会恢复原生行,
346
+ 并移除每一个监听器与样式。
347
+ - **只有极客标记存放在 `localStorage`**(`dsh.recentUsageMetrics.mode.v1`)。
348
+ 抽屉里的记录不再持久化:它们从当前会话的统计数据派生,见下面的说明。
349
+
350
+ ## 抽屉的数据从哪来
351
+
352
+ 抽屉的每一行读自 **DSH 自带「统计」页所用的同一份数据**(`stats` 视图目标),
353
+ 而不是插件自己再算一遍:
354
+
355
+ ```js
356
+ const target = uiConversation.binding(sessionId).target('stats')
357
+ ```
358
+
359
+ 那份数据由宿主侧的 `sessionStats` fold 从会话日志算出,每步都带着 `ttft`、`model`、
360
+ `decode`、`tps`、`output`、`wall`。
361
+
362
+ **字段对应关系:**
363
+
364
+ | 抽屉的列 | 取自 | 说明 |
365
+ | --- | --- | --- |
366
+ | 时间 | `startTime` | `step/start` 的时刻,与统计页一致 |
367
+ | 首字 | `ttft` | `firstToken − startTime` |
368
+ | 速度 | `tps` | `output ÷ decode` |
369
+ | 输出 Token | `output` | 上游返回的 usage |
370
+ | **总用时** | **`model`** | **模型用时,不是统计页的「总用时」列** |
371
+ | 折算速度 | `output ÷ model` | 本插件独有 |
372
+
373
+ **总用时为何取 `model` 而不是 `wall`:**
374
+
375
+ 统计页的「总用时」列是 `wall = endTime − startTime`,它**把工具调用也算了进去**。
376
+ 一个思考 11.5 秒、随后跑 31 秒工具的步骤,`wall` 是 41.9 秒而 `model` 是 11.5 秒。
377
+ 本插件只统计模型输出性能,所以取 `model`——它天然不含工具时间,回复一落地就是最终值,
378
+ 不再随工具执行增长。
379
+
380
+ **流式期间为什么带 `~`:**
381
+
382
+ 模型正在输出时,宿主还没拿到上游的 `usage`,此时 `output` 为 `null`。
383
+ 插件改用字符估算(按内容类型分别标定系数),所以那段数字带 `~`。
384
+ `usage` 一到(几乎就是回复完成的同一刻),立刻切成实测值,`~` 消失。
385
+
386
+ **为什么不再本地保存:**
387
+
388
+ 以前抽屉把历史写进 `localStorage`,用全局单一 key,行键是 `${turn}:${step}`。
389
+ 而**每个会话的轮次和步骤都从 1 重新编号**,所以两个会话的 `1:1` 会撞在一起——
390
+ 切换会话后看到的其实是另一个会话的数字,切回来又变。这正是「切换回来就不准」的成因。
391
+
392
+ 现在:
393
+
394
+ - 抽屉的行在每次渲染时从该会话自己的统计数据重建,没有本地副本可以过期
395
+ - 快照带会话标记,切换会话的瞬间旧数据即失效,不会短暂显示上一个会话的行
396
+ - 页面刷新后自动从日志重建,不需要额外的持久化
397
+
398
+ **视图目标需要先订阅才会存在**(`getSnapshot` 在订阅前返回 `undefined`)。插件在
399
+ `apply` 时按会话订阅一次;激活是永久且共享的,所以不会在统计页之外再跑一份 fold。
400
+
401
+ **拿不到统计视图时**,插件自动退回 Chat 快照 + 轨迹域自行计算(兜底路径)。
402
+ 这条路径不落盘、也按会话隔离,但它读不到 `model`,所以三态会退化为两态。
403
+ 它的字符估算系数与主路径相同。决定走哪条路径的唯一依据是那份数据源在不在,
404
+ 没有任何开关参与。
405
+
406
+ **流式期间不额外催动 fold**:那套 fold 每来一个 chunk 就会重建整张表,而它本来就由
407
+ 统计页或宿主驱动。状态栏的实时读数在回复完成前用字符估算与本地 tick,回复一落地
408
+ (`model` 有值)就切到折叠给出的实测值。这样既保住了「数字从 0 开始爬」的观感,
409
+ 也不会因为插件而多跑一份 fold。