dsh-tokenlens 1.7.5 → 1.7.7

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 CHANGED
@@ -4,6 +4,29 @@
4
4
 
5
5
  **版本记录约定**:同一发布日只保留一个章节,标题取当日**最后一个**已发布版本号;当日多次发布的改动合并到该章节,并在标题下注明当日实际发布过的版本号(npm 上仍可安装这些独立版本)。若当日已有发布,后续小改动不再单独增加版本号,而是并入下一篇日志。这样日志按天规整、不出现同日多个小版本。
6
6
 
7
+ ## 1.7.7 — 2026-11-10
8
+
9
+ - **feat(ui): TPS 卡片锁定「当前供应商」**——不再跟随上方「当前供应商 / 全部 Harness」切换。这张卡片回答的是「我现在用的模型有多快」,所以始终使用所选供应商自己的日平均序列;没有供应商行时显示空态说明
10
+ - **feat(ui): 点曲线上的点可下钻当日小时级 TPS**——与 token 柱状图的「点某天 → 24 小时」交互一致:点击后曲线切换为该日 24 个本地小时的速率曲线,标题栏显示「2026-11-05 · 小时级」并提供「返回日视图」;再点同一天也会切回日视图
11
+ - 无样本的小时是 `null` 而不是 0,曲线在该处**断开**而不是跌到零(跌到零是假信息)
12
+ - 小时级读数同样是「输出 Token ÷ 模型自身耗时」,悬停显示 `09:00 · 120.0 tok/s(600 输出 ÷ 5.0s)`
13
+ - 该日没有任何小时级样本时显示说明文字,不画空图
14
+ - **feat(stats): Host 侧新增按小时的 TPS 采样**——`foldSession` 在记录每日速率的同时把样本写入按 epoch 小时分桶的 `speedHours`,聚合层再换算成本地 24 槽 `speedDayHours`,随 `speed.byDay[].hours` 下发(仅 provider 维度,合计序列不带)。缓存 schema 升至 7,首次会重扫
15
+ - 文档:`PROTOCOL.md` 补上 `hours` 结构与「null 表示无样本」的约定
16
+ - 测试:373 → 377(TPS 卡片不受作用域切换影响、点柱下钻与返回、无样本下钻的空态、按小时分桶与 null 槽位)
17
+
18
+ ## 1.7.6 — 2026-11-06
19
+
20
+ (当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7 / v1.7.0 / v1.7.1 / v1.7.2 / v1.7.3 / v1.7.4 / v1.7.5 / v1.7.6)
21
+
22
+ - **feat(ui): 版本号左侧新增仓库链接**——弹窗底部一行现在是 `[GitHub] 插件 v1.7.6 · 已是最新版本`,链接指向 `github.com/senyingxidi/dsh-tokenlens`,`target=_blank` + `rel=noopener`,带 `title` 说明;图标沿用同一套 stroke 图标(`aria-hidden`,对读屏透明)
23
+ - **fix(ui): TPS 悬停索引不再跨视图残留**——`speedHover` 原先只存在默认状态里,切换「当前供应商 / 全部 Harness」、切换「数据 / 图表」、切换供应商时都不会重置,于是同一个索引会指向新序列里的另一天(或越界)。现在它与 `chartHover` 一起被清理
24
+ - **fix(ui): 单点 TPS 曲线的命中区溢出**——只有一天样本时命中列从负 x 开始,左半区域无法触发悬停;现在把它夹进 viewBox
25
+ - **fix(stats): 启动期统计结果可能带着未锚定的窗口被缓存**——重启后「套餐起点」要等第一次额度读取才知道,而在此之前算出的统计结果会按 TTL 一直复用。现在 Host 在锚点集合变化后强制重算一次
26
+ - **perf: 模型切换不再无谓重取**——只有额度真正随模型变化的供应商(当前仅 `commandcode`)才会在切换模型时丢弃快照;在 DeepSeek / OpenCode 等模型无关的供应商上切模型不再触发上游请求。客户端也只在 `commandcode` 下补发一次快照
27
+ - 文档:`PROTOCOL.md` 补上 `credits.poolUsd / allowanceUsd / allowanceModel / allowanceBasis`、`speed` 序列,以及 v1.7.2 的窗口锚定与 v1.7.3 的分母结论
28
+ - 测试:370 → 373(仓库链接的位置/属性/图标、悬停状态清理、按供应商差异化的缓存失效)
29
+
7
30
  ## 1.7.5 — 2026-11-06
8
31
 
9
32
  (当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7 / v1.7.0 / v1.7.1 / v1.7.2 / v1.7.3 / v1.7.4 / v1.7.5)
package/PROTOCOL.md CHANGED
@@ -84,7 +84,15 @@ from `dsh-app://app/` and forwards non-asset paths to the Host).
84
84
  "monthly": { … } | null
85
85
  } | null, // null for balance-only providers
86
86
  "balance": { "amount": 2.26, "currency": "CNY", "granted": 0, "toppedUp": 2.26 } | null,
87
- "credits": { "remaining": 4.5, "granted": 10, "purchased": 0 } | null,
87
+ "credits": {
88
+ "remaining": 4.5, "granted": 10, "purchased": 0,
89
+ // v1.7.2: the SELECTED model's advertised allowance. Advertising only: the
90
+ // monthly percentage is always measured against the credit pool (v1.7.3),
91
+ // because that is what the provider's own console does.
92
+ "poolUsd": 10, "allowanceUsd": 14,
93
+ "allowanceModel": "deepseek/deepseek-v4.1-flash" | null,
94
+ "allowanceBasis": "model" | "pool" | "unknown"
95
+ } | null,
88
96
  "account": { "name": "…", "userName": "…" } | null, // non-secret display info
89
97
  "links": { "console": "https://opencode.ai/console" } | null, // provider-owned web page, follows the selection
90
98
  "fetchedAt": "2026-10-25T09:03:00.000Z",
@@ -129,9 +137,13 @@ plugin never sizes a month from a plan catalog.
129
137
 
130
138
  **The denominator is deliberately model-independent.** GOAT advertises "up to $100" and lists
131
139
  per-model allowances ($100 on DeepSeek V4.1 Flash, $60 on several others) because the same
132
- credits stretch further on a discounted model; the pool itself is one number. Letting the
133
- picker change the denominator would move the meter while the account did not change, so
134
- `parseCommandCode` takes no model input.
140
+ credits stretch further on a discounted model; the pool itself is one number. Measured against
141
+ the provider's own console: with $2.94 spent it prints 每月限额 **4%**, i.e. `2.94 / 70 = 4.2%`;
142
+ `2.94 / 100` would print 2.9%. So the percentage is pool-based, and the per-model allowance is
143
+ reported as `credits.allowanceUsd` for context only (v1.7.3). The model *is* forwarded with the
144
+ request (`session.modelSelection.next.model`) so that the allowance can be resolved, and the Host
145
+ drops the cached snapshot on a model switch **only** for providers whose allowance actually
146
+ follows the model (`MODEL_SENSITIVE_ALLOWANCE`, currently `commandcode`).
135
147
 
136
148
  `windowLimits.limited` is a **capability flag** ("this plan paces you with rolling windows"),
137
149
  not a state: the live payload carries `limited: true` together with `fiveHour.exceeded: false`.
@@ -311,7 +323,12 @@ provider effective-date fallback (`effectiveDay`, `effectiveDays`,
311
323
  folder basename, used by the client to match a sidebar hover card; the full
312
324
  `cwd` is Host-only and never sent on the wire), the rolling 5-hour local window
313
325
  (`fiveHours`, DSH-only because external harnesses are day-granular), and the
314
- bounded `daily[]` series used by the chart view. Absolute local paths are
326
+ bounded `daily[]` series used by the chart view, the `speed` series behind the
327
+ TPS trend (v1.7.0, with 24 local-hour slots per day for the drill-down in
328
+ v1.7.7), and the subscription window (`effectiveDay` is the plan's
329
+ period start when the Host learned it, and `sinceEffective` is summed from the
330
+ per-day buckets inside `[window start, today]` rather than borrowed from the
331
+ 30-day or all-time bucket — v1.7.2). Absolute local paths are
315
332
  stripped from the wire; free-form error messages have path substrings replaced
316
333
  with `[path]`.
317
334
 
@@ -357,7 +374,7 @@ with `[path]`.
357
374
  "last30Days": { "total": …, "messages": … },
358
375
  "allTime": { "total": …, "messages": … } }
359
376
  ],
360
- "daily": [ // <=120 days, ascending; chart series
377
+ "daily": [ // <=30 days (v1.7.0), ascending; chart series
361
378
  { "day": "2026-10-25",
362
379
  "total": { "total": 123456, "messages": 42 },
363
380
  "hours": [0, 0, 0, 1234, 5678, …], // 24 integers: local-hour token totals
@@ -366,6 +383,25 @@ with `[path]`.
366
383
  "hours": [0, 0, 0, 500, 734, …] }
367
384
  } }
368
385
  ],
386
+ // v1.7.0: output-speed (TPS) series. `tps` is output tokens over the summed
387
+ // model time of the steps that produced them (`step/start` ->
388
+ // `assistant/message`), so a step's tool calls — which are emitted after the
389
+ // message — do not dilute it. A day with no positive duration is omitted
390
+ // instead of being given an infinite rate. `total.tps` is null when the
391
+ // window holds no samples. Each provider row carries its own `speed` object
392
+ // with the same shape, following the same window as its tokens (v1.7.2).
393
+ "speed": {
394
+ "byDay": [
395
+ { "day": "2026-11-05", "outputTokens": 516951, "seconds": 8493.1,
396
+ "tps": 60.9, "steps": 859,
397
+ // v1.7.7: 24 local-hour slots for the click-to-drill-down view. A slot
398
+ // with no sample is null rather than a zero, so the curve breaks
399
+ // instead of dipping to zero. Only provider-scoped series carry it.
400
+ "hours": [ null, null, …, { "outputTokens": 600, "seconds": 5,
401
+ "tps": 120, "steps": 1 }, … ] }
402
+ ],
403
+ "total": { "outputTokens": 939643, "seconds": 14123.9, "tps": 66.5, "steps": 1424 }
404
+ },
369
405
  "zcode": { // extra-harness detail
370
406
  "dbPath": "C:\\Users\\…\\.zcode\\cli\\db\\db.sqlite",
371
407
  "available": true, "error": null, "rows": 65, "cached": false,
package/README.en.md CHANGED
@@ -144,7 +144,7 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
144
144
 
145
145
  ## Development
146
146
 
147
- Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 370 tests.
147
+ Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 377 tests.
148
148
 
149
149
  Release: `node scripts/bump-version.mjs <version> "<note>"` updates `package.json`, `PLUGIN_VERSION` and `CHANGELOG.md`; see [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
150
150
 
package/README.md CHANGED
@@ -445,7 +445,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
445
445
 
446
446
  ## 验证
447
447
 
448
- - `node --test`:**370 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器、Command Code 月度额度反推)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、TPS 速度序列、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
448
+ - `node --test`:**377 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器、Command Code 月度额度反推)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、TPS 速度序列、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
449
449
  - 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
450
450
  - 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
451
451
 
package/client.js CHANGED
@@ -165,6 +165,8 @@ window.__ModuleLoader__.load({
165
165
  }
166
166
  /** The npm package page the 「打开 npm」 update links point at. */
167
167
  const NPM_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-tokenlens'
168
+ /** The source repository, linked to the left of the version (v1.7.6). */
169
+ const REPO_URL = 'https://github.com/senyingxidi/dsh-tokenlens'
168
170
 
169
171
  const zh = {
170
172
  title: '词元透镜(dsh-TokenLens)',
@@ -231,6 +233,12 @@ window.__ModuleLoader__.load({
231
233
  statsSpeedTotal: '均值 {tps} tok/s',
232
234
  statsSpeedPeak: '峰值 {tps} tok/s',
233
235
  statsSpeedEmpty: '暂无可计算的速度样本(需要 step 计时数据)',
236
+ statsSpeedHourTitle: '{date} · 小时级',
237
+ statsSpeedHourHint: '同一天按本地小时切分;仍是输出 Token ÷ 模型自身耗时,无样本的小时留空',
238
+ statsSpeedHourEmpty: '该日没有可下钻的小时级样本',
239
+ statsSpeedHourBarTitle: '{hour} · {tps} tok/s({tokens} 输出 ÷ {seconds}s)',
240
+ statsSpeedHourAria: '{date} 的小时级输出速度',
241
+ statsSpeedBack: '返回日视图',
234
242
  statsSpeedBarTitle: '{date} · {tps} tok/s({tokens} 输出 ÷ {seconds}s)',
235
243
  statsSpeedDetail: '{tokens} 输出 ÷ {seconds}s',
236
244
  statsLast7Days: '最近7天',
@@ -272,6 +280,8 @@ window.__ModuleLoader__.load({
272
280
  capacityTokenNoEstimate: '预计可消耗 —',
273
281
  capacityTokenEstimateHint: '本机 Token 记录 ÷ 当前套餐百分比估算,仅供参考',
274
282
  pluginVersion: '插件 v{version}',
283
+ repoLink: 'GitHub',
284
+ repoLinkHint: '在 GitHub 上打开 dsh-TokenLens 仓库(源码、更新日志、问题反馈)',
275
285
  statsToday: '今日',
276
286
  statsAllTime: '总计',
277
287
  statsInput: '输入',
@@ -506,6 +516,13 @@ window.__ModuleLoader__.load({
506
516
  statsSpeedTotal: 'avg {tps} tok/s',
507
517
  statsSpeedPeak: 'peak {tps} tok/s',
508
518
  statsSpeedEmpty: 'No speed samples yet (needs step timing data)',
519
+ statsSpeedHourTitle: '{date} · hourly',
520
+ statsSpeedHourHint:
521
+ 'The same day split by local hour; still output tokens ÷ model time, hours without samples stay empty',
522
+ statsSpeedHourEmpty: 'No hourly samples to drill into for this day',
523
+ statsSpeedHourBarTitle: '{hour} · {tps} tok/s ({tokens} output ÷ {seconds}s)',
524
+ statsSpeedHourAria: 'Hourly output speed for {date}',
525
+ statsSpeedBack: 'Back to daily',
509
526
  statsSpeedBarTitle: '{date} · {tps} tok/s ({tokens} output ÷ {seconds}s)',
510
527
  statsSpeedDetail: '{tokens} output ÷ {seconds}s',
511
528
  statsLast7Days: 'Last 7 days',
@@ -547,6 +564,8 @@ window.__ModuleLoader__.load({
547
564
  capacityTokenNoEstimate: 'Est. quota —',
548
565
  capacityTokenEstimateHint: 'Local token records ÷ current plan percentage; estimate only',
549
566
  pluginVersion: 'Plugin v{version}',
567
+ repoLink: 'GitHub',
568
+ repoLinkHint: 'Open the dsh-TokenLens repository on GitHub (source, changelog, issues)',
550
569
  statsToday: 'Today',
551
570
  statsAllTime: 'All time',
552
571
  statsInput: 'Input',
@@ -774,6 +793,10 @@ window.__ModuleLoader__.load({
774
793
  windows: ['M2.6 3.4h10.8v9.2H2.6z', 'M8 3.4v9.2', 'M2.6 7.9h10.8'],
775
794
  coin: ['M8 2.4a5.6 5.6 0 1 0 0 11.2A5.6 5.6 0 0 0 8 2.4Z', 'M8 5.2v5.6', 'M6.3 6.6h3.4', 'M6.3 9.4h3.4'],
776
795
  check: ['M3 8.4 6.2 11.4 13 4.4'],
796
+ // v1.7.6: the repository mark, drawn stroke-only like every other icon.
797
+ github: [
798
+ 'M8 2.4a5.6 5.6 0 0 0-1.7 10.9c.3.1.4-.1.4-.3v-1c-1.5.3-1.9-.7-1.9-.7-.2-.6-.6-.7-.6-.7-.5-.4 0-.4 0-.4.5 0 .8.5.8.5.5.9 1.3.6 1.7.5 0-.4.2-.7.3-.8-1.3-.1-2.6-.6-2.6-2.7 0-.6.2-1.1.6-1.5-.1-.1-.2-.7 0-1.4 0 0 .5-.2 1.6.6a5.4 5.4 0 0 1 2.8 0c1.1-.8 1.6-.6 1.6-.6.3.7.1 1.3.1 1.4.3.4.5.9.5 1.5 0 2.1-1.3 2.6-2.6 2.7.2.2.4.5.4 1v1.5c0 .2.1.4.4.3A5.6 5.6 0 0 0 8 2.4Z',
799
+ ],
777
800
  alert: ['M8 2.5 14 13.2H2z', 'M8 6.2v3.2', 'M8 11.3h.01'],
778
801
  spinner: ['M8 2.5a5.5 5.5 0 1 1-5.5 5.5', 'M8 5.5v2.8'],
779
802
  ban: ['M3.2 3.2 12.8 12.8', 'M8 2.4a5.6 5.6 0 1 0 0 11.2A5.6 5.6 0 0 0 8 2.4Z'],
@@ -1601,13 +1624,31 @@ window.__ModuleLoader__.load({
1601
1624
  const byDay = Array.isArray(raw.byDay)
1602
1625
  ? raw.byDay
1603
1626
  .filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.day === 'string')
1604
- .map((entry) => ({
1605
- day: entry.day,
1606
- outputTokens: numberOrNull(entry.outputTokens) ?? 0,
1607
- seconds: numberOrNull(entry.seconds) ?? 0,
1608
- tps: numberOrNull(entry.tps),
1609
- steps: numberOrNull(entry.steps) ?? 0,
1610
- }))
1627
+ .map((entry) => {
1628
+ // v1.7.7: the day's 24 local-hour slots (null where there is no
1629
+ // sample), used by the click-to-drill-down hourly view.
1630
+ const hours = Array.isArray(entry.hours)
1631
+ ? entry.hours.map((cell) => {
1632
+ if (cell === null || typeof cell !== 'object') return null
1633
+ const tps = numberOrNull(cell.tps)
1634
+ if (tps === null || tps < 0) return null
1635
+ return {
1636
+ outputTokens: numberOrNull(cell.outputTokens) ?? 0,
1637
+ seconds: numberOrNull(cell.seconds) ?? 0,
1638
+ tps,
1639
+ steps: numberOrNull(cell.steps) ?? 0,
1640
+ }
1641
+ })
1642
+ : null
1643
+ return {
1644
+ day: entry.day,
1645
+ outputTokens: numberOrNull(entry.outputTokens) ?? 0,
1646
+ seconds: numberOrNull(entry.seconds) ?? 0,
1647
+ tps: numberOrNull(entry.tps),
1648
+ steps: numberOrNull(entry.steps) ?? 0,
1649
+ hours: hours === null ? null : hours.slice(0, 24),
1650
+ }
1651
+ })
1611
1652
  .filter((entry) => entry.tps !== null && entry.tps >= 0)
1612
1653
  : []
1613
1654
  const totalRaw = raw.total !== null && typeof raw.total === 'object' ? raw.total : {}
@@ -2097,6 +2138,9 @@ window.__ModuleLoader__.load({
2097
2138
  chartHover: null,
2098
2139
  // v1.7.2: the hovered point of the output-speed (TPS) curve.
2099
2140
  speedHover: null,
2141
+ // v1.7.7: the day whose 24 local hours the TPS card charts, or null
2142
+ // (daily trend). Clicking a point on the curve toggles it.
2143
+ speedDay: null,
2100
2144
  // v1.2.0 chart drill-down: the clicked day whose 0..23 hour histogram
2101
2145
  // is charted instead of the daily trend, or null (daily trend).
2102
2146
  chartDay: null,
@@ -2206,6 +2250,12 @@ window.__ModuleLoader__.load({
2206
2250
  detailsWindow: settings.detailsWindow,
2207
2251
  chartHover: null,
2208
2252
  chartDay: null,
2253
+ // v1.7.6: the TPS hover index is view state like the chart's, so it must
2254
+ // not survive a provider switch (the series behind it changed).
2255
+ speedHover: null,
2256
+ // v1.7.7: an hourly TPS drill-down belongs to one provider's series, so a
2257
+ // provider switch returns to the daily trend.
2258
+ speedDay: null,
2209
2259
  }
2210
2260
  }
2211
2261
 
@@ -3312,7 +3362,12 @@ window.__ModuleLoader__.load({
3312
3362
  if (modelChanged) store.set({ modelId: nextModel })
3313
3363
  syncProvider(providerId)
3314
3364
  const after = store.get()
3315
- if (modelChanged && after.providerId !== null && after.inFlight !== true) {
3365
+ // v1.7.6: only Command Code's allowance follows the model, so only its
3366
+ // snapshots are worth refetching on a switch (see index.js
3367
+ // MODEL_SENSITIVE_ALLOWANCE). Everyone else keeps the cached snapshot.
3368
+ const modelSensitive =
3369
+ after.providerId !== null && canonicalProviderId(after.providerId) === 'commandcode'
3370
+ if (modelChanged && modelSensitive && after.inFlight !== true) {
3316
3371
  void requestUsage('snapshot')
3317
3372
  }
3318
3373
  }
@@ -3485,6 +3540,10 @@ window.__ModuleLoader__.load({
3485
3540
  .dsh-tokenlens-speed-yaxis{display:flex;flex-direction:column;justify-content:space-between;align-items:flex-end;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1;font-variant-numeric:tabular-nums;flex:0 0 auto;padding:1px 0}
3486
3541
  .dsh-tokenlens-speed-plot{position:relative;flex:1 1 auto;min-width:0}
3487
3542
  .dsh-tokenlens-speed-dot{position:absolute;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;background:#2e9e5b;box-shadow:0 0 0 2px var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.35));pointer-events:none}
3543
+ /* v1.7.7: the daily curve is clickable (drills into that day's hours). */
3544
+ .dsh-tokenlens-speed-svg[data-speed-mode=day] rect[data-speed-day]{cursor:pointer}
3545
+ .dsh-tokenlens-link-button{border:0;background:none;padding:0 0 0 6px;color:var(--dsw-alias-label-secondary,currentColor);font:inherit;font-size:11px;line-height:inherit;text-decoration:underline;cursor:pointer}
3546
+ .dsh-tokenlens-link-button:hover,.dsh-tokenlens-link-button:focus-visible{color:var(--dsw-alias-label-primary,currentColor)}
3488
3547
  .dsh-tokenlens-speed-axis{display:flex;justify-content:space-between;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.2;font-variant-numeric:tabular-nums;min-width:0}
3489
3548
  .dsh-tokenlens-speed-axis>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3490
3549
  @media (max-width:680px){.dsh-tokenlens-chart-layout-row>*{flex-basis:100%!important}}
@@ -3614,7 +3673,11 @@ window.__ModuleLoader__.load({
3614
3673
  .dsh-tokenlens-chart-drill-title{color:var(--dsw-alias-label-secondary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
3615
3674
  /* 明细 donut window switch (v1.2.0): one pill row under the details title. */
3616
3675
  .dsh-tokenlens-details-window{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
3617
- .dsh-tokenlens-version{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;text-align:right;opacity:.75;overflow-wrap:anywhere}
3676
+ .dsh-tokenlens-version{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;text-align:right;opacity:.75;overflow-wrap:anywhere;display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap}
3677
+ /* v1.7.6: the repository link sits left of the version text. */
3678
+ .dsh-tokenlens-repo{display:inline-flex;align-items:center;gap:4px;color:inherit;text-decoration:none;flex:0 0 auto;border-radius:var(--dsw-radius-sm);transition:color .12s ease,opacity .12s ease}
3679
+ .dsh-tokenlens-repo:hover,.dsh-tokenlens-repo:focus-visible{color:var(--dsw-alias-label-primary,currentColor);opacity:1;text-decoration:underline}
3680
+ .dsh-tokenlens-version-text{flex:0 0 auto}
3618
3681
  /* Update status on the plugin-version line (dialog body): wraps on narrow screens. */
3619
3682
  .dsh-tokenlens-version-update{white-space:normal;overflow-wrap:anywhere}
3620
3683
  .dsh-tokenlens-version .dsh-tokenlens-update-link{color:inherit;font-size:inherit;vertical-align:baseline}
@@ -4495,7 +4558,7 @@ window.__ModuleLoader__.load({
4495
4558
  'data-scope': scope,
4496
4559
  'data-active': effective === scope ? 'true' : 'false',
4497
4560
  disabled: stats === null,
4498
- onClick: () => store.set({ statsScope: scope, chartHover: null, chartDay: null }),
4561
+ onClick: () => store.set({ statsScope: scope, chartHover: null, chartDay: null, speedHover: null }),
4499
4562
  },
4500
4563
  label,
4501
4564
  )
@@ -4521,7 +4584,7 @@ window.__ModuleLoader__.load({
4521
4584
  'data-active': active === view ? 'true' : 'false',
4522
4585
  'aria-pressed': active === view ? 'true' : 'false',
4523
4586
  onClick: () => {
4524
- if (active !== view) store.set({ statsView: view, chartHover: null, chartDay: null })
4587
+ if (active !== view) store.set({ statsView: view, chartHover: null, chartDay: null, speedHover: null })
4525
4588
  },
4526
4589
  },
4527
4590
  Icon(iconName, 12),
@@ -4707,6 +4770,24 @@ window.__ModuleLoader__.load({
4707
4770
  if (store.get().speedHover !== next) store.set({ speedHover: next })
4708
4771
  }
4709
4772
 
4773
+ /**
4774
+ * Drill the TPS card into one day's 24 local hours (v1.7.7), or back out.
4775
+ *
4776
+ * Toggling mirrors the token chart's `activateChartDay`: clicking the already
4777
+ * active day returns to the daily trend, so no separate "back" button is
4778
+ * needed — but one is still offered in the header for discoverability.
4779
+ */
4780
+ function activateSpeedDay(day) {
4781
+ if (typeof day !== 'string' || day === '') return
4782
+ const next = store.get().speedDay === day ? null : day
4783
+ store.set({ speedDay: next, speedHover: null })
4784
+ }
4785
+
4786
+ /** Leave the hourly TPS drill-down and chart the daily trend again (v1.7.7). */
4787
+ function clearSpeedDay() {
4788
+ if (store.get().speedDay !== null) store.set({ speedDay: null, speedHover: null })
4789
+ }
4790
+
4710
4791
  /** The active today bucket of the current stats scope (v0.27.2). */
4711
4792
  function todayBucketOf(ctx) {
4712
4793
  return ctx.providerScoped ? ctx.currentRow?.today ?? null : ctx.stats?.today ?? null
@@ -6114,17 +6195,33 @@ window.__ModuleLoader__.load({
6114
6195
  }
6115
6196
 
6116
6197
  /**
6117
- * The output-speed (TPS) trend card (v1.7.0), shown under 明细.
6198
+ * The output-speed (TPS) trend card (v1.7.0, reworked in v1.7.7).
6199
+ *
6200
+ * Always the CURRENT provider's series (v1.7.7): the user reads this card as
6201
+ * "how fast is the model I am using right now", so it deliberately ignores
6202
+ * the 当前供应商 / 全部 Harness switch above it.
6118
6203
  *
6119
6204
  * `tps` is output tokens over the model's OWN request time (`step/start` to
6120
- * `assistant/message`), so the step's tool calls do not dilute it — but
6205
+ * `assistant/message`), so a step's tool calls do not dilute it — but
6121
6206
  * reasoning tokens are included in "output", which the hint says.
6207
+ *
6208
+ * Clicking a point drills into that day's 24 local hours (v1.7.7), the same
6209
+ * interaction the token chart uses for its daily bars.
6122
6210
  */
6123
6211
  function buildSpeedSection(state, ctx, t) {
6124
- const stats = ctx.stats
6125
- const scoped = ctx.providerScoped ? ctx.currentRow?.speed ?? null : stats?.speed ?? null
6126
- const series = scoped === null || scoped === undefined ? null : scoped
6212
+ // v1.7.7: always provider-scoped, whatever the scope tabs say.
6213
+ const series = ctx.currentRow?.speed ?? null
6127
6214
  const byDay = series === null ? [] : series.byDay
6215
+ const drilledDay = typeof state.speedDay === 'string' ? state.speedDay : null
6216
+ const drilled = drilledDay === null ? null : byDay.find((entry) => entry.day === drilledDay) ?? null
6217
+ // v1.7.7: being drilled into a day is a MODE, independent of whether that
6218
+ // day actually carries hourly samples — otherwise a day without them would
6219
+ // silently fall back to the daily chart instead of saying so.
6220
+ const hourlyMode = drilledDay !== null && drilled !== null
6221
+ const hourly = hourlyMode && Array.isArray(drilled.hours) ? drilled.hours : null
6222
+ const pointsSeries = hourlyMode ? hourly ?? [] : byDay
6223
+ const dailyMode = !hourlyMode
6224
+
6128
6225
  const head = h(
6129
6226
  'div',
6130
6227
  { key: 'head', className: 'dsh-tokenlens-section-head' },
@@ -6134,203 +6231,266 @@ window.__ModuleLoader__.load({
6134
6231
  Icon('chart', 14),
6135
6232
  t('statsSpeedTitle'),
6136
6233
  ),
6137
- series !== null && series.total.tps !== null
6138
- ? h(
6139
- 'span',
6140
- { className: 'dsh-tokenlens-hint', 'data-speed-total': String(series.total.tps) },
6141
- t('statsSpeedTotal', { tps: series.total.tps.toFixed(1) }),
6142
- )
6143
- : null,
6144
- )
6145
- const body =
6146
- byDay.length === 0
6147
- ? h(
6148
- 'div',
6149
- { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6150
- h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, t('statsSpeedEmpty')),
6151
- )
6152
- : (() => {
6153
- // v1.7.1: a CURVE, not bars — the trend is a rate, and a line reads
6154
- // it far better than per-day columns. The SVG scales to the card
6155
- // width; `preserveAspectRatio="none"` lets it fill without keeping
6156
- // the viewBox ratio, and the per-day hover targets are transparent
6157
- // columns so nothing is distorted.
6158
- const width = 320
6159
- const height = 90
6160
- const pad = 4
6161
- const max = Math.max(...byDay.map((entry) => entry.tps ?? 0), 1)
6162
- const stepX = byDay.length > 1 ? (width - pad * 2) / (byDay.length - 1) : 0
6163
- const points = byDay.map((entry, index) => {
6164
- const x = byDay.length > 1 ? pad + stepX * index : width / 2
6165
- const y = height - pad - ((entry.tps ?? 0) / max) * (height - pad * 2)
6166
- return { x, y }
6167
- })
6168
- const line = points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
6169
- const area = `${pad.toFixed(1)},${(height - pad).toFixed(1)} ${line} ${(width - pad).toFixed(1)},${(height - pad).toFixed(1)}`
6170
- const columnWidth = byDay.length > 1 ? stepX : width - pad * 2
6171
- const columns = byDay.map((entry, index) =>
6172
- h(
6173
- 'rect',
6174
- {
6175
- key: `hit-${entry.day}-${index}`,
6176
- x: (points[index].x - columnWidth / 2).toFixed(1),
6177
- y: 0,
6178
- width: Math.max(1, columnWidth).toFixed(1),
6179
- height: String(height),
6180
- fill: 'transparent',
6181
- 'data-speed-day': entry.day,
6182
- 'data-speed-tps': String(entry.tps ?? 0),
6183
- },
6184
- h(
6185
- 'title',
6186
- { key: 'tip' },
6187
- t('statsSpeedBarTitle', {
6188
- date: entry.day,
6189
- tps: (entry.tps ?? 0).toFixed(1),
6190
- tokens: formatTokensPrecise(entry.outputTokens),
6191
- seconds: entry.seconds.toFixed(1),
6192
- }),
6193
- ),
6194
- ),
6234
+ // While drilled in, the header says which day and offers a way back.
6235
+ hourly === null
6236
+ ? series !== null && series.total.tps !== null
6237
+ ? h(
6238
+ 'span',
6239
+ { className: 'dsh-tokenlens-hint', 'data-speed-total': String(series.total.tps) },
6240
+ t('statsSpeedTotal', { tps: series.total.tps.toFixed(1) }),
6195
6241
  )
6196
- const svg = h(
6197
- 'svg',
6242
+ : null
6243
+ : h(
6244
+ 'span',
6245
+ { key: 'drill', className: 'dsh-tokenlens-hint', 'data-speed-drill': drilled.day },
6246
+ t('statsSpeedHourTitle', { date: drilled.day }),
6247
+ h(
6248
+ 'button',
6198
6249
  {
6199
- key: 'svg',
6200
- className: 'dsh-tokenlens-speed-svg',
6201
- 'data-speed-plot': 'true',
6202
- viewBox: `0 0 ${width} ${height}`,
6203
- preserveAspectRatio: 'none',
6204
- role: 'img',
6205
- 'aria-label': t('statsSpeedTitle'),
6250
+ key: 'back',
6251
+ type: 'button',
6252
+ className: 'dsh-tokenlens-link-button',
6253
+ 'data-speed-back': 'true',
6254
+ onClick: () => clearSpeedDay(),
6206
6255
  },
6207
- h('polygon', {
6208
- key: 'area',
6256
+ t('statsSpeedBack'),
6257
+ ),
6258
+ ),
6259
+ )
6260
+
6261
+ const plotOf = () => {
6262
+ if (pointsSeries.length === 0) {
6263
+ return h(
6264
+ 'div',
6265
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6266
+ h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, t('statsSpeedEmpty')),
6267
+ )
6268
+ }
6269
+ // The SVG scales to the card width; `preserveAspectRatio="none"` lets it
6270
+ // fill without keeping the viewBox ratio, and the hover targets are
6271
+ // transparent columns so nothing is distorted.
6272
+ const width = 320
6273
+ const height = 90
6274
+ const pad = 4
6275
+ const real = pointsSeries.filter((entry) => entry !== null)
6276
+ const max = Math.max(...real.map((entry) => entry.tps ?? 0), 1)
6277
+ const count = pointsSeries.length
6278
+ const stepX = count > 1 ? (width - pad * 2) / (count - 1) : 0
6279
+ // A null slot (an hour with no sample) breaks the line instead of being
6280
+ // drawn as a dip to zero, which would be a lie.
6281
+ let segment = []
6282
+ const segments = []
6283
+ pointsSeries.forEach((entry, index) => {
6284
+ if (entry === null) {
6285
+ if (segment.length > 0) segments.push(segment)
6286
+ segment = []
6287
+ return
6288
+ }
6289
+ const x = count > 1 ? pad + stepX * index : width / 2
6290
+ const y = height - pad - ((entry.tps ?? 0) / max) * (height - pad * 2)
6291
+ segment.push({ x, y })
6292
+ })
6293
+ if (segment.length > 0) segments.push(segment)
6294
+ const xOf = (index) => (count > 1 ? pad + stepX * index : width / 2)
6295
+ const yOf = (entry) => height - pad - ((entry.tps ?? 0) / max) * (height - pad * 2)
6296
+ const columnWidth = count > 1 ? stepX : width - pad * 2
6297
+ const columns = pointsSeries.map((entry, index) => {
6298
+ if (entry === null) return null
6299
+ const label = dailyMode
6300
+ ? t('statsSpeedBarTitle', {
6301
+ date: entry.day,
6302
+ tps: (entry.tps ?? 0).toFixed(1),
6303
+ tokens: formatTokensPrecise(entry.outputTokens),
6304
+ seconds: entry.seconds.toFixed(1),
6305
+ })
6306
+ : t('statsSpeedHourBarTitle', {
6307
+ hour: `${String(index).padStart(2, '0')}:00`,
6308
+ tps: (entry.tps ?? 0).toFixed(1),
6309
+ tokens: formatTokensPrecise(entry.outputTokens),
6310
+ seconds: entry.seconds.toFixed(1),
6311
+ })
6312
+ return h(
6313
+ 'rect',
6314
+ {
6315
+ key: `hit-${index}`,
6316
+ x: Math.max(0, Math.min(width - 1, xOf(index) - columnWidth / 2)).toFixed(1),
6317
+ y: 0,
6318
+ width: Math.max(1, Math.min(columnWidth, width)).toFixed(1),
6319
+ height: String(height),
6320
+ fill: 'transparent',
6321
+ // In daily mode the column is also the drill-down target.
6322
+ ...(dailyMode ? { 'data-speed-day': entry.day, onClick: () => activateSpeedDay(entry.day) } : {}),
6323
+ ...(dailyMode ? {} : { 'data-speed-hour': String(index) }),
6324
+ 'data-speed-tps': String(entry.tps ?? 0),
6325
+ },
6326
+ h('title', { key: 'tip' }, label),
6327
+ )
6328
+ })
6329
+ const lineNodes = segments.map((segment, index) => {
6330
+ const line = segment.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
6331
+ const area =
6332
+ segment.length > 1
6333
+ ? `${segment[0].x.toFixed(1)},${(height - pad).toFixed(1)} ${line} ${segment[segment.length - 1].x.toFixed(1)},${(height - pad).toFixed(1)}`
6334
+ : null
6335
+ return [
6336
+ area === null
6337
+ ? null
6338
+ : h('polygon', {
6339
+ key: `area-${index}`,
6209
6340
  className: 'dsh-tokenlens-speed-area',
6210
6341
  points: area,
6211
6342
  fill: 'rgba(46,158,91,.18)',
6212
6343
  'aria-hidden': 'true',
6213
6344
  }),
6214
- h('polyline', {
6215
- key: 'line',
6216
- className: 'dsh-tokenlens-speed-line',
6217
- points: line,
6218
- fill: 'none',
6219
- stroke: '#2e9e5b',
6220
- 'stroke-width': '1.6',
6221
- 'stroke-linecap': 'round',
6222
- 'stroke-linejoin': 'round',
6223
- 'vector-effect': 'non-scaling-stroke',
6224
- 'aria-hidden': 'true',
6225
- }),
6226
- columns,
6227
- )
6228
- const hoverIndex =
6229
- Number.isInteger(state.speedHover) && state.speedHover >= 0 && state.speedHover < byDay.length
6230
- ? state.speedHover
6231
- : null
6232
- const hovered = hoverIndex === null ? null : byDay[hoverIndex]
6233
- const handleMove = (event) => {
6234
- const index = chartIndexFromEvent(event, byDay.length)
6235
- if (index !== null) setSpeedHover(index)
6236
- }
6237
- const handleLeave = () => setSpeedHover(null)
6238
- // v1.7.2: a Y axis, like the native charts — the curve alone gives no
6239
- // sense of magnitude.
6240
- const ticks = [max, max / 2, 0]
6241
- const yAxis = h(
6242
- 'div',
6243
- { key: 'y', className: 'dsh-tokenlens-speed-yaxis', 'aria-hidden': 'true' },
6244
- ticks.map((value, index) =>
6245
- h('span', { key: `tick-${index}`, className: 'dsh-tokenlens-chart-tick' }, `${Math.round(value)}`),
6246
- ),
6247
- )
6248
- const crosshair =
6249
- hoverIndex === null
6250
- ? null
6251
- : h('div', {
6252
- key: 'cross',
6253
- className: 'dsh-tokenlens-chart-crosshair',
6254
- 'data-speed-crosshair': String(hoverIndex),
6255
- 'aria-hidden': 'true',
6256
- style: { left: `${(points[hoverIndex].x / width) * 100}%` },
6257
- })
6258
- const marker =
6259
- hoverIndex === null
6260
- ? null
6261
- : h('div', {
6262
- key: 'dot',
6263
- className: 'dsh-tokenlens-speed-dot',
6264
- 'aria-hidden': 'true',
6265
- style: {
6266
- left: `${(points[hoverIndex].x / width) * 100}%`,
6267
- top: `${(points[hoverIndex].y / height) * 100}%`,
6268
- },
6269
- })
6270
- const readout =
6271
- hovered === null
6272
- ? null
6273
- : h(
6274
- 'div',
6275
- {
6276
- key: 'readout',
6277
- className: 'dsh-tokenlens-chart-readout',
6278
- 'data-speed-readout': 'true',
6279
- role: 'status',
6280
- style:
6281
- hoverIndex === 0
6282
- ? { left: '0%' }
6283
- : hoverIndex === byDay.length - 1
6284
- ? { right: '0%' }
6285
- : {
6286
- left: `${(points[hoverIndex].x / width) * 100}%`,
6287
- transform: 'translateX(-50%)',
6288
- },
6289
- },
6290
- `${hovered.day} · ${(hovered.tps ?? 0).toFixed(1)} tok/s`,
6291
- ` · ${t('statsSpeedDetail', {
6292
- tokens: formatTokensPrecise(hovered.outputTokens),
6293
- seconds: hovered.seconds.toFixed(1),
6294
- })}`,
6295
- )
6296
- const axis = h(
6297
- 'div',
6298
- { key: 'axis', className: 'dsh-tokenlens-speed-axis', 'aria-hidden': 'true' },
6299
- h('span', { key: 'first' }, byDay[0].day),
6300
- byDay.length > 1 ? h('span', { key: 'last' }, byDay[byDay.length - 1].day) : null,
6301
- )
6302
- return h(
6345
+ h('polyline', {
6346
+ key: `line-${index}`,
6347
+ className: 'dsh-tokenlens-speed-line',
6348
+ points: line,
6349
+ fill: 'none',
6350
+ stroke: '#2e9e5b',
6351
+ 'stroke-width': '1.6',
6352
+ 'stroke-linecap': 'round',
6353
+ 'stroke-linejoin': 'round',
6354
+ 'vector-effect': 'non-scaling-stroke',
6355
+ 'aria-hidden': 'true',
6356
+ }),
6357
+ ].filter(Boolean)
6358
+ })
6359
+ const svg = h(
6360
+ 'svg',
6361
+ {
6362
+ key: 'svg',
6363
+ className: 'dsh-tokenlens-speed-svg',
6364
+ 'data-speed-mode': dailyMode ? 'day' : 'hour',
6365
+ viewBox: `0 0 ${width} ${height}`,
6366
+ preserveAspectRatio: 'none',
6367
+ role: 'img',
6368
+ 'aria-label': dailyMode ? t('statsSpeedTitle') : t('statsSpeedHourAria', { date: drilled.day }),
6369
+ },
6370
+ lineNodes.flat(),
6371
+ columns.filter(Boolean),
6372
+ )
6373
+ const hoverIndex =
6374
+ Number.isInteger(state.speedHover) && state.speedHover >= 0 && state.speedHover < count
6375
+ ? state.speedHover
6376
+ : null
6377
+ const hovered = hoverIndex === null ? null : pointsSeries[hoverIndex]
6378
+ const handleMove = (event) => {
6379
+ const index = chartIndexFromEvent(event, count)
6380
+ if (index !== null && pointsSeries[index] !== null) setSpeedHover(index)
6381
+ }
6382
+ const handleLeave = () => setSpeedHover(null)
6383
+ const ticks = [max, max / 2, 0]
6384
+ const yAxis = h(
6385
+ 'div',
6386
+ { key: 'y', className: 'dsh-tokenlens-speed-yaxis', 'aria-hidden': 'true' },
6387
+ ticks.map((value, index) =>
6388
+ h('span', { key: `tick-${index}`, className: 'dsh-tokenlens-chart-tick' }, `${Math.round(value)}`),
6389
+ ),
6390
+ )
6391
+ const crosshair =
6392
+ hovered === null || hovered === undefined
6393
+ ? null
6394
+ : h('div', {
6395
+ key: 'cross',
6396
+ className: 'dsh-tokenlens-chart-crosshair',
6397
+ 'data-speed-crosshair': String(hoverIndex),
6398
+ 'aria-hidden': 'true',
6399
+ style: { left: `${(xOf(hoverIndex) / width) * 100}%` },
6400
+ })
6401
+ const marker =
6402
+ hovered === null || hovered === undefined
6403
+ ? null
6404
+ : h('div', {
6405
+ key: 'dot',
6406
+ className: 'dsh-tokenlens-speed-dot',
6407
+ 'aria-hidden': 'true',
6408
+ style: {
6409
+ left: `${(xOf(hoverIndex) / width) * 100}%`,
6410
+ top: `${(yOf(hovered) / height) * 100}%`,
6411
+ },
6412
+ })
6413
+ const readoutLabel = dailyMode ? hovered?.day : `${String(hoverIndex).padStart(2, '0')}:00`
6414
+ const readout =
6415
+ hovered === null || hovered === undefined
6416
+ ? null
6417
+ : h(
6303
6418
  'div',
6304
- { key: 'plot', className: 'dsh-tokenlens-speed-body' },
6305
- yAxis,
6306
- h(
6307
- 'div',
6308
- {
6309
- key: 'plotarea',
6310
- className: 'dsh-tokenlens-speed-plot',
6311
- 'data-speed-plot': 'true',
6312
- onMouseMove: handleMove,
6313
- onMouseLeave: handleLeave,
6314
- onPointerMove: handleMove,
6315
- onPointerLeave: handleLeave,
6316
- },
6317
- svg,
6318
- crosshair,
6319
- marker,
6320
- readout,
6321
- axis,
6322
- ),
6419
+ {
6420
+ key: 'readout',
6421
+ className: 'dsh-tokenlens-chart-readout',
6422
+ 'data-speed-readout': 'true',
6423
+ role: 'status',
6424
+ style:
6425
+ hoverIndex === 0
6426
+ ? { left: '0%' }
6427
+ : hoverIndex === count - 1
6428
+ ? { right: '0%' }
6429
+ : {
6430
+ left: `${(xOf(hoverIndex) / width) * 100}%`,
6431
+ transform: 'translateX(-50%)',
6432
+ },
6433
+ },
6434
+ `${readoutLabel} · ${(hovered.tps ?? 0).toFixed(1)} tok/s`,
6435
+ ` · ${t('statsSpeedDetail', {
6436
+ tokens: formatTokensPrecise(hovered.outputTokens),
6437
+ seconds: hovered.seconds.toFixed(1),
6438
+ })}`,
6323
6439
  )
6324
- })()
6440
+ const axis = h(
6441
+ 'div',
6442
+ { key: 'axis', className: 'dsh-tokenlens-speed-axis', 'aria-hidden': 'true' },
6443
+ h('span', { key: 'first' }, dailyMode ? (pointsSeries[0]?.day ?? '') : '00:00'),
6444
+ h('span', { key: 'last' }, dailyMode ? (pointsSeries[count - 1]?.day ?? '') : '23:00'),
6445
+ )
6446
+ return h(
6447
+ 'div',
6448
+ { key: 'plot', className: 'dsh-tokenlens-speed-body' },
6449
+ yAxis,
6450
+ h(
6451
+ 'div',
6452
+ {
6453
+ key: 'plotarea',
6454
+ className: `dsh-tokenlens-speed-plot${dailyMode ? '' : ' dsh-tokenlens-speed-plot-hourly'}`,
6455
+ 'data-speed-mode': dailyMode ? 'day' : 'hour',
6456
+ onMouseMove: handleMove,
6457
+ onMouseLeave: handleLeave,
6458
+ onPointerMove: handleMove,
6459
+ onPointerLeave: handleLeave,
6460
+ },
6461
+ svg,
6462
+ crosshair,
6463
+ marker,
6464
+ readout,
6465
+ axis,
6466
+ ),
6467
+ )
6468
+ }
6469
+
6470
+ // A drilled-in day with no hourly samples explains itself instead of
6471
+ // silently drawing nothing.
6472
+ const allEmpty =
6473
+ pointsSeries.length === 0 || pointsSeries.every((entry) => entry === null || entry === undefined)
6474
+ const body = allEmpty
6475
+ ? h(
6476
+ 'div',
6477
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6478
+ h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, hourlyMode ? t('statsSpeedHourEmpty') : t('statsSpeedEmpty')),
6479
+ )
6480
+ : plotOf()
6481
+
6325
6482
  return h(
6326
6483
  'div',
6327
6484
  { key: 'speed', className: 'dsh-tokenlens-section', 'data-speed-section': 'true' },
6328
6485
  head,
6329
- h('div', { key: 'hint', className: 'dsh-tokenlens-subtitle' }, t('statsSpeedHint')),
6486
+ h(
6487
+ 'div',
6488
+ { key: 'hint', className: 'dsh-tokenlens-subtitle' },
6489
+ dailyMode ? t('statsSpeedHint') : t('statsSpeedHourHint'),
6490
+ ),
6330
6491
  body,
6331
6492
  )
6332
6493
  }
6333
-
6334
6494
  function buildStatsDetails(state, ctx, t) {
6335
6495
  const { stats, hasMonth } = ctx
6336
6496
  if (stats === null) return null
@@ -7804,11 +7964,29 @@ window.__ModuleLoader__.load({
7804
7964
  const body = [buildManageButton(state, t), statsArea]
7805
7965
  const displayVersion = currentPluginVersionOf(state)
7806
7966
  if (displayVersion !== '') {
7967
+ // v1.7.6: the repository link sits to the LEFT of the version, so the
7968
+ // version stays the right-most element of the row.
7969
+ const repo = h(
7970
+ 'a',
7971
+ {
7972
+ key: 'repo',
7973
+ className: 'dsh-tokenlens-repo',
7974
+ href: REPO_URL,
7975
+ target: '_blank',
7976
+ rel: 'noreferrer noopener',
7977
+ 'data-repo-link': 'true',
7978
+ title: t('repoLinkHint'),
7979
+ 'aria-label': t('repoLinkHint'),
7980
+ },
7981
+ Icon('github', 13),
7982
+ t('repoLink'),
7983
+ )
7807
7984
  body.push(
7808
7985
  h(
7809
7986
  'div',
7810
7987
  { key: 'version', className: 'dsh-tokenlens-version' },
7811
- t('pluginVersion', { version: displayVersion }),
7988
+ repo,
7989
+ h('span', { key: 'text', className: 'dsh-tokenlens-version-text' }, t('pluginVersion', { version: displayVersion })),
7812
7990
  // Update status sits right next to the plugin version: 有新版本 vX
7813
7991
  // (link to the npm page) or 已是最新版本, only for a successful check.
7814
7992
  buildVersionUpdateStatus(state, t),
package/index.js CHANGED
@@ -84,7 +84,16 @@ export const inject = []
84
84
  export const PLUGIN_NAME = 'dsh-tokenlens'
85
85
 
86
86
  /** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
87
- export const PLUGIN_VERSION = '1.7.5'
87
+ export const PLUGIN_VERSION = '1.7.7'
88
+
89
+ /**
90
+ * Providers whose monthly allowance depends on the SELECTED model (v1.7.6).
91
+ *
92
+ * Only these need their cached snapshot dropped when the picker changes: for
93
+ * everyone else a model switch does not alter a single number, so refetching
94
+ * would be pure waste.
95
+ */
96
+ const MODEL_SENSITIVE_ALLOWANCE = new Set(['commandcode'])
88
97
 
89
98
  /** Documented defaults; every field of the raw row config is optional. */
90
99
  const DEFAULTS = {
@@ -1460,8 +1469,7 @@ export function apply(ctx, rawConfig) {
1460
1469
  return catalog
1461
1470
  }
1462
1471
 
1463
- const statsService = config.stats.enabled
1464
- ? createUsageStats({
1472
+ const statsService = config.stats.enabled ? createUsageStats({
1465
1473
  dshHome: config.stats.dshHome !== '' ? config.stats.dshHome : dshHomeOf(process.env),
1466
1474
  config,
1467
1475
  providerCatalog: providerModelCatalog,
@@ -1614,6 +1622,14 @@ export function apply(ctx, rawConfig) {
1614
1622
  return scrubMessagePaths(out)
1615
1623
  }
1616
1624
 
1625
+ /**
1626
+ * The subscription-anchor set the last stats result was computed with (v1.7.6).
1627
+ *
1628
+ * Empty until a plan period start is known; a change invalidates the cached
1629
+ * stats result so the 最近X天 window is anchored immediately.
1630
+ */
1631
+ let statsAnchorSignature = '[]'
1632
+
1617
1633
  async function getStats(options = {}) {
1618
1634
  if (statsService === null) {
1619
1635
  return {
@@ -1622,11 +1638,18 @@ export function apply(ctx, rawConfig) {
1622
1638
  }
1623
1639
  }
1624
1640
  try {
1641
+ const starts = Object.fromEntries(state.subscriptionStarts)
1642
+ // v1.7.6: a cached stats result computed BEFORE a plan period start was
1643
+ // known would keep the un-anchored window until the TTL expired. Recompute
1644
+ // once whenever the anchor set changes, so the window is right immediately.
1645
+ const signature = JSON.stringify(Object.entries(starts).sort())
1646
+ const anchorChanged = signature !== statsAnchorSignature
1625
1647
  const stats = await statsService.get({
1626
- fresh: options.fresh === true,
1648
+ fresh: options.fresh === true || (signature !== '[]' && anchorChanged),
1627
1649
  // v1.7.2: anchor each subscription provider's window to its plan start.
1628
- subscriptionStarts: Object.fromEntries(state.subscriptionStarts),
1650
+ subscriptionStarts: starts,
1629
1651
  })
1652
+ if (signature !== '[]') statsAnchorSignature = signature
1630
1653
  // v0.22.1: only subscription-backed providers use the 最近X天 window;
1631
1654
  // pay-as-you-go/balance providers keep the fixed 最近30天 column.
1632
1655
  const decorated =
@@ -1835,6 +1858,10 @@ export function apply(ctx, rawConfig) {
1835
1858
  * V4.1 Flash $100 while its credit pool is $70), so a model switch must also
1836
1859
  * drop the cached snapshot — otherwise the chip would keep showing the old
1837
1860
  * allowance until the next poll.
1861
+ *
1862
+ * v1.7.6: only for providers whose allowance is actually model-sensitive.
1863
+ * Invalidating on every provider made switching models on DeepSeek/OpenCode
1864
+ * throw away a perfectly good snapshot and refetch it.
1838
1865
  */
1839
1866
  function rememberRequestedModel(body, providerId) {
1840
1867
  const modelId = typeof body?.modelId === 'string' ? body.modelId.trim() : ''
@@ -1842,8 +1869,10 @@ export function apply(ctx, rawConfig) {
1842
1869
  const id = canonicalProviderId(providerId)
1843
1870
  if (state.selectedModels.get(id) === modelId) return
1844
1871
  state.selectedModels.set(id, modelId)
1845
- state.caches.delete(id)
1846
- state.flights.delete(id)
1872
+ if (MODEL_SENSITIVE_ALLOWANCE.has(id)) {
1873
+ state.caches.delete(id)
1874
+ state.flights.delete(id)
1875
+ }
1847
1876
  noteStatus('client-model', { providerId: id, modelId })
1848
1877
  }
1849
1878
 
@@ -61,7 +61,7 @@ const DECODES_CONCATENATED_FRAMES = (() => {
61
61
  const SESSION_FILE = 'session.v4.jsonl.zstd'
62
62
 
63
63
  /** Cache schema version; a mismatch discards the cache. */
64
- const CACHE_VERSION = 6
64
+ const CACHE_VERSION = 7
65
65
 
66
66
  /**
67
67
  * How many days the daily series (and therefore the 图表 mode and the
@@ -249,6 +249,9 @@ export function foldSession(records) {
249
249
  // `assistant/message.time − step/start.time`: the step's tool calls are emitted
250
250
  // AFTER the message, so they do not dilute it.
251
251
  const speed = Object.create(null)
252
+ // v1.7.7: the same samples bucketed per epoch HOUR, so the TPS curve can be
253
+ // drilled into one day's 24 local hours (mirrors `hours` above).
254
+ const speedHours = Object.create(null)
252
255
  const stepStarts = new Map()
253
256
  let latestTime = 0
254
257
  let sessionCwd = null
@@ -341,6 +344,13 @@ export function foldSession(records) {
341
344
  slot.output += usage.output
342
345
  slot.seconds += elapsedMs / 1000
343
346
  slot.steps += 1
347
+ // v1.7.7: the hour bucket reuses `hour`, already computed for the token
348
+ // histogram, so both drill-downs describe exactly the same slot.
349
+ const hourSpeed = (speedHours[hour] ??= { providers: Object.create(null) })
350
+ const hourSlot = (hourSpeed.providers[attributedProvider] ??= { output: 0, seconds: 0, steps: 0 })
351
+ hourSlot.output += usage.output
352
+ hourSlot.seconds += elapsedMs / 1000
353
+ hourSlot.steps += 1
344
354
  stepStarts.delete(`${turn}:${step}`)
345
355
  }
346
356
  }
@@ -361,7 +371,7 @@ export function foldSession(records) {
361
371
  if (Number(key) < cutoff) delete hours[key]
362
372
  }
363
373
  }
364
- return { days, providers, models, hours, workspaces, session, speed }
374
+ return { days, providers, models, hours, workspaces, session, speed, speedHours }
365
375
  }
366
376
 
367
377
  /** Merge one file's folded contribution into an aggregate being built. */
@@ -382,6 +392,16 @@ function mergeFold(aggregate, fold) {
382
392
  into.steps += slot.steps
383
393
  }
384
394
  }
395
+ // v1.7.7: the hourly speed buckets merge with the hourly token buckets.
396
+ for (const [hour, hourSpeed] of Object.entries(fold.speedHours ?? {})) {
397
+ const target = (aggregate.speedHours[hour] ??= { providers: Object.create(null) })
398
+ for (const [provider, slot] of Object.entries(hourSpeed.providers ?? {})) {
399
+ const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
400
+ into.output += slot.output
401
+ into.seconds += slot.seconds
402
+ into.steps += slot.steps
403
+ }
404
+ }
385
405
  for (const [provider, bucket] of Object.entries(fold.providers ?? {})) {
386
406
  addBucket((aggregate.providers[provider] ??= emptyBucket()), bucket)
387
407
  }
@@ -562,7 +582,18 @@ export function createUsageStats(input) {
562
582
  const workspacesAggregate = Object.create(null)
563
583
  // v1.7.0: `speedAggregate[day].providers[providerId] = {output, seconds, steps}`
564
584
  const speedAggregate = Object.create(null)
585
+ // v1.7.7: the same, per epoch hour (drives the TPS hour drill-down).
586
+ const speedHoursAggregate = Object.create(null)
565
587
  for (const entry of Object.values(cache.files)) {
588
+ for (const [hour, hourSpeed] of Object.entries(entry.speedHours ?? {})) {
589
+ const target = (speedHoursAggregate[hour] ??= { providers: Object.create(null) })
590
+ for (const [provider, slot] of Object.entries(hourSpeed.providers ?? {})) {
591
+ const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
592
+ into.output += typeof slot.output === 'number' ? slot.output : 0
593
+ into.seconds += typeof slot.seconds === 'number' ? slot.seconds : 0
594
+ into.steps += typeof slot.steps === 'number' ? slot.steps : 0
595
+ }
596
+ }
566
597
  for (const [day, daySpeed] of Object.entries(entry.speed ?? {})) {
567
598
  const target = (speedAggregate[day] ??= { providers: Object.create(null) })
568
599
  for (const [provider, slot] of Object.entries(daySpeed.providers ?? {})) {
@@ -626,6 +657,26 @@ export function createUsageStats(input) {
626
657
  }
627
658
  }
628
659
 
660
+ // v1.7.7: local-hour OUTPUT-SPEED series per day, for the TPS drill-down.
661
+ // `speedDayHours[day][providerId]` is 24 `{output, seconds, steps}` slots in
662
+ // local time, built from the same epoch-hour buckets as the token histogram
663
+ // so both drill-downs describe the same slot.
664
+ const daySpeedHours = Object.create(null)
665
+ for (const [epochHour, bucket] of Object.entries(speedHoursAggregate)) {
666
+ const hourStart = Number(epochHour) * 3600_000
667
+ const date = new Date(hourStart)
668
+ if (!Number.isFinite(date.getTime())) continue
669
+ const slot = date.getHours()
670
+ const dayEntry = (daySpeedHours[dayKey(hourStart)] ??= Object.create(null))
671
+ for (const [provider, sample] of Object.entries(bucket.providers ?? {})) {
672
+ if (sample === null || typeof sample !== 'object') continue
673
+ const series = (dayEntry[provider] ??= Array.from({ length: 24 }, () => ({ output: 0, seconds: 0, steps: 0 })))
674
+ series[slot].output += typeof sample.output === 'number' ? sample.output : 0
675
+ series[slot].seconds += typeof sample.seconds === 'number' ? sample.seconds : 0
676
+ series[slot].steps += typeof sample.steps === 'number' ? sample.steps : 0
677
+ }
678
+ }
679
+
629
680
  const allTime = emptyBucket()
630
681
  for (const bucket of Object.values(providersAggregate)) addBucket(allTime, bucket)
631
682
  // Rolling 5-hour window from the hourly DSH buckets.
@@ -791,6 +842,10 @@ export function createUsageStats(input) {
791
842
  daily,
792
843
  // v1.7.0: raw per-day output-speed samples for the wire builder below.
793
844
  speed: speedAggregate,
845
+ // v1.7.7: per-hour samples, converted to 24 local slots by the composer.
846
+ speedHours: speedHoursAggregate,
847
+ // v1.7.7: ready-to-ship 24-slot local-hour speed series per day/provider.
848
+ speedDayHours: daySpeedHours,
794
849
  scan,
795
850
  }
796
851
  }
@@ -857,6 +912,8 @@ export function createUsageStats(input) {
857
912
  workspaces: fold.workspaces,
858
913
  // v1.7.0: per-day output-speed samples (CACHE_VERSION 6).
859
914
  speed: fold.speed,
915
+ // v1.7.7: per-hour samples for the TPS drill-down (CACHE_VERSION 7).
916
+ speedHours: fold.speedHours,
860
917
  }
861
918
  nextFiles[path] = entry
862
919
  scanned += 1
@@ -902,6 +959,8 @@ export function createUsageStats(input) {
902
959
  allTime: emptyBucket(),
903
960
  providers: [],
904
961
  models: [],
962
+ speed: Object.create(null),
963
+ speedHours: Object.create(null),
905
964
  scan: {
906
965
  sessions: 0,
907
966
  scanned: 0,
@@ -1390,13 +1449,32 @@ export function createUsageStats(input) {
1390
1449
  daySteps += slot.steps
1391
1450
  }
1392
1451
  if (daySeconds <= 0 || dayOutput <= 0) continue
1393
- byDay.push({
1452
+ const entry = {
1394
1453
  day,
1395
1454
  outputTokens: dayOutput,
1396
1455
  seconds: Math.round(daySeconds * 10) / 10,
1397
1456
  tps: Math.round((dayOutput / daySeconds) * 10) / 10,
1398
1457
  steps: daySteps,
1399
- })
1458
+ }
1459
+ // v1.7.7: the day's 24 local-hour slots, for the click-to-drill-down
1460
+ // chart. Only a provider-scoped series carries them: the combined series
1461
+ // is not what the TPS card renders any more.
1462
+ if (providerId !== null) {
1463
+ const slots = dsh.speedDayHours?.[day]?.[providerId]
1464
+ if (Array.isArray(slots)) {
1465
+ entry.hours = slots.map((cell) => {
1466
+ if (cell === null || typeof cell !== 'object') return null
1467
+ if (!(cell.seconds > 0) || !(cell.output > 0)) return null
1468
+ return {
1469
+ outputTokens: cell.output,
1470
+ seconds: Math.round(cell.seconds * 10) / 10,
1471
+ tps: Math.round((cell.output / cell.seconds) * 10) / 10,
1472
+ steps: cell.steps,
1473
+ }
1474
+ })
1475
+ }
1476
+ }
1477
+ byDay.push(entry)
1400
1478
  output += dayOutput
1401
1479
  seconds += daySeconds
1402
1480
  steps += daySteps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tokenlens",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "type": "module",
5
5
  "description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
6
6
  "license": "MIT",