dsh-recent-usage-metrics 0.2.1 → 0.2.3
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 +14 -0
- package/README.en.md +64 -8
- package/README.md +69 -13
- package/client/client.js +162 -77
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 0.2.3
|
|
4
|
+
|
|
5
|
+
- 中断的行现在显示它跑了多久:被手动停止的请求没有 model,总用时回退到 wall
|
|
6
|
+
(endTime - startTime),刷新后仍是同一个数。
|
|
7
|
+
- 「中断」标记移到速度列。首字若已返回就照常显示实测值,总用时也照常显示——
|
|
8
|
+
缺的只是生成速度,由速度列说明原因。
|
|
9
|
+
- 中断行不计入底部平均值,但标题仍数满十行。
|
|
10
|
+
|
|
11
|
+
## 0.2.2
|
|
12
|
+
|
|
13
|
+
- 修复:切换模型后抽屉卡在旧的一轮。数据源的 target 曾被缓存,而会话重建
|
|
14
|
+
(换模型、重连、binding 释放)会替换掉它依赖的视图存储,旧闭包于是永远返回
|
|
15
|
+
最后一份快照。现在每次都取当前 target,并按秒重新绑定。
|
|
16
|
+
|
|
3
17
|
## 0.2.1
|
|
4
18
|
|
|
5
19
|
- 发布流程改为 GitHub Actions 的 OIDC trusted publishing:推 `v*` 标签即自动发布,
|
package/README.en.md
CHANGED
|
@@ -281,14 +281,32 @@ built, and undated rows keep showing `--` — the previous behaviour, with no er
|
|
|
281
281
|
|
|
282
282
|
## Install
|
|
283
283
|
|
|
284
|
+
Published to npm as `dsh-recent-usage-metrics`:
|
|
285
|
+
|
|
284
286
|
```powershell
|
|
285
|
-
# from the dsh GUI's Plugin Manager, or:
|
|
286
|
-
|
|
287
|
+
# from the dsh GUI's Plugin Manager, or directly:
|
|
288
|
+
npm install dsh-recent-usage-metrics
|
|
287
289
|
```
|
|
288
290
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
291
|
+
It can also be added to the profile's `package.json` dependencies and installed from there.
|
|
292
|
+
|
|
293
|
+
**Development mode**: point the profile dependency at this directory with `link:`, so editing
|
|
294
|
+
`client/client.js` and reloading the page picks up the change. Host-side code never needs a
|
|
295
|
+
restart because there is none.
|
|
296
|
+
|
|
297
|
+
## Release
|
|
298
|
+
|
|
299
|
+
Pushing a `v*` tag publishes automatically through GitHub Actions, authenticated with OIDC
|
|
300
|
+
trusted publishing — **no npm token is involved**.
|
|
301
|
+
|
|
302
|
+
```powershell
|
|
303
|
+
npm version patch # bump the version and create the tag
|
|
304
|
+
git push --follow-tags # push, and the release publishes itself
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The workflow first checks that the tag matches the `package.json` version and fails on a
|
|
308
|
+
mismatch, so a wrong version cannot slip out. Published tarballs carry a provenance statement,
|
|
309
|
+
visible on the package page.
|
|
292
310
|
|
|
293
311
|
## Layout
|
|
294
312
|
|
|
@@ -299,6 +317,8 @@ there is none.
|
|
|
299
317
|
| `cordis.patch.yml` | The one inserted Loader row |
|
|
300
318
|
| `locale/en.json`, `locale/zh.json` | Plugin Manager title and description |
|
|
301
319
|
| `icon.svg` | Plugin icon |
|
|
320
|
+
| `CHANGELOG.md` | What changed in each version |
|
|
321
|
+
| `.github/workflows/publish.yml` | Publishes to npm when a tag is pushed |
|
|
302
322
|
| `test/lifecycle.cjs` | Runs the real client against a DOM/hook shim: metrics, ten-row cap, insertion order, click/open, disposal |
|
|
303
323
|
| `test/repro-coldstart.cjs` | Regression: a cold page must paint Chinese and stay switchable both ways |
|
|
304
324
|
| `test/repro-hmr.cjs` | Regression: a reload must not collide with the live dictionary claim |
|
|
@@ -379,9 +399,45 @@ const target = uiConversation.binding(sessionId).target('stats')
|
|
|
379
399
|
```
|
|
380
400
|
|
|
381
401
|
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`.
|
|
383
|
-
|
|
384
|
-
|
|
402
|
+
carries `ttft`, `model`, `decode`, `tps`, `output` and `wall`.
|
|
403
|
+
|
|
404
|
+
**How the columns map:**
|
|
405
|
+
|
|
406
|
+
| Drawer column | Field | Note |
|
|
407
|
+
| --- | --- | --- |
|
|
408
|
+
| At | `startTime` | the `step/start` moment, same as the Statistics view |
|
|
409
|
+
| TTFT | `ttft` | `firstToken − startTime` |
|
|
410
|
+
| Speed | `tps` | `output ÷ decode` |
|
|
411
|
+
| Tokens | `output` | the provider's reported usage |
|
|
412
|
+
| **Time** | **`model`** | **model time, not the Statistics view's total** |
|
|
413
|
+
| Amortised | `output ÷ model` | this plugin's own figure |
|
|
414
|
+
|
|
415
|
+
**Interrupted rows:**
|
|
416
|
+
|
|
417
|
+
A request that was stopped never produced an `assistant/message`, so `model`, `decode` and
|
|
418
|
+
`tps` are all null. That row's **total falls back to `wall`** (`endTime − startTime`) — how
|
|
419
|
+
long the request actually ran before it was stopped. It is a log fact, so it survives a reload
|
|
420
|
+
unchanged, which is exactly what that column is for here.
|
|
421
|
+
|
|
422
|
+
The word **"interrupted" sits in the speed column**, not the first-token column: a request that
|
|
423
|
+
already streamed keeps its measured first-token time and still shows its total; the only figure
|
|
424
|
+
missing is the generation rate, so that is the column that says why. Interrupted rows are
|
|
425
|
+
excluded from the footer average — their total is wall-clock time, and folding it into the mean
|
|
426
|
+
would pull it toward a number describing neither case — but the caption still counts all ten.
|
|
427
|
+
|
|
428
|
+
**Why the total is `model` and not `wall`:**
|
|
429
|
+
|
|
430
|
+
The Statistics view's total column is `wall = endTime − startTime`, which **includes the step's
|
|
431
|
+
tool calls**. A step that thinks for 11.5 s and then runs a 31 s command shows `wall` 41.9 s but
|
|
432
|
+
`model` 11.5 s. This plugin measures model output only, so it reports `model` — a figure that
|
|
433
|
+
contains no tool time by construction, and is therefore final the moment the reply lands.
|
|
434
|
+
|
|
435
|
+
**Why the figures carry `~` while streaming:**
|
|
436
|
+
|
|
437
|
+
Until the model finishes, the Host has no `usage` from the provider, so `output` is `null`. The
|
|
438
|
+
plugin falls back to a character estimate (coefficients fitted per content type), which is why
|
|
439
|
+
those figures carry `~`. The moment `usage` arrives — which is essentially the same instant the
|
|
440
|
+
reply completes — the measured value takes over and the `~` disappears.
|
|
385
441
|
|
|
386
442
|
**Why the history is no longer stored locally:**
|
|
387
443
|
|
package/README.md
CHANGED
|
@@ -249,13 +249,30 @@ Chat 存储从实时 token 增量中累积它,而会话日志不存 live chunk
|
|
|
249
249
|
|
|
250
250
|
## 安装
|
|
251
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
|
+
|
|
252
269
|
```powershell
|
|
253
|
-
|
|
254
|
-
#
|
|
270
|
+
npm version patch # 改版本号并打 tag
|
|
271
|
+
git push --follow-tags # 推送后自动发布
|
|
255
272
|
```
|
|
256
273
|
|
|
257
|
-
|
|
258
|
-
|
|
274
|
+
工作流会先校验标签与 `package.json` 的版本一致,不一致直接失败,避免发错版本。
|
|
275
|
+
发布产物带 provenance 签名,npm 上可在包页面看到来源证明。
|
|
259
276
|
|
|
260
277
|
## 目录结构
|
|
261
278
|
|
|
@@ -266,6 +283,8 @@ Chat 存储从实时 token 增量中累积它,而会话日志不存 live chunk
|
|
|
266
283
|
| `cordis.patch.yml` | 唯一插入的那一行 Loader 条目 |
|
|
267
284
|
| `locale/en.json`、`locale/zh.json` | 插件管理器里显示的标题与说明 |
|
|
268
285
|
| `icon.svg` | 插件图标 |
|
|
286
|
+
| `CHANGELOG.md` | 各版本改动 |
|
|
287
|
+
| `.github/workflows/publish.yml` | 推标签时自动发布到 npm |
|
|
269
288
|
| `test/lifecycle.cjs` | 在 DOM/hook shim 上跑真实的客户端:指标、十行上限、插入顺序、点击开合、清理 |
|
|
270
289
|
| `test/repro-coldstart.cjs` | 回归:冷页面必须首屏中文,且双向可切换 |
|
|
271
290
|
| `test/repro-hmr.cjs` | 回归:重载不得与存活的字典占用冲突 |
|
|
@@ -278,6 +297,8 @@ node test/repro-hmr.cjs
|
|
|
278
297
|
$env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
|
|
279
298
|
```
|
|
280
299
|
|
|
300
|
+
四套共 227 项检查。
|
|
301
|
+
|
|
281
302
|
## 用真实会话日志复核
|
|
282
303
|
|
|
283
304
|
统计页的数字来自磁盘上的会话日志:
|
|
@@ -336,8 +357,41 @@ const target = uiConversation.binding(sessionId).target('stats')
|
|
|
336
357
|
```
|
|
337
358
|
|
|
338
359
|
那份数据由宿主侧的 `sessionStats` fold 从会话日志算出,每步都带着 `ttft`、`model`、
|
|
339
|
-
`decode`、`tps`、`output`、`wall
|
|
340
|
-
|
|
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
|
+
**中断的行:**
|
|
374
|
+
|
|
375
|
+
被手动停止的请求没有 `assistant/message`,因此 `model`、`decode`、`tps` 全为 null。
|
|
376
|
+
那一行的**总用时回退到 `wall`**(`endTime − startTime`),也就是这条请求从发出到被
|
|
377
|
+
停止实际过了多久——它是日志里的事实,刷新多少次都不变,正是这个场合要的数字。
|
|
378
|
+
|
|
379
|
+
**「中断」写在速度列**,不在首字列:首字若已经返回就照样显示实测值,总用时也照常显示;
|
|
380
|
+
缺的只是生成速度,所以由速度列说明原因。中断行不计入底部的平均值——它的总用时是墙钟
|
|
381
|
+
时间,混进均值会拉向一个既不代表完成、也不代表中断的数;但平均行的标题仍数满十行。
|
|
382
|
+
|
|
383
|
+
**总用时为何取 `model` 而不是 `wall`:**
|
|
384
|
+
|
|
385
|
+
统计页的「总用时」列是 `wall = endTime − startTime`,它**把工具调用也算了进去**。
|
|
386
|
+
一个思考 11.5 秒、随后跑 31 秒工具的步骤,`wall` 是 41.9 秒而 `model` 是 11.5 秒。
|
|
387
|
+
本插件只统计模型输出性能,所以取 `model`——它天然不含工具时间,回复一落地就是最终值,
|
|
388
|
+
不再随工具执行增长。
|
|
389
|
+
|
|
390
|
+
**流式期间为什么带 `~`:**
|
|
391
|
+
|
|
392
|
+
模型正在输出时,宿主还没拿到上游的 `usage`,此时 `output` 为 `null`。
|
|
393
|
+
插件改用字符估算(按内容类型分别标定系数),所以那段数字带 `~`。
|
|
394
|
+
`usage` 一到(几乎就是回复完成的同一刻),立刻切成实测值,`~` 消失。
|
|
341
395
|
|
|
342
396
|
**为什么不再本地保存:**
|
|
343
397
|
|
|
@@ -348,16 +402,18 @@ const target = uiConversation.binding(sessionId).target('stats')
|
|
|
348
402
|
现在:
|
|
349
403
|
|
|
350
404
|
- 抽屉的行在每次渲染时从该会话自己的统计数据重建,没有本地副本可以过期
|
|
351
|
-
-
|
|
405
|
+
- 快照带会话标记,切换会话的瞬间旧数据即失效,不会短暂显示上一个会话的行
|
|
352
406
|
- 页面刷新后自动从日志重建,不需要额外的持久化
|
|
353
407
|
|
|
354
408
|
**视图目标需要先订阅才会存在**(`getSnapshot` 在订阅前返回 `undefined`)。插件在
|
|
355
409
|
`apply` 时按会话订阅一次;激活是永久且共享的,所以不会在统计页之外再跑一份 fold。
|
|
356
410
|
|
|
357
|
-
|
|
358
|
-
|
|
411
|
+
**拿不到统计视图时**,插件自动退回 Chat 快照 + 轨迹域自行计算(兜底路径)。
|
|
412
|
+
这条路径不落盘、也按会话隔离,但它读不到 `model`,所以三态会退化为两态。
|
|
413
|
+
它的字符估算系数与主路径相同。决定走哪条路径的唯一依据是那份数据源在不在,
|
|
414
|
+
没有任何开关参与。
|
|
359
415
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
416
|
+
**流式期间不额外催动 fold**:那套 fold 每来一个 chunk 就会重建整张表,而它本来就由
|
|
417
|
+
统计页或宿主驱动。状态栏的实时读数在回复完成前用字符估算与本地 tick,回复一落地
|
|
418
|
+
(`model` 有值)就切到折叠给出的实测值。这样既保住了「数字从 0 开始爬」的观感,
|
|
419
|
+
也不会因为插件而多跑一份 fold。
|
package/client/client.js
CHANGED
|
@@ -880,6 +880,12 @@ window.__ModuleLoader__.load({
|
|
|
880
880
|
function readingOfStatsStep(entry) {
|
|
881
881
|
const stats = entry.metrics
|
|
882
882
|
const ttftMs = numberOrNull(stats.ttft)
|
|
883
|
+
/*
|
|
884
|
+
* A step the fold marks aborted ended without a settled reply: the user stopped it, or the
|
|
885
|
+
* upstream failed mid-request. Either way there is no `messageTime`, so `model`, `decode`
|
|
886
|
+
* and `tps` are all null and the row would be entirely blank.
|
|
887
|
+
*/
|
|
888
|
+
const interrupted = stats.aborted === true || stats.interrupted === true
|
|
883
889
|
/*
|
|
884
890
|
* The drawer's "total" is the Statistics view's MODEL time, not its wall time.
|
|
885
891
|
*
|
|
@@ -888,17 +894,15 @@ window.__ModuleLoader__.load({
|
|
|
888
894
|
* The drawer answers "how long did this request take to answer", so it reports `model` —
|
|
889
895
|
* and that figure is final the moment the reply lands, which is why it must not climb
|
|
890
896
|
* while tools run.
|
|
897
|
+
*
|
|
898
|
+
* An interrupted step has no `model` at all, so it falls back to `wall`. That is the
|
|
899
|
+
* figure the user actually wants there: how long the request ran before it was stopped.
|
|
900
|
+
* It is a log fact — `endTime - startTime` — so it survives a reload unchanged, unlike
|
|
901
|
+
* the live reading, which is recomputed and lost.
|
|
891
902
|
*/
|
|
892
|
-
const totalMs = numberOrNull(stats.model)
|
|
903
|
+
const totalMs = numberOrNull(stats.model) ?? (interrupted ? numberOrNull(stats.wall) : null)
|
|
893
904
|
const tokens = numberOrNull(stats.output)
|
|
894
905
|
const speed = numberOrNull(stats.tps)
|
|
895
|
-
/*
|
|
896
|
-
* A step the fold marks interrupted or aborted never produced a settled reply: a
|
|
897
|
-
* cancelled request has no first token to subtract from, so its ttft is null and the
|
|
898
|
-
* drawer used to print the same dash a not-yet-started step would. The flag lets the
|
|
899
|
-
* cell say what actually happened instead.
|
|
900
|
-
*/
|
|
901
|
-
const interrupted = stats.interrupted === true || stats.aborted === true
|
|
902
906
|
return {
|
|
903
907
|
key: `${entry.turn}:${entry.step}`,
|
|
904
908
|
at: numberOrNull(stats.startTime) ?? numberOrNull(stats.endTime),
|
|
@@ -2020,13 +2024,19 @@ window.__ModuleLoader__.load({
|
|
|
2020
2024
|
* Average of the rows on screen, computed over the ones that actually carry each
|
|
2021
2025
|
* figure (an interrupted request may have no TTFT, a placeholder no tokens). Returns
|
|
2022
2026
|
* null for a column with no samples so the row can print a dash instead of a fake 0.
|
|
2027
|
+
*
|
|
2028
|
+
* Interrupted rows are excluded from every mean. Their total is wall-clock time — it
|
|
2029
|
+
* includes however long the request ran before being stopped — so averaging it against
|
|
2030
|
+
* model times would pull the figures toward a number that describes neither case. The
|
|
2031
|
+
* row still counts toward `count`, because the caption names the list, not the sample.
|
|
2023
2032
|
*/
|
|
2024
2033
|
function averageOf(rows) {
|
|
2025
2034
|
if (rows.length === 0) return null
|
|
2035
|
+
const measurable = rows.filter((row) => row.interrupted !== true)
|
|
2026
2036
|
const mean = (pick) => {
|
|
2027
2037
|
let sum = 0
|
|
2028
2038
|
let count = 0
|
|
2029
|
-
for (const row of
|
|
2039
|
+
for (const row of measurable) {
|
|
2030
2040
|
const value = pick(row)
|
|
2031
2041
|
if (value === null || value === undefined || !Number.isFinite(value)) continue
|
|
2032
2042
|
sum += value
|
|
@@ -2036,6 +2046,7 @@ window.__ModuleLoader__.load({
|
|
|
2036
2046
|
}
|
|
2037
2047
|
return {
|
|
2038
2048
|
count: rows.length,
|
|
2049
|
+
measured: measurable.length,
|
|
2039
2050
|
ttftMs: mean((row) => row.ttftMs),
|
|
2040
2051
|
speed: mean((row) => row.speed),
|
|
2041
2052
|
tokens: mean((row) => row.tokens),
|
|
@@ -2043,8 +2054,8 @@ window.__ModuleLoader__.load({
|
|
|
2043
2054
|
amortized: mean((row) => row.amortized),
|
|
2044
2055
|
// A column is "estimated" when any sample in it was; the tilde then still means
|
|
2045
2056
|
// the same thing it does on a single row.
|
|
2046
|
-
estimated:
|
|
2047
|
-
anyEstimated:
|
|
2057
|
+
estimated: measurable.some((row) => row.estimated === true),
|
|
2058
|
+
anyEstimated: measurable.some((row) => row.estimated === true),
|
|
2048
2059
|
}
|
|
2049
2060
|
}
|
|
2050
2061
|
|
|
@@ -2155,12 +2166,12 @@ window.__ModuleLoader__.load({
|
|
|
2155
2166
|
* subscribes. Reading it inline during render froze the rows at whatever the first
|
|
2156
2167
|
* render saw — which is why the drawer showed timestamps minutes behind the log.
|
|
2157
2168
|
*/
|
|
2158
|
-
/*
|
|
2159
|
-
* The shipped Statistics view's data source for this session. `undefined` when that
|
|
2160
|
-
* view is absent — a composition without it — which is what routes the drawer to its
|
|
2161
|
-
* Chat-snapshot fallback. No switch decides this: the presence of the source does.
|
|
2162
|
-
*/
|
|
2163
|
-
const statsSource = typeof props.statsSource === 'function' ? props.statsSource() : undefined
|
|
2169
|
+
/*
|
|
2170
|
+
* The shipped Statistics view's data source for this session. `undefined` when that
|
|
2171
|
+
* view is absent — a composition without it — which is what routes the drawer to its
|
|
2172
|
+
* Chat-snapshot fallback. No switch decides this: the presence of the source does.
|
|
2173
|
+
*/
|
|
2174
|
+
const statsSource = typeof props.statsSource === 'function' ? props.statsSource() : undefined
|
|
2164
2175
|
const statsSubscribe = useCallback(
|
|
2165
2176
|
(listener) => {
|
|
2166
2177
|
if (statsSource === undefined || typeof statsSource.subscribe !== 'function') {
|
|
@@ -2183,10 +2194,45 @@ window.__ModuleLoader__.load({
|
|
|
2183
2194
|
[statsSource, sessionId],
|
|
2184
2195
|
)
|
|
2185
2196
|
const [statsSnapshot, setStatsSnapshot] = useState(statsRead)
|
|
2197
|
+
/*
|
|
2198
|
+
* Re-point the subscription at whatever target is current, then re-read.
|
|
2199
|
+
*
|
|
2200
|
+
* A conversation that is rebuilt underneath the drawer (a model switch, a reconnect, a
|
|
2201
|
+
* released binding) replaces its `BoundConversation`, and with it the view store the old
|
|
2202
|
+
* `target()` closure captured. Refreshing on every render rebinds the listener to the new
|
|
2203
|
+
* target, so the drawer cannot keep rendering a retired snapshot while the Statistics page
|
|
2204
|
+
* has already moved on.
|
|
2205
|
+
*/
|
|
2206
|
+
if (statsSource !== undefined && typeof statsSource.refresh === 'function') {
|
|
2207
|
+
statsSource.refresh()
|
|
2208
|
+
}
|
|
2186
2209
|
useEffect(() => {
|
|
2187
2210
|
setStatsSnapshot(statsRead())
|
|
2188
2211
|
return statsSubscribe(() => setStatsSnapshot(statsRead()))
|
|
2189
2212
|
}, [statsRead, statsSubscribe])
|
|
2213
|
+
/*
|
|
2214
|
+
* Follow a conversation that is rebuilt underneath the drawer.
|
|
2215
|
+
*
|
|
2216
|
+
* A model switch, a reconnect, or a released binding replaces the session's
|
|
2217
|
+
* `BoundConversation`, and with it the view store the old `target()` closure captured.
|
|
2218
|
+
* That stale closure keeps returning its last snapshot forever — which is exactly how the
|
|
2219
|
+
* drawer froze on one turn while the Statistics page moved on.
|
|
2220
|
+
*
|
|
2221
|
+
* On a slow tick this re-points the listener at the target that is current now and
|
|
2222
|
+
* re-reads the snapshot. Both are cheap (a map lookup and a field read) and neither
|
|
2223
|
+
* depends on the stale chain firing.
|
|
2224
|
+
*/
|
|
2225
|
+
useEffect(() => {
|
|
2226
|
+
if (statsSource === undefined) return undefined
|
|
2227
|
+
const sync = () => {
|
|
2228
|
+
if (typeof statsSource.refresh === 'function') statsSource.refresh()
|
|
2229
|
+
const next = statsRead()
|
|
2230
|
+
if (next !== undefined) setStatsSnapshot(next)
|
|
2231
|
+
}
|
|
2232
|
+
sync()
|
|
2233
|
+
const timer = setInterval(sync, 1000)
|
|
2234
|
+
return () => clearInterval(timer)
|
|
2235
|
+
}, [statsSource, statsRead])
|
|
2190
2236
|
/*
|
|
2191
2237
|
* A snapshot only counts while it belongs to the session being shown.
|
|
2192
2238
|
*
|
|
@@ -2777,18 +2823,24 @@ window.__ModuleLoader__.load({
|
|
|
2777
2823
|
'span',
|
|
2778
2824
|
{ className: 'dsh-rum-col', role: 'cell' },
|
|
2779
2825
|
/*
|
|
2780
|
-
*
|
|
2781
|
-
*
|
|
2782
|
-
*
|
|
2826
|
+
* The first-token column reports whatever was actually measured. An
|
|
2827
|
+
* interrupted request that had already produced its first token shows
|
|
2828
|
+
* that real figure; one stopped before any token shows the dash.
|
|
2783
2829
|
*/
|
|
2784
|
-
entry.row.
|
|
2785
|
-
? t('interrupted')
|
|
2786
|
-
: secondsText(entry.row.ttftMs, t),
|
|
2830
|
+
secondsText(entry.row.ttftMs, t),
|
|
2787
2831
|
),
|
|
2788
2832
|
h(
|
|
2789
2833
|
'span',
|
|
2790
2834
|
{ className: 'dsh-rum-col dsh-rum-primary', role: 'cell' },
|
|
2791
|
-
|
|
2835
|
+
/*
|
|
2836
|
+
* A cancelled step never settled, so it has no generation speed. The
|
|
2837
|
+
* column says what happened instead of the same dash a step that has
|
|
2838
|
+
* not started would show — the speed column is where "this produced no
|
|
2839
|
+
* rate" belongs, while first-token and total still carry real figures.
|
|
2840
|
+
*/
|
|
2841
|
+
entry.row.interrupted === true
|
|
2842
|
+
? t('interrupted')
|
|
2843
|
+
: speedText(entry.row.speed, false, t),
|
|
2792
2844
|
),
|
|
2793
2845
|
h(
|
|
2794
2846
|
'span',
|
|
@@ -2940,82 +2992,115 @@ window.__ModuleLoader__.load({
|
|
|
2940
2992
|
*/
|
|
2941
2993
|
const statsSources = new Map()
|
|
2942
2994
|
/*
|
|
2943
|
-
* The Statistics target for one session
|
|
2995
|
+
* The Statistics target for one session.
|
|
2944
2996
|
*
|
|
2945
|
-
*
|
|
2946
|
-
*
|
|
2947
|
-
* the
|
|
2948
|
-
*
|
|
2949
|
-
*
|
|
2997
|
+
* The target object is looked up FRESH on every call rather than cached alongside the
|
|
2998
|
+
* source. `uiConversation.binding(id)` keeps a `BoundConversation` per session, and that
|
|
2999
|
+
* object owns the assembler and the view store; when the conversation is rebuilt — a model
|
|
3000
|
+
* switch, a reconnect, a released binding — the old `target()` closure keeps pointing at
|
|
3001
|
+
* the retired store and returns its last snapshot forever. Caching it froze the drawer on
|
|
3002
|
+
* one turn while the Statistics page moved on.
|
|
3003
|
+
*
|
|
3004
|
+
* Only the subscriptions are cached, because those must be released on eviction and
|
|
3005
|
+
* re-subscribing on every render would multiply listeners.
|
|
2950
3006
|
*/
|
|
2951
3007
|
const STATS_SOURCE_LIMIT = 4
|
|
2952
3008
|
const statsSourceFor = (sessionId) => {
|
|
2953
3009
|
if (sessionId === null || sessionId === undefined) return undefined
|
|
2954
|
-
|
|
3010
|
+
const uiConversation = ctx.get('uiConversation')
|
|
3011
|
+
if (uiConversation === undefined || uiConversation === null) return undefined
|
|
3012
|
+
|
|
3013
|
+
/** The target that is current right now, or undefined when it cannot be resolved. */
|
|
3014
|
+
const targetNow = () => {
|
|
3015
|
+
try {
|
|
3016
|
+
return uiConversation.binding(sessionId).target(STATS_TARGET) ?? undefined
|
|
3017
|
+
} catch {
|
|
3018
|
+
// The session was released (or is inactive) — no statistics for it right now.
|
|
3019
|
+
return undefined
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
const cached = statsSources.get(sessionId)
|
|
3024
|
+
if (cached !== undefined) {
|
|
2955
3025
|
// Refresh recency so the active conversation is the last one evicted.
|
|
2956
|
-
const existing = statsSources.get(sessionId)
|
|
2957
3026
|
statsSources.delete(sessionId)
|
|
2958
|
-
statsSources.set(sessionId,
|
|
2959
|
-
return
|
|
3027
|
+
statsSources.set(sessionId, cached)
|
|
3028
|
+
return cached.source
|
|
2960
3029
|
}
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
const
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
const dispose = () => {
|
|
2980
|
-
if (subscriptions.get(listener)?.unsubscribe !== unsubscribe) return
|
|
2981
|
-
subscriptions.delete(listener)
|
|
2982
|
-
try {
|
|
2983
|
-
unsubscribe()
|
|
2984
|
-
} catch {}
|
|
2985
|
-
}
|
|
2986
|
-
subscriptions.set(listener, { unsubscribe, dispose })
|
|
2987
|
-
return dispose
|
|
2988
|
-
},
|
|
2989
|
-
release: () => {
|
|
2990
|
-
for (const [listener, entry] of [...subscriptions]) {
|
|
2991
|
-
subscriptions.delete(listener)
|
|
2992
|
-
try {
|
|
2993
|
-
entry.unsubscribe()
|
|
2994
|
-
} catch {}
|
|
2995
|
-
}
|
|
2996
|
-
},
|
|
3030
|
+
|
|
3031
|
+
/*
|
|
3032
|
+
* Each listener is bound to the target that was current when it subscribed; a listener
|
|
3033
|
+
* whose target has since been replaced is re-bound the next time it asks, which is what
|
|
3034
|
+
* keeps a drawn row tracking the live fold instead of a retired one.
|
|
3035
|
+
*/
|
|
3036
|
+
const subscriptions = new Map()
|
|
3037
|
+
|
|
3038
|
+
const bind = (listener) => {
|
|
3039
|
+
const target = targetNow()
|
|
3040
|
+
if (target === undefined) return
|
|
3041
|
+
const entry = subscriptions.get(listener)
|
|
3042
|
+
if (entry !== undefined && entry.target === target) return
|
|
3043
|
+
if (entry !== undefined) {
|
|
3044
|
+
try {
|
|
3045
|
+
entry.unsubscribe()
|
|
3046
|
+
} catch {}
|
|
3047
|
+
subscriptions.delete(listener)
|
|
2997
3048
|
}
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3049
|
+
try {
|
|
3050
|
+
subscriptions.set(listener, { target, unsubscribe: target.subscribe(listener) })
|
|
3051
|
+
} catch {}
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
const dispose = (listener) => {
|
|
3055
|
+
const entry = subscriptions.get(listener)
|
|
3056
|
+
if (entry === undefined) return
|
|
3057
|
+
subscriptions.delete(listener)
|
|
3058
|
+
try {
|
|
3059
|
+
entry.unsubscribe()
|
|
3060
|
+
} catch {}
|
|
3001
3061
|
}
|
|
3002
|
-
|
|
3062
|
+
|
|
3063
|
+
const source = {
|
|
3064
|
+
/** Always the newest snapshot the session's own target holds. */
|
|
3065
|
+
getSnapshot: () => {
|
|
3066
|
+
const target = targetNow()
|
|
3067
|
+
return target === undefined ? undefined : target.getSnapshot()
|
|
3068
|
+
},
|
|
3069
|
+
subscribe: (listener) => {
|
|
3070
|
+
bind(listener)
|
|
3071
|
+
return () => dispose(listener)
|
|
3072
|
+
},
|
|
3073
|
+
/**
|
|
3074
|
+
* Re-point every listener at the target that is current now.
|
|
3075
|
+
*
|
|
3076
|
+
* Called on each render: one map lookup per listener, and it is what lets the drawer
|
|
3077
|
+
* follow a conversation that was rebuilt underneath it.
|
|
3078
|
+
*/
|
|
3079
|
+
refresh: () => {
|
|
3080
|
+
for (const listener of [...subscriptions.keys()]) bind(listener)
|
|
3081
|
+
},
|
|
3082
|
+
release: () => {
|
|
3083
|
+
for (const listener of [...subscriptions.keys()]) dispose(listener)
|
|
3084
|
+
},
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
statsSources.set(sessionId, { source })
|
|
3003
3088
|
while (statsSources.size > STATS_SOURCE_LIMIT) {
|
|
3004
3089
|
const oldest = statsSources.keys().next()
|
|
3005
3090
|
if (oldest.done === true) break
|
|
3006
3091
|
const evicted = statsSources.get(oldest.value)
|
|
3007
3092
|
statsSources.delete(oldest.value)
|
|
3008
3093
|
try {
|
|
3009
|
-
evicted?.release?.()
|
|
3094
|
+
evicted?.source?.release?.()
|
|
3010
3095
|
} catch {}
|
|
3011
3096
|
}
|
|
3012
3097
|
return source
|
|
3013
3098
|
}
|
|
3014
3099
|
ctx.effect(
|
|
3015
3100
|
() => () => {
|
|
3016
|
-
for (const
|
|
3101
|
+
for (const entry of statsSources.values()) {
|
|
3017
3102
|
try {
|
|
3018
|
-
source.release?.()
|
|
3103
|
+
entry.source.release?.()
|
|
3019
3104
|
} catch {}
|
|
3020
3105
|
}
|
|
3021
3106
|
statsSources.clear()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-recent-usage-metrics",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Geek level for the Performance & usage setting: a live reading of the newest request (first-token latency, speed, tokens, total, amortised) plus a drawer listing the last ten, all derived from the shipped Statistics view data",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|