dsh-recent-usage-metrics 0.0.0-stage → 0.2.0
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/CHANGELOG.md +54 -0
- package/README.en.md +412 -0
- package/README.md +363 -3
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/client/client.js +3070 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +10 -0
- package/lib/index.js +12 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +40 -4
- package/screenshots.json +4 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 更新记录
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
这一版把抽屉的数据源换成了 DSH 自带「统计」页所用的同一份数据,并解决了一批会导致
|
|
6
|
+
数字对不上、状态不切换的问题。
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- **三种状态**:模型输出中(闪电脉冲)、工具调用中(T 脉冲、抽屉写「调用工具中」)、
|
|
11
|
+
空闲。工具执行期间模型早已停止输出,不该继续算作生成中。
|
|
12
|
+
- **抽屉底部平均值**:一行显示最近十次的均值。
|
|
13
|
+
- **中断标记**:被取消的请求在首字列显示「中断」,不再与「还没开始」用同一个破折号。
|
|
14
|
+
|
|
15
|
+
### 数据源
|
|
16
|
+
|
|
17
|
+
- 抽屉的每一行改为读自统计页的视图目标(`uiConversation.binding(sessionId).target('stats')`),
|
|
18
|
+
字段与统计页一一对应:时间取 `startTime`、首字取 `ttft`、Token 取 `output`、
|
|
19
|
+
**总用时取 `model`(模型用时)而不是 `wall`(含工具的总用时)**。
|
|
20
|
+
- 流式期间仍用字符估算(此时上游还没有 usage),回复一落地立刻切成实测值。
|
|
21
|
+
- 保留 Chat 快照的兜底路径:拿不到统计视图时自动启用,取得到时不参与。
|
|
22
|
+
|
|
23
|
+
### 修复
|
|
24
|
+
|
|
25
|
+
- **切换会话后数据串位**:历史曾存在一个全局 localStorage 键里,行键是 `${turn}:${step}`,
|
|
26
|
+
而每个会话的编号都从 1 开始,于是两个会话的「1:1」互相覆盖。现在历史按会话派生、
|
|
27
|
+
不落盘,快照还带会话标记,切换瞬间旧数据即失效。
|
|
28
|
+
- **时间点用了结束时刻**:改为取 `step/start`,与统计页一致。
|
|
29
|
+
- **生成中的请求同时占用实时行与 #1**:实时行按 `turn:step` 排除,一条请求只会出现在一处。
|
|
30
|
+
- **抽屉只剩九行**:先截断十条再排除实时行会少一条,改为先排除后截断。
|
|
31
|
+
- **手动停止后仍在计数**:统计折叠的 `running` 依赖 `step/end`,取消的步骤不一定有该事件。
|
|
32
|
+
改以框架 session store 的 `running` 为准,它无论回合如何结束都会清掉。
|
|
33
|
+
- **回复已结束、工具仍在跑时继续累加**:判定「模型是否还在写」曾使用步骤对象上不存在的
|
|
34
|
+
`messageTime` 字段,恒为 `undefined`,实测分支从未执行。改用 `model` 与 `output`。
|
|
35
|
+
- **估算标记 `~` 被硬编码在实时行上**:它表示「这是估算」,不是「这是实时行」。
|
|
36
|
+
- **实时行抓到了几分钟前的旧步骤**:改为只在最后一个回合里找运行中的步骤,且该回合
|
|
37
|
+
必须是未关闭的。
|
|
38
|
+
- **抽屉标题条数与实际行数不一致**:标题读的是模型副本,列表读的是派生结果。
|
|
39
|
+
- **降级路径输出整行空白**:Chat 快照对重放节点可能没有 `timing`,这类行现在被丢弃。
|
|
40
|
+
|
|
41
|
+
### 测试
|
|
42
|
+
|
|
43
|
+
- 四套测试共 227 项:`lifecycle`(205)、`repro-coldstart`(17)、`repro-hmr`(5)、
|
|
44
|
+
`repro-log`(12)。
|
|
45
|
+
- `repro-log` 会用本机真实会话日志按统计折叠的算法复核,覆盖上千步。
|
|
46
|
+
- 兜底路径新增端到端用例,用固定夹具验证时间点、Token 的两个来源、会话隔离。
|
|
47
|
+
|
|
48
|
+
## 0.1.0
|
|
49
|
+
|
|
50
|
+
首个版本。
|
|
51
|
+
|
|
52
|
+
- 在「性能与用量」设置里增加「极客」档。
|
|
53
|
+
- 输入区状态行实时显示最新一次请求的首字延迟、生成速度、输出 Token、总用时与折算速度。
|
|
54
|
+
- 点击气泡向上弹出抽屉,列出最近十次请求。
|
package/README.en.md
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# dsh-recent-usage-metrics
|
|
2
|
+
|
|
3
|
+
**English** · [简体中文](README.md)
|
|
4
|
+
|
|
5
|
+
Geek-level request metrics for DSH. It adds a third level — **Geek / 极客** — to the
|
|
6
|
+
native **Performance & usage** preference in Settings → General, and while that level
|
|
7
|
+
is active it keeps a live reading of the newest request in the composer status row
|
|
8
|
+
plus a glass drawer with the last ten requests.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
The drawer open: the step still streaming sits on top (its first-token figure climbing,
|
|
13
|
+
speed shown as `—`), the last ten requests follow newest first, and their average closes
|
|
14
|
+
the list. The status pill sits below it, its columns aligned with the table's.
|
|
15
|
+
|
|
16
|
+
The UI is bilingual and follows the DSH language setting. The same drawer in Chinese:
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
## What you get
|
|
21
|
+
|
|
22
|
+
**Settings → General → Performance & usage** now offers three levels:
|
|
23
|
+
|
|
24
|
+
| Level | Behaviour |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| Compact / 简洁 | Shipped behaviour, unchanged |
|
|
27
|
+
| Detailed / 详细 | Shipped behaviour, unchanged |
|
|
28
|
+
| **Geek / 极客** | Everything below |
|
|
29
|
+
|
|
30
|
+
Compact and Detailed keep writing the shipped `ui-chat` → `performanceUsage` field, so
|
|
31
|
+
they behave exactly as before and stay in sync with the shipped row's data. Geek is a
|
|
32
|
+
separate durable flag, because the shipped Chat schema only accepts two values.
|
|
33
|
+
|
|
34
|
+
**Composer status row** (left of the shipped `55 轮 2307 步` pill):
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
⚡ 2.3 s 445 tok/s 1024 tok 4.6 s 223 tok/s
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
time to first token · live generation speed · output tokens · total time · amortised
|
|
41
|
+
speed (output ÷ total), separated by whitespace only. The pill reproduces the shipped
|
|
42
|
+
statistics pill: the same `--dsw-alias-label-tertiary` foreground for both the text and
|
|
43
|
+
the icon (the native pills never tint their glyph), and the same type scale. Height comes
|
|
44
|
+
from padding + line-height, so it matches the native row.
|
|
45
|
+
|
|
46
|
+
Each figure's digits are reserved with a **fixed width on the number element only** —
|
|
47
|
+
never on the wrapping cell, which also carries the unit and would otherwise clip it. The
|
|
48
|
+
estimate marker is a permanently reserved 1ch span that only toggles its paint. A value
|
|
49
|
+
growing from `3.1` to `57.8`, a speed dropping from `328` to `9`, a `--` placeholder, and
|
|
50
|
+
a `~` appearing or disappearing all leave the pill's width byte-identical — the centred
|
|
51
|
+
composer row never shifts.
|
|
52
|
+
|
|
53
|
+
The budgets come from the real distribution of a 433-reading session log, and each
|
|
54
|
+
formatter is written to stay inside its budget:
|
|
55
|
+
|
|
56
|
+
| Slot | Digits | Why that is enough |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| first token / total | `4.5ch` | `57.8` normally; from 100 s up it prints whole seconds (`126`) |
|
|
59
|
+
| speed / amortised | `4.5ch` | p50 `85`, max `328` in the log |
|
|
60
|
+
| output tokens | `5.5ch` | p50 `219`; `123456` collapses to `123k` |
|
|
61
|
+
|
|
62
|
+
The pill and the drawer use **disjoint class names** (`dsh-rum-part` vs `dsh-rum-col`).
|
|
63
|
+
Sharing one cell class let the drawer's ellipsis rule clip the pill's units — the regression
|
|
64
|
+
that produced digits with a truncated ` s` / ` tok/s`.
|
|
65
|
+
|
|
66
|
+
Drawer columns are **measured from the pill at runtime**, not derived from `ch`. `ch` is
|
|
67
|
+
the advance of `0`, while `tok/s` and the thin `s` are narrower, so an arithmetic column
|
|
68
|
+
can never sit exactly under the rendered cell. On open the plugin reads each
|
|
69
|
+
`[data-metric]` cell's `getBoundingClientRect().width` and publishes one custom property
|
|
70
|
+
per metric (`--dsh-rum-col-ttft`, `--dsh-rum-col-speed`, `--dsh-rum-col-tokens`,
|
|
71
|
+
`--dsh-rum-col-total`, `--dsh-rum-col-amortized`), which the row grid consumes. A
|
|
72
|
+
`ResizeObserver` on the pill re-measures after a font-scale change or a metric swap, and a
|
|
73
|
+
`ch` expression stays as the fallback for the frame before measurement lands.
|
|
74
|
+
|
|
75
|
+
| Drawer column | Consumes | Sits under |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| first token | `--dsh-rum-col-ttft` | the pill's first-token cell |
|
|
78
|
+
| speed | `--dsh-rum-col-speed` | the pill's speed cell |
|
|
79
|
+
| output tokens | `--dsh-rum-col-tokens` | the pill's token cell |
|
|
80
|
+
| total | `--dsh-rum-col-total` | the pill's total-time cell |
|
|
81
|
+
| amortised | `--dsh-rum-col-amortized` | the pill's amortised cell |
|
|
82
|
+
|
|
83
|
+
Two further details make the edges actually coincide:
|
|
84
|
+
|
|
85
|
+
- **Content edges, not box edges.** The pill pads its content by 8px, while the drawer
|
|
86
|
+
stacks 16px of its own padding with a row's 6px. Aligning the two *boxes* therefore
|
|
87
|
+
leaves the numbers visibly inset. Both insets are measured
|
|
88
|
+
(`drawerContentInset` / `pillContentInset`) and subtracted, so the last figure in the
|
|
89
|
+
drawer lines up with the last figure in the pill.
|
|
90
|
+
- **Spare width goes left.** The drawer keeps a `min-width` so its own headers are not
|
|
91
|
+
truncated; each row uses `justify-content: end`, pushing any surplus to the left so the
|
|
92
|
+
final column stays flush. The drawer is also re-placed on the next animation frame,
|
|
93
|
+
because the first pass can run before the measured columns have been applied.
|
|
94
|
+
|
|
95
|
+
Click the pill to open the drawer; click it again, or press Escape, or click outside, to
|
|
96
|
+
close it. There is no hover-open and no tooltip.
|
|
97
|
+
|
|
98
|
+
## Three states
|
|
99
|
+
|
|
100
|
+
This plugin has two kinds of busy and one kind of idle, and all three look different:
|
|
101
|
+
|
|
102
|
+
| State | Decided by | Pill icon | Drawer header | Live row |
|
|
103
|
+
| --- | --- | --- | --- | --- |
|
|
104
|
+
| **Model writing** | `model` is `null` | ⚡ pulsing bolt | `Streaming` | yes (11 rows) |
|
|
105
|
+
| **Tools running** | `model` set, `endTime` `null` | **pulsing T** | **`Calling tools`** | yes (11 rows) |
|
|
106
|
+
| **Idle** | `endTime` set, or no running step | still | `last N` | no (10 rows) |
|
|
107
|
+
|
|
108
|
+
### Why the tool phase is its own state
|
|
109
|
+
|
|
110
|
+
This plugin measures **model output**, and `step/end` only arrives after the step's tool calls
|
|
111
|
+
have all returned. Keying off `running` (which `step/end` clears) therefore kept the plugin in
|
|
112
|
+
"streaming" for the entire tool phase after the reply had already finished: the bolt kept
|
|
113
|
+
pulsing and the live row kept occupying an eleventh row.
|
|
114
|
+
|
|
115
|
+
The field that separates the first two is `model`, which is `messageTime − startTime`:
|
|
116
|
+
|
|
117
|
+
- the model is still writing → `null`
|
|
118
|
+
- `assistant/message` has landed → a fixed value, **even while tools run**
|
|
119
|
+
|
|
120
|
+
`endTime` then separates the tool phase from idle. Both fields come from the Host's
|
|
121
|
+
`sessionStats` fold — the same data the Statistics page renders.
|
|
122
|
+
|
|
123
|
+
### The live row stays through the tool phase
|
|
124
|
+
|
|
125
|
+
The request is genuinely still in flight, so it keeps its row; only the marker changes from the
|
|
126
|
+
bolt to a T, and the drawer header switches from "Streaming" to "Calling tools". That makes all
|
|
127
|
+
three states distinguishable at a glance.
|
|
128
|
+
|
|
129
|
+
Both glyphs share **the same 14×14 svg box** (`.dsh-rum-trigger svg`) and the same `pulse`
|
|
130
|
+
animation. Switching changes the shape and colour inside the box, never the box, so the status
|
|
131
|
+
row does not shift. The T is drawn narrower on purpose so it does not read heavier than the bolt.
|
|
132
|
+
|
|
133
|
+
### Fallback
|
|
134
|
+
|
|
135
|
+
A composition without the statistics fold has no `model` to read. There, `partial` and a
|
|
136
|
+
Trajectory request can only mean the model is writing, so the state is always "model writing" —
|
|
137
|
+
it never degrades to idle just because the fold is missing.
|
|
138
|
+
|
|
139
|
+
**Drawer** — slides up from the status row, right-aligned to that pill, `position: fixed`
|
|
140
|
+
on `document.body` so no scroll container can clip it. Its material is the shipped
|
|
141
|
+
statistics dialog's own, copied token for token: the `--dsw-specific-menu` fill
|
|
142
|
+
(`#303136f0` in dark mode, i.e. nearly opaque, the same surface behind the native
|
|
143
|
+
numbers), the `--dsw-menu-backdrop-filter` 40px/150% blur, `--dsw-elevation-prominent`
|
|
144
|
+
stroke and shadow, `--dsw-radius-lg`, and 16px padding. It lists the last ten requests,
|
|
145
|
+
newest first, with no explanatory footer:
|
|
146
|
+
|
|
147
|
+
| # | At | TTFT | Speed | Tokens | Time | Amortised |
|
|
148
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
149
|
+
| 1 | 18:42:07 | 2.3 s | 445 tok/s | 1024 tok | 4.6 s | 223 tok/s |
|
|
150
|
+
|
|
151
|
+
The `At` column is when the request was **issued** (`step/start`), matching the Statistics
|
|
152
|
+
view. It is not the end time: a row stamped 18:42:07 with a 4.6 s total actually finished
|
|
153
|
+
at 18:42:12, so an end stamp would read as "it finished then" and would also order the list
|
|
154
|
+
by completion rather than by issue.
|
|
155
|
+
|
|
156
|
+
A new request slides in at the top; the row that falls off the ten-row cap is removed in
|
|
157
|
+
the **same frame** rather than fading out on its own. An exit phase would keep an
|
|
158
|
+
eleventh row laid out for the animation's duration, growing the drawer by a row and
|
|
159
|
+
shrinking it back — a visible height jump on every insertion. Opening the drawer also
|
|
160
|
+
establishes a baseline instead of animating, so the whole list never slides in at once.
|
|
161
|
+
|
|
162
|
+
While a step is streaming, the drawer shows it as a live row above the history, and the
|
|
163
|
+
status reading switches to a live style. Under a rule below the history sits a summary
|
|
164
|
+
row — the average of the visible requests, in the same grid so each mean lands under its
|
|
165
|
+
column. A column with no samples (say no TTFT on an interrupted request) prints a dash
|
|
166
|
+
rather than a misleading zero.
|
|
167
|
+
|
|
168
|
+
## How the numbers are measured
|
|
169
|
+
|
|
170
|
+
Everything is derived in the browser from the Chat snapshot the Conversation layer
|
|
171
|
+
already maintains — the plugin adds no Host service, session event, or projection.
|
|
172
|
+
|
|
173
|
+
- **Time to first token** — `timing.firstTokenTime − timing.stepStartTime`, the recorded
|
|
174
|
+
moment the request was issued and the moment the first visible delta arrived.
|
|
175
|
+
- **Total time** — `timing.completedTime − timing.stepStartTime`.
|
|
176
|
+
- **Live speed** — output tokens ÷ decode time (`completedTime − firstTokenTime`).
|
|
177
|
+
- **Output tokens** — the provider's own `usage.outputTokens`, which is the authority.
|
|
178
|
+
It is read from `assistant/message.data.usage` (verified against a real session log:
|
|
179
|
+
262 of 262 settled steps carried it). `reasoningTokens` is already included in that
|
|
180
|
+
count and is never added on top. OpenAI-shaped `completionTokens` spellings are also
|
|
181
|
+
accepted for adapters that forward their usage verbatim.
|
|
182
|
+
- **Amortised speed** — output tokens ÷ total time.
|
|
183
|
+
|
|
184
|
+
The character estimate is a genuine fallback, not the normal path: it is used only for a
|
|
185
|
+
step whose provider returned no usage at all (an interrupted or failed request), and such
|
|
186
|
+
a row is marked `~`.
|
|
187
|
+
|
|
188
|
+
Its coefficients are calibrated, not guessed. The content is not homogeneous: prose and
|
|
189
|
+
reasoning tokenize near 4 ASCII characters per token, while tool-call arguments are JSON —
|
|
190
|
+
short keys, punctuation, quoted values — and tokenize markedly worse, near 2.5. Measured
|
|
191
|
+
against the provider's own `outputTokens` over 729 settled steps of a real session log,
|
|
192
|
+
using the median absolute log error:
|
|
193
|
+
|
|
194
|
+
| Rule | Median error | Estimate ÷ actual (p50) |
|
|
195
|
+
| --- | --- | --- |
|
|
196
|
+
| one shared `/4` for everything | 0.361 | 0.70 |
|
|
197
|
+
| prose `/4`, tool args `/2.5` (shipped) | **0.128** | **1.00** |
|
|
198
|
+
|
|
199
|
+
Splitting the two is what removed the bias. The old shared divisor systematically
|
|
200
|
+
under-counted by about 30%, which is why the live amortised speed read low during
|
|
201
|
+
generation; a fine grid search put the optimum at 0.125, so the shipped rule is within
|
|
202
|
+
three thousandths of it. CJK stays at the conventional one token per character: the search
|
|
203
|
+
suggested a lower cost, but only 45% of those steps contain any CJK, so that term is
|
|
204
|
+
poorly identified in this sample and a fitted value would be noise.
|
|
205
|
+
|
|
206
|
+
### Tool arguments while they stream
|
|
207
|
+
|
|
208
|
+
`legacy.partial.blocks` does **not** carry argument text during a call. The store's
|
|
209
|
+
`tool-call-delta` branch copies `argsRaw` unchanged, so it stays an empty string until
|
|
210
|
+
`block-end` fills it in one go. Counting only `blocks` therefore froze the live figure for
|
|
211
|
+
the whole write phase — and that phase dominates real output: across 874 settled steps of
|
|
212
|
+
a session log, tool arguments were a median **93%** of the estimated tokens, and 98% of
|
|
213
|
+
steps had them.
|
|
214
|
+
|
|
215
|
+
The fragments do exist during the call. They accumulate on the call's own `args.chunks`
|
|
216
|
+
view, which `legacy.runningCalls` exposes for a call still in its `preparing` phase, so the
|
|
217
|
+
live count reads them there and weights them at the tool coefficient. This is what makes
|
|
218
|
+
the number climb while a file is being written instead of jumping when it finishes.
|
|
219
|
+
|
|
220
|
+
The streaming row is approximate by nature — the Chat snapshot exposes the running step
|
|
221
|
+
only as `legacy.partial` (turn, step, blocks), with no per-step start time. Two things keep
|
|
222
|
+
that approximation honest:
|
|
223
|
+
|
|
224
|
+
- **A step-level origin.** The settled figure is `firstToken − stepStart`, so the live one
|
|
225
|
+
must use the same origin. Subtracting the *turn* start instead overstates TTFT by
|
|
226
|
+
everything earlier in the turn: in a real session log the gap from turn start to a later
|
|
227
|
+
step's start has a **174 s median** and reaches **2720 s**. That is why a live reading
|
|
228
|
+
could show tens of seconds while reasoning yet a few seconds once the step settled.
|
|
229
|
+
- **A tool result, not `completedTime`.** `timing.completedTime` is the `assistant/message`
|
|
230
|
+
time, which lands *before* the step's tools run. Using it as the next step's origin counts
|
|
231
|
+
the whole tool execution into that step's TTFT — measured, that gap has a 243 ms median but
|
|
232
|
+
reaches **120 s**, which is exactly the "six seconds while streaming, two once it settles"
|
|
233
|
+
symptom. A step's last `tool/result` sits within 1 ms of its own `step/end` and a median
|
|
234
|
+
9 ms before the next `step/start`, making it the tightest origin a browser-only plugin can
|
|
235
|
+
reach. Attribution runs on the **seq window**, not timestamps: a resumed log replays steps
|
|
236
|
+
and times repeat. Over 1113 steps the origin error fell from p50 100 ms / p99 20.8 s to
|
|
237
|
+
**p50 9 ms / p99 486 ms**, and steps off by more than a second from 114 to **1**. A step
|
|
238
|
+
that ran no tools falls back to its own `completedTime` (it was the turn's final answer, so
|
|
239
|
+
nothing follows it), and a turn's first step to the turn's start (26 ms earlier).
|
|
240
|
+
- **First delta, not first visible text.** The settled `firstTokenTime` counts reasoning and
|
|
241
|
+
tool-call deltas too, and `legacy.partial` only appears once the step has visible content —
|
|
242
|
+
so the moment a new partial key appears *is* the first-token moment.
|
|
243
|
+
- **Counting from `step/start`, not from the first visible block.** Chat's `legacy.partial`
|
|
244
|
+
waits for a visible block — a **1.9 s median** (p90 2.9 s) after the step begins, which is
|
|
245
|
+
most of what a first-token latency measures. Reading only Chat therefore made the figure
|
|
246
|
+
appear at its final value with no visible growth. The Trajectory domain instead opens its
|
|
247
|
+
assistant record on `step/start` itself, and that record reaches the snapshot's `requests[]`
|
|
248
|
+
with `status: "running"` and an exact `startedAt` — `step/start` is a location boundary, so
|
|
249
|
+
the conversation assembler raises the publication priority from the definition's declared
|
|
250
|
+
`none` to `immediate`. The plugin uses that to know the step is live and where it started,
|
|
251
|
+
so the figure climbs as `now − startedAt` and then locks to `firstTokenTime − startedAt`,
|
|
252
|
+
the same subtraction the settled row performs.
|
|
253
|
+
|
|
254
|
+
## First-token time for replayed history
|
|
255
|
+
|
|
256
|
+
Seeding the drawer from an existing conversation used to leave the first-token column
|
|
257
|
+
empty, even though the Trajectory panel showed a value for the same steps. The reason is
|
|
258
|
+
a data-flow fact, not a missing calculation: `timing.firstTokenTime` is *streamed* state.
|
|
259
|
+
The Chat store accumulates it from live token deltas, and the session log stores no live
|
|
260
|
+
chunks — a real log held 787 settled messages and **zero** `assistant/live-chunk` events.
|
|
261
|
+
A step replayed from that log therefore has no such field.
|
|
262
|
+
|
|
263
|
+
The value is still recoverable. Every settled message carries `data.stream`, the whole
|
|
264
|
+
chunk timeline with per-fragment timestamps, and the Trajectory domain rebuilds the mark
|
|
265
|
+
from it (`firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream)`), summing
|
|
266
|
+
`time0 + dt` across each packed run. That is why its panel can date history that the Chat
|
|
267
|
+
snapshot cannot.
|
|
268
|
+
|
|
269
|
+
The plugin reads those times from the Trajectory snapshot, which a slot entry reaches
|
|
270
|
+
through the standard `useTrajectory` hook, and dates its rows with them. Two consequences
|
|
271
|
+
worth knowing:
|
|
272
|
+
|
|
273
|
+
- The Trajectory domain is another plugin's projection, so its times can arrive *after*
|
|
274
|
+
the plugin seeded. Rows that were stored undated are repaired in place when that happens
|
|
275
|
+
(`backfill`), instead of staying at `--` for the rest of the session.
|
|
276
|
+
- A value that streamed live is never overwritten: the repair only touches rows whose
|
|
277
|
+
first-token time is still absent.
|
|
278
|
+
|
|
279
|
+
If a composition has no Trajectory domain, `useTrajectory` is simply absent, no index is
|
|
280
|
+
built, and undated rows keep showing `--` — the previous behaviour, with no error.
|
|
281
|
+
|
|
282
|
+
## Install
|
|
283
|
+
|
|
284
|
+
```powershell
|
|
285
|
+
# from the dsh GUI's Plugin Manager, or:
|
|
286
|
+
# plugin_manager install_bundle with target = this directory
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The bundle links this directory into the profile, so editing `client/client.js` and
|
|
290
|
+
reloading the page picks up the change. Host-side code never needs a restart because
|
|
291
|
+
there is none.
|
|
292
|
+
|
|
293
|
+
## Layout
|
|
294
|
+
|
|
295
|
+
| Path | Purpose |
|
|
296
|
+
| --- | --- |
|
|
297
|
+
| `client/client.js` | The whole feature: settings row, status reading, drawer |
|
|
298
|
+
| `lib/index.js` | Empty Host half — makes the package a Loader entry so `./client` is published |
|
|
299
|
+
| `cordis.patch.yml` | The one inserted Loader row |
|
|
300
|
+
| `locale/en.json`, `locale/zh.json` | Plugin Manager title and description |
|
|
301
|
+
| `icon.svg` | Plugin icon |
|
|
302
|
+
| `test/lifecycle.cjs` | Runs the real client against a DOM/hook shim: metrics, ten-row cap, insertion order, click/open, disposal |
|
|
303
|
+
| `test/repro-coldstart.cjs` | Regression: a cold page must paint Chinese and stay switchable both ways |
|
|
304
|
+
| `test/repro-hmr.cjs` | Regression: a reload must not collide with the live dictionary claim |
|
|
305
|
+
| `test/repro-log.cjs` | Verifies against a real session log (see below); skips itself when none exists |
|
|
306
|
+
|
|
307
|
+
```powershell
|
|
308
|
+
node test/lifecycle.cjs
|
|
309
|
+
node test/repro-coldstart.cjs
|
|
310
|
+
node test/repro-hmr.cjs
|
|
311
|
+
$env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Verifying against a real session log
|
|
315
|
+
|
|
316
|
+
The Statistics page's figures come from the durable session log on disk:
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`test/repro-log.cjs` finds the newest one, folds it with the Statistics view's own
|
|
323
|
+
`foldEvent` / `stepMetrics` algorithm, then checks the properties the drawer depends on:
|
|
324
|
+
every step has a start time, first-token latency is non-negative and never exceeds the step's
|
|
325
|
+
wall time, settled steps have a total, running steps do not, and the drawer selects the newest
|
|
326
|
+
ten rows in descending order.
|
|
327
|
+
|
|
328
|
+
That is far more reliable than comparing a few rows on screen: one run covers thousands of
|
|
329
|
+
steps.
|
|
330
|
+
|
|
331
|
+
Two traps the script handles:
|
|
332
|
+
|
|
333
|
+
- **The log is many concatenated zstd frames.** A single `zstdDecompressSync` call returns
|
|
334
|
+
only the first frame — a few hundred bytes. The frames must be split at the magic
|
|
335
|
+
`28 B5 2F FD` and decompressed one by one, otherwise all you get is the `session` header.
|
|
336
|
+
- **The first-token time lives in packed records.** The log contains no
|
|
337
|
+
`assistant/live-chunk` events (client-only, never persisted); the moment is inside the
|
|
338
|
+
`reasoning-chunks` / `tool-call-chunks` records of `assistant/message.data.stream`, as
|
|
339
|
+
`time0` plus the cumulative `dt` offsets. Reading only loose `chunk` records leaves the
|
|
340
|
+
first-token time at `--` for nearly every step (measured: 0% → 96%).
|
|
341
|
+
|
|
342
|
+
## Language after a restart
|
|
343
|
+
|
|
344
|
+
`locale` is declared as a **hard dependency** (`inject: ['slots', 'locale']`) and `apply`
|
|
345
|
+
reads `ctx.locale`. Probing with `ctx.get('locale')` was a real defect with a confusing
|
|
346
|
+
symptom: the locale plugin itself waits on the connection, settings and remotes services,
|
|
347
|
+
so on a cold page this plugin could apply first, read `undefined`, skip registering its
|
|
348
|
+
dictionaries, and then render through a dictionary-less `t` — English, or raw keys, that
|
|
349
|
+
stayed English even after switching the language back.
|
|
350
|
+
|
|
351
|
+
`configForms` is optional (a profile may compose this bundle without the settings domain),
|
|
352
|
+
so it is awaited with `ctx.inject(['configForms'], …)` rather than probed once. A single
|
|
353
|
+
probe would latch the settings row onto the fallback preference for the whole session if
|
|
354
|
+
the domain arrived late. Both choices follow the plugin guidance: required services go in
|
|
355
|
+
`inject`, optional ones go in `ctx.inject`.
|
|
356
|
+
|
|
357
|
+
The dictionary claim is parked on `globalThis` so a hot reload can release the previous
|
|
358
|
+
generation's claim before registering; otherwise the second `register` for the namespace
|
|
359
|
+
would throw and leave the UI dictionary-less — the same visible symptom by another route.
|
|
360
|
+
|
|
361
|
+
## Notes
|
|
362
|
+
|
|
363
|
+
- The row is registered into the shipped `performance-usage` cell with `priority: -1`
|
|
364
|
+
and the shipped `order: 30`, so it renders in the same place and the shipped cell is
|
|
365
|
+
shadowed rather than duplicated.
|
|
366
|
+
- Everything that outlives a render is created inside `ctx.effect`, so disabling the
|
|
367
|
+
plugin restores the shipped row and removes every listener and style.
|
|
368
|
+
- **Only the Geek flag is kept in `localStorage`** (`dsh.recentUsageMetrics.mode.v1`).
|
|
369
|
+
The drawer's history is no longer persisted; it is derived from the session's own
|
|
370
|
+
statistics, explained below.
|
|
371
|
+
|
|
372
|
+
## Where the drawer's rows come from
|
|
373
|
+
|
|
374
|
+
Each row reads the **same data the shipped Statistics page renders** (the `stats` view
|
|
375
|
+
target) instead of computing its own copy:
|
|
376
|
+
|
|
377
|
+
```js
|
|
378
|
+
const target = uiConversation.binding(sessionId).target('stats')
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
That data comes from the Host-side `sessionStats` fold over the session log, and each step
|
|
382
|
+
carries `ttft`, `model`, `decode`, `tps`, `output` and `wall`. The Statistics page prints
|
|
383
|
+
its first-token figure as `firstToken − step/start`, so the two agree row for row instead of
|
|
384
|
+
being two parallel calculations kept in step by hand.
|
|
385
|
+
|
|
386
|
+
**Why the history is no longer stored locally:**
|
|
387
|
+
|
|
388
|
+
The drawer used to write its history to `localStorage` under one global key, with row keys
|
|
389
|
+
of the form `${turn}:${step}`. **Every conversation numbers its turns and steps from 1**, so
|
|
390
|
+
one conversation's `1:1` collided with another's — switching conversations showed the wrong
|
|
391
|
+
figures and switching back showed them again. That was the reported symptom.
|
|
392
|
+
|
|
393
|
+
Now:
|
|
394
|
+
|
|
395
|
+
- Rows are rebuilt from that conversation's own statistics on every render, so there is no
|
|
396
|
+
local copy that can go stale.
|
|
397
|
+
- A reported session change clears them immediately, so the previous conversation's rows
|
|
398
|
+
never appear under the new one while its data is still arriving.
|
|
399
|
+
- A page reload rebuilds from the log; nothing extra needs persisting.
|
|
400
|
+
|
|
401
|
+
**A view target only exists once something subscribes** (`getSnapshot` returns `undefined`
|
|
402
|
+
before that). The plugin subscribes once per session at apply time. Activation is permanent
|
|
403
|
+
and shared, so this does not start a second fold alongside the Statistics view.
|
|
404
|
+
|
|
405
|
+
**Fallback**: in a composition without that data (`uiConversation` absent), the drawer
|
|
406
|
+
returns to the Chat-snapshot plus Trajectory-domain calculation and marks estimates with `~`.
|
|
407
|
+
That path only runs when the statistics data is missing.
|
|
408
|
+
|
|
409
|
+
**The live reading does not touch the statistics during streaming**: its fold rebuilds the
|
|
410
|
+
whole table on every chunk. The status pill uses a lighter path instead — the Trajectory
|
|
411
|
+
domain's `step/start` time plus a 150 ms local tick — and reads the statistics once each
|
|
412
|
+
step settles. That keeps the counting-up effect without adding fold work on every chunk.
|