@mzzsfy/dsh-usage-dash 0.2.0 → 0.3.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,9 +9,9 @@
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 明细(配置定价规则后附当日估算费用行,受「费用显示」开关)
12
+ - 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线(单独颜色,读数走悬停;仅含有时长数据的槽,整图无时长数据不绘制),悬停显示当前时段命中率、平均生成速度与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
13
13
  - 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量),列表含命中率与占比,并为每模型显示平均生成速度(tok/s,无时长数据不显示),悬停联动
14
- - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示)
14
+ - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效
15
15
  - 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
16
16
  - 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项与趋势 tooltip 费用行);三开关全关时与官方逐字节一致
17
17
  - 会话尾部单轮行:每轮对话结束后动作行上方显示该轮 token 摘要与估算费用(最新一轮常显,历史轮悬停显现,触屏恒显;有产出文件的轮次自动让位产物行)
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.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -13,7 +13,7 @@ const MINUTE_PRESET_MINUTES = { '3h': 3 * 60, '24h': 24 * 60, '3d': 3 * 24 * 60,
13
13
 
14
14
  const DEFAULT_RANGE = '30'
15
15
  const DEFAULT_HOUR_PRESET = '24h'
16
- const DEFAULT_MINUTE_PRESET = '60m'
16
+ const DEFAULT_MINUTE_PRESET = '24h'
17
17
 
18
18
  // 天视图渲染上限;时/分上限 = 闭区间桶数(hour N+1 槽,minute N/10+1 槽)
19
19
  const DAY_MAX_SLOTS = 180
@@ -126,6 +126,8 @@ const MESSAGES_ZH = {
126
126
  cacheRateHint: '时间段内缓存命中 token 占输入 token 的比例',
127
127
  cacheHitRate: '缓存命中率',
128
128
  hitRateLegend: '缓存命中率',
129
+ avgSpeed: '平均生成速度',
130
+ speedLegend: '平均生成速度',
129
131
  topModel: '最常用模型',
130
132
  topModelHint: '按 token 用量排序,非调用次数',
131
133
  heatmap: '活跃热力图',
@@ -251,6 +253,8 @@ const MESSAGES_EN = {
251
253
  cacheRateHint: 'Cache-hit tokens as a share of input tokens within the range',
252
254
  cacheHitRate: 'Cache-hit rate',
253
255
  hitRateLegend: 'Cache-hit rate',
256
+ avgSpeed: 'Avg speed',
257
+ speedLegend: 'Avg speed',
254
258
  topModel: 'Top model',
255
259
  topModelHint: 'Ranked by token usage, not call count',
256
260
  heatmap: 'Activity heatmap',
@@ -383,13 +387,15 @@ function formatCompact(value) {
383
387
  function formatPercent(value) {
384
388
  return (Math.round(value * 10) / 10).toFixed(1) + '%'
385
389
  }
390
+ // tooltip 数值缺席占位
391
+ const TOOLTIP_MISSING = '—'
386
392
  function cacheRate(hit, miss) {
387
393
  const total = hit + miss
388
394
  return total <= 0 ? null : (hit / total) * 100
389
395
  }
390
396
  function cacheRateText(hit, miss) {
391
397
  const rate = cacheRate(hit, miss)
392
- return rate === null ? '—' : formatPercent(rate)
398
+ return rate === null ? TOOLTIP_MISSING : formatPercent(rate)
393
399
  }
394
400
 
395
401
  // 模型键展示分段与定价/存储口径同源:首个 / 前 vendor 段,余为模型段
@@ -417,6 +423,15 @@ function minuteTickLabel(key) {
417
423
  return time === MIDNIGHT_TIME ? `${shortDay(key.slice(0, DAY_KEY_LENGTH))} ${time}` : time
418
424
  }
419
425
 
426
+ // 悬浮窗槽标签:完整日期去 T 分隔,小时以 h 后缀标定(避免 i18n 单位问题),分钟保留 HH:MM
427
+ function hourSlotLabel(key) {
428
+ return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}h`
429
+ }
430
+
431
+ function minuteSlotLabel(key) {
432
+ return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}`
433
+ }
434
+
420
435
  function isEmptyRange(value) {
421
436
  return value.tokens === 0 && value.cacheHit === 0 && value.requests === 0 && value.turns === 0
422
437
  }
@@ -488,6 +503,13 @@ const BAR_WIDTH_RATIO = 0.62
488
503
  const BAR_MIN_WIDTH = 3
489
504
  const BAR_MAX_WIDTH = 30
490
505
  const AXIS_TICK_COUNT = 4
506
+ // 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
507
+ const SPEED_SCALE_FLOOR = 1
508
+ // 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
509
+ const AXIS_SCALE_HEADROOM = 1.1
510
+ // 图例键:折线项与模型项共处同一显隐集合
511
+ const LEGEND_KEY_RATE = 'rate'
512
+ const LEGEND_KEY_SPEED = 'speed'
491
513
 
492
514
  function niceTicks(max, count) {
493
515
  if (max <= 0 || count <= 0) return []
@@ -500,14 +522,43 @@ function niceTicks(max, count) {
500
522
  return ticks
501
523
  }
502
524
 
503
- // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度
525
+ // 图例显隐切换:current 为 null 表示全部可见;普通点击单选/再点恢复,
526
+ // ctrl 单项切换,隐藏最后一项为无操作(返回原引用)
527
+ function legendToggle(current, key, ctrl) {
528
+ if (ctrl) {
529
+ const next = new Set(current ?? [])
530
+ if (next.has(key)) next.delete(key)
531
+ else next.add(key)
532
+ return next.size === 0 ? current : next
533
+ }
534
+ const solo = current !== null && current.size === 1 && current.has(key)
535
+ return solo ? null : new Set([key])
536
+ }
537
+
538
+ // 左轴刻度:速度刻度存在时左轴标定速度(tok/s),否则标定 token;
539
+ // 刻度值统一换算为绘图区高度占比
540
+ function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax) {
541
+ const useSpeed = speedTicks.length > 0
542
+ return (useSpeed ? speedTicks : tokenTicks).map((tick) => ({
543
+ label: formatCompact(tick),
544
+ ratio: tick / (useSpeed ? speedMax : tokenMax),
545
+ }))
546
+ }
547
+
548
+ // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度;
549
+ // maxTotal 按可见模型求和,单选模型时刻度跟随归一
504
550
  function trendLayout(slots, modelOrder, avail, labelMinPitch) {
505
551
  const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
506
552
  const innerWidth = Math.max(1, avail - CHART_PAD.left - CHART_PAD.right)
507
553
  const count = slots.length
508
554
  const step = count > 1 ? innerWidth / (count - 1) : innerWidth
509
555
  const barWidth = Math.max(BAR_MIN_WIDTH, Math.min(BAR_MAX_WIDTH, step * BAR_WIDTH_RATIO))
510
- const maxTotal = Math.max(1, ...slots.map((slot) => slot.total))
556
+ const slotVisibleTotal = (slot) => modelOrder.reduce((sum, model) => sum + (slot.byModel[model] ?? 0), 0)
557
+ const maxTotal = Math.max(1, ...slots.map(slotVisibleTotal))
558
+ // 轴上限 = 数据峰 × 留白系数,柱高分母与左轴刻度分母同源
559
+ const scaleMax = maxTotal * AXIS_SCALE_HEADROOM
560
+ // 可见模型无任何数据时左轴无标定对象,空刻度防钳底值漏成假刻度
561
+ const visibleTotal = slots.reduce((sum, slot) => sum + slotVisibleTotal(slot), 0)
511
562
  const bars = slots.map((slot, index) => {
512
563
  const centerX = CHART_PAD.left + barWidth / 2 + index * step
513
564
  let yBottom = CHART_PAD.top + plotHeight
@@ -515,7 +566,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
515
566
  for (const model of modelOrder) {
516
567
  const tokens = slot.byModel[model] ?? 0
517
568
  if (tokens === 0) continue
518
- const height = (tokens / maxTotal) * plotHeight
569
+ const height = (tokens / scaleMax) * plotHeight
519
570
  yBottom -= height
520
571
  segments.push({ model, y: yBottom, height })
521
572
  }
@@ -528,7 +579,8 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
528
579
  step,
529
580
  barWidth,
530
581
  maxTotal,
531
- ticks: niceTicks(maxTotal, AXIS_TICK_COUNT),
582
+ scaleMax,
583
+ ticks: visibleTotal === 0 ? [] : niceTicks(maxTotal, AXIS_TICK_COUNT),
532
584
  labelEvery: Math.max(1, Math.ceil(labelMinPitch / step)),
533
585
  bars,
534
586
  }
@@ -560,6 +612,20 @@ function trendRatePoints(slots, bars, plotHeight) {
560
612
  return points
561
613
  }
562
614
 
615
+ // 速度曲线点:仅带速度槽产出,高度按刻度上限归一
616
+ function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
617
+ const points = []
618
+ slots.forEach((slot, index) => {
619
+ if (slot.speed === undefined) return
620
+ points.push({
621
+ day: slot.day,
622
+ x: bars[index].x,
623
+ y: CHART_PAD.top + plotHeight - (slot.speed / scaleMax) * plotHeight,
624
+ })
625
+ })
626
+ return points
627
+ }
628
+
563
629
  // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
564
630
  function smoothPath(points) {
565
631
  if (points.length === 0) return ''
@@ -619,6 +685,16 @@ function modelSpeedText(speed) {
619
685
  return `${formatTokensPerSecond(speed)} tok/s`
620
686
  }
621
687
 
688
+ // tooltip 速度行文本:无速度与命中率同款占位符
689
+ function speedTipText(speed) {
690
+ return speed === undefined ? TOOLTIP_MISSING : modelSpeedText(speed)
691
+ }
692
+
693
+ // 速度刻度上限:全零或无速度钳底,防除零
694
+ function speedScaleMax(slots) {
695
+ return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
696
+ }
697
+
622
698
  // 热力图:窗口固定 26 周,与所选范围无关
623
699
  const HEAT_WEEKS = 26
624
700
  const HEAT_ROW_COUNT = 7
@@ -1473,9 +1549,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1473
1549
  const STYLE_CSS = `
1474
1550
  .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1475
1551
  --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);
1476
- --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}
1552
+ --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}
1477
1553
  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);
1478
- --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}
1554
+ --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}
1479
1555
  .ud-toolbar{display:flex;align-items:flex-start;gap:8px}
1480
1556
  .ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
1481
1557
  .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)}
@@ -1535,7 +1611,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1535
1611
  .ud-grid{stroke:var(--dsw-alias-border-l1);stroke-width:1}
1536
1612
  .ud-axis{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
1537
1613
  .ud-legend{display:flex;flex-wrap:wrap;gap:4px 12px}
1538
- .ud-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0}
1614
+ .ud-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0;user-select:none;cursor:pointer}
1615
+ .ud-legend-item--off{opacity:.35}
1539
1616
  .ud-legend-item span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1540
1617
  .ud-legend-swatch{width:8px;height:8px;border-radius:2px;flex:none}
1541
1618
  .ud-heat-wrap{width:100%;min-width:0;overflow:hidden}
@@ -1558,8 +1635,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1558
1635
  .ud-bar{transform-box:fill-box;transform-origin:center}
1559
1636
  .ud-bar-hit{fill:transparent;pointer-events:all}
1560
1637
  .ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
1638
+ .ud-trend--speed{stroke:var(--ud-trend-speed)}
1561
1639
  .ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
1640
+ .ud-trend-dot--speed{fill:var(--ud-trend-speed)}
1562
1641
  .ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
1642
+ .ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
1563
1643
  .ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
1564
1644
  .ud-donut-wrap{flex:0 0 auto}
1565
1645
  .ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
@@ -1701,14 +1781,22 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1701
1781
  h(FitText, null, String(stats.activeDays))))
1702
1782
  }
1703
1783
 
1704
- function Legend({ models, colorFor, t = defaultT }) {
1784
+ function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
1785
+ const itemProps = (key) => ({
1786
+ className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
1787
+ onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
1788
+ role: onItem ? 'button' : undefined,
1789
+ })
1705
1790
  return h('div', { className: 'ud-legend' },
1706
- models.map((item) => h('span', { key: item.model, className: 'ud-legend-item', title: item.model === OTHER_MODEL ? t('other') : item.model },
1791
+ models.map((item) => h('span', { key: item.model, title: item.model === OTHER_MODEL ? t('other') : item.model, ...itemProps(item.model) },
1707
1792
  h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1708
1793
  h('span', null, item.model === OTHER_MODEL ? t('other') : item.model))),
1709
- h('span', { key: 'hit-rate', className: 'ud-legend-item', title: t('hitRateLegend') },
1794
+ h('span', { key: 'hit-rate', title: t('hitRateLegend'), ...itemProps(LEGEND_KEY_RATE) },
1710
1795
  h('i', { className: 'ud-legend-swatch ud-legend-swatch--line' }),
1711
- h('span', null, t('hitRateLegend'))))
1796
+ h('span', null, t('hitRateLegend'))),
1797
+ speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
1798
+ h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
1799
+ h('span', null, t('speedLegend'))) : null)
1712
1800
  }
1713
1801
 
1714
1802
  const colorForModel = (models) => (model) => {
@@ -1718,10 +1806,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1718
1806
  return `var(--ud-chart-${rank})`
1719
1807
  }
1720
1808
 
1721
- function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
1809
+ function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
1722
1810
  const wrapRef = useRef(null)
1723
1811
  const [avail, setAvail] = useState(CHART_NOMINAL_WIDTH)
1724
1812
  const [hover, setHover] = useState(null)
1813
+ // 图例显隐:null = 全部可见;键集 = 模型项与折线项(rate/speed)
1814
+ const [visibleKeys, setVisibleKeys] = useState(null)
1725
1815
  // prefs 仅用于 hover tooltip 的费用显隐,经 ref 读取:开关切换不触发本组件重渲染,
1726
1816
  // 避免宿主设置弹窗滚动锚定被重渲染扰动而跳变
1727
1817
  const prefsRef = useRef(statsLineState.get())
@@ -1735,11 +1825,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1735
1825
  observer.observe(element)
1736
1826
  return () => observer.disconnect()
1737
1827
  }, [])
1738
- const layout = trendLayout(slots, modelOrder, avail, labelMinPitch)
1828
+ const hasSpeed = slots.some((slot) => slot.speed !== undefined)
1829
+ const visibleSet = visibleKeys ?? new Set([...modelOrder, LEGEND_KEY_RATE, ...(hasSpeed ? [LEGEND_KEY_SPEED] : [])])
1830
+ const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
1831
+ const showRate = visibleSet.has(LEGEND_KEY_RATE)
1832
+ const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
1833
+ const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
1739
1834
  const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
1740
1835
  const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
1836
+ const speedMax = speedScaleMax(slots)
1837
+ const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
1838
+ const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
1839
+ // 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
1840
+ const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
1841
+ const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
1741
1842
  const hoverSlot = hover ? slots[hover.index] : null
1742
- const hoverRatePoint = hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1843
+ const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1844
+ const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
1743
1845
  const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
1744
1846
  const clear = () => setHover(null)
1745
1847
  const otherEntries = hoverSlot
@@ -1754,13 +1856,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1754
1856
  className: 'ud-chart', viewBox: `0 0 ${avail} ${CHART_HEIGHT}`, width: '100%', role: 'img',
1755
1857
  'aria-label': title, onMouseLeave: clear,
1756
1858
  },
1757
- layout.ticks.map((tick) => {
1758
- const y = CHART_PAD.top + layout.plotHeight - (tick / layout.maxTotal) * layout.plotHeight
1759
- return h('g', { key: tick },
1859
+ yTicks.map((tick) => {
1860
+ const y = CHART_PAD.top + layout.plotHeight - tick.ratio * layout.plotHeight
1861
+ return h('g', { key: tick.label },
1760
1862
  h('line', { className: 'ud-grid', x1: CHART_PAD.left, x2: plotRight, y1: y, y2: y }),
1761
- h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, formatCompact(tick)))
1863
+ h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, tick.label))
1762
1864
  }),
1763
- rateAxisTicks().map((tick) => {
1865
+ speedAxisTicks.length > 0
1866
+ ? h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: CHART_PAD.top + AXIS_LABEL_BASELINE, textAnchor: 'end' }, 'tok/s')
1867
+ : null,
1868
+ (showRate ? rateAxisTicks() : []).map((tick) => {
1764
1869
  const y = CHART_PAD.top + layout.plotHeight - (tick / PERCENT_SCALE) * layout.plotHeight
1765
1870
  return h('text', {
1766
1871
  key: `rate-${tick}`, className: 'ud-axis-rate',
@@ -1778,30 +1883,42 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1778
1883
  slots.map((slot, index) => (index % layout.labelEvery === 0 || index === slots.length - 1)
1779
1884
  ? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
1780
1885
  : null),
1781
- h('path', {
1886
+ showRate ? h('path', {
1782
1887
  className: 'ud-trend', d: smoothPath(ratePoints),
1783
1888
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1784
- }),
1889
+ }) : null,
1890
+ showSpeed ? h('path', {
1891
+ className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
1892
+ strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1893
+ }) : null,
1785
1894
  hoverRatePoint
1786
1895
  ? h('circle', { className: 'ud-trend-dot', cx: hoverRatePoint.x, cy: hoverRatePoint.y, r: TREND_DOT_RADIUS })
1787
1896
  : null,
1897
+ hoverSpeedPoint
1898
+ ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
1899
+ : null,
1788
1900
  slots.map((slot, index) => h('rect', {
1789
1901
  key: `hit-${slot.day}`, className: 'ud-bar-hit',
1790
1902
  x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
1791
1903
  onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
1792
1904
  })))),
1793
- h(Legend, { models: legendModels, colorFor }),
1905
+ h(Legend, {
1906
+ models: legendModels, colorFor, speedEnabled: hasSpeed,
1907
+ isVisible: (key) => visibleSet.has(key),
1908
+ onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
1909
+ }),
1794
1910
  h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
1795
1911
  hoverSlot
1796
1912
  ? [
1797
- h('div', { key: 'title', className: 'ud-tip-title' }, hoverSlot.day),
1913
+ h('div', { key: 'title', className: 'ud-tip-title' }, slotLabelFor ? slotLabelFor(hoverSlot.day) : hoverSlot.day),
1798
1914
  h('div', { key: 'total', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(hoverSlot.total)}`),
1799
- ...legendModels.map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
1915
+ ...legendModels.filter((item) => visibleSet.has(item.model)).map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
1800
1916
  h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1801
1917
  `${item.model === OTHER_MODEL ? t('other') : item.model}: ${formatTokens(hoverSlot.byModel[item.model] ?? 0)}`)),
1802
- ...otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
1803
- `${model}: ${formatTokens(tokens)}`)),
1804
- h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`),
1918
+ ...(visibleSet.has(OTHER_MODEL) ? otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
1919
+ `${model}: ${formatTokens(tokens)}`)) : []),
1920
+ showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
1921
+ showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
1805
1922
  costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
1806
1923
  ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
1807
1924
  : null,
@@ -2413,6 +2530,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2413
2530
  : id === 'hour' ? t('trendLimitedHour', { n: count }) : t('trendLimitedMinute', { n: count }))
2414
2531
  const presetLabel = (t, view, id) => t(`${view === 'hour' ? 'hourPreset' : 'minutePreset'}.${id}`)
2415
2532
  const tickLabelFor = (view) => (view === 'day' ? shortDay : view === 'hour' ? hourTickLabel : minuteTickLabel)
2533
+ const slotLabelFor = (view) => (view === 'hour' ? hourSlotLabel : view === 'minute' ? minuteSlotLabel : (day) => day)
2416
2534
 
2417
2535
  function UsageDashPanel({ t = defaultT }) {
2418
2536
  const [view, setView] = useState('day')
@@ -2667,6 +2785,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2667
2785
  modelOrder: trendSource.models.map((item) => item.model),
2668
2786
  colorFor,
2669
2787
  labelFor: tickLabelFor(view),
2788
+ slotLabelFor: slotLabelFor(view),
2670
2789
  labelMinPitch: pointActive ? LABEL_PITCH_TIME : LABEL_PITCH_DAY,
2671
2790
  busy,
2672
2791
  legendModels: trendSource.models,
package/src/query.js CHANGED
@@ -105,6 +105,8 @@ export function aggregateRange(rows, g, from, to) {
105
105
  const modelTotals = new Map()
106
106
  const providerTotals = new Map()
107
107
  const activeBuckets = new Set()
108
+ // 槽级速度配对:桶串 → {outputTokens, durationMs},与模型级同口径(仅带时长行)
109
+ const slotSpeeds = new Map()
108
110
  for (const row of rows) {
109
111
  const slot = slotByKey.get(row.bucket)
110
112
  // 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
@@ -115,6 +117,12 @@ export function aggregateRange(rows, g, from, to) {
115
117
  activeBuckets.add(row.bucket)
116
118
  slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
117
119
  slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
120
+ if (row.durationMs) {
121
+ const pair = slotSpeeds.get(row.bucket) ?? { outputTokens: 0, durationMs: 0 }
122
+ pair.outputTokens += row.outputTokens
123
+ pair.durationMs += row.durationMs
124
+ slotSpeeds.set(row.bucket, pair)
125
+ }
118
126
  const modelTotal = modelTotals.get(row.model)
119
127
  if (modelTotal) {
120
128
  modelTotal.tokens += tokens
@@ -130,6 +138,11 @@ export function aggregateRange(rows, g, from, to) {
130
138
  }
131
139
  providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
132
140
  }
141
+ // 槽级 speed 条件挂:无时长数据的槽不挂字段(存量槽形契约不变)
142
+ for (const slot of slots) {
143
+ const pair = slotSpeeds.get(slot.day)
144
+ if (pair && pair.durationMs > 0) slot.speed = pair.outputTokens / (pair.durationMs / MS_PER_SECOND)
145
+ }
133
146
  const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
134
147
  for (const slot of slots) {
135
148
  totals.tokens += slot.total
@@ -15,6 +15,9 @@ const {
15
15
  CHART_PAD,
16
16
  DAY_MAX_SLOTS,
17
17
  DAY_PRESETS,
18
+ DEFAULT_HOUR_PRESET,
19
+ DEFAULT_MINUTE_PRESET,
20
+ DEFAULT_RANGE,
18
21
  DONUT_CENTER_XY,
19
22
  DONUT_CIRCUMFERENCE,
20
23
  DONUT_OUTER_RADIUS,
@@ -80,6 +83,13 @@ const {
80
83
  trimSlots,
81
84
  trendLayout,
82
85
  trendRatePoints,
86
+ trendSpeedPoints,
87
+ speedTipText,
88
+ speedScaleMax,
89
+ legendToggle,
90
+ leftAxisTicks,
91
+ hourSlotLabel,
92
+ minuteSlotLabel,
83
93
  validatePricingRules,
84
94
  } = core
85
95
 
@@ -129,6 +139,13 @@ test('预设定义表覆盖三视图', () => {
129
139
  assert.deepEqual(MINUTE_PRESETS, ['3h', '24h', '3d', '7d'])
130
140
  })
131
141
 
142
+ test('默认挡位均在其挡位列表内,分钟窗口可解析', () => {
143
+ assert.ok(DAY_PRESETS.includes(DEFAULT_RANGE))
144
+ assert.ok(HOUR_PRESETS.includes(DEFAULT_HOUR_PRESET))
145
+ assert.ok(MINUTE_PRESETS.includes(DEFAULT_MINUTE_PRESET))
146
+ assert.notEqual(resolveMinuteRange(DEFAULT_MINUTE_PRESET, NOW), null)
147
+ })
148
+
132
149
  test('时/分挡位文案键逐挡齐备,含跨表同 id 挡位', () => {
133
150
  // Given 时/分挡位表存在同 id('24h') When 逐挡查词典 Then 两语言均有对应键,漏配即回退键名
134
151
  for (const id of HOUR_PRESETS) {
@@ -320,23 +337,53 @@ test('trendLayout 单槽列距铺满且柱宽取上限', () => {
320
337
  assert.equal(layout.maxTotal, 50)
321
338
  })
322
339
 
323
- test('trendLayout 零值槽无分段但保留刻度', () => {
340
+ test('trendLayout 零值槽无分段,无可见数据时刻度为空表', () => {
324
341
  const layout = trendLayout([{ day: '2026-03-15', total: 0, byModel: {} }], [], 720, 46)
325
342
  assert.deepEqual(layout.bars[0].segments, [])
326
- assert.deepEqual(layout.ticks, [0.5, 1])
343
+ assert.deepEqual(layout.ticks, [])
327
344
  })
328
345
 
329
- test('trendLayout 堆叠分段自底向上且高度求和等于绘图高', () => {
346
+ test('trendLayout 堆叠分段自底向上,轴上限为数据峰乘留白系数', () => {
330
347
  const layout = trendLayout([{ day: 'd', total: 50, byModel: { a: 30, b: 20 } }], ['a', 'b'], 720, 46)
331
348
  const [segA, segB] = layout.bars[0].segments
332
349
  assert.deepEqual(segA.model, 'a')
333
350
  assert.deepEqual(segB.model, 'b')
334
- approx(segA.height, (30 / 50) * 184)
335
- approx(segB.height, (20 / 50) * 184)
336
- approx(segA.height + segB.height, 184)
351
+ approx(segA.height, (30 / (50 * 1.1)) * 184)
352
+ approx(segB.height, (20 / (50 * 1.1)) * 184)
353
+ approx(segA.height + segB.height, (50 / (50 * 1.1)) * 184)
337
354
  approx(segB.y + segB.height, segA.y)
338
355
  })
339
356
 
357
+ test('trendLayout 数据峰不顶满绘图区,留白系数 1.1', () => {
358
+ const layout = trendLayout([{ day: 'd', total: 30, byModel: { a: 30 } }], ['a'], 720, 46)
359
+ const [segA] = layout.bars[0].segments
360
+ approx(segA.height + CHART_PAD.top, CHART_PAD.top + (1 / 1.1) * 184)
361
+ })
362
+
363
+ test('trendLayout 可见模型无数据时左轴刻度为空表', () => {
364
+ const slots = [{ day: 'd', total: 50, byModel: { a: 50 } }]
365
+ assert.deepEqual(trendLayout(slots, [], 720, 46).ticks, [])
366
+ assert.deepEqual(trendLayout(slots, ['b'], 720, 46).ticks, [])
367
+ assert.ok(trendLayout(slots, ['a'], 720, 46).ticks.length > 0)
368
+ })
369
+
370
+ test('leftAxisTicks 速度模式标定速度刻度,否则标定 token 刻度', () => {
371
+ const tokenTicks = leftAxisTicks([100, 200], 200, [], 1)
372
+ assert.deepEqual(tokenTicks.map((tick) => tick.label), ['100', '200'])
373
+ approx(tokenTicks[0].ratio, 0.5)
374
+ const speedTicks = leftAxisTicks([], 1, [0.5, 1], 1)
375
+ assert.deepEqual(speedTicks.map((tick) => tick.label), ['0.5', '1'])
376
+ approx(speedTicks[0].ratio, 0.5)
377
+ approx(speedTicks[1].ratio, 1)
378
+ })
379
+
380
+ test('hour/minute 悬浮窗槽标签:T 换空格,小时带 h 后缀,分钟保 HH:MM', () => {
381
+ assert.equal(hourSlotLabel('2026-03-15T14'), '2026-03-15 14h')
382
+ assert.equal(hourSlotLabel('2026-03-15T00'), '2026-03-15 00h')
383
+ assert.equal(minuteSlotLabel('2026-03-15T14:30'), '2026-03-15 14:30')
384
+ assert.equal(minuteSlotLabel('2026-03-15T00:00'), '2026-03-15 00:00')
385
+ })
386
+
340
387
  test('trendLayout 标签密度随列距收敛', () => {
341
388
  const slots = Array.from({ length: 100 }, (_, i) => ({ day: `d${i}`, total: 0, byModel: {} }))
342
389
  const layout = trendLayout(slots, [], 720, 46)
@@ -473,6 +520,61 @@ test('trendRatePoints 点映射列中心与百分比高度且零数据日跳过'
473
520
  approx(points[1].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
474
521
  })
475
522
 
523
+ test('trendSpeedPoints 点映射列中心与刻度上限比例高度且无速度槽跳过', () => {
524
+ const slots = [
525
+ { day: 'd0', speed: 50 },
526
+ { day: 'd1', total: 10 },
527
+ { day: 'd2', speed: 25 },
528
+ ]
529
+ const bars = [{ x: 10 }, { x: 20 }, { x: 30 }]
530
+ const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
531
+ const points = trendSpeedPoints(slots, bars, plotHeight, 100)
532
+ assert.equal(points.length, 2)
533
+ assert.equal(points[0].day, 'd0')
534
+ assert.equal(points[0].x, 10)
535
+ approx(points[0].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
536
+ assert.equal(points[1].day, 'd2')
537
+ assert.equal(points[1].x, 30)
538
+ approx(points[1].y, CHART_PAD.top + plotHeight - 0.25 * plotHeight)
539
+ })
540
+
541
+ test('speedTipText 无速度为占位符有速度为官方吞吐口径', () => {
542
+ assert.equal(speedTipText(undefined), '—')
543
+ assert.equal(speedTipText(5.5556), '5.6 tok/s')
544
+ assert.equal(speedTipText(12.34), '12 tok/s')
545
+ })
546
+
547
+ test('legendToggle 普通点击单选,再点恢复全部,ctrl 多选且禁全藏', () => {
548
+ // null 表示全部可见
549
+ assert.deepEqual(legendToggle(null, 'a', false), new Set(['a']))
550
+ // 单选态再点同项恢复全部(null)
551
+ assert.equal(legendToggle(new Set(['a']), 'a', false), null)
552
+ // 单选态点他项 → 切换单选目标
553
+ assert.deepEqual(legendToggle(new Set(['a']), 'b', false), new Set(['b']))
554
+ // ctrl 切换单项:加入与移除
555
+ assert.deepEqual(legendToggle(new Set(['a']), 'b', true), new Set(['a', 'b']))
556
+ assert.deepEqual(legendToggle(new Set(['a', 'b']), 'b', true), new Set(['a']))
557
+ // ctrl 隐藏最后一项:无操作,保持原态
558
+ const solo = new Set(['a'])
559
+ assert.equal(legendToggle(solo, 'a', true), solo)
560
+ })
561
+
562
+ test('trendLayout 刻度跟随可见模型:单模型刻度按该槽内合计归一', () => {
563
+ const slots = [
564
+ { day: 'd0', total: 50, byModel: { a: 30, b: 20 } },
565
+ { day: 'd1', total: 90, byModel: { a: 40, b: 50 } },
566
+ ]
567
+ assert.equal(trendLayout(slots, ['a', 'b'], 720, 46).maxTotal, 90)
568
+ assert.equal(trendLayout(slots, ['a'], 720, 46).maxTotal, 40)
569
+ assert.equal(trendLayout(slots, [], 720, 46).maxTotal, 1)
570
+ })
571
+
572
+ test('speedScaleMax 全零或空槽钳底防除零,混合取最大速度', () => {
573
+ assert.equal(speedScaleMax([]), 1)
574
+ assert.equal(speedScaleMax([{ day: 'd0' }, { day: 'd1', speed: 0 }]), 1)
575
+ assert.equal(speedScaleMax([{ day: 'd0', speed: 30 }, { day: 'd1', speed: 12.5 }]), 30)
576
+ })
577
+
476
578
  test('smoothPath 空点集为空串单点为移动命令', () => {
477
579
  assert.equal(smoothPath([]), '')
478
580
  assert.equal(smoothPath([{ x: 1, y: 2 }]), 'M 1 2')
@@ -296,6 +296,26 @@ test('纯缓存行带时长:输出 0 计入分母,speed 为 0', () => {
296
296
  assert.equal(out.models[0].speed, 0)
297
297
  })
298
298
 
299
+ test('槽级 speed 配对聚合:同槽带时长行合计,无时长槽无 speed 字段', () => {
300
+ const rows = [
301
+ makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 20, durationMs: 6500 }),
302
+ makeRow({ bucket: '2020-01-01', model: 'm2', provider: 'p2', outputTokens: 30, durationMs: 2500 }),
303
+ makeRow({ bucket: '2020-01-02', model: 'm1', provider: 'p1', outputTokens: 100 }),
304
+ ]
305
+ const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-02')
306
+ assert.equal(out.daily[0].speed, (20 + 30) / ((6500 + 2500) / 1000))
307
+ assert.equal('speed' in out.daily[1], false)
308
+ })
309
+
310
+ test('attachCosts 保留槽级 speed 字段供曲线消费', () => {
311
+ const rows = [
312
+ makeRow({ bucket: '2020-01-01T01', model: 'm1', provider: 'p1', outputTokens: 20, durationMs: 4000 }),
313
+ ]
314
+ const result = aggregateRange(rows, 'H', '2020-01-01T00', '2020-01-01T02')
315
+ const out = attachCosts(result, rows, 'H', [ruleOf()])
316
+ assert.equal(out.daily[1].speed, 5)
317
+ })
318
+
299
319
  test('attachCosts H 槽按桶起点计价并归集 totals 与 models', () => {
300
320
  const rows = [
301
321
  makeRow({ bucket: '2020-01-01T01', model: 'm1', provider: 'p1', inputTokens: 1000000, outputTokens: 500000 }),