@mzzsfy/dsh-usage-dash 0.2.0 → 0.4.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/src/client.js CHANGED
@@ -13,12 +13,12 @@ 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
20
20
  const API_PREFIX = '/api/usage-dash/'
21
- const ENDPOINTS = { range: 'range', hours: 'hours', minutes: 'minutes', status: 'status', reset: 'reset', pricing: 'pricing' }
21
+ const ENDPOINTS = { range: 'range', hours: 'hours', minutes: 'minutes', status: 'status', reset: 'reset', restore: 'restore', pricing: 'pricing' }
22
22
 
23
23
  // 宿主语义 token 之外的插件本地模型色板容量与哨兵
24
24
  const GROUP_TOP_COUNT = 5
@@ -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: '活跃热力图',
@@ -146,6 +148,9 @@ const MESSAGES_ZH = {
146
148
  'status.running': '回扫中 {done}/{total}',
147
149
  rebuild: '重建',
148
150
  rebuildConfirm: '确认重建',
151
+ restore: '回退数据',
152
+ restoreConfirm: '确认回退',
153
+ restoreMissing: '无可用回退点(重建时自动生成)',
149
154
  hourTrend: '按小时 Token 趋势',
150
155
  minuteTrend: '按分钟 Token 趋势',
151
156
  'hourPreset.24h': '24 小时',
@@ -161,6 +166,7 @@ const MESSAGES_ZH = {
161
166
  trendTruncated: '数据量过大,仅显示最近部分',
162
167
  recordFailures: '{n} 条记录写入失败',
163
168
  skippedSessions: '跳过 {n} 个无法读取的会话',
169
+ skipBreakdown: '宿主拒读 {d} / 存档损坏 {c} / 旧格式 {l} / 其他 {o}——宿主修复后下轮回扫自动补齐',
164
170
  anomalyLog: '扫描异常日志',
165
171
  logKindSkipped: '跳过会话',
166
172
  logKindRecord: '写入失败',
@@ -171,18 +177,18 @@ const MESSAGES_ZH = {
171
177
  'stats.tokensPerSecond': '{throughput} tok/s',
172
178
  'stats.cacheHit': '缓存命中 {percent}%',
173
179
  'stats.tokens': '输入 {input} tok · 输出 {output} tok',
180
+ 'turnCostChip': '{cost}',
174
181
  'stats.tokensDetail': '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok',
175
182
  cachePrecision: '精确缓存命中率',
176
183
  cachePrecisionDesc: '在会话底部信息栏以两位小数显示缓存命中率。',
177
184
  tokenDetail: '会话 Token 明细',
178
185
  tokenDetailDesc: '在会话底部信息栏显示总 Token、命中/未命中缓存与输出明细。',
179
186
  costDisplay: '费用显示',
180
- costDisplayDesc: '在信息栏与趋势悬浮中显示按当前费率估算的费用。',
187
+ costDisplayDesc: '在信息栏、趋势悬浮与回合费用芯片中显示按当前费率估算的费用。',
181
188
  costTitle: '按当前费率对历史用量估算,精度为小时级',
182
189
  costUnpriced: '{n} 个小时桶未计价',
183
190
  statsCostTitle: '按当前费率对会话累计 token 估算',
184
191
  'stats.cost': '费用 ≈ {cost}',
185
- 'stats.turnCost': '{summary} · 费用 ≈ {cost}',
186
192
  turnCostTitle: '单轮用量按当前费率估算',
187
193
  turnTokensUnreported: '该提供商未上报此桶',
188
194
  pricing: '定价规则',
@@ -195,8 +201,8 @@ const MESSAGES_ZH = {
195
201
  priceOutput: '输出',
196
202
  priceCacheRead: '缓存读',
197
203
  priceCacheWrite: '缓存写',
198
- noCondition: '无条件 = 恒生效',
199
204
  addCondition: '添加条件',
205
+ confirmDelete: '确认删除',
200
206
  deleteCondition: '删除条件',
201
207
  condKind: '条件类型',
202
208
  condDailyWindow: '每日时段',
@@ -216,9 +222,17 @@ const MESSAGES_ZH = {
216
222
  condWeekday: '需 0-6 整数',
217
223
  condMonthDay: '需 1-31 整数',
218
224
  condDate: '需 YYYY-MM-DD',
219
- condRange: '起始不得晚于结束',
220
- deleteRule: '删除规则',
221
- addRule: '添加规则',
225
+ condRange: '结束不得早于起始(两端均含)',
226
+ ruleOrderHint: '附加计费规则从上到下匹配,首个命中生效;全不命中落默认价',
227
+ moveUp: '上移',
228
+ moveDown: '下移',
229
+ addGroup: '添加模型',
230
+ addRule: '添加额外计费规则',
231
+ addDefaultPrice: '添加默认价',
232
+ deleteGroup: '删除模型及其全部计费规则',
233
+ deleteRule: '删除计费规则',
234
+ addCondition: '添加条件',
235
+ defaultPriceHint: '默认价:附加规则全不命中时生效,不设条件',
222
236
  save: '保存',
223
237
  saved: '已保存',
224
238
  required: '必填',
@@ -251,6 +265,8 @@ const MESSAGES_EN = {
251
265
  cacheRateHint: 'Cache-hit tokens as a share of input tokens within the range',
252
266
  cacheHitRate: 'Cache-hit rate',
253
267
  hitRateLegend: 'Cache-hit rate',
268
+ avgSpeed: 'Avg speed',
269
+ speedLegend: 'Avg speed',
254
270
  topModel: 'Top model',
255
271
  topModelHint: 'Ranked by token usage, not call count',
256
272
  heatmap: 'Activity heatmap',
@@ -271,6 +287,9 @@ const MESSAGES_EN = {
271
287
  'status.running': 'Rescanning {done}/{total}',
272
288
  rebuild: 'Rebuild',
273
289
  rebuildConfirm: 'Confirm rebuild',
290
+ restore: 'Restore data',
291
+ restoreConfirm: 'Confirm restore',
292
+ restoreMissing: 'No restore point (created automatically on rebuild)',
274
293
  hourTrend: 'Hourly token trend',
275
294
  minuteTrend: 'Per-minute token trend',
276
295
  'hourPreset.24h': '24 hours',
@@ -286,6 +305,7 @@ const MESSAGES_EN = {
286
305
  trendTruncated: 'Too much data, showing only the latest part',
287
306
  recordFailures: '{n} records failed to write',
288
307
  skippedSessions: '{n} unreadable sessions skipped',
308
+ skipBreakdown: 'host-refused {d} / corrupt {c} / legacy format {l} / other {o} — auto-retried once the host can read them',
289
309
  anomalyLog: 'Scan anomaly log',
290
310
  logKindSkipped: 'skipped',
291
311
  logKindRecord: 'write failed',
@@ -296,18 +316,18 @@ const MESSAGES_EN = {
296
316
  'stats.tokensPerSecond': '{throughput} tok/s',
297
317
  'stats.cacheHit': 'Cache hit {percent}%',
298
318
  'stats.tokens': 'Input {input} tok · Output {output} tok',
319
+ 'turnCostChip': '{cost}',
299
320
  'stats.tokensDetail': 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok',
300
321
  cachePrecision: 'Precise cache-hit rate',
301
322
  cachePrecisionDesc: 'Show the cache-hit rate with two decimals in the session stats line.',
302
323
  tokenDetail: 'Session token detail',
303
324
  tokenDetailDesc: 'Show total, cache hit/miss and output tokens in the session stats line.',
304
325
  costDisplay: 'Cost display',
305
- costDisplayDesc: 'Show costs estimated at current rates in the stats line and trend tooltips.',
326
+ costDisplayDesc: 'Show costs estimated at current rates in the stats line, trend tooltips and the turn cost chip.',
306
327
  costTitle: 'Estimated at current rates over historical usage, hourly precision',
307
328
  costUnpriced: '{n} hour buckets unpriced',
308
329
  statsCostTitle: 'Estimated at current rates over session token totals',
309
330
  'stats.cost': 'Cost ≈ {cost}',
310
- 'stats.turnCost': '{summary} · Cost ≈ {cost}',
311
331
  turnCostTitle: 'Per-turn usage estimated at current rates',
312
332
  turnTokensUnreported: 'Not reported by this provider',
313
333
  pricing: 'Pricing rules',
@@ -320,8 +340,8 @@ const MESSAGES_EN = {
320
340
  priceOutput: 'Output',
321
341
  priceCacheRead: 'Cache read',
322
342
  priceCacheWrite: 'Cache write',
323
- noCondition: 'No condition = always applies',
324
343
  addCondition: 'Add condition',
344
+ confirmDelete: 'Confirm delete',
325
345
  deleteCondition: 'Remove condition',
326
346
  condKind: 'Condition kind',
327
347
  condDailyWindow: 'Daily window',
@@ -341,9 +361,17 @@ const MESSAGES_EN = {
341
361
  condWeekday: 'Requires integer 0-6',
342
362
  condMonthDay: 'Requires integer 1-31',
343
363
  condDate: 'Requires YYYY-MM-DD',
344
- condRange: 'From must not be after to',
345
- deleteRule: 'Remove rule',
346
- addRule: 'Add rule',
364
+ condRange: 'End must not be before start (both ends inclusive)',
365
+ ruleOrderHint: '附加计费规则从上到下匹配,首个命中生效;全不命中落默认价',
366
+ moveUp: 'Move up',
367
+ moveDown: 'Move down',
368
+ addGroup: 'Add model',
369
+ addRule: 'Add extra pricing rule',
370
+ addDefaultPrice: 'Add default price',
371
+ deleteGroup: 'Delete model and all its pricing rules',
372
+ deleteRule: 'Remove pricing rule',
373
+ addCondition: 'Add condition',
374
+ defaultPriceHint: 'Default price: applies when no extra rule above matches; no conditions',
347
375
  save: 'Save',
348
376
  saved: 'Saved',
349
377
  required: 'Required',
@@ -383,13 +411,15 @@ function formatCompact(value) {
383
411
  function formatPercent(value) {
384
412
  return (Math.round(value * 10) / 10).toFixed(1) + '%'
385
413
  }
414
+ // tooltip 数值缺席占位
415
+ const TOOLTIP_MISSING = '—'
386
416
  function cacheRate(hit, miss) {
387
417
  const total = hit + miss
388
418
  return total <= 0 ? null : (hit / total) * 100
389
419
  }
390
420
  function cacheRateText(hit, miss) {
391
421
  const rate = cacheRate(hit, miss)
392
- return rate === null ? '—' : formatPercent(rate)
422
+ return rate === null ? TOOLTIP_MISSING : formatPercent(rate)
393
423
  }
394
424
 
395
425
  // 模型键展示分段与定价/存储口径同源:首个 / 前 vendor 段,余为模型段
@@ -417,6 +447,15 @@ function minuteTickLabel(key) {
417
447
  return time === MIDNIGHT_TIME ? `${shortDay(key.slice(0, DAY_KEY_LENGTH))} ${time}` : time
418
448
  }
419
449
 
450
+ // 悬浮窗槽标签:完整日期去 T 分隔,小时以 h 后缀标定(避免 i18n 单位问题),分钟保留 HH:MM
451
+ function hourSlotLabel(key) {
452
+ return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}h`
453
+ }
454
+
455
+ function minuteSlotLabel(key) {
456
+ return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}`
457
+ }
458
+
420
459
  function isEmptyRange(value) {
421
460
  return value.tokens === 0 && value.cacheHit === 0 && value.requests === 0 && value.turns === 0
422
461
  }
@@ -488,6 +527,13 @@ const BAR_WIDTH_RATIO = 0.62
488
527
  const BAR_MIN_WIDTH = 3
489
528
  const BAR_MAX_WIDTH = 30
490
529
  const AXIS_TICK_COUNT = 4
530
+ // 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
531
+ const SPEED_SCALE_FLOOR = 1
532
+ // 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
533
+ const AXIS_SCALE_HEADROOM = 1.1
534
+ // 图例键:折线项与模型项共处同一显隐集合
535
+ const LEGEND_KEY_RATE = 'rate'
536
+ const LEGEND_KEY_SPEED = 'speed'
491
537
 
492
538
  function niceTicks(max, count) {
493
539
  if (max <= 0 || count <= 0) return []
@@ -500,14 +546,43 @@ function niceTicks(max, count) {
500
546
  return ticks
501
547
  }
502
548
 
503
- // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度
549
+ // 图例显隐切换:current 为 null 表示全部可见;普通点击单选/再点恢复,
550
+ // ctrl 单项切换,隐藏最后一项为无操作(返回原引用)
551
+ function legendToggle(current, key, ctrl) {
552
+ if (ctrl) {
553
+ const next = new Set(current ?? [])
554
+ if (next.has(key)) next.delete(key)
555
+ else next.add(key)
556
+ return next.size === 0 ? current : next
557
+ }
558
+ const solo = current !== null && current.size === 1 && current.has(key)
559
+ return solo ? null : new Set([key])
560
+ }
561
+
562
+ // 左轴刻度:速度刻度存在时左轴标定速度(tok/s),否则标定 token;
563
+ // 刻度值统一换算为绘图区高度占比
564
+ function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax) {
565
+ const useSpeed = speedTicks.length > 0
566
+ return (useSpeed ? speedTicks : tokenTicks).map((tick) => ({
567
+ label: formatCompact(tick),
568
+ ratio: tick / (useSpeed ? speedMax : tokenMax),
569
+ }))
570
+ }
571
+
572
+ // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度;
573
+ // maxTotal 按可见模型求和,单选模型时刻度跟随归一
504
574
  function trendLayout(slots, modelOrder, avail, labelMinPitch) {
505
575
  const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
506
576
  const innerWidth = Math.max(1, avail - CHART_PAD.left - CHART_PAD.right)
507
577
  const count = slots.length
508
578
  const step = count > 1 ? innerWidth / (count - 1) : innerWidth
509
579
  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))
580
+ const slotVisibleTotal = (slot) => modelOrder.reduce((sum, model) => sum + (slot.byModel[model] ?? 0), 0)
581
+ const maxTotal = Math.max(1, ...slots.map(slotVisibleTotal))
582
+ // 轴上限 = 数据峰 × 留白系数,柱高分母与左轴刻度分母同源
583
+ const scaleMax = maxTotal * AXIS_SCALE_HEADROOM
584
+ // 可见模型无任何数据时左轴无标定对象,空刻度防钳底值漏成假刻度
585
+ const visibleTotal = slots.reduce((sum, slot) => sum + slotVisibleTotal(slot), 0)
511
586
  const bars = slots.map((slot, index) => {
512
587
  const centerX = CHART_PAD.left + barWidth / 2 + index * step
513
588
  let yBottom = CHART_PAD.top + plotHeight
@@ -515,7 +590,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
515
590
  for (const model of modelOrder) {
516
591
  const tokens = slot.byModel[model] ?? 0
517
592
  if (tokens === 0) continue
518
- const height = (tokens / maxTotal) * plotHeight
593
+ const height = (tokens / scaleMax) * plotHeight
519
594
  yBottom -= height
520
595
  segments.push({ model, y: yBottom, height })
521
596
  }
@@ -528,7 +603,8 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
528
603
  step,
529
604
  barWidth,
530
605
  maxTotal,
531
- ticks: niceTicks(maxTotal, AXIS_TICK_COUNT),
606
+ scaleMax,
607
+ ticks: visibleTotal === 0 ? [] : niceTicks(maxTotal, AXIS_TICK_COUNT),
532
608
  labelEvery: Math.max(1, Math.ceil(labelMinPitch / step)),
533
609
  bars,
534
610
  }
@@ -560,6 +636,20 @@ function trendRatePoints(slots, bars, plotHeight) {
560
636
  return points
561
637
  }
562
638
 
639
+ // 速度曲线点:仅带速度槽产出,高度按刻度上限归一
640
+ function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
641
+ const points = []
642
+ slots.forEach((slot, index) => {
643
+ if (slot.speed === undefined) return
644
+ points.push({
645
+ day: slot.day,
646
+ x: bars[index].x,
647
+ y: CHART_PAD.top + plotHeight - (slot.speed / scaleMax) * plotHeight,
648
+ })
649
+ })
650
+ return points
651
+ }
652
+
563
653
  // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
564
654
  function smoothPath(points) {
565
655
  if (points.length === 0) return ''
@@ -619,6 +709,16 @@ function modelSpeedText(speed) {
619
709
  return `${formatTokensPerSecond(speed)} tok/s`
620
710
  }
621
711
 
712
+ // tooltip 速度行文本:无速度与命中率同款占位符
713
+ function speedTipText(speed) {
714
+ return speed === undefined ? TOOLTIP_MISSING : modelSpeedText(speed)
715
+ }
716
+
717
+ // 速度刻度上限:全零或无速度钳底,防除零
718
+ function speedScaleMax(slots) {
719
+ return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
720
+ }
721
+
622
722
  // 热力图:窗口固定 26 周,与所选范围无关
623
723
  const HEAT_WEEKS = 26
624
724
  const HEAT_ROW_COUNT = 7
@@ -676,10 +776,13 @@ function heatLevel(tokens, max) {
676
776
  return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
677
777
  }
678
778
 
679
- // ChartTip 定位:锚定格子矩形,边界内 clamp,下方优先放不下翻上方
779
+ // ChartTip 定位:锚定区装得下贴内顶(趋势图悬停区即绘图区,悬浮窗留在图表内),装不下上翻、再下翻、末了钳边界顶
680
780
  const TIP_GAP_PX = 8
681
781
  const TIP_MARGIN_PX = 8
682
782
 
783
+ // 回合费用芯片与前置官方芯片(结束时钟)的间距
784
+ const TURN_COST_GAP_PX = 8
785
+
683
786
  function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
684
787
  if (!tip || tip.width <= 0 || tip.height <= 0) return null
685
788
  if (!anchor || (anchor.left === 0 && anchor.top === 0 && anchor.right === 0 && anchor.bottom === 0)) return null
@@ -688,11 +791,13 @@ function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX)
688
791
  const maxX = bounds.right - margin
689
792
  const maxY = bounds.bottom - margin
690
793
  const left = Math.max(minX, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, maxX - tip.width))
691
- const below = anchor.bottom + gap
794
+ const inside = anchor.top + gap
692
795
  const above = anchor.top - gap - tip.height
796
+ const below = anchor.bottom + gap
693
797
  let top
694
- if (below + tip.height <= maxY) top = below
798
+ if (inside + tip.height <= anchor.bottom) top = inside
695
799
  else if (above >= minY) top = above
800
+ else if (below + tip.height <= maxY) top = below
696
801
  else top = minY
697
802
  return { left, top }
698
803
  }
@@ -976,15 +1081,14 @@ const toMinutesOfDay = (hhmm) => {
976
1081
  return Number.isFinite(h) && Number.isFinite(m) ? h * MINUTES_PER_HOUR + m : Number.NaN
977
1082
  }
978
1083
 
979
- // from<to 含头不含尾;from>to 跨午夜;from===to 全天生效
1084
+ // 所有范围条件统一双侧包含;from>to 跨午夜/跨月环绕;from===to 单点/单日(与宿主 pricing.js 镜像,parity 测试锁定)
980
1085
  function dailyWindowMatches(condition, date) {
981
1086
  const from = toMinutesOfDay(condition.from)
982
1087
  const to = toMinutesOfDay(condition.to)
983
1088
  if (Number.isNaN(from) || Number.isNaN(to)) return false
984
1089
  const m = minutesOfDay(date)
985
- if (from < to) return m >= from && m < to
986
- if (from > to) return m >= from || m < to
987
- return true
1090
+ if (from <= to) return m >= from && m <= to
1091
+ return m >= from || m <= to
988
1092
  }
989
1093
 
990
1094
  // days 空数组不成立;0=周日,取 getDay()
@@ -993,7 +1097,7 @@ function weekdaysMatches(condition, date) {
993
1097
  return Array.isArray(days) && days.length > 0 && days.includes(date.getDay())
994
1098
  }
995
1099
 
996
- // 号段双闭;from>to 跨月环绕;日号必须整数,2 月无 31 号自然不触发
1100
+ // 号段双侧包含;from>to 跨月环绕;from===to 单日,2 月无 31 号自然不触发
997
1101
  function monthDaysMatches(condition, date) {
998
1102
  const { from, to } = condition
999
1103
  if (!Number.isInteger(from) || !Number.isInteger(to)) return false
@@ -1001,7 +1105,7 @@ function monthDaysMatches(condition, date) {
1001
1105
  return from <= to ? d >= from && d <= to : d >= from || d <= to
1002
1106
  }
1003
1107
 
1004
- // 要求零填充 YYYY-MM-DD 字典序双闭;from>to 属配置错误不成立,非规范串同样不成立
1108
+ // 零填充 YYYY-MM-DD 字典序双侧包含;from>to 配置错误不成立(编辑器校验拦截)
1005
1109
  const ISO_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
1006
1110
  function dateRangeMatches(condition, date) {
1007
1111
  const { from, to } = condition
@@ -1153,29 +1257,55 @@ function costTitleText(t, unpriced) {
1153
1257
  return unpriced > 0 ? `${base},${t('costUnpriced', { n: unpriced })}` : base
1154
1258
  }
1155
1259
 
1156
- // ===== 注入点B:turnTail 单轮用量行(chain 条目,官方 TurnTailNodeView 容器内渲染) =====
1157
- // chain 尝试顺序 = priority 升序:deliverables 产物行(默认 0)先试,本条目后试让位
1158
- const TURN_TAIL_DATA_KEY = 'turn-tail'
1159
- const TURN_TAIL_PRIORITY = 1
1160
- const TURN_COST_REVEAL_MS = 80
1161
- const TURN_TAIL_ACTIONS_INSET_PX = -6
1260
+ // ===== 注入点B:回合费用芯片(官方动作行 assistant-actions 槽条目,赞踩/上下文跳转同排) =====
1261
+ // 排序取上下文插件条目(20)之后,贴近尾部用量/用时芯片一侧
1262
+ const TURN_COST_CHIP_ORDER = 20 + 10
1162
1263
 
1163
- // Turn 位置数据读取:owner 形状残缺一律 null 不抛;tokenUsage 缺失(证据不完整)即放弃渲染
1164
- function selectTurnTokenUsage(owner) {
1165
- return owner?.turn?.data?.get
1166
- ? (owner.turn.data.get(TURN_TAIL_DATA_KEY)?.tokenUsage ?? null)
1167
- : null
1264
+ // messageId 反查:节点表为 chat 节点仓库(values() 可枚举,Map/仓库两态兼容);
1265
+ // 回合级用量优先取回合位置数据(location.turn.data.get('turn-tail')).tokenUsage(官方 tokenUsage 聚合,
1266
+ // 分页窗口缺 turn/start 时缺席),回退视图节点 data.closing.usage(末步用量采样,输入侧已含缓存,计费口径同源)
1267
+ function turnTokenUsageOfMessage(nodes, messageId) {
1268
+ 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 { /* 单节点形状残缺跳过,扫描继续 */ }
1289
+ }
1290
+ return null
1168
1291
  }
1169
1292
 
1170
- // 计价模型键:routes 首个 route.model,缺席回退全通配键(与注入点A 同款)
1171
- const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ?? MODEL_UNROUTED
1293
+ // 计价模型键:routes 首条按官方 messageRoute 分离字段拼两段键,与采集器 refOf 双实现同源
1294
+ // (官方 source.provider/model 是分离字段,routes[].model 是裸模型名,展示侧才拼 provider),
1295
+ // 仅 model 用裸名命中 */model 档,双缺回退全通配键;多 route 取首条(单轮估算口径)
1296
+ const turnModelOf = (tokenUsage) => {
1297
+ const route = tokenUsage?.routes?.[0]
1298
+ if (!route) return MODEL_UNROUTED
1299
+ if (route.provider && route.model) return `${route.provider}/${route.model}`
1300
+ return route.model || MODEL_UNROUTED
1301
+ }
1172
1302
 
1173
1303
  // 可选桶(cacheRead/cacheWrite)仅部分 provider 上报,缺失按 0 计入摘要与费用
1174
1304
  const turnReportedBucket = (value) => (value ?? 0)
1175
1305
 
1176
- // 摘要计费输入 = prompt 侧三桶(官方 billing 分母口径)
1306
+ // 摘要计费输入 = prompt 侧三桶(官方 billing 分母口径),核心桶缺省同按 0 保证降级形态对称
1177
1307
  function turnBilledInputTokens(tokenUsage) {
1178
- return tokenUsage.uncachedInputTokens
1308
+ return turnReportedBucket(tokenUsage.uncachedInputTokens)
1179
1309
  + turnReportedBucket(tokenUsage.cacheReadTokens)
1180
1310
  + turnReportedBucket(tokenUsage.cacheWriteTokens)
1181
1311
  }
@@ -1185,33 +1315,44 @@ function turnOptionalUnreported(tokenUsage) {
1185
1315
  return tokenUsage?.cacheReadTokens === undefined || tokenUsage?.cacheWriteTokens === undefined
1186
1316
  }
1187
1317
 
1188
- // 单轮行文本:摘要 + 费用;token 复用官方 compact 族,价未命中为占位符
1189
- function buildTurnCostLine(t, tokenUsage, price, currency) {
1190
- const summary = t('stats.tokens', {
1191
- input: formatTokensCompact(turnBilledInputTokens(tokenUsage), t),
1192
- output: formatTokensCompact(tokenUsage.outputTokens, t),
1193
- })
1194
- const cost = price ? formatCost(costOf(price, pricingBucketsOf(tokenUsage)), currency) : COST_PLACEHOLDER
1195
- return t('stats.turnCost', { summary, cost })
1318
+ // 芯片计费额:价命中才算,0 元(全零价或全零桶)返回 null 由调用方决定不渲染;价未命中同 null
1319
+ function turnCostAmountOf(tokenUsage, price) {
1320
+ if (!price) return null
1321
+ const cost = costOf(price, pricingBucketsOf(tokenUsage))
1322
+ return cost > 0 ? cost : null
1196
1323
  }
1197
1324
 
1198
- // title:估算口径说明,可选桶未上报时追加标注
1325
+ // 芯片文本:仅金额,不带货币前缀文字(官方用量芯片弹窗已有 token 明细)
1326
+ function buildTurnCostChipText(t, tokenUsage, price, currency) {
1327
+ const cost = turnCostAmountOf(tokenUsage, price)
1328
+ return t('turnCostChip', { cost: cost === null ? COST_PLACEHOLDER : formatCost(cost, currency) })
1329
+ }
1330
+
1331
+ // 芯片 title:token 摘要 + 估算口径,可选桶未上报时追加标注
1199
1332
  function turnCostTitleText(t, tokenUsage) {
1200
- const base = t('turnCostTitle')
1201
- return turnOptionalUnreported(tokenUsage) ? `${base},${t('turnTokensUnreported')}` : base
1333
+ const notes = [
1334
+ t('stats.tokens', {
1335
+ input: formatTokensCompact(turnBilledInputTokens(tokenUsage), t),
1336
+ output: formatTokensCompact(turnReportedBucket(tokenUsage.outputTokens), t),
1337
+ }),
1338
+ t('turnCostTitle'),
1339
+ ]
1340
+ if (turnOptionalUnreported(tokenUsage)) notes.push(t('turnTokensUnreported'))
1341
+ return notes.join(',')
1202
1342
  }
1203
1343
 
1204
1344
  // ===== 定价编辑器纯函数(校验/规整/默认值) =====
1205
1345
  const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
1206
1346
  const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
1207
1347
  const ISO_DAY_PATTERN_CLIENT = /^\d{4}-\d{2}-\d{2}$/
1208
- // 时刻分量界:小时含头不含尾,分钟双闭
1209
- const HOUR_MAX = 24
1348
+ // 时刻分量界:小时/分钟均双闭
1349
+ const HOUR_MAX = 23
1210
1350
  const MINUTE_MAX = 59
1211
1351
  const WEEKDAY_MIN = 0
1212
1352
  const WEEKDAY_MAX = 6
1213
1353
  const MONTH_DAY_MIN = 1
1214
1354
  const MONTH_DAY_MAX = 31
1355
+ const FULL_DAY_WINDOW = { from: '00:00', to: '23:59' }
1215
1356
  const CONDITION_KIND_OPTIONS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
1216
1357
  const CONDITION_KIND_LABEL_KEYS = {
1217
1358
  dailyWindow: 'condDailyWindow',
@@ -1224,18 +1365,18 @@ const WEEKDAY_COUNT = 7
1224
1365
  const parseHHMM = (value) => {
1225
1366
  if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) return null
1226
1367
  const [hours, minutes] = value.split(':').map(Number)
1227
- return hours >= 0 && hours < HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
1368
+ return hours >= 0 && hours <= HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
1228
1369
  }
1229
1370
 
1230
1371
  const isValidMonthDay = (value) => Number.isInteger(value) && value >= MONTH_DAY_MIN && value <= MONTH_DAY_MAX
1231
1372
 
1232
1373
  const isValidWeekday = (value) => Number.isInteger(value) && value >= WEEKDAY_MIN && value <= WEEKDAY_MAX
1233
1374
 
1234
- // 各条件类型合法默认:时段全天(from===to)、周几空、号段全月(短月高位号自然不触发)、日期段当天
1375
+ // 各条件类型合法默认即填即用:时段全天、周几空、号段全月、日期段当天单日
1235
1376
  const defaultCondition = (kind, now = new Date()) => {
1236
1377
  const today = formatDate(now)
1237
1378
  const defaults = {
1238
- dailyWindow: { kind: 'dailyWindow', from: '00:00', to: '00:00' },
1379
+ dailyWindow: { kind: 'dailyWindow', ...FULL_DAY_WINDOW },
1239
1380
  weekdays: { kind: 'weekdays', days: [] },
1240
1381
  monthDays: { kind: 'monthDays', from: MONTH_DAY_MIN, to: MONTH_DAY_MAX },
1241
1382
  dateRange: { kind: 'dateRange', from: today, to: today },
@@ -1243,15 +1384,18 @@ const defaultCondition = (kind, now = new Date()) => {
1243
1384
  return defaults[kind] ? { ...defaults[kind] } : null
1244
1385
  }
1245
1386
 
1246
- // 条件字段级校验:路径前缀 + 键 → 错误键;倒序仅 dateRange 非法(时段/号段倒序为跨午夜/跨月语义)
1387
+ // 条件字段级校验:路径前缀 + 键 → 错误键;时段/号段倒序为跨午夜/跨月语义;
1388
+ // 所有范围双侧包含,from===to 单点/单日合法;仅日期段倒序属配置错误(condRange)
1247
1389
  const validateCondition = (condition, path, errors) => {
1248
1390
  if (!condition || typeof condition !== 'object') {
1249
1391
  errors.set(path, 'required')
1250
1392
  return
1251
1393
  }
1252
1394
  if (condition.kind === 'dailyWindow') {
1253
- if (parseHHMM(condition.from) === null) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condTime')
1254
- if (parseHHMM(condition.to) === null) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condTime')
1395
+ const from = parseHHMM(condition.from)
1396
+ const to = parseHHMM(condition.to)
1397
+ if (from === null) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condTime')
1398
+ if (to === null) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condTime')
1255
1399
  return
1256
1400
  }
1257
1401
  if (condition.kind === 'weekdays') {
@@ -1326,6 +1470,58 @@ const defaultPricingRule = (currency = CURRENCIES[0]) => ({
1326
1470
 
1327
1471
  const patchItemAt = (array, index, patch) => array.map((item, i) => (i === index ? { ...item, ...patch } : item))
1328
1472
 
1473
+ // 编辑器分组视图纯函数:wire 契约(平面数组)不变,组/槽位仅是展示层投影。
1474
+ // 槽位移动/增删均落回平面数组的对应位置变换,组内相对顺序即匹配优先序。
1475
+
1476
+ // 按模型键聚合:组序为首次出现序,槽位保序并携带平面索引(错误路径与移动操作的寻址键)
1477
+ function groupRulesOf(rules) {
1478
+ const groups = new Map()
1479
+ ;(rules ?? []).forEach((rule, index) => {
1480
+ const key = rule.model
1481
+ if (!groups.has(key)) groups.set(key, { model: key, slots: [] })
1482
+ groups.get(key).slots.push({ rule, index })
1483
+ })
1484
+ return [...groups.values()]
1485
+ }
1486
+
1487
+ // 组内划分:末条无条件规则即该模型的默认价(恒兜底,UI 禁条件/排序/删除),其余为附加计费规则。
1488
+ // 全条件组默认价为 null(UI 提供"添加默认价"入口);多条无条件时末条为默认价,存量数据自然收敛
1489
+ function partitionGroupOf(group) {
1490
+ const last = group.slots[group.slots.length - 1]
1491
+ const isDefault = last !== undefined && (last.rule.conditions ?? []).length === 0
1492
+ return {
1493
+ defaultSlot: isDefault ? last : null,
1494
+ extras: isDefault ? group.slots.slice(0, -1) : group.slots,
1495
+ }
1496
+ }
1497
+
1498
+ // 槽位跨位移动(splice 语义):同位或越界返回原引用
1499
+ function moveRuleTo(rules, fromIndex, toIndex) {
1500
+ if (!Array.isArray(rules) || fromIndex === toIndex) return rules
1501
+ if (fromIndex < 0 || fromIndex >= rules.length || toIndex < 0 || toIndex >= rules.length) return rules
1502
+ const moved = [...rules]
1503
+ const [item] = moved.splice(fromIndex, 1)
1504
+ moved.splice(toIndex, 0, item)
1505
+ return moved
1506
+ }
1507
+
1508
+ // 组级改名:仅命中索引集的规则变更 model,其余保持引用不变
1509
+ function renameRulesAt(rules, indexes, model) {
1510
+ const hit = new Set(indexes)
1511
+ return rules.map((rule, i) => (hit.has(i) ? { ...rule, model } : rule))
1512
+ }
1513
+
1514
+ // 插入到目标平面位之后(组内添加槽位 = 组内末槽位索引 + 1)
1515
+ function insertRuleAt(rules, position, rule) {
1516
+ return [...rules.slice(0, position + 1), rule, ...rules.slice(position + 1)]
1517
+ }
1518
+
1519
+ // 批量移除(组删除)
1520
+ function removeRulesAt(rules, indexes) {
1521
+ const hit = new Set(indexes)
1522
+ return rules.filter((_, i) => !hit.has(i))
1523
+ }
1524
+
1329
1525
 
1330
1526
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1331
1527
  window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-usage-dash', factory })
@@ -1460,6 +1656,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1460
1656
  model: ['M5 5h14v14H5Z', 'M9 9h6v6H9Z', 'M9 2v3', 'M15 2v3', 'M9 19v3', 'M15 19v3', 'M2 9h3', 'M2 15h3', 'M19 9h3', 'M19 15h3'],
1461
1657
  rate: ['M22 12h-4l-3 9L9 3l-3 9H2'],
1462
1658
  days: ['M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z', 'M16 3v4', 'M8 3v4', 'M3 11h18'],
1659
+ trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
1463
1660
  }
1464
1661
 
1465
1662
  function Icon({ paths }) {
@@ -1470,12 +1667,35 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1470
1667
  }, paths.map((d, index) => h('path', { key: index, d })))
1471
1668
  }
1472
1669
 
1670
+ // 危险删除按钮:首点进武装态(垃圾桶变"确认删除"文字),再点才执行,超时自动解除
1671
+ function DeleteArmedButton({ labelKey, confirmKey, onConfirm, t = defaultT }) {
1672
+ const [armed, setArmed] = useState(false)
1673
+ const armedTimerRef = useRef(null)
1674
+ useEffect(() => () => {
1675
+ if (armedTimerRef.current) clearTimeout(armedTimerRef.current)
1676
+ }, [])
1677
+ const click = () => {
1678
+ if (!armed) {
1679
+ setArmed(true)
1680
+ armedTimerRef.current = setTimeout(() => setArmed(false), REBUILD_CONFIRM_MS)
1681
+ return
1682
+ }
1683
+ if (armedTimerRef.current) clearTimeout(armedTimerRef.current)
1684
+ setArmed(false)
1685
+ onConfirm()
1686
+ }
1687
+ return h('button', {
1688
+ className: cx('ud-btn ud-btn--text', armed && 'ud-delete-armed'), type: 'button', onClick: click,
1689
+ 'aria-label': t(labelKey), title: t(labelKey),
1690
+ }, armed ? t(confirmKey) : h(Icon, { paths: ICONS.trash }))
1691
+ }
1692
+
1473
1693
  const STYLE_CSS = `
1474
1694
  .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1475
1695
  --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}
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}
1477
1697
  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}
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}
1479
1699
  .ud-toolbar{display:flex;align-items:flex-start;gap:8px}
1480
1700
  .ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
1481
1701
  .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)}
@@ -1493,6 +1713,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1493
1713
  .ud-icon-btn{padding:0 12px}
1494
1714
  .ud-icon{display:inline-flex;align-items:center;justify-content:center}
1495
1715
  .ud-btn--text{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);padding:2px 4px}
1716
+ .ud-delete-armed{color:var(--dsw-alias-state-error-primary);font-size:12px;white-space:nowrap}
1496
1717
  .ud-error{border:1px solid var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-label);border-radius:8px;padding:8px 12px;font-size:12px}
1497
1718
  .ud-loading{color:var(--dsw-alias-label-tertiary);text-align:center;padding:32px 0}
1498
1719
  .ud-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;color:var(--dsw-alias-label-tertiary);text-align:center;padding:24px 16px;font-size:12px}
@@ -1535,7 +1756,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1535
1756
  .ud-grid{stroke:var(--dsw-alias-border-l1);stroke-width:1}
1536
1757
  .ud-axis{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
1537
1758
  .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}
1759
+ .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}
1760
+ .ud-legend-item--off{opacity:.35}
1539
1761
  .ud-legend-item span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1540
1762
  .ud-legend-swatch{width:8px;height:8px;border-radius:2px;flex:none}
1541
1763
  .ud-heat-wrap{width:100%;min-width:0;overflow:hidden}
@@ -1558,8 +1780,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1558
1780
  .ud-bar{transform-box:fill-box;transform-origin:center}
1559
1781
  .ud-bar-hit{fill:transparent;pointer-events:all}
1560
1782
  .ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
1783
+ .ud-trend--speed{stroke:var(--ud-trend-speed)}
1561
1784
  .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
+ .ud-trend-dot--speed{fill:var(--ud-trend-speed)}
1562
1786
  .ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
1787
+ .ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
1563
1788
  .ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
1564
1789
  .ud-donut-wrap{flex:0 0 auto}
1565
1790
  .ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
@@ -1600,14 +1825,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1600
1825
  .ud-pref-title{font-size:13px;color:var(--dsw-alias-label-primary)}
1601
1826
  .ud-pref-desc{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1602
1827
  .ud-rule{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px 12px}
1828
+ .ud-rule-group{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px}
1829
+ .ud-rule-group-head{display:flex;align-items:flex-end;gap:8px}
1830
+ .ud-rule-group-head .ud-field{flex:1}
1831
+ .ud-rule-group-slots{display:flex;flex-direction:column;gap:8px}
1832
+ .ud-rule-group-slots .ud-rule{background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 40%,transparent)}
1833
+ .ud-rule-default{border-style:dashed}
1834
+ .ud-slot-head{display:flex;align-items:flex-end;gap:8px}
1835
+ .ud-slot-head .ud-price-grid{flex:1}
1603
1836
  .ud-rule-head{display:flex;align-items:flex-end;gap:8px}
1604
1837
  .ud-rule-head .ud-field{flex:1}
1605
1838
  .ud-rule-cond{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1606
1839
  .ud-rule-conds{display:flex;flex-direction:column;gap:6px}
1607
- .ud-cond{display:flex;align-items:flex-start;gap:6px;flex-wrap:wrap}
1608
- .ud-cond-kind{width:auto;min-width:88px}
1609
- .ud-cond-fields{display:flex;align-items:flex-end;gap:6px;flex-wrap:wrap;flex:1;min-width:0}
1610
- .ud-cond-fields .ud-field{flex:0 1 auto}
1840
+ .ud-cond{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
1841
+ /* specificity 须高于 .ud-input 的 width:100%,否则类型下拉撑满整行把字段区挤到下一行 */
1842
+ .ud-cond .ud-cond-kind{width:auto;min-width:88px;flex:0 0 auto}
1843
+ .ud-cond-fields{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1;min-width:0}
1844
+ .ud-cond-fields .ud-field{flex-direction:row;align-items:center;gap:4px;flex:0 1 auto}
1611
1845
  .ud-cond-fields .ud-input{width:auto}
1612
1846
  .ud-cond-add{display:flex;gap:6px;flex-wrap:wrap}
1613
1847
  .ud-field{display:flex;flex-direction:column;gap:3px;min-width:0}
@@ -1625,8 +1859,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1625
1859
  .ud-rule-add:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1626
1860
  .ud-statsline-root{text-align:center;max-width:var(--dsh-chat-content-width);box-sizing:border-box;width:100%;padding:4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;margin:0 auto;display:block;overflow:hidden}
1627
1861
  .ud-statsline-sep{color:var(--dsw-alias-separator-primary);margin:0 10px}
1628
- .ud-turn-cost{font-size:var(--dsh-content-font-size-secondary,13px);color:var(--dsw-alias-label-tertiary);margin-left:${TURN_TAIL_ACTIONS_INSET_PX}px;font-variant-numeric:tabular-nums;white-space:nowrap}
1629
- @media (hover:hover){[data-actions-reveal=hover] .ud-turn-cost{opacity:0;transition:opacity ${TURN_COST_REVEAL_MS}ms}[data-actions-reveal=hover]:hover .ud-turn-cost,[data-actions-reveal=hover]:focus-within .ud-turn-cost{opacity:1}}
1862
+ /* 芯片 portal 至动作行末尾,历史轮悬停/焦点显隐随父级 data-actions-reveal,无需自绘规则 */
1863
+ .ud-turn-cost{font-size:var(--dsh-content-font-size-secondary,13px);color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap;margin-left:${TURN_COST_GAP_PX}px}
1630
1864
  `
1631
1865
 
1632
1866
  function ensureStyle(document) {
@@ -1701,14 +1935,22 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1701
1935
  h(FitText, null, String(stats.activeDays))))
1702
1936
  }
1703
1937
 
1704
- function Legend({ models, colorFor, t = defaultT }) {
1938
+ function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
1939
+ const itemProps = (key) => ({
1940
+ className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
1941
+ onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
1942
+ role: onItem ? 'button' : undefined,
1943
+ })
1705
1944
  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 },
1945
+ models.map((item) => h('span', { key: item.model, title: item.model === OTHER_MODEL ? t('other') : item.model, ...itemProps(item.model) },
1707
1946
  h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1708
1947
  h('span', null, item.model === OTHER_MODEL ? t('other') : item.model))),
1709
- h('span', { key: 'hit-rate', className: 'ud-legend-item', title: t('hitRateLegend') },
1948
+ h('span', { key: 'hit-rate', title: t('hitRateLegend'), ...itemProps(LEGEND_KEY_RATE) },
1710
1949
  h('i', { className: 'ud-legend-swatch ud-legend-swatch--line' }),
1711
- h('span', null, t('hitRateLegend'))))
1950
+ h('span', null, t('hitRateLegend'))),
1951
+ speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
1952
+ h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
1953
+ h('span', null, t('speedLegend'))) : null)
1712
1954
  }
1713
1955
 
1714
1956
  const colorForModel = (models) => (model) => {
@@ -1718,10 +1960,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1718
1960
  return `var(--ud-chart-${rank})`
1719
1961
  }
1720
1962
 
1721
- function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
1963
+ function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
1722
1964
  const wrapRef = useRef(null)
1723
1965
  const [avail, setAvail] = useState(CHART_NOMINAL_WIDTH)
1724
1966
  const [hover, setHover] = useState(null)
1967
+ // 图例显隐:null = 全部可见;键集 = 模型项与折线项(rate/speed)
1968
+ const [visibleKeys, setVisibleKeys] = useState(null)
1725
1969
  // prefs 仅用于 hover tooltip 的费用显隐,经 ref 读取:开关切换不触发本组件重渲染,
1726
1970
  // 避免宿主设置弹窗滚动锚定被重渲染扰动而跳变
1727
1971
  const prefsRef = useRef(statsLineState.get())
@@ -1735,11 +1979,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1735
1979
  observer.observe(element)
1736
1980
  return () => observer.disconnect()
1737
1981
  }, [])
1738
- const layout = trendLayout(slots, modelOrder, avail, labelMinPitch)
1982
+ const hasSpeed = slots.some((slot) => slot.speed !== undefined)
1983
+ const visibleSet = visibleKeys ?? new Set([...modelOrder, LEGEND_KEY_RATE, ...(hasSpeed ? [LEGEND_KEY_SPEED] : [])])
1984
+ const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
1985
+ const showRate = visibleSet.has(LEGEND_KEY_RATE)
1986
+ const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
1987
+ const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
1739
1988
  const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
1740
1989
  const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
1990
+ const speedMax = speedScaleMax(slots)
1991
+ const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
1992
+ const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
1993
+ // 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
1994
+ const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
1995
+ const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
1741
1996
  const hoverSlot = hover ? slots[hover.index] : null
1742
- const hoverRatePoint = hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1997
+ const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1998
+ const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
1743
1999
  const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
1744
2000
  const clear = () => setHover(null)
1745
2001
  const otherEntries = hoverSlot
@@ -1754,13 +2010,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1754
2010
  className: 'ud-chart', viewBox: `0 0 ${avail} ${CHART_HEIGHT}`, width: '100%', role: 'img',
1755
2011
  'aria-label': title, onMouseLeave: clear,
1756
2012
  },
1757
- layout.ticks.map((tick) => {
1758
- const y = CHART_PAD.top + layout.plotHeight - (tick / layout.maxTotal) * layout.plotHeight
1759
- return h('g', { key: tick },
2013
+ yTicks.map((tick) => {
2014
+ const y = CHART_PAD.top + layout.plotHeight - tick.ratio * layout.plotHeight
2015
+ return h('g', { key: tick.label },
1760
2016
  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)))
2017
+ h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, tick.label))
1762
2018
  }),
1763
- rateAxisTicks().map((tick) => {
2019
+ speedAxisTicks.length > 0
2020
+ ? h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: CHART_PAD.top + AXIS_LABEL_BASELINE, textAnchor: 'end' }, 'tok/s')
2021
+ : null,
2022
+ (showRate ? rateAxisTicks() : []).map((tick) => {
1764
2023
  const y = CHART_PAD.top + layout.plotHeight - (tick / PERCENT_SCALE) * layout.plotHeight
1765
2024
  return h('text', {
1766
2025
  key: `rate-${tick}`, className: 'ud-axis-rate',
@@ -1778,30 +2037,42 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1778
2037
  slots.map((slot, index) => (index % layout.labelEvery === 0 || index === slots.length - 1)
1779
2038
  ? 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
2039
  : null),
1781
- h('path', {
2040
+ showRate ? h('path', {
1782
2041
  className: 'ud-trend', d: smoothPath(ratePoints),
1783
2042
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1784
- }),
2043
+ }) : null,
2044
+ showSpeed ? h('path', {
2045
+ className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
2046
+ strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2047
+ }) : null,
1785
2048
  hoverRatePoint
1786
2049
  ? h('circle', { className: 'ud-trend-dot', cx: hoverRatePoint.x, cy: hoverRatePoint.y, r: TREND_DOT_RADIUS })
1787
2050
  : null,
2051
+ hoverSpeedPoint
2052
+ ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
2053
+ : null,
1788
2054
  slots.map((slot, index) => h('rect', {
1789
2055
  key: `hit-${slot.day}`, className: 'ud-bar-hit',
1790
2056
  x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
1791
2057
  onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
1792
2058
  })))),
1793
- h(Legend, { models: legendModels, colorFor }),
2059
+ h(Legend, {
2060
+ models: legendModels, colorFor, speedEnabled: hasSpeed,
2061
+ isVisible: (key) => visibleSet.has(key),
2062
+ onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
2063
+ }),
1794
2064
  h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
1795
2065
  hoverSlot
1796
2066
  ? [
1797
- h('div', { key: 'title', className: 'ud-tip-title' }, hoverSlot.day),
2067
+ h('div', { key: 'title', className: 'ud-tip-title' }, slotLabelFor ? slotLabelFor(hoverSlot.day) : hoverSlot.day),
1798
2068
  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' },
2069
+ ...legendModels.filter((item) => visibleSet.has(item.model)).map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
1800
2070
  h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1801
2071
  `${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)}`),
2072
+ ...(visibleSet.has(OTHER_MODEL) ? otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
2073
+ `${model}: ${formatTokens(tokens)}`)) : []),
2074
+ showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
2075
+ showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
1805
2076
  costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
1806
2077
  ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
1807
2078
  : null,
@@ -2076,10 +2347,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2076
2347
  entry.title ? h('span', { title: entry.title }, entry.text) : entry.text)))
2077
2348
  })
2078
2349
 
2079
- // 注入点B 组件:Turn 尾部单轮用量行,chain matched 即 TurnTokenUsage,渲染于动作行之前;
2080
- // 无独立费用开关(plan 决策),显隐随容器 data-actions-reveal;价格未载期间费用为占位符
2081
- const CostTail = React.memo(function CostTail({ matched, useChat, t = defaultT }) {
2082
- if (!matched || typeof useChat !== 'function') return null
2350
+ // 注入点B 组件:回合费用芯片,官方动作行内渲染(复制与分支图标之间,赞踩/上下文跳转同排);
2351
+ // 受费用显示开关;价格异步首帧未回不渲染,回包后补渲染;显隐节奏随官方 data-actions-reveal
2352
+ // 官方槽容器固定在用量/用时芯片之前,末位排布由 SlotTailPortal 移交实现
2353
+ const CostChip = React.memo(function CostChip({ messageId, useChat, t = defaultT }) {
2354
+ if (typeof useChat !== 'function') return null
2355
+ const chatNodes = useChat((state) => (state && typeof state === 'object') ? state.nodes : undefined)
2356
+ const [prefs, setPrefs] = useState(() => statsLineState.get())
2357
+ useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
2083
2358
  const [pricingRules, setPricingRules] = useState(null)
2084
2359
  useEffect(() => {
2085
2360
  let alive = true
@@ -2088,14 +2363,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2088
2363
  })
2089
2364
  return () => { alive = false }
2090
2365
  }, [])
2091
- const model = turnModelOf(matched)
2092
- const now = new Date()
2093
- const price = pricingRules ? matchPrice(pricingRules, model, now) : null
2094
- const currency = pricingRules ? aggregateCurrencyOf(pricingRules) : ''
2095
- return h('div', { className: 'ud-turn-cost', title: turnCostTitleText(t, matched) },
2096
- buildTurnCostLine(t, matched, price, currency))
2366
+ const matched = typeof messageId === 'string' && messageId !== ''
2367
+ ? turnTokenUsageOfMessage(chatNodes, messageId)
2368
+ : null
2369
+ const price = matched && prefs.costDisplay && pricingRules !== null
2370
+ ? matchPrice(pricingRules, turnModelOf(matched), new Date())
2371
+ : null
2372
+ // 0 元(全零价/全零桶)与价未命中同不渲染,无占位符
2373
+ const cost = matched && price ? turnCostAmountOf(matched, price) : null
2374
+ const chip = cost === null ? null : h('span', { className: 'ud-turn-cost', title: turnCostTitleText(t, matched) },
2375
+ t('turnCostChip', { cost: formatCost(cost, aggregateCurrencyOf(pricingRules)) }))
2376
+ return h(SlotTailPortal, null, chip)
2097
2377
  })
2098
2378
 
2379
+ // 槽内容末位移交:锚点藏于槽容器内,portal 目标取槽容器(display:contents)的父级即官方动作行 div,
2380
+ // portal 子树 append 到动作行末尾(官方用量/用时芯片与结束时钟之后);portal 缺席降级锚点原位
2381
+ function SlotTailPortal({ children }) {
2382
+ const anchorRef = useRef(null)
2383
+ const [container, setContainer] = useState(null)
2384
+ useEffect(() => {
2385
+ const anchor = anchorRef.current
2386
+ const slotHost = anchor?.closest('[data-slot="conversation.chat.assistant-actions"]') ?? anchor?.parentElement
2387
+ setContainer(slotHost?.parentElement ?? null)
2388
+ }, [])
2389
+ const ported = container && createPortal ? createPortal(children, container) : null
2390
+ return h('span', { ref: anchorRef, style: ported ? { display: 'none' } : undefined }, ported ?? children)
2391
+ }
2392
+
2099
2393
  // 偏好卡行:说明文案承担 aria-describedby 目标
2100
2394
  function StatsLineOptionRow({ labelKey, descKey, checked, onToggle, t = defaultT }) {
2101
2395
  const describeId = React.useId()
@@ -2139,8 +2433,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2139
2433
  const PRICING_STATE_READY = 'ready'
2140
2434
  const PRICING_STATE_UNAVAILABLE = 'unavailable'
2141
2435
 
2142
- // 条件行:类型下拉 + 按类型字段区 + 删除;类型切换重置为该类型默认值(字段结构互不相通)
2143
- function PricingRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove }) {
2436
+ // 价格四桶 grid:默认价槽与附加规则卡共用;错误路径按规则平面索引寻址
2437
+ function PricingPriceGrid({ rule, currency, errors, pathPrefix, t, onPricePatch }) {
2144
2438
  const errorTextOf = (path) => {
2145
2439
  const key = errors.get(path)
2146
2440
  return key ? h('span', { className: 'ud-field-error' }, t(key)) : null
@@ -2152,9 +2446,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2152
2446
  h('input', {
2153
2447
  type: 'number', className: 'ud-input', min: 0, step: 'any',
2154
2448
  value: rule.price?.[key] ?? '',
2155
- onChange: (event) => onPatch({ price: { ...rule.price, [key]: event.target.value } }),
2449
+ onChange: (event) => onPricePatch({ price: { ...rule.price, [key]: event.target.value } }),
2156
2450
  })),
2157
2451
  errorTextOf(`${pathPrefix}price.${key}`))
2452
+ return h('div', { className: 'ud-price-grid' },
2453
+ priceField('input', 'priceInput'),
2454
+ priceField('output', 'priceOutput'),
2455
+ priceField('cacheRead', 'priceCacheRead'),
2456
+ priceField('cacheWrite', 'priceCacheWrite'))
2457
+ }
2458
+
2459
+ // 附加计费规则卡:头行 = 价格四桶 + 上移/下移/删除;下方为条件组合区(组内从上到下首个命中者生效)。
2460
+ // 默认价在组内独立成槽,附加规则不再承担兜底语义
2461
+ function PricingExtraRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove, onMove, canMoveUp, canMoveDown }) {
2462
+ const errorTextOf = (path) => {
2463
+ const key = errors.get(path)
2464
+ return key ? h('span', { className: 'ud-field-error' }, t(key)) : null
2465
+ }
2158
2466
  const patchConditions = (conditions) => onPatch({ conditions })
2159
2467
  const patchConditionAt = (conditionIndex, patch) => patchConditions(patchItemAt(rule.conditions, conditionIndex, patch))
2160
2468
  const removeConditionAt = (conditionIndex) => patchConditions(rule.conditions.filter((_, i) => i !== conditionIndex))
@@ -2168,10 +2476,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2168
2476
  onChange: (event) => patchCondition({ [field]: event.target.value }),
2169
2477
  }),
2170
2478
  errorTextOf(`${condPath}.${field}`))
2479
+ // 号段 from/to 同域 1~31(双闭),统一上限
2171
2480
  const numberInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
2172
2481
  h('span', { className: 'ud-field-label' }, t(labelKey)),
2173
2482
  h('input', {
2174
- type: 'number', className: 'ud-input', min: MONTH_DAY_MIN, max: MONTH_DAY_MAX, step: 1,
2483
+ type: 'number', className: 'ud-input', min: MONTH_DAY_MIN,
2484
+ max: MONTH_DAY_MAX, step: 1,
2175
2485
  value: condition[field] ?? '',
2176
2486
  onChange: (event) => patchCondition({ [field]: event.target.value }),
2177
2487
  }),
@@ -2208,32 +2518,74 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2208
2518
  }, '×'))
2209
2519
  }
2210
2520
  return h('div', { className: 'ud-rule' },
2211
- h('div', { className: 'ud-rule-head' },
2521
+ h('div', { className: 'ud-slot-head' },
2522
+ h(PricingPriceGrid, {
2523
+ rule, currency, errors, pathPrefix, t,
2524
+ onPricePatch: (part) => onPatch(part),
2525
+ }),
2526
+ canMoveUp ? h('button', {
2527
+ className: 'ud-btn ud-btn--text', type: 'button', onClick: () => onMove(-1),
2528
+ 'aria-label': t('moveUp'), title: t('moveUp'),
2529
+ }, '↑') : null,
2530
+ canMoveDown ? h('button', {
2531
+ className: 'ud-btn ud-btn--text', type: 'button', onClick: () => onMove(1),
2532
+ 'aria-label': t('moveDown'), title: t('moveDown'),
2533
+ }, '↓') : null,
2534
+ h(DeleteArmedButton, { labelKey: 'deleteRule', confirmKey: 'confirmDelete', onConfirm: onRemove, t })),
2535
+ h('div', { className: 'ud-rule-conds' },
2536
+ (rule.conditions ?? []).map((condition, conditionIndex) => conditionRow(condition, conditionIndex)),
2537
+ h('div', { className: 'ud-cond-add' },
2538
+ h('button', {
2539
+ type: 'button', className: 'ud-btn ud-btn--text',
2540
+ onClick: () => patchConditions([...(rule.conditions ?? []), defaultCondition(CONDITION_KIND_OPTIONS[0])]),
2541
+ }, `+${t('addCondition')}`))))
2542
+ }
2543
+
2544
+ // 模型组卡:组头 = 模型键输入(整组一次改名)+ 删除整组;组内 = 默认价槽(禁条件/排序/删除,恒兜底)
2545
+ // + 附加计费规则列表(从上到下首个命中生效,全不命中落默认价)。
2546
+ // 组件 key 由调用方锚定首槽位平面索引,改名引发的重新聚合不会丢焦点
2547
+ function PricingModelGroup({ group, currency, errors, t, onModelChange, onGroupRemove, onSlotPatch, onSlotRemove, onSlotMove, onSlotAdd }) {
2548
+ const modelError = group.slots.map((slot) => errors.get(`${slot.index}.model`)).find(Boolean)
2549
+ const { defaultSlot, extras } = partitionGroupOf(group)
2550
+ // 新附加规则插到默认价平面位之前(无默认价则组末),保证默认价恒居组末
2551
+ const insertPosition = defaultSlot ? defaultSlot.index - 1 : group.slots[group.slots.length - 1].index
2552
+ return h('div', { className: 'ud-rule-group' },
2553
+ h('div', { className: 'ud-rule-group-head' },
2212
2554
  h('label', { className: 'ud-field' },
2213
2555
  h('span', { className: 'ud-field-label' }, t('pricingModel')),
2214
2556
  h('input', {
2215
- type: 'text', className: 'ud-input', value: rule.model,
2557
+ type: 'text', className: 'ud-input', value: group.model,
2216
2558
  placeholder: t('pricingModelPlaceholder'),
2217
- onChange: (event) => onPatch({ model: event.target.value }),
2559
+ onChange: (event) => onModelChange(event.target.value),
2218
2560
  }),
2219
- errorTextOf(`${pathPrefix}model`)),
2220
- h('button', {
2221
- className: 'ud-btn ud-btn--text', type: 'button', onClick: onRemove,
2222
- 'aria-label': t('deleteRule'), title: t('deleteRule'),
2223
- }, '×')),
2224
- h('div', { className: 'ud-price-grid' },
2225
- priceField('input', 'priceInput'),
2226
- priceField('output', 'priceOutput'),
2227
- priceField('cacheRead', 'priceCacheRead'),
2228
- priceField('cacheWrite', 'priceCacheWrite')),
2229
- h('div', { className: 'ud-rule-conds' },
2230
- (rule.conditions ?? []).length === 0 ? h('span', { className: 'ud-rule-cond' }, t('noCondition')) : null,
2231
- (rule.conditions ?? []).map((condition, conditionIndex) => conditionRow(condition, conditionIndex)),
2232
- h('div', { className: 'ud-cond-add' },
2233
- CONDITION_KIND_OPTIONS.map((kind) => h('button', {
2234
- key: kind, type: 'button', className: 'ud-btn ud-btn--text',
2235
- onClick: () => patchConditions([...(rule.conditions ?? []), defaultCondition(kind)]),
2236
- }, `+${t(CONDITION_KIND_LABEL_KEYS[kind])}`)))))
2561
+ modelError ? h('span', { className: 'ud-field-error' }, t(modelError)) : null),
2562
+ h(DeleteArmedButton, { labelKey: 'deleteGroup', confirmKey: 'confirmDelete', onConfirm: onGroupRemove, t })),
2563
+ h('div', { className: 'ud-rule-group-slots' },
2564
+ defaultSlot ? h('div', { className: 'ud-rule ud-rule-default' },
2565
+ h('div', { className: 'ud-slot-head' },
2566
+ h(PricingPriceGrid, {
2567
+ rule: defaultSlot.rule, currency, errors,
2568
+ pathPrefix: `${defaultSlot.index}.`, t,
2569
+ onPricePatch: (part) => onSlotPatch(defaultSlot.index, part),
2570
+ })),
2571
+ h('span', { className: 'ud-rule-cond' }, t('defaultPriceHint'))) : null,
2572
+ extras.map((slot, position) => h(PricingExtraRuleCard, {
2573
+ key: slot.index,
2574
+ rule: slot.rule,
2575
+ currency,
2576
+ errors,
2577
+ pathPrefix: `${slot.index}.`,
2578
+ t,
2579
+ onPatch: (part) => onSlotPatch(slot.index, part),
2580
+ onRemove: () => onSlotRemove(slot.index),
2581
+ onMove: (delta) => onSlotMove(slot.index, extras[position + delta].index),
2582
+ canMoveUp: position > 0,
2583
+ canMoveDown: position < extras.length - 1,
2584
+ }))),
2585
+ h('button', {
2586
+ className: 'ud-rule-add', type: 'button',
2587
+ onClick: () => onSlotAdd(insertPosition, group.model),
2588
+ }, defaultSlot ? t('addRule') : t('addDefaultPrice')))
2237
2589
  }
2238
2590
 
2239
2591
  function PricingEditor({ t = defaultT }) {
@@ -2316,20 +2668,30 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2316
2668
  }, symbol))),
2317
2669
  h('button', { className: 'ud-btn', type: 'button', disabled: saving, onClick: save }, t('save')))),
2318
2670
  saveError ? h('div', { className: 'ud-error' }, saveError) : null,
2319
- rules.map((rule, index) => h(PricingRuleCard, {
2320
- key: index,
2321
- rule,
2671
+ h('span', { className: 'ud-rule-cond' }, t('ruleOrderHint')),
2672
+ groupRulesOf(rules).map((group) => h(PricingModelGroup, {
2673
+ // key 锚定首槽位平面索引:改名重聚合不重建组件,输入焦点不丢
2674
+ key: String(group.slots[0].index),
2675
+ group,
2322
2676
  currency,
2323
2677
  errors,
2324
- pathPrefix: `${index}.`,
2325
2678
  t,
2326
- onPatch: (part) => setRules((prev) => patchItemAt(prev, index, part)),
2327
- onRemove: () => setRules((prev) => prev.filter((_, i) => i !== index)),
2679
+ onModelChange: (model) => setRules((prev) => renameRulesAt(prev, group.slots.map((slot) => slot.index), model)),
2680
+ onGroupRemove: () => setRules((prev) => removeRulesAt(prev, group.slots.map((slot) => slot.index))),
2681
+ onSlotPatch: (index, part) => setRules((prev) => patchItemAt(prev, index, part)),
2682
+ onSlotRemove: (index) => setRules((prev) => prev.filter((_, i) => i !== index)),
2683
+ onSlotMove: (fromIndex, toIndex) => setRules((prev) => moveRuleTo(prev, fromIndex, toIndex)),
2684
+ onSlotAdd: (position, model) => setRules((prev) => insertRuleAt(prev, position, {
2685
+ ...defaultPricingRule(currency),
2686
+ model,
2687
+ // 附加规则默认带全天时段条件:默认价已独立成槽,附加规则应以可编辑条件呈现
2688
+ conditions: [defaultCondition(CONDITION_KIND_OPTIONS[0])],
2689
+ })),
2328
2690
  })),
2329
2691
  h('button', {
2330
2692
  className: 'ud-rule-add', type: 'button',
2331
2693
  onClick: () => setRules((prev) => [...prev, defaultPricingRule(currency)]),
2332
- }, t('addRule')))
2694
+ }, t('addGroup')))
2333
2695
  }
2334
2696
 
2335
2697
  // 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示
@@ -2360,14 +2722,22 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2360
2722
  status.error ? h('span', { className: 'ud-status-err' }, status.error) : null)
2361
2723
  }
2362
2724
 
2363
- // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容)
2725
+ // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容);
2726
+ // skipped 细分归因(宿主拒读/损坏/旧格式)挂在汇总行 title,供跨宿主边界诊断
2364
2727
  function AnomalyLog({ status, t = defaultT }) {
2365
2728
  const lines = status.log ?? []
2366
2729
  if (lines.length === 0) return null
2730
+ const breakdown = status.skippedBreakdown ?? {}
2731
+ const breakdownText = t('skipBreakdown', {
2732
+ d: breakdown.descriptor ?? 0,
2733
+ c: breakdown.corrupt ?? 0,
2734
+ l: breakdown.legacy ?? 0,
2735
+ o: breakdown.other ?? 0,
2736
+ })
2367
2737
  return h('div', { className: 'ud-log' },
2368
2738
  h('div', { className: 'ud-log-summary' },
2369
2739
  (status.skippedSessions ?? 0) > 0
2370
- ? h('span', null, t('skippedSessions', { n: status.skippedSessions }))
2740
+ ? h('span', { title: breakdownText }, t('skippedSessions', { n: status.skippedSessions }))
2371
2741
  : null,
2372
2742
  (status.recordFailures ?? 0) > 0
2373
2743
  ? h('span', { className: 'ud-status-err' }, t('recordFailures', { n: status.recordFailures }))
@@ -2406,6 +2776,40 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2406
2776
  armed ? t('rebuildConfirm') : t('rebuild'))
2407
2777
  }
2408
2778
 
2779
+ // 回退按钮:仅当存在重建快照(status.backup.available)时可点;
2780
+ // 恢复动作本身也会被重新快照覆盖,回退链不断
2781
+ function RestoreButton({ machineRef, busy, backup, onError, t = defaultT }) {
2782
+ const [armed, setArmed] = useState(false)
2783
+ const armedTimerRef = useRef(null)
2784
+
2785
+ useEffect(() => () => {
2786
+ if (armedTimerRef.current) clearTimeout(armedTimerRef.current)
2787
+ }, [])
2788
+
2789
+ if (!backup?.available) {
2790
+ return h('button', { className: 'ud-btn ud-btn--text', disabled: true, title: t('restoreMissing') },
2791
+ t('restore'))
2792
+ }
2793
+
2794
+ const restore = async () => {
2795
+ if (!armed) {
2796
+ setArmed(true)
2797
+ armedTimerRef.current = setTimeout(() => setArmed(false), REBUILD_CONFIRM_MS)
2798
+ return
2799
+ }
2800
+ setArmed(false)
2801
+ const result = await requestPost(ENDPOINTS.restore)
2802
+ if (!result.ok) {
2803
+ onError(result.message)
2804
+ return
2805
+ }
2806
+ machineRef.current?.restart()
2807
+ }
2808
+
2809
+ return h('button', { className: 'ud-btn ud-btn--text', disabled: busy, onClick: restore },
2810
+ armed ? t('restoreConfirm') : t('restore'))
2811
+ }
2812
+
2409
2813
  const viewLabel = (t, id) => (id === 'day' ? t('viewDay') : id === 'hour' ? t('viewHour') : t('viewMinute'))
2410
2814
  const trendTitle = (t, id) => (id === 'day' ? t('dailyTrend') : id === 'hour' ? t('hourTrend') : t('minuteTrend'))
2411
2815
  const trendLimitedText = (t, id, count) => (id === 'day'
@@ -2413,6 +2817,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2413
2817
  : id === 'hour' ? t('trendLimitedHour', { n: count }) : t('trendLimitedMinute', { n: count }))
2414
2818
  const presetLabel = (t, view, id) => t(`${view === 'hour' ? 'hourPreset' : 'minutePreset'}.${id}`)
2415
2819
  const tickLabelFor = (view) => (view === 'day' ? shortDay : view === 'hour' ? hourTickLabel : minuteTickLabel)
2820
+ const slotLabelFor = (view) => (view === 'hour' ? hourSlotLabel : view === 'minute' ? minuteSlotLabel : (day) => day)
2416
2821
 
2417
2822
  function UsageDashPanel({ t = defaultT }) {
2418
2823
  const [view, setView] = useState('day')
@@ -2651,7 +3056,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2651
3056
  ? h('div', { className: 'ud-detail' },
2652
3057
  h(AnomalyLog, { status, t }),
2653
3058
  h('div', { className: 'ud-detail-foot' },
2654
- h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t })))
3059
+ h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t }),
3060
+ h(RestoreButton, { machineRef: statusMachineRef, busy: status?.running === true, backup: status?.backup, onError: setError, t })))
2655
3061
  : null,
2656
3062
  error ? h('div', { className: 'ud-error' }, error) : null,
2657
3063
  loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
@@ -2667,6 +3073,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2667
3073
  modelOrder: trendSource.models.map((item) => item.model),
2668
3074
  colorFor,
2669
3075
  labelFor: tickLabelFor(view),
3076
+ slotLabelFor: slotLabelFor(view),
2670
3077
  labelMinPitch: pointActive ? LABEL_PITCH_TIME : LABEL_PITCH_DAY,
2671
3078
  busy,
2672
3079
  legendModels: trendSource.models,
@@ -2704,15 +3111,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2704
3111
  } catch (error) {
2705
3112
  console.warn('[usage-dash] 底部信息栏未注册(宿主无 conversation.composer.dock 插槽)', error)
2706
3113
  }
2707
- // 注入点B:turnTail chain 条目,同款两段式与降级;旧宿主无该插槽仅告警禁用
3114
+ // 注入点B:回合费用芯片走官方动作行槽,回合结束随 messageId 出现;旧宿主无该插槽仅告警禁用
2708
3115
  try {
2709
- ctx.slots.inject('conversation.chat.turnTail', () =>
3116
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
2710
3117
  ctx.slots.register(
2711
- { name: 'conversation.chat.turnTail', select: selectTurnTokenUsage, priority: TURN_TAIL_PRIORITY, locale: LOCALE_NS },
2712
- CostTail,
3118
+ { name: 'conversation.chat.assistant-actions', id: 'usage-dash-turn-cost', order: TURN_COST_CHIP_ORDER, locale: LOCALE_NS },
3119
+ CostChip,
2713
3120
  ))
2714
3121
  } catch (error) {
2715
- console.warn('[usage-dash] 会话尾部用量行未注册(宿主无 conversation.chat.turnTail 插槽)', error)
3122
+ console.warn('[usage-dash] 回合费用芯片未注册(宿主无 conversation.chat.assistant-actions 插槽)', error)
2716
3123
  }
2717
3124
  // 跨实例同步:其他实例写开关经 storage 事件触发重读(同实例写入不触发该事件)
2718
3125
  window.addEventListener('storage', (event) => {