dsh-tokenlens 1.6.7 → 1.7.1

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,28 @@
4
4
 
5
5
  **版本记录约定**:同一发布日只保留一个章节,标题取当日**最后一个**已发布版本号;当日多次发布的改动合并到该章节,并在标题下注明当日实际发布过的版本号(npm 上仍可安装这些独立版本)。若当日已有发布,后续小改动不再单独增加版本号,而是并入下一篇日志。这样日志按天规整、不出现同日多个小版本。
6
6
 
7
+ ## 1.7.1 — 2026-11-06
8
+
9
+ (当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7 / v1.7.0 / v1.7.1)
10
+
11
+ - **fix(ui): 未最大化窗口时芯片悬停导致整个对话区闪烁**——`Tooltip` 的气泡锚在芯片右缘,窗口变窄时会溢出 dock,撑出**对话区底部的横向滚动条**;滚动条又挤压布局、导致 hover 被反复触发,于是整片区域持续重绘。芯片改用**原生 `title`**:不产生任何 DOM,不可能影响布局。v1.7.0 只去掉原生 `title` 保留 Tooltip,方向反了——真正的成因是 Tooltip 本身
12
+ - **feat(ui): TPS 趋势图由柱状改为曲线**——速率是连续量,折线比逐日立柱更易读。改为单条 `polyline` 曲线 + 淡色面积填充;每天一个透明命中列承载 `title`,鼠标移上去显示「日期 · 速率(输出 ÷ 耗时)」
13
+ - fix(ui): **TPS 卡片宽度与「明细」卡片一致**(`flex: 1 1 100%`)——此前它没有 flex 规则,宽度收缩到内容宽
14
+ - 测试:363 → 364(芯片不再包 Tooltip、标题为原生字符串、速度卡片满宽、曲线为 polyline+area 且不填充线体、透明命中列携带日期与速率)
15
+
16
+ ## 1.7.0 — 2026-11-06
17
+
18
+ (当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7 / v1.7.0)
19
+
20
+ - **fix(ui): 弹窗改为 portal 到 `<body>`,恢复视口居中**——DSH 0.2.2-rc.1 里 `conversation.input.overlay` 槽位的祖先建立了包含块(transform / filter / contain),`position: fixed` 因此相对「对话区」解析,弹窗只能贴在对话区底部。改用 `react-dom` 的 `createPortal` 挂到 `document.body`,彻底脱离该祖先链。`react-dom` 缺失时(含 Node 测试环境)自动回退为原地渲染,绝不抛错
21
+ - **fix(ui): 芯片悬停不再让整个对话区闪烁**(v1.7.1 进一步修正为改用原生 `title`)——v1.7.0 曾移除冗余的原生 `title` 并保留平台 `Tooltip`,但窄窗口下成因其实是 Tooltip 气泡溢出
22
+ - **feat(ui): 新增「输出速度(TPS)」趋势图**,位于明细下方、独占整行——输出 Token ÷ 模型自身耗时(`step/start` → `assistant/message`)。工具调用发生在消息之后,因此等待时间不被计入;输出 Token 含思考 Token,弹窗提示已说明
23
+ - feat(stats): Host 侧新增 `speed` 序列(顶层为全 Harness 合计,每个 provider 行另有自己的序列,跟随作用域切换);按天聚合 `{day, outputTokens, seconds, tps, steps}`,无正耗时的日期直接跳过而不是给出无限速率。缓存 schema 升至 6,首次会重扫
24
+ - **fix(stats): 「最近X天」封顶 30 天**——此前 provider 首次出现超过 30 天时会报「最近90天」,而同期所有其他数字与图表都只覆盖 30 天。现在 `effectiveDays` 封顶 30,且 `sinceEffective` 在超窗时改用 30 天桶,保证**标签与数字描述同一区间**;`daily` 序列尾部由 120 天收敛为 30 天
25
+ - 测试:357 → 363(TPS 配对与未配对步骤跳过、零耗时日期跳过、provider 作用域序列、30 天封顶与标签/数据一致、弹窗 portal 与无 react-dom 回退、TPS 图表与空态)
26
+
27
+ ## 1.7.0 之前的同日版本见下方各节
28
+
7
29
  ## 1.6.7 — 2026-11-05
8
30
 
9
31
  (当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7)
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 357 tests.
147
+ Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 364 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`:**357 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器、Command Code 月度额度反推)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
448
+ - `node --test`:**364 个自动化用例**,覆盖 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
@@ -40,6 +40,19 @@ window.__ModuleLoader__.load({
40
40
  // words in the shell's module table, so they need no `dsh.client.external`.
41
41
  const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
42
42
  const { Modal, Button, Tooltip, IconRefreshOutlineRegular } = primitives
43
+ // v1.7.0: the dialog is portalled to <body>. Rendering it inside the
44
+ // `conversation.input.overlay` slot stopped reaching the viewport in DSH
45
+ // 0.2.2-rc.1: an ancestor there establishes a containing block (transform /
46
+ // filter / contain), so `position: fixed` resolved against the conversation
47
+ // area and the dialog could only appear at its bottom. `createPortal` escapes
48
+ // that ancestor entirely. Tolerated as optional so a Host without react-dom
49
+ // (and the Node test harness) simply renders inline as before.
50
+ let ReactDOM = null
51
+ try {
52
+ ReactDOM = require('react-dom')
53
+ } catch {
54
+ ReactDOM = null
55
+ }
43
56
 
44
57
  const h = React.createElement
45
58
 
@@ -215,6 +228,12 @@ window.__ModuleLoader__.load({
215
228
  statsProviderFallback: '当前供应商({provider})没有本机记录,显示全部 Harness 合计',
216
229
  statsDshTotalLabel: 'DSH 合计',
217
230
  statsLast30Days: '最近30天',
231
+ statsSpeedTitle: '输出速度(TPS)',
232
+ statsSpeedHint: '输出 Token ÷ 模型自身耗时(step 开始到消息返回);不含工具调用等待,但输出含思考 Token',
233
+ statsSpeedTotal: '均值 {tps} tok/s',
234
+ statsSpeedPeak: '峰值 {tps} tok/s',
235
+ statsSpeedEmpty: '暂无可计算的速度样本(需要 step 计时数据)',
236
+ statsSpeedBarTitle: '{date} · {tps} tok/s({tokens} 输出 ÷ {seconds}s)',
218
237
  statsLast7Days: '最近7天',
219
238
  stats7DaysShort: '7天',
220
239
  statsDaysShort: '{n}天',
@@ -484,6 +503,13 @@ window.__ModuleLoader__.load({
484
503
  statsProviderFallback: 'No local records for the current provider ({provider}) — showing all-harness totals',
485
504
  statsDshTotalLabel: 'DSH total',
486
505
  statsLast30Days: 'Last 30 days',
506
+ statsSpeedTitle: 'Output speed (TPS)',
507
+ statsSpeedHint:
508
+ 'Output tokens ÷ the model’s own request time (step start to message); tool waits excluded, reasoning tokens included',
509
+ statsSpeedTotal: 'avg {tps} tok/s',
510
+ statsSpeedPeak: 'peak {tps} tok/s',
511
+ statsSpeedEmpty: 'No speed samples yet (needs step timing data)',
512
+ statsSpeedBarTitle: '{date} · {tps} tok/s ({tokens} output ÷ {seconds}s)',
487
513
  statsLast7Days: 'Last 7 days',
488
514
  stats7DaysShort: '7d',
489
515
  statsDaysShort: '{n}d',
@@ -1523,6 +1549,8 @@ window.__ModuleLoader__.load({
1523
1549
  // v0.22.1: only subscription-backed providers use the 最近X天 window.
1524
1550
  subscribed: entry.subscribed === true,
1525
1551
  harnesses,
1552
+ // v1.7.0: this provider's own output-speed series.
1553
+ speed: normalizeSpeed(entry.speed),
1526
1554
  }
1527
1555
  }
1528
1556
 
@@ -1557,6 +1585,38 @@ window.__ModuleLoader__.load({
1557
1585
  }
1558
1586
 
1559
1587
  /** The whole stats payload, or null. Handles v1/v2 (DSH-ish) and v3 (provider-scoped). */
1588
+ /**
1589
+ * One output-speed (TPS) series (v1.7.0): `{byDay, total}` or null.
1590
+ *
1591
+ * `tps` is output tokens over the model's own request time, so a day whose
1592
+ * `seconds` is not positive is dropped rather than shown as an infinite rate.
1593
+ */
1594
+ function normalizeSpeed(raw) {
1595
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
1596
+ const byDay = Array.isArray(raw.byDay)
1597
+ ? raw.byDay
1598
+ .filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.day === 'string')
1599
+ .map((entry) => ({
1600
+ day: entry.day,
1601
+ outputTokens: numberOrNull(entry.outputTokens) ?? 0,
1602
+ seconds: numberOrNull(entry.seconds) ?? 0,
1603
+ tps: numberOrNull(entry.tps),
1604
+ steps: numberOrNull(entry.steps) ?? 0,
1605
+ }))
1606
+ .filter((entry) => entry.tps !== null && entry.tps >= 0)
1607
+ : []
1608
+ const totalRaw = raw.total !== null && typeof raw.total === 'object' ? raw.total : {}
1609
+ return {
1610
+ byDay,
1611
+ total: {
1612
+ outputTokens: numberOrNull(totalRaw.outputTokens) ?? 0,
1613
+ seconds: numberOrNull(totalRaw.seconds) ?? 0,
1614
+ tps: numberOrNull(totalRaw.tps),
1615
+ steps: numberOrNull(totalRaw.steps) ?? 0,
1616
+ },
1617
+ }
1618
+ }
1619
+
1560
1620
  function normalizeStats(raw) {
1561
1621
  if (raw === null || typeof raw !== 'object') return null
1562
1622
  const version = Number.isFinite(raw.version) ? raw.version : 1
@@ -1684,6 +1744,8 @@ window.__ModuleLoader__.load({
1684
1744
  workspaces,
1685
1745
  // v0.24.0 daily series for the chart mode (may be empty on old Hosts).
1686
1746
  daily,
1747
+ // v1.7.0: output-speed (TPS) series across every harness.
1748
+ speed: normalizeSpeed(raw.speed),
1687
1749
  zcode,
1688
1750
  }
1689
1751
  }
@@ -3380,6 +3442,11 @@ window.__ModuleLoader__.load({
3380
3442
  .dsh-tokenlens-chart-layout-row>.dsh-tokenlens-capacity{flex:0.85 1 260px}
3381
3443
  .dsh-tokenlens-chart-layout-row>.dsh-tokenlens-stats{flex:1.25 1 330px;min-width:0}
3382
3444
  .dsh-tokenlens-chart-layout-row>.dsh-tokenlens-details{flex:1 1 100%;min-width:0}
3445
+ /* v1.7.1: the TPS trend is a full-width row, exactly like the details card. */
3446
+ .dsh-tokenlens-chart-layout-row>.dsh-tokenlens-section[data-speed-section]{flex:1 1 100%;min-width:0}
3447
+ .dsh-tokenlens-speed-svg{display:block;width:100%;height:96px;overflow:visible}
3448
+ .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}
3449
+ .dsh-tokenlens-speed-axis>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3383
3450
  @media (max-width:680px){.dsh-tokenlens-chart-layout-row>*{flex-basis:100%!important}}
3384
3451
  .dsh-tokenlens-details-charts{min-width:0}
3385
3452
  /* Data-mode 明细: three FULL-WIDTH blocks stacked vertically (v0.30.0 layout). */
@@ -3863,26 +3930,28 @@ window.__ModuleLoader__.load({
3863
3930
  const snapshot = normalizeSnapshot(state.snapshot)
3864
3931
  const brand = providerBrand(state, snapshot)
3865
3932
  const tooltip = chipTooltip(snapshot, state.providerId, t)
3933
+ // v1.7.1: a NATIVE title, not the platform Tooltip. On an unmaximized
3934
+ // window the Tooltip's bubble rendered at the chip's right edge, overflowed
3935
+ // the dock and produced a horizontal scrollbar on the conversation area;
3936
+ // that scrollbar shifted the layout, which re-triggered the hover and
3937
+ // flickered the whole area. A native title draws nothing in the DOM, so it
3938
+ // cannot affect layout at all. The text is unchanged.
3866
3939
  return h(
3867
3940
  'span',
3868
3941
  { className: 'dsh-tokenlens-anchor', 'data-composer-stat': 'tokenlens' },
3869
3942
  h(
3870
- Tooltip,
3871
- { label: tooltip, side: 'top' },
3872
- h(
3873
- 'button',
3874
- {
3875
- type: 'button',
3876
- className: 'dsh-tokenlens-chip',
3877
- title: t('chipHint'),
3878
- 'aria-label': t('chipHint'),
3879
- 'aria-haspopup': 'dialog',
3880
- onClick: () => openDialog(sessionId),
3881
- },
3882
- h('span', { className: 'dsh-tokenlens-dot', 'data-level': chipLevel(snapshot, state), 'aria-hidden': true }),
3883
- h('span', { className: 'dsh-tokenlens-brand' }, brand),
3884
- chipPieces(snapshot, state.providerId, t),
3885
- ),
3943
+ 'button',
3944
+ {
3945
+ type: 'button',
3946
+ className: 'dsh-tokenlens-chip',
3947
+ title: tooltip,
3948
+ 'aria-label': t('chipHint'),
3949
+ 'aria-haspopup': 'dialog',
3950
+ onClick: () => openDialog(sessionId),
3951
+ },
3952
+ h('span', { className: 'dsh-tokenlens-dot', 'data-level': chipLevel(snapshot, state), 'aria-hidden': true }),
3953
+ h('span', { className: 'dsh-tokenlens-brand' }, brand),
3954
+ chipPieces(snapshot, state.providerId, t),
3886
3955
  ),
3887
3956
  )
3888
3957
  }
@@ -3902,24 +3971,23 @@ window.__ModuleLoader__.load({
3902
3971
  'span',
3903
3972
  { className: 'dsh-tokenlens-anchor', 'data-composer-stat': 'tokenlens' },
3904
3973
  h(
3905
- Tooltip,
3906
- { label, side: 'top' },
3907
- h(
3908
- 'button',
3909
- {
3910
- type: 'button',
3911
- className: 'dsh-tokenlens-chip',
3912
- title: label,
3913
- 'aria-label': label,
3914
- 'aria-haspopup': 'dialog',
3915
- ...(needsConfig ? { 'data-needs-config': 'true' } : {}),
3916
- ...(statsOnly ? { 'data-stats-only': 'true' } : {}),
3917
- onClick: () => openDialog(sessionId),
3918
- },
3919
- h('span', { className: 'dsh-tokenlens-dot', 'data-level': statsOnly ? 'normal' : 'warning', 'aria-hidden': true }),
3920
- h('span', { className: 'dsh-tokenlens-brand' }, brand),
3921
- h('span', { className: 'dsh-tokenlens-metric' }, label),
3922
- ),
3974
+ 'button',
3975
+ {
3976
+ type: 'button',
3977
+ className: 'dsh-tokenlens-chip',
3978
+ // v1.7.1: native title, for the same reason as the normal chip — a
3979
+ // Tooltip bubble here could overflow a narrow dock and add a
3980
+ // horizontal scrollbar that made the conversation area flicker.
3981
+ title: label,
3982
+ 'aria-label': label,
3983
+ 'aria-haspopup': 'dialog',
3984
+ ...(needsConfig ? { 'data-needs-config': 'true' } : {}),
3985
+ ...(statsOnly ? { 'data-stats-only': 'true' } : {}),
3986
+ onClick: () => openDialog(sessionId),
3987
+ },
3988
+ h('span', { className: 'dsh-tokenlens-dot', 'data-level': statsOnly ? 'normal' : 'warning', 'aria-hidden': true }),
3989
+ h('span', { className: 'dsh-tokenlens-brand' }, brand),
3990
+ h('span', { className: 'dsh-tokenlens-metric' }, label),
3923
3991
  ),
3924
3992
  )
3925
3993
  }
@@ -4976,6 +5044,30 @@ window.__ModuleLoader__.load({
4976
5044
  store.set({ showDetails: store.get().showDetails !== true })
4977
5045
  }
4978
5046
 
5047
+ /**
5048
+ * Detach `node` from the slot's ancestor chain by rendering it into <body>
5049
+ * (v1.7.0).
5050
+ *
5051
+ * The `conversation.input.overlay` slot lives inside the conversation area,
5052
+ * and in DSH 0.2.2-rc.1 an ancestor there establishes a containing block, so
5053
+ * the overlay's `position: fixed` resolved against that ancestor and the
5054
+ * dialog could only appear at the bottom of the conversation. Portalling
5055
+ * detaches it so it centres on the viewport. Returns the node unchanged when
5056
+ * react-dom or a DOM is unavailable (the Node test harness), so the rendered
5057
+ * tree keeps its shape there.
5058
+ */
5059
+ function portalize(node) {
5060
+ const canPortal =
5061
+ ReactDOM !== null &&
5062
+ typeof ReactDOM.createPortal === 'function' &&
5063
+ typeof document !== 'undefined' &&
5064
+ document !== null &&
5065
+ document.body !== undefined &&
5066
+ document.body !== null
5067
+ if (!canPortal) return node
5068
+ return ReactDOM.createPortal(node, document.body)
5069
+ }
5070
+
4979
5071
  /**
4980
5072
  * The dialog shell: a fixed overlay with an explicitly sized panel.
4981
5073
  *
@@ -5007,15 +5099,18 @@ window.__ModuleLoader__.load({
5007
5099
  /* closing must never throw */
5008
5100
  }
5009
5101
  }
5010
- return h(
5011
- 'div',
5012
- {
5013
- className: 'dsh-tokenlens-overlay',
5014
- role: 'presentation',
5015
- onMouseDown: (event) => {
5016
- if (event.target === event.currentTarget) close()
5102
+ // v1.7.0: portal to <body> so the overlay centres on the viewport instead
5103
+ // of resolving `position: fixed` against the conversation area.
5104
+ return portalize(
5105
+ h(
5106
+ 'div',
5107
+ {
5108
+ className: 'dsh-tokenlens-overlay',
5109
+ role: 'presentation',
5110
+ onMouseDown: (event) => {
5111
+ if (event.target === event.currentTarget) close()
5112
+ },
5017
5113
  },
5018
- },
5019
5114
  h(
5020
5115
  'div',
5021
5116
  {
@@ -5042,6 +5137,7 @@ window.__ModuleLoader__.load({
5042
5137
  h('div', { className: 'dsh-tokenlens-modal-body' }, children),
5043
5138
  footer === undefined || footer === null ? null : h('div', { className: 'dsh-tokenlens-modal-foot' }, footer),
5044
5139
  ),
5140
+ ),
5045
5141
  )
5046
5142
  }
5047
5143
 
@@ -5959,6 +6055,136 @@ window.__ModuleLoader__.load({
5959
6055
  )
5960
6056
  }
5961
6057
 
6058
+ /**
6059
+ * The output-speed (TPS) trend card (v1.7.0), shown under 明细.
6060
+ *
6061
+ * `tps` is output tokens over the model's OWN request time (`step/start` to
6062
+ * `assistant/message`), so the step's tool calls do not dilute it — but
6063
+ * reasoning tokens are included in "output", which the hint says.
6064
+ */
6065
+ function buildSpeedSection(state, ctx, t) {
6066
+ const stats = ctx.stats
6067
+ const scoped = ctx.providerScoped ? ctx.currentRow?.speed ?? null : stats?.speed ?? null
6068
+ const series = scoped === null || scoped === undefined ? null : scoped
6069
+ const byDay = series === null ? [] : series.byDay
6070
+ const head = h(
6071
+ 'div',
6072
+ { key: 'head', className: 'dsh-tokenlens-section-head' },
6073
+ h(
6074
+ 'span',
6075
+ { className: 'dsh-tokenlens-section-title' },
6076
+ Icon('chart', 14),
6077
+ t('statsSpeedTitle'),
6078
+ ),
6079
+ series !== null && series.total.tps !== null
6080
+ ? h(
6081
+ 'span',
6082
+ { className: 'dsh-tokenlens-hint', 'data-speed-total': String(series.total.tps) },
6083
+ t('statsSpeedTotal', { tps: series.total.tps.toFixed(1) }),
6084
+ )
6085
+ : null,
6086
+ )
6087
+ const body =
6088
+ byDay.length === 0
6089
+ ? h(
6090
+ 'div',
6091
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6092
+ h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, t('statsSpeedEmpty')),
6093
+ )
6094
+ : (() => {
6095
+ // v1.7.1: a CURVE, not bars — the trend is a rate, and a line reads
6096
+ // it far better than per-day columns. The SVG scales to the card
6097
+ // width; `preserveAspectRatio="none"` lets it fill without keeping
6098
+ // the viewBox ratio, and the per-day hover targets are transparent
6099
+ // columns so nothing is distorted.
6100
+ const width = 320
6101
+ const height = 90
6102
+ const pad = 4
6103
+ const max = Math.max(...byDay.map((entry) => entry.tps ?? 0), 1)
6104
+ const stepX = byDay.length > 1 ? (width - pad * 2) / (byDay.length - 1) : 0
6105
+ const points = byDay.map((entry, index) => {
6106
+ const x = byDay.length > 1 ? pad + stepX * index : width / 2
6107
+ const y = height - pad - ((entry.tps ?? 0) / max) * (height - pad * 2)
6108
+ return { x, y }
6109
+ })
6110
+ const line = points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
6111
+ const area = `${pad.toFixed(1)},${(height - pad).toFixed(1)} ${line} ${(width - pad).toFixed(1)},${(height - pad).toFixed(1)}`
6112
+ const columnWidth = byDay.length > 1 ? stepX : width - pad * 2
6113
+ const columns = byDay.map((entry, index) =>
6114
+ h(
6115
+ 'rect',
6116
+ {
6117
+ key: `hit-${entry.day}-${index}`,
6118
+ x: (points[index].x - columnWidth / 2).toFixed(1),
6119
+ y: 0,
6120
+ width: Math.max(1, columnWidth).toFixed(1),
6121
+ height: String(height),
6122
+ fill: 'transparent',
6123
+ 'data-speed-day': entry.day,
6124
+ 'data-speed-tps': String(entry.tps ?? 0),
6125
+ },
6126
+ h(
6127
+ 'title',
6128
+ { key: 'tip' },
6129
+ t('statsSpeedBarTitle', {
6130
+ date: entry.day,
6131
+ tps: (entry.tps ?? 0).toFixed(1),
6132
+ tokens: formatTokensPrecise(entry.outputTokens),
6133
+ seconds: entry.seconds.toFixed(1),
6134
+ }),
6135
+ ),
6136
+ ),
6137
+ )
6138
+ const svg = h(
6139
+ 'svg',
6140
+ {
6141
+ key: 'svg',
6142
+ className: 'dsh-tokenlens-speed-svg',
6143
+ 'data-speed-plot': 'true',
6144
+ viewBox: `0 0 ${width} ${height}`,
6145
+ preserveAspectRatio: 'none',
6146
+ role: 'img',
6147
+ 'aria-label': t('statsSpeedTitle'),
6148
+ },
6149
+ h('polygon', {
6150
+ key: 'area',
6151
+ className: 'dsh-tokenlens-speed-area',
6152
+ points: area,
6153
+ fill: 'rgba(46,158,91,.18)',
6154
+ 'aria-hidden': 'true',
6155
+ }),
6156
+ h('polyline', {
6157
+ key: 'line',
6158
+ className: 'dsh-tokenlens-speed-line',
6159
+ points: line,
6160
+ fill: 'none',
6161
+ stroke: '#2e9e5b',
6162
+ 'stroke-width': '1.6',
6163
+ 'stroke-linecap': 'round',
6164
+ 'stroke-linejoin': 'round',
6165
+ 'vector-effect': 'non-scaling-stroke',
6166
+ 'aria-hidden': 'true',
6167
+ }),
6168
+ columns,
6169
+ )
6170
+ const axis = h(
6171
+ 'div',
6172
+ { key: 'axis', className: 'dsh-tokenlens-speed-axis', 'aria-hidden': 'true' },
6173
+ h('span', { key: 'first' }, byDay[0].day),
6174
+ byDay.length > 1 ? h('span', { key: 'peak' }, t('statsSpeedPeak', { tps: max.toFixed(1) })) : null,
6175
+ byDay.length > 1 ? h('span', { key: 'last' }, byDay[byDay.length - 1].day) : null,
6176
+ )
6177
+ return h('div', { key: 'plot', className: 'dsh-tokenlens-speed-plot' }, svg, axis)
6178
+ })()
6179
+ return h(
6180
+ 'div',
6181
+ { key: 'speed', className: 'dsh-tokenlens-section', 'data-speed-section': 'true' },
6182
+ head,
6183
+ h('div', { key: 'hint', className: 'dsh-tokenlens-subtitle' }, t('statsSpeedHint')),
6184
+ body,
6185
+ )
6186
+ }
6187
+
5962
6188
  function buildStatsDetails(state, ctx, t) {
5963
6189
  const { stats, hasMonth } = ctx
5964
6190
  if (stats === null) return null
@@ -7422,6 +7648,12 @@ window.__ModuleLoader__.load({
7422
7648
  { key: 'row-details', className: 'dsh-tokenlens-chart-layout-row' },
7423
7649
  buildStatsDetails(state, statsCtx, t),
7424
7650
  ),
7651
+ // v1.7.0: the output-speed trend sits under 明细, as its own full-width row.
7652
+ h(
7653
+ 'div',
7654
+ { key: 'row-speed', className: 'dsh-tokenlens-chart-layout-row' },
7655
+ buildSpeedSection(state, statsCtx, t),
7656
+ ),
7425
7657
  )
7426
7658
  const body = [buildManageButton(state, t), statsArea]
7427
7659
  const displayVersion = currentPluginVersionOf(state)
package/index.js CHANGED
@@ -84,7 +84,7 @@ 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.6.7'
87
+ export const PLUGIN_VERSION = '1.7.1'
88
88
 
89
89
  /** Documented defaults; every field of the raw row config is optional. */
90
90
  const DEFAULTS = {
@@ -61,7 +61,17 @@ 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 = 5
64
+ const CACHE_VERSION = 6
65
+
66
+ /**
67
+ * How many days the daily series (and therefore the 图表 mode and the
68
+ * 最近X天 provider window) may cover (v1.7.0).
69
+ *
70
+ * The wire used to carry 120 days, so a provider first seen months ago reported
71
+ * 最近90天 while every other figure and the charts described 30. One span now
72
+ * governs all of them.
73
+ */
74
+ export const DAILY_SERIES_DAYS = 30
65
75
 
66
76
  /** What the numbers are, by provider id (display fallback; config can override). */
67
77
  const PROVIDER_LABELS = {
@@ -235,6 +245,11 @@ export function foldSession(records) {
235
245
  const models = Object.create(null)
236
246
  const hours = Object.create(null)
237
247
  const workspaces = Object.create(null)
248
+ // v1.7.0: output-speed (TPS) samples. A step's model time is
249
+ // `assistant/message.time − step/start.time`: the step's tool calls are emitted
250
+ // AFTER the message, so they do not dilute it.
251
+ const speed = Object.create(null)
252
+ const stepStarts = new Map()
238
253
  let latestTime = 0
239
254
  let sessionCwd = null
240
255
  const session = { seeded: false, subagent: false, createdAt: null, id: null, inheritedEvents: 0 }
@@ -267,6 +282,17 @@ export function foldSession(records) {
267
282
  continue
268
283
  }
269
284
  if (boundarySeq !== null && Number.isSafeInteger(record.seq) && record.seq <= boundarySeq) continue
285
+ // v1.7.0: remember when each step's model request started, so the matching
286
+ // `assistant/message` can be turned into a duration.
287
+ if (record.type === 'step/start') {
288
+ const turn = record.data?.turn
289
+ const step = record.data?.step
290
+ const startedAt = typeof record.time === 'number' && Number.isFinite(record.time) ? record.time : null
291
+ if (startedAt !== null && Number.isSafeInteger(turn) && Number.isSafeInteger(step)) {
292
+ stepStarts.set(`${turn}:${step}`, startedAt)
293
+ }
294
+ continue
295
+ }
270
296
  const usage = usageOf(record)
271
297
  if (usage === null) continue
272
298
  const time = typeof record.time === 'number' && Number.isFinite(record.time) ? record.time : null
@@ -301,6 +327,23 @@ export function foldSession(records) {
301
327
  const dayProvider = (dayBucket.providers[attributedProvider] ??= emptyBucket())
302
328
  addBucket(dayProvider, usage)
303
329
  addBucket((providers[attributedProvider] ??= emptyBucket()), usage)
330
+ // v1.7.0: one speed sample per completed step.
331
+ if (record.type === 'assistant/message' && usage.output > 0) {
332
+ const turn = record.data?.turn
333
+ const step = record.data?.step
334
+ const startedAt = Number.isSafeInteger(turn) && Number.isSafeInteger(step)
335
+ ? stepStarts.get(`${turn}:${step}`)
336
+ : undefined
337
+ const elapsedMs = startedAt === undefined ? null : time - startedAt
338
+ if (elapsedMs !== null && elapsedMs > 0) {
339
+ const daySpeed = (speed[day] ??= { providers: Object.create(null) })
340
+ const slot = (daySpeed.providers[attributedProvider] ??= { output: 0, seconds: 0, steps: 0 })
341
+ slot.output += usage.output
342
+ slot.seconds += elapsedMs / 1000
343
+ slot.steps += 1
344
+ stepStarts.delete(`${turn}:${step}`)
345
+ }
346
+ }
304
347
  const modelKey = `${attributedProvider}/${attributedModel}`
305
348
  const modelBucket = (models[modelKey] ??= {
306
349
  provider: attributedProvider,
@@ -318,7 +361,7 @@ export function foldSession(records) {
318
361
  if (Number(key) < cutoff) delete hours[key]
319
362
  }
320
363
  }
321
- return { days, providers, models, hours, workspaces, session }
364
+ return { days, providers, models, hours, workspaces, session, speed }
322
365
  }
323
366
 
324
367
  /** Merge one file's folded contribution into an aggregate being built. */
@@ -329,6 +372,16 @@ function mergeFold(aggregate, fold) {
329
372
  addBucket((target.providers[provider] ??= emptyBucket()), bucket)
330
373
  }
331
374
  }
375
+ // v1.7.0: merge the speed samples the same way as the token buckets.
376
+ for (const [day, daySpeed] of Object.entries(fold.speed ?? {})) {
377
+ const target = (aggregate.speed[day] ??= { providers: Object.create(null) })
378
+ for (const [provider, slot] of Object.entries(daySpeed.providers ?? {})) {
379
+ const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
380
+ into.output += slot.output
381
+ into.seconds += slot.seconds
382
+ into.steps += slot.steps
383
+ }
384
+ }
332
385
  for (const [provider, bucket] of Object.entries(fold.providers ?? {})) {
333
386
  addBucket((aggregate.providers[provider] ??= emptyBucket()), bucket)
334
387
  }
@@ -507,7 +560,18 @@ export function createUsageStats(input) {
507
560
  const modelsAggregate = Object.create(null)
508
561
  const hoursAggregate = Object.create(null)
509
562
  const workspacesAggregate = Object.create(null)
563
+ // v1.7.0: `speedAggregate[day].providers[providerId] = {output, seconds, steps}`
564
+ const speedAggregate = Object.create(null)
510
565
  for (const entry of Object.values(cache.files)) {
566
+ for (const [day, daySpeed] of Object.entries(entry.speed ?? {})) {
567
+ const target = (speedAggregate[day] ??= { providers: Object.create(null) })
568
+ for (const [provider, slot] of Object.entries(daySpeed.providers ?? {})) {
569
+ const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
570
+ into.output += typeof slot.output === 'number' ? slot.output : 0
571
+ into.seconds += typeof slot.seconds === 'number' ? slot.seconds : 0
572
+ into.steps += typeof slot.steps === 'number' ? slot.steps : 0
573
+ }
574
+ }
511
575
  for (const [day, dayBucket] of Object.entries(entry.days ?? {})) {
512
576
  const target = (daysAggregate[day] ??= { providers: Object.create(null) })
513
577
  for (const [provider, bucket] of Object.entries(dayBucket.providers ?? {})) {
@@ -725,6 +789,8 @@ export function createUsageStats(input) {
725
789
  providers,
726
790
  models: models.slice(0, 12),
727
791
  daily,
792
+ // v1.7.0: raw per-day output-speed samples for the wire builder below.
793
+ speed: speedAggregate,
728
794
  scan,
729
795
  }
730
796
  }
@@ -789,6 +855,8 @@ export function createUsageStats(input) {
789
855
  models: fold.models,
790
856
  hours: fold.hours,
791
857
  workspaces: fold.workspaces,
858
+ // v1.7.0: per-day output-speed samples (CACHE_VERSION 6).
859
+ speed: fold.speed,
792
860
  }
793
861
  nextFiles[path] = entry
794
862
  scanned += 1
@@ -1259,7 +1327,10 @@ export function createUsageStats(input) {
1259
1327
  }
1260
1328
  const daily = [...dailyMap.entries()]
1261
1329
  .sort(([left], [right]) => left.localeCompare(right))
1262
- .slice(-120)
1330
+ // v1.7.0: 30 days, matching the 最近30天 bucket and the 最近X天 label. The
1331
+ // series used to carry 120 days, so the 图表 mode could plot a wider window
1332
+ // than the 数据 mode described.
1333
+ .slice(-DAILY_SERIES_DAYS)
1263
1334
  .map(([day, entry]) => {
1264
1335
  const providers = Object.create(null)
1265
1336
  for (const [providerId, slot] of entry.providers) {
@@ -1292,15 +1363,77 @@ export function createUsageStats(input) {
1292
1363
  return Math.max(1, Math.round((end - start) / dayMs) + 1)
1293
1364
  }
1294
1365
  const providers = [...providerMap.values()].sort((a, b) => b.allTime.total - a.allTime.total)
1366
+ /**
1367
+ * Build one output-speed series (v1.7.0).
1368
+ *
1369
+ * `tps` is output tokens over the summed model time of the steps that
1370
+ * produced them, so it describes generation speed rather than wall clock:
1371
+ * the tool calls of a step are emitted after its `assistant/message` and are
1372
+ * therefore excluded. A day with no positive duration is dropped instead of
1373
+ * being given an infinite rate.
1374
+ */
1375
+ const speedSeries = (providerId) => {
1376
+ const source = dsh.speed ?? {}
1377
+ const byDay = []
1378
+ let output = 0
1379
+ let seconds = 0
1380
+ let steps = 0
1381
+ for (const day of Object.keys(source).sort()) {
1382
+ if (day < thirtyStart) continue
1383
+ let dayOutput = 0
1384
+ let daySeconds = 0
1385
+ let daySteps = 0
1386
+ for (const [id, slot] of Object.entries(source[day].providers ?? {})) {
1387
+ if (providerId !== null && id !== providerId) continue
1388
+ dayOutput += slot.output
1389
+ daySeconds += slot.seconds
1390
+ daySteps += slot.steps
1391
+ }
1392
+ if (daySeconds <= 0 || dayOutput <= 0) continue
1393
+ byDay.push({
1394
+ day,
1395
+ outputTokens: dayOutput,
1396
+ seconds: Math.round(daySeconds * 10) / 10,
1397
+ tps: Math.round((dayOutput / daySeconds) * 10) / 10,
1398
+ steps: daySteps,
1399
+ })
1400
+ output += dayOutput
1401
+ seconds += daySeconds
1402
+ steps += daySteps
1403
+ }
1404
+ return {
1405
+ byDay,
1406
+ total: {
1407
+ outputTokens: output,
1408
+ seconds: Math.round(seconds * 10) / 10,
1409
+ tps: seconds > 0 ? Math.round((output / seconds) * 10) / 10 : null,
1410
+ steps,
1411
+ },
1412
+ }
1413
+ }
1295
1414
  for (const provider of providers) {
1296
1415
  const effectiveDay = firstDays.get(provider.providerId) ?? todayDay
1297
1416
  provider.effectiveDay = effectiveDay
1298
- provider.effectiveDays = dayCount(effectiveDay, todayDay)
1299
- // A provider has no records before its effective day, so the all-time
1300
- // bucket is exactly the since-effective window.
1301
- provider.sinceEffective = cloneBucket(provider.allTime)
1417
+ // v1.7.0: the 最近X天 window is capped at the same 30 days the charts and
1418
+ // the 最近30天 bucket use. A provider first seen 90 days ago used to report
1419
+ // 最近90天 while every other figure on the page covered 30.
1420
+ const fullDays = dayCount(effectiveDay, todayDay)
1421
+ const cappedDays = Math.min(DAILY_SERIES_DAYS, fullDays)
1422
+ provider.effectiveDays = cappedDays
1423
+ // The label and the numbers must describe the SAME span. When the real
1424
+ // history is longer than the cap, the since-effective bucket is therefore
1425
+ // the 30-day one rather than all-time (which would carry older records
1426
+ // under a 最近30天 label).
1427
+ provider.sinceEffective =
1428
+ cappedDays < fullDays ? cloneBucket(provider.last30Days) : cloneBucket(provider.allTime)
1429
+ // v1.7.0: each provider row carries its own output-speed series so the
1430
+ // chart can follow the same scope switch as the token sections.
1431
+ provider.speed = speedSeries(provider.providerId)
1302
1432
  for (const harness of Object.values(provider.harnesses ?? {})) {
1303
- harness.sinceEffective = cloneBucket(harness.allTime)
1433
+ harness.sinceEffective =
1434
+ cappedDays < fullDays && harness.last30Days !== undefined
1435
+ ? cloneBucket(harness.last30Days)
1436
+ : cloneBucket(harness.allTime)
1304
1437
  }
1305
1438
  }
1306
1439
  return {
@@ -1315,6 +1448,8 @@ export function createUsageStats(input) {
1315
1448
  allTime,
1316
1449
  workspaces,
1317
1450
  providers,
1451
+ // v1.7.0: output-speed (TPS) series across every harness.
1452
+ speed: speedSeries(null),
1318
1453
  harnesses,
1319
1454
  models: dsh.models,
1320
1455
  daily,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tokenlens",
3
- "version": "1.6.7",
3
+ "version": "1.7.1",
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",