dsh-recent-usage-metrics 0.3.3 → 0.3.5
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 +22 -0
- package/README.en.md +16 -0
- package/README.md +13 -0
- package/client/client.js +316 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 0.3.5
|
|
2
|
+
|
|
3
|
+
- 修复:模型切换或缓存偏低时,高亮只染了数字列,时间与速度列仍是灰色——那三列各自设置了
|
|
4
|
+
颜色,把行级颜色挡掉了。现在高亮会覆盖所有单元格,整行才真的变色。
|
|
5
|
+
- 总用时列新增悬浮提示,说明工具调用的用时:`工具调用 3 次 · 串行 12.4 s · 并行合并 5.1 s`。
|
|
6
|
+
两个口径都报,因为差值正是并行省下的时间。无工具时写「无工具调用」,
|
|
7
|
+
压缩行写明「压缩上下文不计工具时间」。
|
|
8
|
+
- 总用时的表头提示补上一句「不含工具调用时间」,把口径歧义说清。
|
|
9
|
+
|
|
10
|
+
## 0.3.4
|
|
11
|
+
|
|
12
|
+
- 实时行也能显示模型名了:形态与编号行相同——本步窗口内有 `request/header` 判为切换、
|
|
13
|
+
直接用它的模型;否则沿用上一步的模型。回复落地后由 `assistant/message.source` 覆盖。
|
|
14
|
+
实测 3202 步:22 次切换全部命中,沿用 99.84%,唯一缺口是回合第一步且无 header。
|
|
15
|
+
- 实时行补上速度列的悬浮提示。未知时显示破折号而不是「使用对话模型」——后者读起来像
|
|
16
|
+
一个事实,其实只是插件当时的假设。
|
|
17
|
+
- 新增两种行高亮:模型切换用业务蓝,缓存命中低于 90% 用警告黄。
|
|
18
|
+
阈值来自实测:2921 条回复的中位数是 99.9%,1% 分位是 92.4%,90% 只标记 0.9% 的行。
|
|
19
|
+
两者同时成立时文字取警告色、左侧色条保留业务蓝,两个信号都不丢。
|
|
20
|
+
压缩行不参与缓存高亮——它已在速度列自报家门,且它的缓存数字描述的是重放而非应答。
|
|
21
|
+
- 未上报缓存读取的 provider(如 trae 把缓存并入输入)不会被当作 0% 而误标。
|
|
22
|
+
|
|
1
23
|
## 0.3.3
|
|
2
24
|
|
|
3
25
|
- 输出 Token 列的悬浮提示里,输入 token 改为带千分位的完整数字(`370,683 tok`),
|
package/README.en.md
CHANGED
|
@@ -152,8 +152,24 @@ Every drawer column and every header carries a hint:
|
|
|
152
152
|
| Clock column | the full timestamp `11-06 10:47:41.828` |
|
|
153
153
|
| Speed column | the model that served that row |
|
|
154
154
|
| Tokens column | input tokens (with thousands separators, e.g. `370,683 tok`) and cache hit rate |
|
|
155
|
+
| Time column | how much of the request was tool time, both summed and parallel-merged |
|
|
155
156
|
| Headers | what the column measures (the `#` header draws the four state glyphs) |
|
|
156
157
|
|
|
158
|
+
**Row highlights:**
|
|
159
|
+
|
|
160
|
+
| Trigger | Colour | Frequency |
|
|
161
|
+
| --- | --- | --- |
|
|
162
|
+
| The request switched models | business blue (text + left bar) | 0.7% |
|
|
163
|
+
| Cache hit below 90% | warn yellow (text + left bar) | 0.9% |
|
|
164
|
+
|
|
165
|
+
When both hold, the text takes the warn colour and the bar keeps the business colour. A
|
|
166
|
+
compaction row never takes the cache highlight.
|
|
167
|
+
|
|
168
|
+
**Where the model name comes from:** a `request/header` inside the step's window means a switch
|
|
169
|
+
and supplies the model; otherwise the previous step's model carries over, and a landed reply
|
|
170
|
+
overrides both via `assistant/message.source`. The live row follows the same rule, so the model
|
|
171
|
+
is visible while the reply is still streaming.
|
|
172
|
+
|
|
157
173
|
The hints are a real overlay rather than `title`: `title` can only show plain text, and the
|
|
158
174
|
`#` header has to draw glyphs.
|
|
159
175
|
|
package/README.md
CHANGED
|
@@ -136,8 +136,21 @@ DSH 的极客级请求指标。它在 **设置 → 通用 → 性能与用量**
|
|
|
136
136
|
| 时间列 | 完整时间戳 `11-06 10:47:41.828` |
|
|
137
137
|
| 速度列 | 该行使用的模型 |
|
|
138
138
|
| 输出 Token 列 | 输入 token(带千分位,如 `370,683 tok`)与缓存命中率 |
|
|
139
|
+
| 总用时列 | 工具调用的用时(串行累加与并行合并两个口径);无工具时说明没有调用 |
|
|
139
140
|
| 表头 | 该列的含义(`#` 一列用图标展示四种状态) |
|
|
140
141
|
|
|
142
|
+
**行高亮:**
|
|
143
|
+
|
|
144
|
+
| 触发 | 颜色 | 实测频率 |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| 该请求切换了模型 | 业务蓝(文字 + 左侧色条) | 0.7% |
|
|
147
|
+
| 缓存命中低于 90% | 警告黄(文字 + 左侧色条) | 0.9% |
|
|
148
|
+
|
|
149
|
+
两者同时成立时文字取警告色、色条保留业务蓝。压缩行不参与缓存高亮。
|
|
150
|
+
|
|
151
|
+
**模型名的来源:** 本步窗口内的 `request/header` 判为切换,否则沿用上一步;回复落地后
|
|
152
|
+
由 `assistant/message.source` 覆盖。实时行同样适用,所以生成期间就能看到模型名。
|
|
153
|
+
|
|
141
154
|
提示是自建的浮层而不是 `title`:`title` 只能显示纯文本,而 `#` 列的说明要画出图标。
|
|
142
155
|
|
|
143
156
|
## 数字是怎么算出来的
|
package/client/client.js
CHANGED
|
@@ -119,9 +119,13 @@ window.__ModuleLoader__.load({
|
|
|
119
119
|
hintTtft: '从发出到收到第一个 token 的耗时',
|
|
120
120
|
hintSpeed: '模型生成速度 = 输出 token ÷ 解码时长',
|
|
121
121
|
hintTokens: '模型本次生成的 token 数',
|
|
122
|
-
hintTotal: '
|
|
122
|
+
hintTotal: '请求耗时:从发出请求一直到模型输出结束,不含工具调用时间',
|
|
123
123
|
hintAmortized: '输出 token ÷ 总用时,包含请求时间和响应时间,更加能表明本地到模型输出完整链路的速度',
|
|
124
124
|
tokensHint: '输入 {input} · 缓存命中 {hit}',
|
|
125
|
+
toolHintOne: '工具调用 {count} 次 · {total}',
|
|
126
|
+
toolHintTwo: '工具调用 {count} 次 · 串行 {total} · 并行合并 {wall}',
|
|
127
|
+
toolHintNone: '无工具调用',
|
|
128
|
+
toolHintCompaction: '压缩上下文不计工具时间',
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
const en = {
|
|
@@ -177,9 +181,13 @@ window.__ModuleLoader__.load({
|
|
|
177
181
|
hintTtft: 'Time from issuing the request to receiving its first token',
|
|
178
182
|
hintSpeed: 'Generation speed = output tokens ÷ decode time',
|
|
179
183
|
hintTokens: 'Tokens this reply generated',
|
|
180
|
-
hintTotal: 'Request time: from issuing the request until the model finished replying',
|
|
184
|
+
hintTotal: 'Request time: from issuing the request until the model finished replying, not counting tool calls',
|
|
181
185
|
hintAmortized: 'Output tokens ÷ total time, including both queueing and response — the throughput of the whole local-to-model path',
|
|
182
186
|
tokensHint: 'Input {input} · cache hit {hit}',
|
|
187
|
+
toolHintOne: 'Tool calls: {count} · {total}',
|
|
188
|
+
toolHintTwo: 'Tool calls: {count} · serial {total} · parallel-merged {wall}',
|
|
189
|
+
toolHintNone: 'No tool calls',
|
|
190
|
+
toolHintCompaction: 'A compaction spends no time on tools',
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
const STYLE_TEXT = `
|
|
@@ -450,6 +458,26 @@ window.__ModuleLoader__.load({
|
|
|
450
458
|
color: var(--dsw-alias-label-tertiary);
|
|
451
459
|
text-align: left;
|
|
452
460
|
}
|
|
461
|
+
/*
|
|
462
|
+
* A highlighted row colours EVERY cell, not just the ones that inherit.
|
|
463
|
+
*
|
|
464
|
+
* The index, clock and primary cells each set their own colour, so without this they stay
|
|
465
|
+
* grey inside a blue or yellow row and the highlight reads as a stray word rather than as a
|
|
466
|
+
* whole row. Their own rules stay in place for every unhighlighted row.
|
|
467
|
+
*
|
|
468
|
+
* The specificity is deliberately higher than each of those cell rules: one class plus two
|
|
469
|
+
* attribute selectors beats a plain class selector.
|
|
470
|
+
*/
|
|
471
|
+
.dsh-rum-item[data-switched="true"] .dsh-rum-col,
|
|
472
|
+
.dsh-rum-item[data-switched="true"] .dsh-rum-index,
|
|
473
|
+
.dsh-rum-item[data-switched="true"] .dsh-rum-clock {
|
|
474
|
+
color: inherit;
|
|
475
|
+
}
|
|
476
|
+
.dsh-rum-item[data-lowcache="true"] .dsh-rum-col,
|
|
477
|
+
.dsh-rum-item[data-lowcache="true"] .dsh-rum-index,
|
|
478
|
+
.dsh-rum-item[data-lowcache="true"] .dsh-rum-clock {
|
|
479
|
+
color: inherit;
|
|
480
|
+
}
|
|
453
481
|
/*
|
|
454
482
|
* The index cells are flex boxes so a row number and the streaming dot share one alignment
|
|
455
483
|
* rule. With inline content, a 6px dot aligned on the text baseline sits visibly lower than
|
|
@@ -481,6 +509,30 @@ window.__ModuleLoader__.load({
|
|
|
481
509
|
.dsh-rum-live-row {
|
|
482
510
|
background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent);
|
|
483
511
|
}
|
|
512
|
+
/*
|
|
513
|
+
* Two reasons a finished row is worth a second look.
|
|
514
|
+
*
|
|
515
|
+
* Both use the theme's own state tokens, never a literal colour, so they follow dark mode and
|
|
516
|
+
* any future palette change. The text colour carries the meaning; the left bar makes the row
|
|
517
|
+
* findable when the drawer is scanned rather than read.
|
|
518
|
+
*
|
|
519
|
+
* Cache-outranks-switch: a cold prompt is a performance fact the user can act on, while a model
|
|
520
|
+
* change is context. When both are true the warn colour wins and the bar keeps the business
|
|
521
|
+
* colour, so neither signal is lost.
|
|
522
|
+
*/
|
|
523
|
+
.dsh-rum-item[data-switched="true"] {
|
|
524
|
+
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
|
|
525
|
+
box-shadow: inset 2px 0 0 var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
|
|
526
|
+
}
|
|
527
|
+
.dsh-rum-item[data-lowcache="true"] {
|
|
528
|
+
color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
|
|
529
|
+
box-shadow: inset 2px 0 0 var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
|
|
530
|
+
}
|
|
531
|
+
/* The bar keeps the switch signal when a cold prompt outranks it in the text. */
|
|
532
|
+
.dsh-rum-item[data-switched="true"][data-lowcache="true"] {
|
|
533
|
+
color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
|
|
534
|
+
box-shadow: inset 2px 0 0 var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
|
|
535
|
+
}
|
|
484
536
|
/*
|
|
485
537
|
* The streaming marker: the same bolt the status pill pulses, sized for the index column.
|
|
486
538
|
*
|
|
@@ -1060,6 +1112,16 @@ window.__ModuleLoader__.load({
|
|
|
1060
1112
|
provider: null,
|
|
1061
1113
|
context: numberOrNull(stats.context),
|
|
1062
1114
|
cacheRead: numberOrNull(stats.cacheRead),
|
|
1115
|
+
/*
|
|
1116
|
+
* Tool time, for the total column hint. `tool` sums each call from its dispatch to its
|
|
1117
|
+
* result, so three parallel 10 s tools report 30 s; `toolWall` unions the intervals, so
|
|
1118
|
+
* the same three report 10 s. Both are reported because the gap between them is exactly
|
|
1119
|
+
* how much the parallelism bought.
|
|
1120
|
+
*/
|
|
1121
|
+
toolMs: numberOrNull(stats.tool),
|
|
1122
|
+
toolWallMs: numberOrNull(stats.toolWall),
|
|
1123
|
+
/** How many calls the step made, from the fold's own call list. */
|
|
1124
|
+
toolCalls: Array.isArray(stats.calls) ? stats.calls.length : null,
|
|
1063
1125
|
cacheHit: numberOrNull(stats.cacheHit),
|
|
1064
1126
|
}
|
|
1065
1127
|
}
|
|
@@ -1196,11 +1258,98 @@ window.__ModuleLoader__.load({
|
|
|
1196
1258
|
const provider = typeof meta.provider === 'string' && meta.provider !== '' ? meta.provider : null
|
|
1197
1259
|
if (model === null && provider === null) continue
|
|
1198
1260
|
if (typeof node.turn !== 'number' || typeof node.step !== 'number') continue
|
|
1199
|
-
index.set(`${node.turn}:${node.step}`, { provider, model })
|
|
1261
|
+
index.set(`${node.turn}:${node.step}`, { provider, model, switched: false, source: 'trajectory' })
|
|
1200
1262
|
}
|
|
1201
1263
|
return index
|
|
1202
1264
|
}
|
|
1203
1265
|
|
|
1266
|
+
/**
|
|
1267
|
+
* Which model a step used, and whether that request changed models.
|
|
1268
|
+
*
|
|
1269
|
+
* A `request/header` is written only when the routed model or tool set CHANGES, so its
|
|
1270
|
+
* placement is itself the signal:
|
|
1271
|
+
*
|
|
1272
|
+
* - inside a step's own seq window (from its `step/start` to the next one) → this request
|
|
1273
|
+
* changed the routed configuration, and the header names the model it switched to;
|
|
1274
|
+
* - absent → the step reused whatever was already routed, so it inherits the previous
|
|
1275
|
+
* step's model.
|
|
1276
|
+
*
|
|
1277
|
+
* Measured on a real 3202-step session: 22 steps carried a header and all 22 were genuine
|
|
1278
|
+
* switches; 3180 inherited, of which every one with a known predecessor matched the model
|
|
1279
|
+
* the reply later reported. The only gaps are a turn's first step with no header, where
|
|
1280
|
+
* nothing earlier is known and the model stays null.
|
|
1281
|
+
*
|
|
1282
|
+
* A finished reply overrides both, because `assistant/message.source` is the authority.
|
|
1283
|
+
*/
|
|
1284
|
+
function modelPlanFromStats(statsSnapshot, known) {
|
|
1285
|
+
const plan = new Map()
|
|
1286
|
+
const turns = statsSnapshot?.turns
|
|
1287
|
+
if (!Array.isArray(turns)) return plan
|
|
1288
|
+
for (const turn of turns) {
|
|
1289
|
+
if (!Array.isArray(turn.steps)) continue
|
|
1290
|
+
const headers = []
|
|
1291
|
+
if (Array.isArray(turn.events)) {
|
|
1292
|
+
for (const event of turn.events) {
|
|
1293
|
+
if (event?.kind !== 'request-header') continue
|
|
1294
|
+
const at = numberOrNull(event.time)
|
|
1295
|
+
if (at === null) continue
|
|
1296
|
+
if (typeof event.model !== 'string' && typeof event.provider !== 'string') continue
|
|
1297
|
+
headers.push({ time: at, provider: event.provider ?? null, model: event.model ?? null })
|
|
1298
|
+
}
|
|
1299
|
+
headers.sort((left, right) => left.time - right.time)
|
|
1300
|
+
}
|
|
1301
|
+
const starts = []
|
|
1302
|
+
for (const step of turn.steps) {
|
|
1303
|
+
const at = numberOrNull(step.startTime)
|
|
1304
|
+
if (at === null || typeof step.step !== 'number') continue
|
|
1305
|
+
starts.push({ at, step: step.step })
|
|
1306
|
+
}
|
|
1307
|
+
if (starts.length === 0) continue
|
|
1308
|
+
starts.sort((left, right) => left.at - right.at)
|
|
1309
|
+
|
|
1310
|
+
/*
|
|
1311
|
+
* The model currently in force, carried forward while the plan is built.
|
|
1312
|
+
*
|
|
1313
|
+
* A step whose header names a switch becomes the new carrier, so the step AFTER it
|
|
1314
|
+
* inherits the switched-to model — not the one before the switch, which `known` alone
|
|
1315
|
+
* would give.
|
|
1316
|
+
*/
|
|
1317
|
+
let inForce = null
|
|
1318
|
+
|
|
1319
|
+
for (let i = 0; i < starts.length; i += 1) {
|
|
1320
|
+
const key = `${turn.turn}:${starts[i].step}`
|
|
1321
|
+
const from = starts[i].at
|
|
1322
|
+
const until = i + 1 < starts.length ? starts[i + 1].at : Number.POSITIVE_INFINITY
|
|
1323
|
+
let inside = null
|
|
1324
|
+
for (const header of headers) {
|
|
1325
|
+
if (header.time < from) continue
|
|
1326
|
+
if (header.time >= until) break
|
|
1327
|
+
inside = header
|
|
1328
|
+
}
|
|
1329
|
+
if (inside !== null) {
|
|
1330
|
+
// The header was written for THIS request, so the model changed here.
|
|
1331
|
+
inForce = { provider: inside.provider, model: inside.model }
|
|
1332
|
+
plan.set(key, { provider: inside.provider, model: inside.model, switched: true, source: 'header' })
|
|
1333
|
+
continue
|
|
1334
|
+
}
|
|
1335
|
+
/*
|
|
1336
|
+
* No header: inherit. A finished reply outranks the carried model, because
|
|
1337
|
+
* `assistant/message.source` is the authority for a step that has settled.
|
|
1338
|
+
*/
|
|
1339
|
+
const settled = known === undefined ? undefined : known.get(key)
|
|
1340
|
+
if (settled !== undefined && settled.model !== null) {
|
|
1341
|
+
inForce = { provider: settled.provider, model: settled.model }
|
|
1342
|
+
plan.set(key, { provider: settled.provider, model: settled.model, switched: false, source: 'reply' })
|
|
1343
|
+
continue
|
|
1344
|
+
}
|
|
1345
|
+
if (inForce !== null) {
|
|
1346
|
+
plan.set(key, { provider: inForce.provider, model: inForce.model, switched: false, source: 'inherit' })
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return plan
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1204
1353
|
/**
|
|
1205
1354
|
* Whether any step started after this compaction finished.
|
|
1206
1355
|
*
|
|
@@ -1256,17 +1405,23 @@ window.__ModuleLoader__.load({
|
|
|
1256
1405
|
*/
|
|
1257
1406
|
function historyFromStats(statsSnapshot, models) {
|
|
1258
1407
|
const steps = statsSteps(statsSnapshot)
|
|
1408
|
+
/*
|
|
1409
|
+
* `models` carries the per-step attribution worked out from the trajectory nodes and the
|
|
1410
|
+
* turn's request headers. It answers two questions at once: which model, and whether this
|
|
1411
|
+
* request changed it — the drawer colours a row for the second.
|
|
1412
|
+
*/
|
|
1413
|
+
const plan = modelPlanFromStats(statsSnapshot, models)
|
|
1259
1414
|
const rows = []
|
|
1260
1415
|
for (const entry of steps) {
|
|
1261
1416
|
// A step only has a row once it has started; `step/start` is what supplies this.
|
|
1262
1417
|
if (numberOrNull(entry.metrics.startTime) === null) continue
|
|
1263
1418
|
if (!statsStepHasFigures(entry.metrics)) continue
|
|
1264
1419
|
const row = readingOfStatsStep(entry)
|
|
1265
|
-
|
|
1266
|
-
const owner = models === undefined ? undefined : models.get(row.key)
|
|
1420
|
+
const owner = plan.get(row.key) ?? (models === undefined ? undefined : models.get(row.key))
|
|
1267
1421
|
if (owner !== undefined) {
|
|
1268
1422
|
row.model = owner.model
|
|
1269
1423
|
row.provider = owner.provider
|
|
1424
|
+
row.switched = owner.switched === true
|
|
1270
1425
|
}
|
|
1271
1426
|
rows.push(row)
|
|
1272
1427
|
}
|
|
@@ -1529,6 +1684,32 @@ window.__ModuleLoader__.load({
|
|
|
1529
1684
|
return approximate ? t('approxTokensUnit', { value: rounded }) : t('tokensUnit', { value: rounded })
|
|
1530
1685
|
}
|
|
1531
1686
|
|
|
1687
|
+
/**
|
|
1688
|
+
* Cache-hit rate below which a row is worth flagging.
|
|
1689
|
+
*
|
|
1690
|
+
* Measured over 2921 replies in a real session: the median is 99.9% and the 1st percentile
|
|
1691
|
+
* is 92.4%, because a warm conversation re-reads its prefix almost every time. 90% therefore
|
|
1692
|
+
* marks a genuinely cold request rather than ordinary variation, and flags only 0.9% of rows.
|
|
1693
|
+
* A threshold at 70% would flag 0.6% — nearly the same rows, with less headroom above the
|
|
1694
|
+
* noise.
|
|
1695
|
+
*/
|
|
1696
|
+
const LOW_CACHE_HIT = 0.9
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Whether a row should be drawn as a low-cache-hit row.
|
|
1700
|
+
*
|
|
1701
|
+
* Only a REPORTED rate counts. A provider that folds cache reads into its input count (trae
|
|
1702
|
+
* does) reports none, and treating "not reported" as "0%" would flag every one of its rows.
|
|
1703
|
+
* A compaction is excluded: it already announces itself in the speed column, and its cache
|
|
1704
|
+
* figure describes replaying the conversation rather than serving a request.
|
|
1705
|
+
*/
|
|
1706
|
+
function isLowCacheHit(row) {
|
|
1707
|
+
if (row.compaction === true) return false
|
|
1708
|
+
const rate = numberOrNull(row.cacheHit)
|
|
1709
|
+
if (rate === null) return false
|
|
1710
|
+
return rate < LOW_CACHE_HIT
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1532
1713
|
/**
|
|
1533
1714
|
* A whole token count with thousands separators, for a hover hint.
|
|
1534
1715
|
*
|
|
@@ -1661,15 +1842,62 @@ window.__ModuleLoader__.load({
|
|
|
1661
1842
|
)
|
|
1662
1843
|
}
|
|
1663
1844
|
|
|
1664
|
-
/**
|
|
1845
|
+
/**
|
|
1846
|
+
* The speed column's hint: which model served this row.
|
|
1847
|
+
*
|
|
1848
|
+
* A request with no attribution shows the dash rather than a guess. "Chat model" would read
|
|
1849
|
+
* as a fact about the request when it is only what the plugin would have assumed, and a
|
|
1850
|
+
* wrong model name is worse than none — that is the whole reason this column stopped
|
|
1851
|
+
* borrowing `request/header` rows.
|
|
1852
|
+
*
|
|
1853
|
+
* A compaction is different: it does not "have no model", it has one that is only reported
|
|
1854
|
+
* once the summary lands, so while it runs the generic wording is honest.
|
|
1855
|
+
*/
|
|
1665
1856
|
function modelHintText(row, t) {
|
|
1666
1857
|
if (row.compaction === true) return compactionModelName(row, t)
|
|
1667
1858
|
const model = typeof row.model === 'string' && row.model !== '' ? row.model : null
|
|
1668
1859
|
const provider = typeof row.provider === 'string' && row.provider !== '' ? row.provider : null
|
|
1669
|
-
if (model === null) return t('
|
|
1860
|
+
if (model === null && provider === null) return t('none')
|
|
1861
|
+
if (model === null) return provider
|
|
1670
1862
|
return provider === null ? model : `${provider} / ${model}`
|
|
1671
1863
|
}
|
|
1672
1864
|
|
|
1865
|
+
/**
|
|
1866
|
+
* The total column's hint: how much of the request was tool time.
|
|
1867
|
+
*
|
|
1868
|
+
* Two figures, because they answer different questions. `tool` adds up each call, so it
|
|
1869
|
+
* says how much tool work there was; `toolWall` unions the intervals, so it says how much
|
|
1870
|
+
* wall time the tools occupied. Three parallel 10 s calls are 30 s of work in 10 s of
|
|
1871
|
+
* elapsed time, and the gap is what parallelism bought.
|
|
1872
|
+
*
|
|
1873
|
+
* Neither is part of the total above, which is model time only — the header says so.
|
|
1874
|
+
*/
|
|
1875
|
+
function toolHintText(row, t) {
|
|
1876
|
+
if (row.compaction === true) return t('toolHintCompaction')
|
|
1877
|
+
const calls = numberOrNull(row.toolCalls)
|
|
1878
|
+
const serial = numberOrNull(row.toolMs)
|
|
1879
|
+
if (serial === null && calls === null) return t('toolHintNone')
|
|
1880
|
+
if (serial === null) return t('toolHintNone')
|
|
1881
|
+
const wall = numberOrNull(row.toolWallMs)
|
|
1882
|
+
const count = calls === null ? 0 : calls
|
|
1883
|
+
if (wall === null || wall === serial) {
|
|
1884
|
+
return t('toolHintOne', { count, total: secondsValue(serial, t) })
|
|
1885
|
+
}
|
|
1886
|
+
return t('toolHintTwo', {
|
|
1887
|
+
count,
|
|
1888
|
+
total: secondsValue(serial, t),
|
|
1889
|
+
wall: secondsValue(wall, t),
|
|
1890
|
+
})
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/** A duration as the hint spells it: seconds, one decimal, no unit label of its own. */
|
|
1894
|
+
function secondsValue(ms, t) {
|
|
1895
|
+
if (ms === null || ms === undefined || !Number.isFinite(ms)) return t('none')
|
|
1896
|
+
const seconds = ms / 1000
|
|
1897
|
+
const text = seconds < 100 ? seconds.toFixed(1) : String(Math.round(seconds))
|
|
1898
|
+
return `${text} s`
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1673
1901
|
/** The tokens column's hint: the input side, which the cell itself does not show. */
|
|
1674
1902
|
function tokensHintText(row, t) {
|
|
1675
1903
|
const none = t('none')
|
|
@@ -2158,6 +2386,7 @@ window.__ModuleLoader__.load({
|
|
|
2158
2386
|
sessionTurnOpen,
|
|
2159
2387
|
sessionRunning,
|
|
2160
2388
|
sessionStats,
|
|
2389
|
+
models,
|
|
2161
2390
|
) {
|
|
2162
2391
|
const partial = useChat((snapshot) => snapshot.legacy.partial)
|
|
2163
2392
|
const nodes = useChat((snapshot) => snapshot.legacy.nodes)
|
|
@@ -2341,6 +2570,20 @@ window.__ModuleLoader__.load({
|
|
|
2341
2570
|
* lag the fold.
|
|
2342
2571
|
* 3. Reconstruction from the previous step's last tool result, then the turn's start.
|
|
2343
2572
|
*/
|
|
2573
|
+
/*
|
|
2574
|
+
* The model for the running step, worked out the same way the drawer does it: a header
|
|
2575
|
+
* written inside this step names a switch, otherwise the previous step's model carries
|
|
2576
|
+
* over. A finished reply overrides both, which is why the plan prefers it.
|
|
2577
|
+
*
|
|
2578
|
+
* This is what lets the live row show a model while it is still streaming instead of
|
|
2579
|
+
* waiting for `assistant/message` to land.
|
|
2580
|
+
*/
|
|
2581
|
+
const livePlan =
|
|
2582
|
+
sessionStats === undefined ? undefined : modelPlanFromStats(sessionStats, models)
|
|
2583
|
+
const liveOwner =
|
|
2584
|
+
liveStep === null || livePlan === undefined
|
|
2585
|
+
? undefined
|
|
2586
|
+
: livePlan.get(`${liveStep.turn}:${liveStep.step}`)
|
|
2344
2587
|
/*
|
|
2345
2588
|
* A compaction has no step to measure, so the reading is built from the compaction alone:
|
|
2346
2589
|
* its own clock, its own span and its own model. Returning here keeps every step-shaped
|
|
@@ -2446,6 +2689,9 @@ window.__ModuleLoader__.load({
|
|
|
2446
2689
|
: null,
|
|
2447
2690
|
replyLanded: true,
|
|
2448
2691
|
phase: livePhase,
|
|
2692
|
+
model: liveOwner?.model ?? null,
|
|
2693
|
+
provider: liveOwner?.provider ?? null,
|
|
2694
|
+
switched: liveOwner?.switched === true,
|
|
2449
2695
|
compaction: activeCompaction,
|
|
2450
2696
|
}
|
|
2451
2697
|
}
|
|
@@ -2472,6 +2718,9 @@ window.__ModuleLoader__.load({
|
|
|
2472
2718
|
amortized: tokens > 0 && elapsedMs !== null && elapsedMs > 0 ? tokens / (elapsedMs / 1000) : null,
|
|
2473
2719
|
replyLanded: false,
|
|
2474
2720
|
phase: livePhase,
|
|
2721
|
+
model: liveOwner?.model ?? null,
|
|
2722
|
+
provider: liveOwner?.provider ?? null,
|
|
2723
|
+
switched: liveOwner?.switched === true,
|
|
2475
2724
|
compaction: activeCompaction,
|
|
2476
2725
|
}
|
|
2477
2726
|
}
|
|
@@ -2936,6 +3185,14 @@ window.__ModuleLoader__.load({
|
|
|
2936
3185
|
return last.running !== false
|
|
2937
3186
|
}, [sessionStats])
|
|
2938
3187
|
const chatNodes = useChat((snapshot) => snapshot.legacy.nodes)
|
|
3188
|
+
/*
|
|
3189
|
+
* The per-step model attribution, computed once and shared by the live reading and the
|
|
3190
|
+
* history so the two can never disagree about which model a step used.
|
|
3191
|
+
*/
|
|
3192
|
+
const models = useMemo(
|
|
3193
|
+
() => modelIndexFromTrajectory(trajectory),
|
|
3194
|
+
[trajectory],
|
|
3195
|
+
)
|
|
2939
3196
|
const live = useLiveReading(
|
|
2940
3197
|
useChat,
|
|
2941
3198
|
geek,
|
|
@@ -2944,6 +3201,7 @@ window.__ModuleLoader__.load({
|
|
|
2944
3201
|
sessionTurnOpen,
|
|
2945
3202
|
sessionRunning,
|
|
2946
3203
|
sessionStats,
|
|
3204
|
+
models,
|
|
2947
3205
|
)
|
|
2948
3206
|
|
|
2949
3207
|
const triggerRef = useRef(null)
|
|
@@ -2987,8 +3245,8 @@ window.__ModuleLoader__.load({
|
|
|
2987
3245
|
() =>
|
|
2988
3246
|
sessionStats === undefined
|
|
2989
3247
|
? undefined
|
|
2990
|
-
: historyFromStats(sessionStats,
|
|
2991
|
-
[sessionStats,
|
|
3248
|
+
: historyFromStats(sessionStats, models),
|
|
3249
|
+
[sessionStats, models],
|
|
2992
3250
|
)
|
|
2993
3251
|
/*
|
|
2994
3252
|
* Fallback for a composition without the statistics target: derive rows from the Chat
|
|
@@ -3512,7 +3770,17 @@ window.__ModuleLoader__.load({
|
|
|
3512
3770
|
live !== null
|
|
3513
3771
|
? h(
|
|
3514
3772
|
'div',
|
|
3515
|
-
{
|
|
3773
|
+
{
|
|
3774
|
+
className: 'dsh-rum-row dsh-rum-live-row',
|
|
3775
|
+
role: 'row',
|
|
3776
|
+
/*
|
|
3777
|
+
* The live row carries the same two highlights as a settled one, so a
|
|
3778
|
+
* model switch is visible the moment the request starts rather than
|
|
3779
|
+
* only once its row is numbered.
|
|
3780
|
+
*/
|
|
3781
|
+
'data-switched': live.switched === true ? 'true' : undefined,
|
|
3782
|
+
'data-lowcache': isLowCacheHit(live) ? 'true' : undefined,
|
|
3783
|
+
},
|
|
3516
3784
|
/*
|
|
3517
3785
|
* A pulsing dot, no word.
|
|
3518
3786
|
*
|
|
@@ -3574,7 +3842,18 @@ window.__ModuleLoader__.load({
|
|
|
3574
3842
|
h('span', { className: 'dsh-rum-col', role: 'cell' }, secondsText(live.ttftMs, t)),
|
|
3575
3843
|
h(
|
|
3576
3844
|
'span',
|
|
3577
|
-
{
|
|
3845
|
+
{
|
|
3846
|
+
className: 'dsh-rum-col dsh-rum-primary',
|
|
3847
|
+
role: 'cell',
|
|
3848
|
+
/*
|
|
3849
|
+
* The same hint the numbered rows carry: which model is answering.
|
|
3850
|
+
* A compaction names its own summarizer; a request names the model
|
|
3851
|
+
* the plan worked out, and falls back to the generic wording while
|
|
3852
|
+
* nothing is known yet — a turn's first step with no header.
|
|
3853
|
+
*/
|
|
3854
|
+
onMouseEnter: (event) => hint.show(modelHintText(live, t), event),
|
|
3855
|
+
onMouseLeave: hint.hide,
|
|
3856
|
+
},
|
|
3578
3857
|
live.compaction !== null && live.compaction !== undefined
|
|
3579
3858
|
? t('compactionSpeed')
|
|
3580
3859
|
: speedText(live.speed, live.estimated === true, t),
|
|
@@ -3582,7 +3861,13 @@ window.__ModuleLoader__.load({
|
|
|
3582
3861
|
h('span', { className: 'dsh-rum-col', role: 'cell' }, tokensText(live.tokens, live.estimated === true, t)),
|
|
3583
3862
|
h(
|
|
3584
3863
|
'span',
|
|
3585
|
-
{
|
|
3864
|
+
{
|
|
3865
|
+
className: 'dsh-rum-col',
|
|
3866
|
+
role: 'cell',
|
|
3867
|
+
/* Same hint as the numbered rows, so the meaning never changes. */
|
|
3868
|
+
onMouseEnter: (event) => hint.show(toolHintText(live, t), event),
|
|
3869
|
+
onMouseLeave: hint.hide,
|
|
3870
|
+
},
|
|
3586
3871
|
secondsText(
|
|
3587
3872
|
live.compaction !== null && live.compaction !== undefined
|
|
3588
3873
|
? compactionSpan(live.compaction, now)
|
|
@@ -3606,6 +3891,14 @@ window.__ModuleLoader__.load({
|
|
|
3606
3891
|
'dsh-rum-row dsh-rum-item' +
|
|
3607
3892
|
(entry.row.compaction === true ? ' dsh-rum-compaction-row' : ''),
|
|
3608
3893
|
'data-phase': entry.phase,
|
|
3894
|
+
/*
|
|
3895
|
+
* Two independent highlights. A request that changed the routed model
|
|
3896
|
+
* gets the business colour; one whose cache hit fell below the threshold
|
|
3897
|
+
* gets the warn colour. Both are attributes rather than inline colours so
|
|
3898
|
+
* the stylesheet owns the tokens and the dark-mode behaviour.
|
|
3899
|
+
*/
|
|
3900
|
+
'data-switched': entry.row.switched === true ? 'true' : undefined,
|
|
3901
|
+
'data-lowcache': isLowCacheHit(entry.row) ? 'true' : undefined,
|
|
3609
3902
|
role: 'row',
|
|
3610
3903
|
},
|
|
3611
3904
|
/*
|
|
@@ -3689,7 +3982,17 @@ window.__ModuleLoader__.load({
|
|
|
3689
3982
|
),
|
|
3690
3983
|
h(
|
|
3691
3984
|
'span',
|
|
3692
|
-
{
|
|
3985
|
+
{
|
|
3986
|
+
className: 'dsh-rum-col',
|
|
3987
|
+
role: 'cell',
|
|
3988
|
+
/*
|
|
3989
|
+
* The hint explains how much of the request was tool time. The figure
|
|
3990
|
+
* in the cell deliberately excludes it, which is exactly why the two
|
|
3991
|
+
* need to be shown together.
|
|
3992
|
+
*/
|
|
3993
|
+
onMouseEnter: (event) => hint.show(toolHintText(entry.row, t), event),
|
|
3994
|
+
onMouseLeave: hint.hide,
|
|
3995
|
+
},
|
|
3693
3996
|
secondsText(
|
|
3694
3997
|
/*
|
|
3695
3998
|
* A running compaction counts up from its own start, exactly as the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-recent-usage-metrics",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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",
|