dsh-tokenlens 1.6.7 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  **版本记录约定**:同一发布日只保留一个章节,标题取当日**最后一个**已发布版本号;当日多次发布的改动合并到该章节,并在标题下注明当日实际发布过的版本号(npm 上仍可安装这些独立版本)。若当日已有发布,后续小改动不再单独增加版本号,而是并入下一篇日志。这样日志按天规整、不出现同日多个小版本。
6
6
 
7
+ ## 1.7.0 — 2026-11-06
8
+
9
+ (当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7 / v1.7.0)
10
+
11
+ - **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 测试环境)自动回退为原地渲染,绝不抛错
12
+ - **fix(ui): 芯片悬停不再让整个对话区闪烁**——芯片同时设置了原生 `title` 与平台 `Tooltip`,两者在悬停时互相抢占/释放,造成区域反复重绘。现在只保留平台 `Tooltip`(文案一致,`aria-label` 保留),移除冗余的原生 `title`
13
+ - **feat(ui): 新增「输出速度(TPS)」趋势图**,位于明细下方、独占整行——输出 Token ÷ 模型自身耗时(`step/start` → `assistant/message`)。工具调用发生在消息之后,因此等待时间不被计入;输出 Token 含思考 Token,弹窗提示已说明
14
+ - feat(stats): Host 侧新增 `speed` 序列(顶层为全 Harness 合计,每个 provider 行另有自己的序列,跟随作用域切换);按天聚合 `{day, outputTokens, seconds, tps, steps}`,无正耗时的日期直接跳过而不是给出无限速率。缓存 schema 升至 6,首次会重扫
15
+ - **fix(stats): 「最近X天」封顶 30 天**——此前 provider 首次出现超过 30 天时会报「最近90天」,而同期所有其他数字与图表都只覆盖 30 天。现在 `effectiveDays` 封顶 30,且 `sinceEffective` 在超窗时改用 30 天桶,保证**标签与数字描述同一区间**;`daily` 序列尾部由 120 天收敛为 30 天
16
+ - 测试:357 → 363(TPS 配对与未配对步骤跳过、零耗时日期跳过、provider 作用域序列、30 天封顶与标签/数据一致、弹窗 portal 与无 react-dom 回退、TPS 图表与空态)
17
+
18
+ ## 1.7.0 之前的同日版本见下方各节
19
+
7
20
  ## 1.6.7 — 2026-11-05
8
21
 
9
22
  (当日已发布版本: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 363 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`:**363 个自动化用例**,覆盖 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,11 @@ 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
+ statsSpeedEmpty: '暂无可计算的速度样本(需要 step 计时数据)',
235
+ statsSpeedBarTitle: '{date} · {tps} tok/s({tokens} 输出 ÷ {seconds}s)',
218
236
  statsLast7Days: '最近7天',
219
237
  stats7DaysShort: '7天',
220
238
  statsDaysShort: '{n}天',
@@ -484,6 +502,12 @@ window.__ModuleLoader__.load({
484
502
  statsProviderFallback: 'No local records for the current provider ({provider}) — showing all-harness totals',
485
503
  statsDshTotalLabel: 'DSH total',
486
504
  statsLast30Days: 'Last 30 days',
505
+ statsSpeedTitle: 'Output speed (TPS)',
506
+ statsSpeedHint:
507
+ 'Output tokens ÷ the model’s own request time (step start to message); tool waits excluded, reasoning tokens included',
508
+ statsSpeedTotal: 'avg {tps} tok/s',
509
+ statsSpeedEmpty: 'No speed samples yet (needs step timing data)',
510
+ statsSpeedBarTitle: '{date} · {tps} tok/s ({tokens} output ÷ {seconds}s)',
487
511
  statsLast7Days: 'Last 7 days',
488
512
  stats7DaysShort: '7d',
489
513
  statsDaysShort: '{n}d',
@@ -1523,6 +1547,8 @@ window.__ModuleLoader__.load({
1523
1547
  // v0.22.1: only subscription-backed providers use the 最近X天 window.
1524
1548
  subscribed: entry.subscribed === true,
1525
1549
  harnesses,
1550
+ // v1.7.0: this provider's own output-speed series.
1551
+ speed: normalizeSpeed(entry.speed),
1526
1552
  }
1527
1553
  }
1528
1554
 
@@ -1557,6 +1583,38 @@ window.__ModuleLoader__.load({
1557
1583
  }
1558
1584
 
1559
1585
  /** The whole stats payload, or null. Handles v1/v2 (DSH-ish) and v3 (provider-scoped). */
1586
+ /**
1587
+ * One output-speed (TPS) series (v1.7.0): `{byDay, total}` or null.
1588
+ *
1589
+ * `tps` is output tokens over the model's own request time, so a day whose
1590
+ * `seconds` is not positive is dropped rather than shown as an infinite rate.
1591
+ */
1592
+ function normalizeSpeed(raw) {
1593
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
1594
+ const byDay = Array.isArray(raw.byDay)
1595
+ ? raw.byDay
1596
+ .filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.day === 'string')
1597
+ .map((entry) => ({
1598
+ day: entry.day,
1599
+ outputTokens: numberOrNull(entry.outputTokens) ?? 0,
1600
+ seconds: numberOrNull(entry.seconds) ?? 0,
1601
+ tps: numberOrNull(entry.tps),
1602
+ steps: numberOrNull(entry.steps) ?? 0,
1603
+ }))
1604
+ .filter((entry) => entry.tps !== null && entry.tps >= 0)
1605
+ : []
1606
+ const totalRaw = raw.total !== null && typeof raw.total === 'object' ? raw.total : {}
1607
+ return {
1608
+ byDay,
1609
+ total: {
1610
+ outputTokens: numberOrNull(totalRaw.outputTokens) ?? 0,
1611
+ seconds: numberOrNull(totalRaw.seconds) ?? 0,
1612
+ tps: numberOrNull(totalRaw.tps),
1613
+ steps: numberOrNull(totalRaw.steps) ?? 0,
1614
+ },
1615
+ }
1616
+ }
1617
+
1560
1618
  function normalizeStats(raw) {
1561
1619
  if (raw === null || typeof raw !== 'object') return null
1562
1620
  const version = Number.isFinite(raw.version) ? raw.version : 1
@@ -1684,6 +1742,8 @@ window.__ModuleLoader__.load({
1684
1742
  workspaces,
1685
1743
  // v0.24.0 daily series for the chart mode (may be empty on old Hosts).
1686
1744
  daily,
1745
+ // v1.7.0: output-speed (TPS) series across every harness.
1746
+ speed: normalizeSpeed(raw.speed),
1687
1747
  zcode,
1688
1748
  }
1689
1749
  }
@@ -3874,7 +3934,10 @@ window.__ModuleLoader__.load({
3874
3934
  {
3875
3935
  type: 'button',
3876
3936
  className: 'dsh-tokenlens-chip',
3877
- title: t('chipHint'),
3937
+ // v1.7.0: no native `title` here. Setting both a native title and
3938
+ // the platform Tooltip made the two compete on hover (the native
3939
+ // one stole/released hover as it appeared), which flickered the
3940
+ // surrounding conversation area. The Tooltip carries the same text.
3878
3941
  'aria-label': t('chipHint'),
3879
3942
  'aria-haspopup': 'dialog',
3880
3943
  onClick: () => openDialog(sessionId),
@@ -3909,7 +3972,8 @@ window.__ModuleLoader__.load({
3909
3972
  {
3910
3973
  type: 'button',
3911
3974
  className: 'dsh-tokenlens-chip',
3912
- title: label,
3975
+ // v1.7.0: same as the normal chip — only the platform Tooltip, so
3976
+ // the native and platform tooltips cannot compete on hover.
3913
3977
  'aria-label': label,
3914
3978
  'aria-haspopup': 'dialog',
3915
3979
  ...(needsConfig ? { 'data-needs-config': 'true' } : {}),
@@ -4976,6 +5040,30 @@ window.__ModuleLoader__.load({
4976
5040
  store.set({ showDetails: store.get().showDetails !== true })
4977
5041
  }
4978
5042
 
5043
+ /**
5044
+ * Detach `node` from the slot's ancestor chain by rendering it into <body>
5045
+ * (v1.7.0).
5046
+ *
5047
+ * The `conversation.input.overlay` slot lives inside the conversation area,
5048
+ * and in DSH 0.2.2-rc.1 an ancestor there establishes a containing block, so
5049
+ * the overlay's `position: fixed` resolved against that ancestor and the
5050
+ * dialog could only appear at the bottom of the conversation. Portalling
5051
+ * detaches it so it centres on the viewport. Returns the node unchanged when
5052
+ * react-dom or a DOM is unavailable (the Node test harness), so the rendered
5053
+ * tree keeps its shape there.
5054
+ */
5055
+ function portalize(node) {
5056
+ const canPortal =
5057
+ ReactDOM !== null &&
5058
+ typeof ReactDOM.createPortal === 'function' &&
5059
+ typeof document !== 'undefined' &&
5060
+ document !== null &&
5061
+ document.body !== undefined &&
5062
+ document.body !== null
5063
+ if (!canPortal) return node
5064
+ return ReactDOM.createPortal(node, document.body)
5065
+ }
5066
+
4979
5067
  /**
4980
5068
  * The dialog shell: a fixed overlay with an explicitly sized panel.
4981
5069
  *
@@ -5007,15 +5095,18 @@ window.__ModuleLoader__.load({
5007
5095
  /* closing must never throw */
5008
5096
  }
5009
5097
  }
5010
- return h(
5011
- 'div',
5012
- {
5013
- className: 'dsh-tokenlens-overlay',
5014
- role: 'presentation',
5015
- onMouseDown: (event) => {
5016
- if (event.target === event.currentTarget) close()
5098
+ // v1.7.0: portal to <body> so the overlay centres on the viewport instead
5099
+ // of resolving `position: fixed` against the conversation area.
5100
+ return portalize(
5101
+ h(
5102
+ 'div',
5103
+ {
5104
+ className: 'dsh-tokenlens-overlay',
5105
+ role: 'presentation',
5106
+ onMouseDown: (event) => {
5107
+ if (event.target === event.currentTarget) close()
5108
+ },
5017
5109
  },
5018
- },
5019
5110
  h(
5020
5111
  'div',
5021
5112
  {
@@ -5042,6 +5133,7 @@ window.__ModuleLoader__.load({
5042
5133
  h('div', { className: 'dsh-tokenlens-modal-body' }, children),
5043
5134
  footer === undefined || footer === null ? null : h('div', { className: 'dsh-tokenlens-modal-foot' }, footer),
5044
5135
  ),
5136
+ ),
5045
5137
  )
5046
5138
  }
5047
5139
 
@@ -5959,6 +6051,83 @@ window.__ModuleLoader__.load({
5959
6051
  )
5960
6052
  }
5961
6053
 
6054
+ /**
6055
+ * The output-speed (TPS) trend card (v1.7.0), shown under 明细.
6056
+ *
6057
+ * `tps` is output tokens over the model's OWN request time (`step/start` to
6058
+ * `assistant/message`), so the step's tool calls do not dilute it — but
6059
+ * reasoning tokens are included in "output", which the hint says.
6060
+ */
6061
+ function buildSpeedSection(state, ctx, t) {
6062
+ const stats = ctx.stats
6063
+ const scoped = ctx.providerScoped ? ctx.currentRow?.speed ?? null : stats?.speed ?? null
6064
+ const series = scoped === null || scoped === undefined ? null : scoped
6065
+ const byDay = series === null ? [] : series.byDay
6066
+ const head = h(
6067
+ 'div',
6068
+ { key: 'head', className: 'dsh-tokenlens-section-head' },
6069
+ h(
6070
+ 'span',
6071
+ { className: 'dsh-tokenlens-section-title' },
6072
+ Icon('chart', 14),
6073
+ t('statsSpeedTitle'),
6074
+ ),
6075
+ series !== null && series.total.tps !== null
6076
+ ? h(
6077
+ 'span',
6078
+ { className: 'dsh-tokenlens-hint', 'data-speed-total': String(series.total.tps) },
6079
+ t('statsSpeedTotal', { tps: series.total.tps.toFixed(1) }),
6080
+ )
6081
+ : null,
6082
+ )
6083
+ const body =
6084
+ byDay.length === 0
6085
+ ? h(
6086
+ 'div',
6087
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6088
+ h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, t('statsSpeedEmpty')),
6089
+ )
6090
+ : (() => {
6091
+ const max = Math.max(...byDay.map((entry) => entry.tps ?? 0), 1)
6092
+ const bars = byDay.map((entry, index) => {
6093
+ const tps = entry.tps ?? 0
6094
+ const height = Math.max(2, Math.round((tps / max) * 1000) / 10)
6095
+ return h(
6096
+ 'div',
6097
+ {
6098
+ key: `speed-${entry.day}-${index}`,
6099
+ className: 'dsh-tokenlens-chart-bar-wrap',
6100
+ 'data-speed-day': entry.day,
6101
+ 'data-speed-tps': String(tps),
6102
+ title: t('statsSpeedBarTitle', {
6103
+ date: entry.day,
6104
+ tps: tps.toFixed(1),
6105
+ tokens: formatTokensPrecise(entry.outputTokens),
6106
+ seconds: entry.seconds.toFixed(1),
6107
+ }),
6108
+ },
6109
+ h('div', {
6110
+ className: 'dsh-tokenlens-chart-bar',
6111
+ style: { height: `${height}%` },
6112
+ 'aria-hidden': 'true',
6113
+ }),
6114
+ )
6115
+ })
6116
+ return h(
6117
+ 'div',
6118
+ { key: 'bars', className: 'dsh-tokenlens-chart-plot', 'data-speed-plot': 'true' },
6119
+ h('div', { key: 'row', className: 'dsh-tokenlens-chart-bars' }, bars),
6120
+ )
6121
+ })()
6122
+ return h(
6123
+ 'div',
6124
+ { key: 'speed', className: 'dsh-tokenlens-section', 'data-speed-section': 'true' },
6125
+ head,
6126
+ h('div', { key: 'hint', className: 'dsh-tokenlens-subtitle' }, t('statsSpeedHint')),
6127
+ body,
6128
+ )
6129
+ }
6130
+
5962
6131
  function buildStatsDetails(state, ctx, t) {
5963
6132
  const { stats, hasMonth } = ctx
5964
6133
  if (stats === null) return null
@@ -7422,6 +7591,12 @@ window.__ModuleLoader__.load({
7422
7591
  { key: 'row-details', className: 'dsh-tokenlens-chart-layout-row' },
7423
7592
  buildStatsDetails(state, statsCtx, t),
7424
7593
  ),
7594
+ // v1.7.0: the output-speed trend sits under 明细, as its own full-width row.
7595
+ h(
7596
+ 'div',
7597
+ { key: 'row-speed', className: 'dsh-tokenlens-chart-layout-row' },
7598
+ buildSpeedSection(state, statsCtx, t),
7599
+ ),
7425
7600
  )
7426
7601
  const body = [buildManageButton(state, t), statsArea]
7427
7602
  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.0'
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.0",
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",