@mzzsfy/dsh-usage-dash 0.4.0 → 0.5.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/README.md CHANGED
@@ -9,8 +9,8 @@
9
9
  - 三粒度视图:天(7/30/90 天/自定义日期段)、小时(24 小时/3 天/7 天/15 天)、分钟(3 小时/24 小时/3 天/7 天,10 分钟桶粒度),后两者为滚动窗口;各视图时间范围统一 4 挡,时/分选择上限与数据保留期一致
10
10
  - 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
11
11
  - 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细;仅按天视图展示
12
- - 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线(单独颜色,读数走悬停;仅含有时长数据的槽,整图无时长数据不绘制),悬停显示当前时段命中率、平均生成速度与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
13
- - 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量),列表含命中率与占比,并为每模型显示平均生成速度(tok/s,无时长数据不显示),悬停联动
12
+ - 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
13
+ - 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量),列表含命中率与占比;每模型右侧两行:第一行占比%·token 数,第二行估算费用·TTFT·生成速度(语言中立,点分隔;费用需配置定价规则,TTFT/速度无配对数据不显示),悬停联动
14
14
  - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效
15
15
  - 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
16
16
  - 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
@@ -19,7 +19,7 @@
19
19
  - 双语:跟随宿主语言设置(设置 → 通用 → 语言)即时切换 en/zh
20
20
  - 数据 API 守卫:POST 同源校验 + JSON content-type(与 dsh-usage-panel 同构),局域网远程访问可用
21
21
 
22
- 口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host 本地时区。平均生成速度 = 输出 token ÷ 模型时长,时长为 `step/start` 到该步首个 usage 汇报(与官方 session-stats 投影 llmMs 口径同构,`llm/retry-started` 重置起点,时长只含最终尝试);仅带时长的行参与速度分子分母,升级前存量数据与未观测到起点的样本不显示速度。保留策略:天桶永久,小时桶固定 15 天,分钟桶默认 7 天且上限 7 天(设置项 `minuteRetentionDays`,0 = 禁用分钟桶)。
22
+ 口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host 本地时区。平均生成速度 = decode 配对分子 ÷ 解码时长,两者均取官方吞吐口径(与官方 session-stats 投影同构):解码时长为该步首 token 时刻(首个产出 token 的 attempt 流,回落 message 自带流)到 usage 汇报时刻,不含首 token 前的排队与提示处理等待;首 token 延迟(TTFT)= token 时刻 − `step/start`(起点不随 `llm/retry-started` 重置,即含失败尝试时间)。首 token 时刻在 chunk token 样本上不可得,由后续 usage 报告(message 为主)补发零桶 timing 增量行承载(decodeTokens 与时长同源配对,不重复计 token);存量旧行(时长为旧全时长口径)速度分子回落输出 token,聚合随新数据自然收敛,重建(重扫)可全量按新口径重建。保留策略:天桶永久,小时桶固定 15 天,分钟桶默认 7 天且上限 7 天(设置项 `minuteRetentionDays`,0 = 禁用分钟桶)。
23
23
 
24
24
  写入模型:样本先同步合并进内存 pending,按 2 秒周期批量落盘(单布局存储域每次持久化写都全量重发布 unit 文档,合并把每样本 3 次写降为每脏行 1 次,回扫万级样本写放大降约 99%);查询前自动 flush 保证读己之写;flush 失败的行留 pending 下轮重试并经异常日志可观测,崩溃丢失窗口 = flush 周期,统计可由会话重扫重建。
25
25
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-dash",
3
3
  "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与官方动作行回合费用芯片,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -128,6 +128,7 @@ const MESSAGES_ZH = {
128
128
  hitRateLegend: '缓存命中率',
129
129
  avgSpeed: '平均生成速度',
130
130
  speedLegend: '平均生成速度',
131
+ ttftLegend: '首 token 延迟',
131
132
  topModel: '最常用模型',
132
133
  topModelHint: '按 token 用量排序,非调用次数',
133
134
  heatmap: '活跃热力图',
@@ -267,6 +268,7 @@ const MESSAGES_EN = {
267
268
  hitRateLegend: 'Cache-hit rate',
268
269
  avgSpeed: 'Avg speed',
269
270
  speedLegend: 'Avg speed',
271
+ ttftLegend: 'First-token latency',
270
272
  topModel: 'Top model',
271
273
  topModelHint: 'Ranked by token usage, not call count',
272
274
  heatmap: 'Activity heatmap',
@@ -529,11 +531,14 @@ const BAR_MAX_WIDTH = 30
529
531
  const AXIS_TICK_COUNT = 4
530
532
  // 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
531
533
  const SPEED_SCALE_FLOOR = 1
534
+ // 首 token 延迟刻度上限钳底(毫秒):全零或无数据防除零(不设轴,读数走 tooltip)
535
+ const TTFT_SCALE_FLOOR = 1
532
536
  // 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
533
537
  const AXIS_SCALE_HEADROOM = 1.1
534
538
  // 图例键:折线项与模型项共处同一显隐集合
535
539
  const LEGEND_KEY_RATE = 'rate'
536
540
  const LEGEND_KEY_SPEED = 'speed'
541
+ const LEGEND_KEY_TTFT = 'ttft'
537
542
 
538
543
  function niceTicks(max, count) {
539
544
  if (max <= 0 || count <= 0) return []
@@ -614,6 +619,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
614
619
  const PERCENT_SCALE = 100
615
620
  const RATE_AXIS_STEPS = 4
616
621
  const TREND_LINE_WIDTH = 2
622
+ const TREND_LINE_GAP_SLOTS = 1.5
617
623
  const TREND_DOT_RADIUS = 4
618
624
  const TREND_DOT_RING = 2
619
625
  const AXIS_RATE_GAP = 8
@@ -650,8 +656,22 @@ function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
650
656
  return points
651
657
  }
652
658
 
653
- // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
654
- function smoothPath(points) {
659
+ // token 延迟曲线点:仅带 ttft 槽产出,高度按刻度上限归一(毫秒域独立归一)
660
+ function trendTtftPoints(slots, bars, plotHeight, scaleMax) {
661
+ const points = []
662
+ slots.forEach((slot, index) => {
663
+ if (slot.ttft === undefined) return
664
+ points.push({
665
+ day: slot.day,
666
+ x: bars[index].x,
667
+ y: CHART_PAD.top + plotHeight - (slot.ttft / scaleMax) * plotHeight,
668
+ })
669
+ })
670
+ return points
671
+ }
672
+
673
+ // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返;相邻点间距超 maxGap 折线断开成新段(稀疏槽防长弧跨越)
674
+ function smoothPath(points, maxGap = Number.POSITIVE_INFINITY) {
655
675
  if (points.length === 0) return ''
656
676
  if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
657
677
  let d = `M ${points[0].x} ${points[0].y}`
@@ -660,6 +680,10 @@ function smoothPath(points) {
660
680
  const p1 = points[i]
661
681
  const p2 = points[i + 1]
662
682
  const p3 = points[i + 2] ?? p2
683
+ if (p2.x - p1.x > maxGap) {
684
+ d += ` M ${p2.x} ${p2.y}`
685
+ continue
686
+ }
663
687
  const c1x = p1.x + (p2.x - p0.x) / 6
664
688
  const c1y = p1.y + (p2.y - p0.y) / 6
665
689
  const c2x = p2.x - (p3.x - p1.x) / 6
@@ -719,6 +743,30 @@ function speedScaleMax(slots) {
719
743
  return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
720
744
  }
721
745
 
746
+ // 首 token 延迟刻度上限(毫秒):全零或无数据钳底防除零
747
+ function ttftScaleMax(slots) {
748
+ return Math.max(TTFT_SCALE_FLOOR, ...slots.map((slot) => slot.ttft ?? 0))
749
+ }
750
+
751
+ // tooltip 首 token 延迟行文本:无 ttft 与命中率同款占位符,有则为官方时长口径
752
+ function ttftTipText(ttft, t) {
753
+ return ttft === undefined ? TOOLTIP_MISSING : formatDuration(ttft, t)
754
+ }
755
+
756
+ // 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(模型列表第二行用,禁本地化)
757
+ function formatDurationShort(ms) {
758
+ const seconds = ms / MS_PER_SECOND
759
+ if (seconds < DURATION_MINUTE_SECONDS) return `${Math.round(seconds * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL}s`
760
+ const whole = Math.round(seconds)
761
+ return `${Math.floor(whole / SECONDS_PER_MINUTE)}m${whole % SECONDS_PER_MINUTE}s`
762
+ }
763
+
764
+ // 模型首 token 延迟短文本:语言中立;无 ttft(无配对数据)为空串
765
+ function modelTtftText(ttft) {
766
+ if (ttft === undefined) return ''
767
+ return `TTFT ${formatDurationShort(ttft)}`
768
+ }
769
+
722
770
  // 热力图:窗口固定 26 周,与所选范围无关
723
771
  const HEAT_WEEKS = 26
724
772
  const HEAT_ROW_COUNT = 7
@@ -1264,32 +1312,76 @@ const TURN_COST_CHIP_ORDER = 20 + 10
1264
1312
  // messageId 反查:节点表为 chat 节点仓库(values() 可枚举,Map/仓库两态兼容);
1265
1313
  // 回合级用量优先取回合位置数据(location.turn.data.get('turn-tail')).tokenUsage(官方 tokenUsage 聚合,
1266
1314
  // 分页窗口缺 turn/start 时缺席),回退视图节点 data.closing.usage(末步用量采样,输入侧已含缓存,计费口径同源)
1267
- function turnTokenUsageOfMessage(nodes, messageId) {
1315
+ // 索引缓存:每份节点表快照只全量扫描一次建 messageId→turn-tail 索引(WeakMap 随快照释放),
1316
+ // 长会话流式期间多芯片各自全量扫描是 O(消息数×节点数) 放大,索引后单快照 O(节点数)
1317
+ const TURN_USAGE_INDEX_CACHE = new WeakMap()
1318
+
1319
+ function turnUsageIndexOf(nodes) {
1320
+ let index = TURN_USAGE_INDEX_CACHE.get(nodes)
1321
+ if (index !== undefined) return index
1322
+ index = new Map()
1268
1323
  const list = nodes && typeof nodes.values === 'function' ? [...nodes.values()] : nodes
1269
- if (!Array.isArray(list)) return null
1270
- for (const node of list) {
1271
- try {
1272
- if (node?.kind !== 'turn-tail') continue
1273
- if (node.data?.closing?.finalNode?.messageId !== messageId) continue
1274
- const turnData = node.location?.turn?.data
1275
- const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1276
- if (tail?.tokenUsage) return tail.tokenUsage
1277
- if (node.data.tokenUsage) return node.data.tokenUsage
1278
- const sampled = node.data?.closing?.usage
1279
- if (!sampled) return null
1280
- return {
1281
- uncachedInputTokens: sampled.inputTokens,
1282
- outputTokens: sampled.outputTokens,
1283
- totalTokens: sampled.totalTokens,
1284
- ...sampled.cacheReadTokens === undefined ? {} : { cacheReadTokens: sampled.cacheReadTokens },
1285
- ...sampled.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: sampled.cacheWriteTokens },
1286
- ...sampled.reasoningTokens === undefined ? {} : { reasoningTokens: sampled.reasoningTokens },
1287
- }
1288
- } catch { /* 单节点形状残缺跳过,扫描继续 */ }
1324
+ if (Array.isArray(list)) {
1325
+ for (const node of list) {
1326
+ try {
1327
+ if (node?.kind !== 'turn-tail') continue
1328
+ const messageId = node.data?.closing?.finalNode?.messageId
1329
+ if (typeof messageId !== 'string' || index.has(messageId)) continue
1330
+ index.set(messageId, node)
1331
+ } catch { /* 单节点形状残缺跳过 */ }
1332
+ }
1289
1333
  }
1334
+ TURN_USAGE_INDEX_CACHE.set(nodes, index)
1335
+ return index
1336
+ }
1337
+
1338
+ function turnTokenUsageOfMessage(nodes, messageId) {
1339
+ // 非对象(含 null/undefined)直接 null:WeakMap 键要求对象,展开校验收进索引构建,
1340
+ // 每快照只物化一次节点序列
1341
+ if (nodes === null || typeof nodes !== 'object') return null
1342
+ const node = turnUsageIndexOf(nodes).get(messageId)
1343
+ if (node === undefined) return null
1344
+ try {
1345
+ const turnData = node.location?.turn?.data
1346
+ const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1347
+ if (tail?.tokenUsage) return tail.tokenUsage
1348
+ if (node.data.tokenUsage) return node.data.tokenUsage
1349
+ return usageSourceBuckets(node.data?.closing?.usage ?? null)
1350
+ } catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
1290
1351
  return null
1291
1352
  }
1292
1353
 
1354
+ // messageId 反查用量源:返回仓库内既有引用(官方聚合 tokenUsage → 视图节点 tokenUsage → closing.usage 采样),
1355
+ // 引用恒定直至该回合数据被替换;全缺返回 null。供 useChat selector 使用 ——
1356
+ // selector 必须返回稳定引用(useSyncExternalStore 以 Object.is 比对快照),返回新建对象会造成无限渲染;
1357
+ // 采样形态(字段名差异)由 usageSourceBuckets 在渲染层归一
1358
+ function turnUsageSourceOfMessage(nodes, messageId) {
1359
+ if (nodes === null || typeof nodes !== 'object') return null
1360
+ const node = turnUsageIndexOf(nodes).get(messageId)
1361
+ if (node === undefined) return null
1362
+ try {
1363
+ const turnData = node.location?.turn?.data
1364
+ const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1365
+ return tail?.tokenUsage ?? node.data.tokenUsage ?? node.data?.closing?.usage ?? null
1366
+ } catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
1367
+ return null
1368
+ }
1369
+
1370
+ // 用量源 → 聚合桶形态:closing.usage 采样字段名不同(inputTokens 即 uncached 口径,实测 total-input=output 恒等),
1371
+ // 聚合形态(已带 uncachedInputTokens)原样直通保持引用
1372
+ function usageSourceBuckets(source) {
1373
+ if (!source || source.uncachedInputTokens !== undefined) return source
1374
+ if (source.inputTokens === undefined) return null
1375
+ return {
1376
+ uncachedInputTokens: source.inputTokens,
1377
+ outputTokens: source.outputTokens,
1378
+ totalTokens: source.totalTokens,
1379
+ ...source.cacheReadTokens === undefined ? {} : { cacheReadTokens: source.cacheReadTokens },
1380
+ ...source.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: source.cacheWriteTokens },
1381
+ ...source.reasoningTokens === undefined ? {} : { reasoningTokens: source.reasoningTokens },
1382
+ }
1383
+ }
1384
+
1293
1385
  // 计价模型键:routes 首条按官方 messageRoute 分离字段拼两段键,与采集器 refOf 双实现同源
1294
1386
  // (官方 source.provider/model 是分离字段,routes[].model 是裸模型名,展示侧才拼 provider),
1295
1387
  // 仅 model 用裸名命中 */model 档,双缺回退全通配键;多 route 取首条(单轮估算口径)
@@ -1693,9 +1785,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1693
1785
  const STYLE_CSS = `
1694
1786
  .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1695
1787
  --ud-chart-1:color-mix(in srgb,#0576ff 70%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 70%,white);--ud-chart-3:color-mix(in srgb,#c46212 70%,white);--ud-chart-4:color-mix(in srgb,#975bf1 70%,white);--ud-chart-5:color-mix(in srgb,#d34591 70%,white);--ud-chart-other:color-mix(in srgb,#576270 70%,white);
1696
- --dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff;--ud-trend-speed:#0ca678}
1788
+ --dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff;--ud-trend-speed:#0ca678;--ud-trend-ttft:#e8590c}
1697
1789
  body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 65%,white);--ud-chart-3:color-mix(in srgb,#c46212 65%,white);--ud-chart-4:color-mix(in srgb,#975bf1 65%,white);--ud-chart-5:color-mix(in srgb,#d34591 65%,white);--ud-chart-other:color-mix(in srgb,#576270 65%,white);
1698
- --dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe;--ud-trend-speed:#2fbf8f}
1790
+ --dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe;--ud-trend-speed:#2fbf8f;--ud-trend-ttft:#ffa94d}
1699
1791
  .ud-toolbar{display:flex;align-items:flex-start;gap:8px}
1700
1792
  .ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
1701
1793
  .ud-group{display:flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
@@ -1781,10 +1873,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1781
1873
  .ud-bar-hit{fill:transparent;pointer-events:all}
1782
1874
  .ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
1783
1875
  .ud-trend--speed{stroke:var(--ud-trend-speed)}
1876
+ .ud-trend--ttft{stroke:var(--ud-trend-ttft)}
1784
1877
  .ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
1785
1878
  .ud-trend-dot--speed{fill:var(--ud-trend-speed)}
1879
+ .ud-trend-dot--ttft{fill:var(--ud-trend-ttft)}
1786
1880
  .ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
1787
1881
  .ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
1882
+ .ud-legend-swatch--line--ttft{background:var(--ud-trend-ttft)}
1788
1883
  .ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
1789
1884
  .ud-donut-wrap{flex:0 0 auto}
1790
1885
  .ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
@@ -1935,7 +2030,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1935
2030
  h(FitText, null, String(stats.activeDays))))
1936
2031
  }
1937
2032
 
1938
- function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
2033
+ function Legend({ models, colorFor, speedEnabled = false, ttftEnabled = false, isVisible, onItem, t = defaultT }) {
1939
2034
  const itemProps = (key) => ({
1940
2035
  className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
1941
2036
  onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
@@ -1950,7 +2045,10 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1950
2045
  h('span', null, t('hitRateLegend'))),
1951
2046
  speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
1952
2047
  h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
1953
- h('span', null, t('speedLegend'))) : null)
2048
+ h('span', null, t('speedLegend'))) : null,
2049
+ ttftEnabled ? h('span', { key: 'ttft', title: t('ttftLegend'), ...itemProps(LEGEND_KEY_TTFT) },
2050
+ h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--ttft' }),
2051
+ h('span', null, t('ttftLegend'))) : null)
1954
2052
  }
1955
2053
 
1956
2054
  const colorForModel = (models) => (model) => {
@@ -1980,22 +2078,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1980
2078
  return () => observer.disconnect()
1981
2079
  }, [])
1982
2080
  const hasSpeed = slots.some((slot) => slot.speed !== undefined)
1983
- const visibleSet = visibleKeys ?? new Set([...modelOrder, LEGEND_KEY_RATE, ...(hasSpeed ? [LEGEND_KEY_SPEED] : [])])
2081
+ const hasTtft = slots.some((slot) => slot.ttft !== undefined)
2082
+ const visibleSet = visibleKeys ?? new Set([
2083
+ ...modelOrder, LEGEND_KEY_RATE,
2084
+ ...(hasSpeed ? [LEGEND_KEY_SPEED] : []),
2085
+ ...(hasTtft ? [LEGEND_KEY_TTFT] : []),
2086
+ ])
1984
2087
  const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
1985
2088
  const showRate = visibleSet.has(LEGEND_KEY_RATE)
1986
2089
  const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
2090
+ const showTtft = hasTtft && visibleSet.has(LEGEND_KEY_TTFT)
1987
2091
  const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
1988
2092
  const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
1989
2093
  const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
1990
2094
  const speedMax = speedScaleMax(slots)
1991
2095
  const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
1992
2096
  const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
2097
+ const ttftMax = ttftScaleMax(slots)
2098
+ const ttftAxisMax = ttftMax * AXIS_SCALE_HEADROOM
2099
+ const ttftPoints = showTtft ? trendTtftPoints(slots, layout.bars, layout.plotHeight, ttftAxisMax) : []
1993
2100
  // 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
2101
+ // (ttft 不设轴,读数走 tooltip,毫秒域与速度轴不同单位不混轴)
1994
2102
  const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
1995
2103
  const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
1996
2104
  const hoverSlot = hover ? slots[hover.index] : null
1997
2105
  const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1998
2106
  const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
2107
+ const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
1999
2108
  const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
2000
2109
  const clear = () => setHover(null)
2001
2110
  const otherEntries = hoverSlot
@@ -2038,11 +2147,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2038
2147
  ? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
2039
2148
  : null),
2040
2149
  showRate ? h('path', {
2041
- className: 'ud-trend', d: smoothPath(ratePoints),
2150
+ className: 'ud-trend', d: smoothPath(ratePoints, layout.step * TREND_LINE_GAP_SLOTS),
2042
2151
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2043
2152
  }) : null,
2044
2153
  showSpeed ? h('path', {
2045
- className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
2154
+ className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints, layout.step * TREND_LINE_GAP_SLOTS),
2155
+ strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2156
+ }) : null,
2157
+ showTtft ? h('path', {
2158
+ className: cx('ud-trend', 'ud-trend--ttft'), d: smoothPath(ttftPoints, layout.step * TREND_LINE_GAP_SLOTS),
2046
2159
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2047
2160
  }) : null,
2048
2161
  hoverRatePoint
@@ -2051,13 +2164,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2051
2164
  hoverSpeedPoint
2052
2165
  ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
2053
2166
  : null,
2167
+ hoverTtftPoint
2168
+ ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--ttft'), cx: hoverTtftPoint.x, cy: hoverTtftPoint.y, r: TREND_DOT_RADIUS })
2169
+ : null,
2054
2170
  slots.map((slot, index) => h('rect', {
2055
2171
  key: `hit-${slot.day}`, className: 'ud-bar-hit',
2056
2172
  x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
2057
2173
  onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
2058
2174
  })))),
2059
2175
  h(Legend, {
2060
- models: legendModels, colorFor, speedEnabled: hasSpeed,
2176
+ models: legendModels, colorFor, speedEnabled: hasSpeed, ttftEnabled: hasTtft,
2061
2177
  isVisible: (key) => visibleSet.has(key),
2062
2178
  onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
2063
2179
  }),
@@ -2073,6 +2189,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2073
2189
  `${model}: ${formatTokens(tokens)}`)) : []),
2074
2190
  showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
2075
2191
  showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
2192
+ showTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
2076
2193
  costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
2077
2194
  ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
2078
2195
  : null,
@@ -2233,6 +2350,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2233
2350
  models.map((item) => {
2234
2351
  const isOther = item.model === OTHER_MODEL
2235
2352
  const speedText = modelSpeedText(item.speed)
2353
+ const ttftText = modelTtftText(item.ttft)
2354
+ const metaParts = [
2355
+ item.cost !== undefined ? `≈ ${formatCost(item.cost, costCurrency)}` : null,
2356
+ ttftText,
2357
+ speedText,
2358
+ ].filter(Boolean)
2236
2359
  return h(React.Fragment, { key: item.model },
2237
2360
  h('div', {
2238
2361
  className: cx('ud-model-row', isOther && 'ud-model-row--expand'),
@@ -2254,11 +2377,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2254
2377
  }, '›')
2255
2378
  : null,
2256
2379
  h('div', { className: 'ud-model-values' },
2257
- h('span', { className: 'ud-model-tokens' }, formatTokens(item.tokens)),
2258
- h('span', { className: 'ud-model-pct' },
2259
- item.cost !== undefined ? h('span', { className: 'ud-model-cost' }, `≈ ${formatCost(item.cost, costCurrency)} · `) : null,
2260
- formatPercent((item.tokens / total) * PERCENT_SCALE),
2261
- speedText ? ` · ${speedText}` : null))),
2380
+ h('span', { className: 'ud-model-tokens' },
2381
+ `${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`),
2382
+ h('span', { className: 'ud-model-pct' }, metaParts.join(' · ')))),
2262
2383
  isOther
2263
2384
  ? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
2264
2385
  h('div', { className: 'ud-model-other-list' },
@@ -2350,9 +2471,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2350
2471
  // 注入点B 组件:回合费用芯片,官方动作行内渲染(复制与分支图标之间,赞踩/上下文跳转同排);
2351
2472
  // 受费用显示开关;价格异步首帧未回不渲染,回包后补渲染;显隐节奏随官方 data-actions-reveal
2352
2473
  // 官方槽容器固定在用量/用时芯片之前,末位排布由 SlotTailPortal 移交实现
2474
+ // 响应性:useChat selector 返回用量源稳定引用(null→引用 / 引用→引用),回合结束数据发布即触发重渲染;
2475
+ // 返回仓库本体则引用恒定,快照比对恒等,新回合永不重渲染(刷新才出现的根因)
2353
2476
  const CostChip = React.memo(function CostChip({ messageId, useChat, t = defaultT }) {
2354
2477
  if (typeof useChat !== 'function') return null
2355
- const chatNodes = useChat((state) => (state && typeof state === 'object') ? state.nodes : undefined)
2478
+ const usageSource = typeof messageId === 'string' && messageId !== ''
2479
+ ? useChat((state) => turnUsageSourceOfMessage(state?.nodes, messageId))
2480
+ : null
2356
2481
  const [prefs, setPrefs] = useState(() => statsLineState.get())
2357
2482
  useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
2358
2483
  const [pricingRules, setPricingRules] = useState(null)
@@ -2363,9 +2488,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2363
2488
  })
2364
2489
  return () => { alive = false }
2365
2490
  }, [])
2366
- const matched = typeof messageId === 'string' && messageId !== ''
2367
- ? turnTokenUsageOfMessage(chatNodes, messageId)
2368
- : null
2491
+ const matched = usageSourceBuckets(usageSource)
2369
2492
  const price = matched && prefs.costDisplay && pricingRules !== null
2370
2493
  ? matchPrice(pricingRules, turnModelOf(matched), new Date())
2371
2494
  : null
package/src/collector.js CHANGED
@@ -35,13 +35,57 @@ function emptySkipCounts() {
35
35
  return { [SKIP_KINDS.descriptor]: 0, [SKIP_KINDS.corrupt]: 0, [SKIP_KINDS.legacy]: 0, [SKIP_KINDS.other]: 0 }
36
36
  }
37
37
 
38
+ // 官方 dsh-llm 助手流读取器同构镜像(isTokenDelta/runFirstTokenTime/
39
+ // assistantStreamFirstTokenTime):从紧凑记录还原首个产出 token 的时刻。
40
+ // 语义由 test/stream-parity.test.mjs 锁定,改一侧必须同步 parity
41
+ function isTokenDelta(chunk) {
42
+ if (chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') return chunk.text !== ''
43
+ return chunk?.type === 'tool-call-delta' && (chunk.argumentsDelta !== '' || chunk.name !== undefined)
44
+ }
45
+
46
+ function runFirstTokenTime(run) {
47
+ if (run.type === 'tool-call-chunks' && run.name !== undefined) return run.time0
48
+ const fragments = run.type === 'tool-call-chunks' ? run.args : run.texts
49
+ let time = run.time0
50
+ for (let index = 0; index < fragments.length; index += 1) {
51
+ if (index > 0) {
52
+ const gap = run.dt[index - 1]
53
+ // 差分短缺属存储形态损坏,按无首 token 处理,保留后续报告锁定机会
54
+ if (typeof gap !== 'number') return undefined
55
+ time += gap
56
+ }
57
+ if (fragments[index] !== '') return time
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ // 已知 packed run 形态白名单:未知记录形态(宿主未来新字段)安全跳过,
63
+ // 采集是观测性的,绝不因流记录形态漂移而崩溃
64
+ const RUN_RECORD_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
65
+
66
+ export function assistantStreamFirstTokenTime(stream) {
67
+ if (!Array.isArray(stream)) return undefined
68
+ for (const record of stream) {
69
+ const time = record?.type === 'chunk'
70
+ ? (isTokenDelta(record.chunk) ? record.time : undefined)
71
+ : RUN_RECORD_TYPES.has(record?.type) ? runFirstTokenTime(record) : undefined
72
+ if (time !== undefined) return time
73
+ }
74
+ return undefined
75
+ }
76
+
38
77
  // 会话内单 pass 折叠:跟踪每个 (turn,step) 槽的最新报告,只把首次发射交 store
39
78
  export class UsageFold {
40
79
  constructor() {
41
80
  this.seen = new Map()
42
- // (turn,step) 最近一次模型启动时刻:retry-started 覆盖重置,时长与首样本
43
- // token 同源配对(分子只含最终尝试,分母不含失败尝试,避免速度被污染)
81
+ // (turn,step) 模型启动时刻:仅 step/start 设定(官方口径 TTFT 含失败尝试,
82
+ // 不随 retry-started 重置);(turn,step) 首 token 时刻:由首个产出 token
83
+ // 的 attempt 锁定,存活于步内重试。时长均为官方 decode 口径:durationMs
84
+ // = 汇报 - 首 token(吞吐分母),ttftMs = 首 token - 启动
44
85
  this.starts = new Map()
86
+ this.firstTokens = new Map()
87
+ // 已补发 timing 的键:token 先发后只补一次,重复报告不重复配对
88
+ this.timingDone = new Set()
45
89
  }
46
90
 
47
91
  keyOf(event) {
@@ -64,9 +108,9 @@ export class UsageFold {
64
108
  }
65
109
  if (event.type === 'step/start' || event.type === 'llm/retry-started') {
66
110
  // step/start 恰开一次模型调用,retry-started 标记每次实际启动的重试;
67
- // 请求只由标记计数,与 token 样本双计
111
+ // 请求只由标记计数,与 token 样本双计;时长起点不随重试重置
68
112
  const key = this.keyOf(event)
69
- if (key !== null) this.starts.set(key, event.time)
113
+ if (event.type === 'step/start' && key !== null) this.starts.set(key, event.time)
70
114
  return {
71
115
  time: event.time,
72
116
  inputTokens: 0,
@@ -76,6 +120,15 @@ export class UsageFold {
76
120
  request: true,
77
121
  }
78
122
  }
123
+ if (event.type === 'assistant/attempt') {
124
+ // 首 token 锁定:仅首个产出 token 的 attempt 生效,后续 attempt 不覆盖
125
+ const key = this.keyOf(event)
126
+ if (key !== null && !this.firstTokens.has(key)) {
127
+ const first = assistantStreamFirstTokenTime(event.data?.stream)
128
+ if (typeof first === 'number') this.firstTokens.set(key, first)
129
+ }
130
+ return null
131
+ }
79
132
  if (event.type === 'assistant/chunk') {
80
133
  const usage = event.data?.chunk?.type === 'usage' ? event.data.chunk.usage : undefined
81
134
  return usage ? this.replaceSample(event, usage) : null
@@ -87,11 +140,39 @@ export class UsageFold {
87
140
  }
88
141
 
89
142
  replaceSample(event, usage) {
90
- // 四桶全零为噪声;纯缓存调用(仅缓存桶非零)仍有效
143
+ const key = this.keyOf(event)
144
+ const first = key !== null
145
+ ? this.firstTokens.get(key) ?? assistantStreamFirstTokenTime(event.data?.stream)
146
+ : assistantStreamFirstTokenTime(event.data?.stream)
147
+ // decode 配对有效性对齐官方 usageOutputTokens 守卫:输出 token 非有效数值
148
+ // 不建配对(官方同款),首字延迟不受 usage 影响照常采集;
149
+ // 负差值按官方 Math.max(0) 钳 0(时钟回拨计 0 延迟样本),0 时长不附配对
150
+ // (存储侧 0 时长配对天然惰性,防 0 分母放大)
151
+ const decodeable = typeof usage.outputTokens === 'number'
152
+ && Number.isFinite(usage.outputTokens) && usage.outputTokens >= 0
153
+ const timing = typeof first === 'number'
154
+ ? {
155
+ durationMs: Math.max(0, event.time - first),
156
+ ttftMs: key !== null && this.starts.has(key) ? Math.max(0, first - this.starts.get(key)) : undefined,
157
+ decodeable,
158
+ }
159
+ : undefined
160
+ const prev = key !== null ? this.seen.get(key) : undefined
161
+ // 首样本生效:内部无条件跟踪最新报告,但交 store 的只有首次发射;
162
+ // 首 token 时刻不在 chunk 事件上,token 先发后由后续报告补纯 timing 增量,
163
+ // 已补发的键不重复补(timingDone 独立集合持久跟踪,防重复报告双计);
164
+ // 补发判定先于四桶和门:usage 全零的报告仍补 timing(token 已由首发承载)
165
+ if (prev) {
166
+ if (timing === undefined || this.timingDone.has(key)) return null
167
+ this.timingDone.add(key)
168
+ return this.emitTimingOnly(usage, timing, event.time)
169
+ }
170
+ // 四桶全零为噪声(不占去重键),纯缓存调用(仅缓存桶非零)仍有效。
171
+ // retry 场景 attempt 先于 chunk 落流时,chunk 首发即带 timing,分母以
172
+ // chunk 时刻近似官方汇报时刻(毫秒级组装间隔),主路径仍由 message 补发
91
173
  const sum = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
92
174
  + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
93
175
  if (sum <= 0) return null
94
- const key = this.keyOf(event)
95
176
  const sample = {
96
177
  time: event.time,
97
178
  inputTokens: usage.inputTokens ?? 0,
@@ -99,18 +180,37 @@ export class UsageFold {
99
180
  cacheReadTokens: usage.cacheReadTokens ?? 0,
100
181
  cacheWriteTokens: usage.cacheWriteTokens ?? 0,
101
182
  }
102
- // 模型时长口径与官方 session-stats 投影 llmMs 同构(step/start → 汇报时刻),
103
- // 附加到交 store 的首样本;未观测起点或时刻倒挂(时钟回拨)不附,0 视为无
104
- const start = key !== null ? this.starts.get(key) : undefined
105
- if (start !== undefined) {
106
- const durationMs = Math.max(0, event.time - start)
107
- if (durationMs > 0) sample.durationMs = durationMs
183
+ if (key !== null) this.seen.set(key, sample)
184
+ return this.emitSample(sample, timing, key)
185
+ }
186
+
187
+ emitSample(sample, timing, key) {
188
+ if (timing === undefined) return { ...sample }
189
+ // decode 口径聚合对:分子 decodeTokens 与分母 durationMs 同源配对
190
+ if (timing.decodeable && timing.durationMs > 0) {
191
+ sample.decodeTokens = sample.outputTokens
192
+ sample.durationMs = timing.durationMs
193
+ }
194
+ if (timing.ttftMs !== undefined) sample.ttftMs = timing.ttftMs
195
+ if (key !== undefined && key !== null) this.timingDone.add(key)
196
+ return { ...sample }
197
+ }
198
+
199
+ emitTimingOnly(usage, timing, time) {
200
+ // 纯 timing 增量:token 桶全零不重复计数,decodeTokens 单独承载速度分子
201
+ const delta = {
202
+ time,
203
+ inputTokens: 0,
204
+ outputTokens: 0,
205
+ cacheReadTokens: 0,
206
+ cacheWriteTokens: 0,
207
+ }
208
+ if (timing.decodeable && timing.durationMs > 0) {
209
+ delta.decodeTokens = usage.outputTokens
210
+ delta.durationMs = timing.durationMs
108
211
  }
109
- if (key === null) return sample
110
- const prev = this.seen.get(key)
111
- this.seen.set(key, sample)
112
- // 首样本生效:内部无条件跟踪最新报告,但交 store 的只有首次发射的独立拷贝
113
- return prev ? null : { ...sample }
212
+ if (timing.ttftMs !== undefined) delta.ttftMs = timing.ttftMs
213
+ return delta
114
214
  }
115
215
  }
116
216