@mzzsfy/dsh-usage-dash 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js CHANGED
@@ -18,7 +18,7 @@ const DEFAULT_MINUTE_PRESET = '24h'
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
@@ -128,6 +128,7 @@ const MESSAGES_ZH = {
128
128
  hitRateLegend: '缓存命中率',
129
129
  avgSpeed: '平均生成速度',
130
130
  speedLegend: '平均生成速度',
131
+ ttftLegend: '首 token 延迟',
131
132
  topModel: '最常用模型',
132
133
  topModelHint: '按 token 用量排序,非调用次数',
133
134
  heatmap: '活跃热力图',
@@ -148,6 +149,9 @@ const MESSAGES_ZH = {
148
149
  'status.running': '回扫中 {done}/{total}',
149
150
  rebuild: '重建',
150
151
  rebuildConfirm: '确认重建',
152
+ restore: '回退数据',
153
+ restoreConfirm: '确认回退',
154
+ restoreMissing: '无可用回退点(重建时自动生成)',
151
155
  hourTrend: '按小时 Token 趋势',
152
156
  minuteTrend: '按分钟 Token 趋势',
153
157
  'hourPreset.24h': '24 小时',
@@ -163,6 +167,7 @@ const MESSAGES_ZH = {
163
167
  trendTruncated: '数据量过大,仅显示最近部分',
164
168
  recordFailures: '{n} 条记录写入失败',
165
169
  skippedSessions: '跳过 {n} 个无法读取的会话',
170
+ skipBreakdown: '宿主拒读 {d} / 存档损坏 {c} / 旧格式 {l} / 其他 {o}——宿主修复后下轮回扫自动补齐',
166
171
  anomalyLog: '扫描异常日志',
167
172
  logKindSkipped: '跳过会话',
168
173
  logKindRecord: '写入失败',
@@ -173,18 +178,18 @@ const MESSAGES_ZH = {
173
178
  'stats.tokensPerSecond': '{throughput} tok/s',
174
179
  'stats.cacheHit': '缓存命中 {percent}%',
175
180
  'stats.tokens': '输入 {input} tok · 输出 {output} tok',
181
+ 'turnCostChip': '{cost}',
176
182
  'stats.tokensDetail': '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok',
177
183
  cachePrecision: '精确缓存命中率',
178
184
  cachePrecisionDesc: '在会话底部信息栏以两位小数显示缓存命中率。',
179
185
  tokenDetail: '会话 Token 明细',
180
186
  tokenDetailDesc: '在会话底部信息栏显示总 Token、命中/未命中缓存与输出明细。',
181
187
  costDisplay: '费用显示',
182
- costDisplayDesc: '在信息栏与趋势悬浮中显示按当前费率估算的费用。',
188
+ costDisplayDesc: '在信息栏、趋势悬浮与回合费用芯片中显示按当前费率估算的费用。',
183
189
  costTitle: '按当前费率对历史用量估算,精度为小时级',
184
190
  costUnpriced: '{n} 个小时桶未计价',
185
191
  statsCostTitle: '按当前费率对会话累计 token 估算',
186
192
  'stats.cost': '费用 ≈ {cost}',
187
- 'stats.turnCost': '{summary} · 费用 ≈ {cost}',
188
193
  turnCostTitle: '单轮用量按当前费率估算',
189
194
  turnTokensUnreported: '该提供商未上报此桶',
190
195
  pricing: '定价规则',
@@ -197,8 +202,8 @@ const MESSAGES_ZH = {
197
202
  priceOutput: '输出',
198
203
  priceCacheRead: '缓存读',
199
204
  priceCacheWrite: '缓存写',
200
- noCondition: '无条件 = 恒生效',
201
205
  addCondition: '添加条件',
206
+ confirmDelete: '确认删除',
202
207
  deleteCondition: '删除条件',
203
208
  condKind: '条件类型',
204
209
  condDailyWindow: '每日时段',
@@ -218,9 +223,17 @@ const MESSAGES_ZH = {
218
223
  condWeekday: '需 0-6 整数',
219
224
  condMonthDay: '需 1-31 整数',
220
225
  condDate: '需 YYYY-MM-DD',
221
- condRange: '起始不得晚于结束',
222
- deleteRule: '删除规则',
223
- addRule: '添加规则',
226
+ condRange: '结束不得早于起始(两端均含)',
227
+ ruleOrderHint: '附加计费规则从上到下匹配,首个命中生效;全不命中落默认价',
228
+ moveUp: '上移',
229
+ moveDown: '下移',
230
+ addGroup: '添加模型',
231
+ addRule: '添加额外计费规则',
232
+ addDefaultPrice: '添加默认价',
233
+ deleteGroup: '删除模型及其全部计费规则',
234
+ deleteRule: '删除计费规则',
235
+ addCondition: '添加条件',
236
+ defaultPriceHint: '默认价:附加规则全不命中时生效,不设条件',
224
237
  save: '保存',
225
238
  saved: '已保存',
226
239
  required: '必填',
@@ -255,6 +268,7 @@ const MESSAGES_EN = {
255
268
  hitRateLegend: 'Cache-hit rate',
256
269
  avgSpeed: 'Avg speed',
257
270
  speedLegend: 'Avg speed',
271
+ ttftLegend: 'First-token latency',
258
272
  topModel: 'Top model',
259
273
  topModelHint: 'Ranked by token usage, not call count',
260
274
  heatmap: 'Activity heatmap',
@@ -275,6 +289,9 @@ const MESSAGES_EN = {
275
289
  'status.running': 'Rescanning {done}/{total}',
276
290
  rebuild: 'Rebuild',
277
291
  rebuildConfirm: 'Confirm rebuild',
292
+ restore: 'Restore data',
293
+ restoreConfirm: 'Confirm restore',
294
+ restoreMissing: 'No restore point (created automatically on rebuild)',
278
295
  hourTrend: 'Hourly token trend',
279
296
  minuteTrend: 'Per-minute token trend',
280
297
  'hourPreset.24h': '24 hours',
@@ -290,6 +307,7 @@ const MESSAGES_EN = {
290
307
  trendTruncated: 'Too much data, showing only the latest part',
291
308
  recordFailures: '{n} records failed to write',
292
309
  skippedSessions: '{n} unreadable sessions skipped',
310
+ skipBreakdown: 'host-refused {d} / corrupt {c} / legacy format {l} / other {o} — auto-retried once the host can read them',
293
311
  anomalyLog: 'Scan anomaly log',
294
312
  logKindSkipped: 'skipped',
295
313
  logKindRecord: 'write failed',
@@ -300,18 +318,18 @@ const MESSAGES_EN = {
300
318
  'stats.tokensPerSecond': '{throughput} tok/s',
301
319
  'stats.cacheHit': 'Cache hit {percent}%',
302
320
  'stats.tokens': 'Input {input} tok · Output {output} tok',
321
+ 'turnCostChip': '{cost}',
303
322
  'stats.tokensDetail': 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok',
304
323
  cachePrecision: 'Precise cache-hit rate',
305
324
  cachePrecisionDesc: 'Show the cache-hit rate with two decimals in the session stats line.',
306
325
  tokenDetail: 'Session token detail',
307
326
  tokenDetailDesc: 'Show total, cache hit/miss and output tokens in the session stats line.',
308
327
  costDisplay: 'Cost display',
309
- costDisplayDesc: 'Show costs estimated at current rates in the stats line and trend tooltips.',
328
+ costDisplayDesc: 'Show costs estimated at current rates in the stats line, trend tooltips and the turn cost chip.',
310
329
  costTitle: 'Estimated at current rates over historical usage, hourly precision',
311
330
  costUnpriced: '{n} hour buckets unpriced',
312
331
  statsCostTitle: 'Estimated at current rates over session token totals',
313
332
  'stats.cost': 'Cost ≈ {cost}',
314
- 'stats.turnCost': '{summary} · Cost ≈ {cost}',
315
333
  turnCostTitle: 'Per-turn usage estimated at current rates',
316
334
  turnTokensUnreported: 'Not reported by this provider',
317
335
  pricing: 'Pricing rules',
@@ -324,8 +342,8 @@ const MESSAGES_EN = {
324
342
  priceOutput: 'Output',
325
343
  priceCacheRead: 'Cache read',
326
344
  priceCacheWrite: 'Cache write',
327
- noCondition: 'No condition = always applies',
328
345
  addCondition: 'Add condition',
346
+ confirmDelete: 'Confirm delete',
329
347
  deleteCondition: 'Remove condition',
330
348
  condKind: 'Condition kind',
331
349
  condDailyWindow: 'Daily window',
@@ -345,9 +363,17 @@ const MESSAGES_EN = {
345
363
  condWeekday: 'Requires integer 0-6',
346
364
  condMonthDay: 'Requires integer 1-31',
347
365
  condDate: 'Requires YYYY-MM-DD',
348
- condRange: 'From must not be after to',
349
- deleteRule: 'Remove rule',
350
- addRule: 'Add rule',
366
+ condRange: 'End must not be before start (both ends inclusive)',
367
+ ruleOrderHint: '附加计费规则从上到下匹配,首个命中生效;全不命中落默认价',
368
+ moveUp: 'Move up',
369
+ moveDown: 'Move down',
370
+ addGroup: 'Add model',
371
+ addRule: 'Add extra pricing rule',
372
+ addDefaultPrice: 'Add default price',
373
+ deleteGroup: 'Delete model and all its pricing rules',
374
+ deleteRule: 'Remove pricing rule',
375
+ addCondition: 'Add condition',
376
+ defaultPriceHint: 'Default price: applies when no extra rule above matches; no conditions',
351
377
  save: 'Save',
352
378
  saved: 'Saved',
353
379
  required: 'Required',
@@ -505,11 +531,14 @@ const BAR_MAX_WIDTH = 30
505
531
  const AXIS_TICK_COUNT = 4
506
532
  // 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
507
533
  const SPEED_SCALE_FLOOR = 1
534
+ // 首 token 延迟刻度上限钳底(毫秒):全零或无数据防除零(不设轴,读数走 tooltip)
535
+ const TTFT_SCALE_FLOOR = 1
508
536
  // 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
509
537
  const AXIS_SCALE_HEADROOM = 1.1
510
538
  // 图例键:折线项与模型项共处同一显隐集合
511
539
  const LEGEND_KEY_RATE = 'rate'
512
540
  const LEGEND_KEY_SPEED = 'speed'
541
+ const LEGEND_KEY_TTFT = 'ttft'
513
542
 
514
543
  function niceTicks(max, count) {
515
544
  if (max <= 0 || count <= 0) return []
@@ -590,6 +619,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
590
619
  const PERCENT_SCALE = 100
591
620
  const RATE_AXIS_STEPS = 4
592
621
  const TREND_LINE_WIDTH = 2
622
+ const TREND_LINE_GAP_SLOTS = 1.5
593
623
  const TREND_DOT_RADIUS = 4
594
624
  const TREND_DOT_RING = 2
595
625
  const AXIS_RATE_GAP = 8
@@ -626,8 +656,22 @@ function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
626
656
  return points
627
657
  }
628
658
 
629
- // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
630
- function smoothPath(points) {
659
+ // token 延迟曲线点:仅带 ttft 槽产出,高度按刻度上限归一(毫秒域独立归一)
660
+ function trendTtftPoints(slots, bars, plotHeight, scaleMax) {
661
+ const points = []
662
+ slots.forEach((slot, index) => {
663
+ if (slot.ttft === undefined) return
664
+ points.push({
665
+ day: slot.day,
666
+ x: bars[index].x,
667
+ y: CHART_PAD.top + plotHeight - (slot.ttft / scaleMax) * plotHeight,
668
+ })
669
+ })
670
+ return points
671
+ }
672
+
673
+ // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返;相邻点间距超 maxGap 折线断开成新段(稀疏槽防长弧跨越)
674
+ function smoothPath(points, maxGap = Number.POSITIVE_INFINITY) {
631
675
  if (points.length === 0) return ''
632
676
  if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
633
677
  let d = `M ${points[0].x} ${points[0].y}`
@@ -636,6 +680,10 @@ function smoothPath(points) {
636
680
  const p1 = points[i]
637
681
  const p2 = points[i + 1]
638
682
  const p3 = points[i + 2] ?? p2
683
+ if (p2.x - p1.x > maxGap) {
684
+ d += ` M ${p2.x} ${p2.y}`
685
+ continue
686
+ }
639
687
  const c1x = p1.x + (p2.x - p0.x) / 6
640
688
  const c1y = p1.y + (p2.y - p0.y) / 6
641
689
  const c2x = p2.x - (p3.x - p1.x) / 6
@@ -695,6 +743,30 @@ function speedScaleMax(slots) {
695
743
  return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
696
744
  }
697
745
 
746
+ // 首 token 延迟刻度上限(毫秒):全零或无数据钳底防除零
747
+ function ttftScaleMax(slots) {
748
+ return Math.max(TTFT_SCALE_FLOOR, ...slots.map((slot) => slot.ttft ?? 0))
749
+ }
750
+
751
+ // tooltip 首 token 延迟行文本:无 ttft 与命中率同款占位符,有则为官方时长口径
752
+ function ttftTipText(ttft, t) {
753
+ return ttft === undefined ? TOOLTIP_MISSING : formatDuration(ttft, t)
754
+ }
755
+
756
+ // 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(模型列表第二行用,禁本地化)
757
+ function formatDurationShort(ms) {
758
+ const seconds = ms / MS_PER_SECOND
759
+ if (seconds < DURATION_MINUTE_SECONDS) return `${Math.round(seconds * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL}s`
760
+ const whole = Math.round(seconds)
761
+ return `${Math.floor(whole / SECONDS_PER_MINUTE)}m${whole % SECONDS_PER_MINUTE}s`
762
+ }
763
+
764
+ // 模型首 token 延迟短文本:语言中立;无 ttft(无配对数据)为空串
765
+ function modelTtftText(ttft) {
766
+ if (ttft === undefined) return ''
767
+ return `TTFT ${formatDurationShort(ttft)}`
768
+ }
769
+
698
770
  // 热力图:窗口固定 26 周,与所选范围无关
699
771
  const HEAT_WEEKS = 26
700
772
  const HEAT_ROW_COUNT = 7
@@ -752,10 +824,13 @@ function heatLevel(tokens, max) {
752
824
  return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
753
825
  }
754
826
 
755
- // ChartTip 定位:锚定格子矩形,边界内 clamp,下方优先放不下翻上方
827
+ // ChartTip 定位:锚定区装得下贴内顶(趋势图悬停区即绘图区,悬浮窗留在图表内),装不下上翻、再下翻、末了钳边界顶
756
828
  const TIP_GAP_PX = 8
757
829
  const TIP_MARGIN_PX = 8
758
830
 
831
+ // 回合费用芯片与前置官方芯片(结束时钟)的间距
832
+ const TURN_COST_GAP_PX = 8
833
+
759
834
  function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
760
835
  if (!tip || tip.width <= 0 || tip.height <= 0) return null
761
836
  if (!anchor || (anchor.left === 0 && anchor.top === 0 && anchor.right === 0 && anchor.bottom === 0)) return null
@@ -764,11 +839,13 @@ function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX)
764
839
  const maxX = bounds.right - margin
765
840
  const maxY = bounds.bottom - margin
766
841
  const left = Math.max(minX, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, maxX - tip.width))
767
- const below = anchor.bottom + gap
842
+ const inside = anchor.top + gap
768
843
  const above = anchor.top - gap - tip.height
844
+ const below = anchor.bottom + gap
769
845
  let top
770
- if (below + tip.height <= maxY) top = below
846
+ if (inside + tip.height <= anchor.bottom) top = inside
771
847
  else if (above >= minY) top = above
848
+ else if (below + tip.height <= maxY) top = below
772
849
  else top = minY
773
850
  return { left, top }
774
851
  }
@@ -1052,15 +1129,14 @@ const toMinutesOfDay = (hhmm) => {
1052
1129
  return Number.isFinite(h) && Number.isFinite(m) ? h * MINUTES_PER_HOUR + m : Number.NaN
1053
1130
  }
1054
1131
 
1055
- // from<to 含头不含尾;from>to 跨午夜;from===to 全天生效
1132
+ // 所有范围条件统一双侧包含;from>to 跨午夜/跨月环绕;from===to 单点/单日(与宿主 pricing.js 镜像,parity 测试锁定)
1056
1133
  function dailyWindowMatches(condition, date) {
1057
1134
  const from = toMinutesOfDay(condition.from)
1058
1135
  const to = toMinutesOfDay(condition.to)
1059
1136
  if (Number.isNaN(from) || Number.isNaN(to)) return false
1060
1137
  const m = minutesOfDay(date)
1061
- if (from < to) return m >= from && m < to
1062
- if (from > to) return m >= from || m < to
1063
- return true
1138
+ if (from <= to) return m >= from && m <= to
1139
+ return m >= from || m <= to
1064
1140
  }
1065
1141
 
1066
1142
  // days 空数组不成立;0=周日,取 getDay()
@@ -1069,7 +1145,7 @@ function weekdaysMatches(condition, date) {
1069
1145
  return Array.isArray(days) && days.length > 0 && days.includes(date.getDay())
1070
1146
  }
1071
1147
 
1072
- // 号段双闭;from>to 跨月环绕;日号必须整数,2 月无 31 号自然不触发
1148
+ // 号段双侧包含;from>to 跨月环绕;from===to 单日,2 月无 31 号自然不触发
1073
1149
  function monthDaysMatches(condition, date) {
1074
1150
  const { from, to } = condition
1075
1151
  if (!Number.isInteger(from) || !Number.isInteger(to)) return false
@@ -1077,7 +1153,7 @@ function monthDaysMatches(condition, date) {
1077
1153
  return from <= to ? d >= from && d <= to : d >= from || d <= to
1078
1154
  }
1079
1155
 
1080
- // 要求零填充 YYYY-MM-DD 字典序双闭;from>to 属配置错误不成立,非规范串同样不成立
1156
+ // 零填充 YYYY-MM-DD 字典序双侧包含;from>to 配置错误不成立(编辑器校验拦截)
1081
1157
  const ISO_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
1082
1158
  function dateRangeMatches(condition, date) {
1083
1159
  const { from, to } = condition
@@ -1229,29 +1305,99 @@ function costTitleText(t, unpriced) {
1229
1305
  return unpriced > 0 ? `${base},${t('costUnpriced', { n: unpriced })}` : base
1230
1306
  }
1231
1307
 
1232
- // ===== 注入点B:turnTail 单轮用量行(chain 条目,官方 TurnTailNodeView 容器内渲染) =====
1233
- // chain 尝试顺序 = priority 升序:deliverables 产物行(默认 0)先试,本条目后试让位
1234
- const TURN_TAIL_DATA_KEY = 'turn-tail'
1235
- const TURN_TAIL_PRIORITY = 1
1236
- const TURN_COST_REVEAL_MS = 80
1237
- const TURN_TAIL_ACTIONS_INSET_PX = -6
1238
-
1239
- // Turn 位置数据读取:owner 形状残缺一律 null 不抛;tokenUsage 缺失(证据不完整)即放弃渲染
1240
- function selectTurnTokenUsage(owner) {
1241
- return owner?.turn?.data?.get
1242
- ? (owner.turn.data.get(TURN_TAIL_DATA_KEY)?.tokenUsage ?? null)
1243
- : null
1308
+ // ===== 注入点B:回合费用芯片(官方动作行 assistant-actions 槽条目,赞踩/上下文跳转同排) =====
1309
+ // 排序取上下文插件条目(20)之后,贴近尾部用量/用时芯片一侧
1310
+ const TURN_COST_CHIP_ORDER = 20 + 10
1311
+
1312
+ // messageId 反查:节点表为 chat 节点仓库(values() 可枚举,Map/仓库两态兼容);
1313
+ // 回合级用量优先取回合位置数据(location.turn.data.get('turn-tail')).tokenUsage(官方 tokenUsage 聚合,
1314
+ // 分页窗口缺 turn/start 时缺席),回退视图节点 data.closing.usage(末步用量采样,输入侧已含缓存,计费口径同源)
1315
+ // 索引缓存:每份节点表快照只全量扫描一次建 messageId→turn-tail 索引(WeakMap 随快照释放),
1316
+ // 长会话流式期间多芯片各自全量扫描是 O(消息数×节点数) 放大,索引后单快照 O(节点数)
1317
+ const TURN_USAGE_INDEX_CACHE = new WeakMap()
1318
+
1319
+ function turnUsageIndexOf(nodes) {
1320
+ let index = TURN_USAGE_INDEX_CACHE.get(nodes)
1321
+ if (index !== undefined) return index
1322
+ index = new Map()
1323
+ const list = nodes && typeof nodes.values === 'function' ? [...nodes.values()] : nodes
1324
+ if (Array.isArray(list)) {
1325
+ for (const node of list) {
1326
+ try {
1327
+ if (node?.kind !== 'turn-tail') continue
1328
+ const messageId = node.data?.closing?.finalNode?.messageId
1329
+ if (typeof messageId !== 'string' || index.has(messageId)) continue
1330
+ index.set(messageId, node)
1331
+ } catch { /* 单节点形状残缺跳过 */ }
1332
+ }
1333
+ }
1334
+ TURN_USAGE_INDEX_CACHE.set(nodes, index)
1335
+ return index
1336
+ }
1337
+
1338
+ function turnTokenUsageOfMessage(nodes, messageId) {
1339
+ // 非对象(含 null/undefined)直接 null:WeakMap 键要求对象,展开校验收进索引构建,
1340
+ // 每快照只物化一次节点序列
1341
+ if (nodes === null || typeof nodes !== 'object') return null
1342
+ const node = turnUsageIndexOf(nodes).get(messageId)
1343
+ if (node === undefined) return null
1344
+ try {
1345
+ const turnData = node.location?.turn?.data
1346
+ const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1347
+ if (tail?.tokenUsage) return tail.tokenUsage
1348
+ if (node.data.tokenUsage) return node.data.tokenUsage
1349
+ return usageSourceBuckets(node.data?.closing?.usage ?? null)
1350
+ } catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
1351
+ return null
1352
+ }
1353
+
1354
+ // messageId 反查用量源:返回仓库内既有引用(官方聚合 tokenUsage → 视图节点 tokenUsage → closing.usage 采样),
1355
+ // 引用恒定直至该回合数据被替换;全缺返回 null。供 useChat selector 使用 ——
1356
+ // selector 必须返回稳定引用(useSyncExternalStore 以 Object.is 比对快照),返回新建对象会造成无限渲染;
1357
+ // 采样形态(字段名差异)由 usageSourceBuckets 在渲染层归一
1358
+ function turnUsageSourceOfMessage(nodes, messageId) {
1359
+ if (nodes === null || typeof nodes !== 'object') return null
1360
+ const node = turnUsageIndexOf(nodes).get(messageId)
1361
+ if (node === undefined) return null
1362
+ try {
1363
+ const turnData = node.location?.turn?.data
1364
+ const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1365
+ return tail?.tokenUsage ?? node.data.tokenUsage ?? node.data?.closing?.usage ?? null
1366
+ } catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
1367
+ return null
1368
+ }
1369
+
1370
+ // 用量源 → 聚合桶形态:closing.usage 采样字段名不同(inputTokens 即 uncached 口径,实测 total-input=output 恒等),
1371
+ // 聚合形态(已带 uncachedInputTokens)原样直通保持引用
1372
+ function usageSourceBuckets(source) {
1373
+ if (!source || source.uncachedInputTokens !== undefined) return source
1374
+ if (source.inputTokens === undefined) return null
1375
+ return {
1376
+ uncachedInputTokens: source.inputTokens,
1377
+ outputTokens: source.outputTokens,
1378
+ totalTokens: source.totalTokens,
1379
+ ...source.cacheReadTokens === undefined ? {} : { cacheReadTokens: source.cacheReadTokens },
1380
+ ...source.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: source.cacheWriteTokens },
1381
+ ...source.reasoningTokens === undefined ? {} : { reasoningTokens: source.reasoningTokens },
1382
+ }
1244
1383
  }
1245
1384
 
1246
- // 计价模型键:routes 首个 route.model,缺席回退全通配键(与注入点A 同款)
1247
- const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ?? MODEL_UNROUTED
1385
+ // 计价模型键:routes 首条按官方 messageRoute 分离字段拼两段键,与采集器 refOf 双实现同源
1386
+ // (官方 source.provider/model 是分离字段,routes[].model 是裸模型名,展示侧才拼 provider),
1387
+ // 仅 model 用裸名命中 */model 档,双缺回退全通配键;多 route 取首条(单轮估算口径)
1388
+ const turnModelOf = (tokenUsage) => {
1389
+ const route = tokenUsage?.routes?.[0]
1390
+ if (!route) return MODEL_UNROUTED
1391
+ if (route.provider && route.model) return `${route.provider}/${route.model}`
1392
+ return route.model || MODEL_UNROUTED
1393
+ }
1248
1394
 
1249
1395
  // 可选桶(cacheRead/cacheWrite)仅部分 provider 上报,缺失按 0 计入摘要与费用
1250
1396
  const turnReportedBucket = (value) => (value ?? 0)
1251
1397
 
1252
- // 摘要计费输入 = prompt 侧三桶(官方 billing 分母口径)
1398
+ // 摘要计费输入 = prompt 侧三桶(官方 billing 分母口径),核心桶缺省同按 0 保证降级形态对称
1253
1399
  function turnBilledInputTokens(tokenUsage) {
1254
- return tokenUsage.uncachedInputTokens
1400
+ return turnReportedBucket(tokenUsage.uncachedInputTokens)
1255
1401
  + turnReportedBucket(tokenUsage.cacheReadTokens)
1256
1402
  + turnReportedBucket(tokenUsage.cacheWriteTokens)
1257
1403
  }
@@ -1261,33 +1407,44 @@ function turnOptionalUnreported(tokenUsage) {
1261
1407
  return tokenUsage?.cacheReadTokens === undefined || tokenUsage?.cacheWriteTokens === undefined
1262
1408
  }
1263
1409
 
1264
- // 单轮行文本:摘要 + 费用;token 复用官方 compact 族,价未命中为占位符
1265
- function buildTurnCostLine(t, tokenUsage, price, currency) {
1266
- const summary = t('stats.tokens', {
1267
- input: formatTokensCompact(turnBilledInputTokens(tokenUsage), t),
1268
- output: formatTokensCompact(tokenUsage.outputTokens, t),
1269
- })
1270
- const cost = price ? formatCost(costOf(price, pricingBucketsOf(tokenUsage)), currency) : COST_PLACEHOLDER
1271
- return t('stats.turnCost', { summary, cost })
1410
+ // 芯片计费额:价命中才算,0 元(全零价或全零桶)返回 null 由调用方决定不渲染;价未命中同 null
1411
+ function turnCostAmountOf(tokenUsage, price) {
1412
+ if (!price) return null
1413
+ const cost = costOf(price, pricingBucketsOf(tokenUsage))
1414
+ return cost > 0 ? cost : null
1415
+ }
1416
+
1417
+ // 芯片文本:仅金额,不带货币前缀文字(官方用量芯片弹窗已有 token 明细)
1418
+ function buildTurnCostChipText(t, tokenUsage, price, currency) {
1419
+ const cost = turnCostAmountOf(tokenUsage, price)
1420
+ return t('turnCostChip', { cost: cost === null ? COST_PLACEHOLDER : formatCost(cost, currency) })
1272
1421
  }
1273
1422
 
1274
- // title:估算口径说明,可选桶未上报时追加标注
1423
+ // 芯片 title:token 摘要 + 估算口径,可选桶未上报时追加标注
1275
1424
  function turnCostTitleText(t, tokenUsage) {
1276
- const base = t('turnCostTitle')
1277
- return turnOptionalUnreported(tokenUsage) ? `${base},${t('turnTokensUnreported')}` : base
1425
+ const notes = [
1426
+ t('stats.tokens', {
1427
+ input: formatTokensCompact(turnBilledInputTokens(tokenUsage), t),
1428
+ output: formatTokensCompact(turnReportedBucket(tokenUsage.outputTokens), t),
1429
+ }),
1430
+ t('turnCostTitle'),
1431
+ ]
1432
+ if (turnOptionalUnreported(tokenUsage)) notes.push(t('turnTokensUnreported'))
1433
+ return notes.join(',')
1278
1434
  }
1279
1435
 
1280
1436
  // ===== 定价编辑器纯函数(校验/规整/默认值) =====
1281
1437
  const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
1282
1438
  const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
1283
1439
  const ISO_DAY_PATTERN_CLIENT = /^\d{4}-\d{2}-\d{2}$/
1284
- // 时刻分量界:小时含头不含尾,分钟双闭
1285
- const HOUR_MAX = 24
1440
+ // 时刻分量界:小时/分钟均双闭
1441
+ const HOUR_MAX = 23
1286
1442
  const MINUTE_MAX = 59
1287
1443
  const WEEKDAY_MIN = 0
1288
1444
  const WEEKDAY_MAX = 6
1289
1445
  const MONTH_DAY_MIN = 1
1290
1446
  const MONTH_DAY_MAX = 31
1447
+ const FULL_DAY_WINDOW = { from: '00:00', to: '23:59' }
1291
1448
  const CONDITION_KIND_OPTIONS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
1292
1449
  const CONDITION_KIND_LABEL_KEYS = {
1293
1450
  dailyWindow: 'condDailyWindow',
@@ -1300,18 +1457,18 @@ const WEEKDAY_COUNT = 7
1300
1457
  const parseHHMM = (value) => {
1301
1458
  if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) return null
1302
1459
  const [hours, minutes] = value.split(':').map(Number)
1303
- return hours >= 0 && hours < HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
1460
+ return hours >= 0 && hours <= HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
1304
1461
  }
1305
1462
 
1306
1463
  const isValidMonthDay = (value) => Number.isInteger(value) && value >= MONTH_DAY_MIN && value <= MONTH_DAY_MAX
1307
1464
 
1308
1465
  const isValidWeekday = (value) => Number.isInteger(value) && value >= WEEKDAY_MIN && value <= WEEKDAY_MAX
1309
1466
 
1310
- // 各条件类型合法默认:时段全天(from===to)、周几空、号段全月(短月高位号自然不触发)、日期段当天
1467
+ // 各条件类型合法默认即填即用:时段全天、周几空、号段全月、日期段当天单日
1311
1468
  const defaultCondition = (kind, now = new Date()) => {
1312
1469
  const today = formatDate(now)
1313
1470
  const defaults = {
1314
- dailyWindow: { kind: 'dailyWindow', from: '00:00', to: '00:00' },
1471
+ dailyWindow: { kind: 'dailyWindow', ...FULL_DAY_WINDOW },
1315
1472
  weekdays: { kind: 'weekdays', days: [] },
1316
1473
  monthDays: { kind: 'monthDays', from: MONTH_DAY_MIN, to: MONTH_DAY_MAX },
1317
1474
  dateRange: { kind: 'dateRange', from: today, to: today },
@@ -1319,15 +1476,18 @@ const defaultCondition = (kind, now = new Date()) => {
1319
1476
  return defaults[kind] ? { ...defaults[kind] } : null
1320
1477
  }
1321
1478
 
1322
- // 条件字段级校验:路径前缀 + 键 → 错误键;倒序仅 dateRange 非法(时段/号段倒序为跨午夜/跨月语义)
1479
+ // 条件字段级校验:路径前缀 + 键 → 错误键;时段/号段倒序为跨午夜/跨月语义;
1480
+ // 所有范围双侧包含,from===to 单点/单日合法;仅日期段倒序属配置错误(condRange)
1323
1481
  const validateCondition = (condition, path, errors) => {
1324
1482
  if (!condition || typeof condition !== 'object') {
1325
1483
  errors.set(path, 'required')
1326
1484
  return
1327
1485
  }
1328
1486
  if (condition.kind === 'dailyWindow') {
1329
- if (parseHHMM(condition.from) === null) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condTime')
1330
- if (parseHHMM(condition.to) === null) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condTime')
1487
+ const from = parseHHMM(condition.from)
1488
+ const to = parseHHMM(condition.to)
1489
+ if (from === null) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condTime')
1490
+ if (to === null) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condTime')
1331
1491
  return
1332
1492
  }
1333
1493
  if (condition.kind === 'weekdays') {
@@ -1402,6 +1562,58 @@ const defaultPricingRule = (currency = CURRENCIES[0]) => ({
1402
1562
 
1403
1563
  const patchItemAt = (array, index, patch) => array.map((item, i) => (i === index ? { ...item, ...patch } : item))
1404
1564
 
1565
+ // 编辑器分组视图纯函数:wire 契约(平面数组)不变,组/槽位仅是展示层投影。
1566
+ // 槽位移动/增删均落回平面数组的对应位置变换,组内相对顺序即匹配优先序。
1567
+
1568
+ // 按模型键聚合:组序为首次出现序,槽位保序并携带平面索引(错误路径与移动操作的寻址键)
1569
+ function groupRulesOf(rules) {
1570
+ const groups = new Map()
1571
+ ;(rules ?? []).forEach((rule, index) => {
1572
+ const key = rule.model
1573
+ if (!groups.has(key)) groups.set(key, { model: key, slots: [] })
1574
+ groups.get(key).slots.push({ rule, index })
1575
+ })
1576
+ return [...groups.values()]
1577
+ }
1578
+
1579
+ // 组内划分:末条无条件规则即该模型的默认价(恒兜底,UI 禁条件/排序/删除),其余为附加计费规则。
1580
+ // 全条件组默认价为 null(UI 提供"添加默认价"入口);多条无条件时末条为默认价,存量数据自然收敛
1581
+ function partitionGroupOf(group) {
1582
+ const last = group.slots[group.slots.length - 1]
1583
+ const isDefault = last !== undefined && (last.rule.conditions ?? []).length === 0
1584
+ return {
1585
+ defaultSlot: isDefault ? last : null,
1586
+ extras: isDefault ? group.slots.slice(0, -1) : group.slots,
1587
+ }
1588
+ }
1589
+
1590
+ // 槽位跨位移动(splice 语义):同位或越界返回原引用
1591
+ function moveRuleTo(rules, fromIndex, toIndex) {
1592
+ if (!Array.isArray(rules) || fromIndex === toIndex) return rules
1593
+ if (fromIndex < 0 || fromIndex >= rules.length || toIndex < 0 || toIndex >= rules.length) return rules
1594
+ const moved = [...rules]
1595
+ const [item] = moved.splice(fromIndex, 1)
1596
+ moved.splice(toIndex, 0, item)
1597
+ return moved
1598
+ }
1599
+
1600
+ // 组级改名:仅命中索引集的规则变更 model,其余保持引用不变
1601
+ function renameRulesAt(rules, indexes, model) {
1602
+ const hit = new Set(indexes)
1603
+ return rules.map((rule, i) => (hit.has(i) ? { ...rule, model } : rule))
1604
+ }
1605
+
1606
+ // 插入到目标平面位之后(组内添加槽位 = 组内末槽位索引 + 1)
1607
+ function insertRuleAt(rules, position, rule) {
1608
+ return [...rules.slice(0, position + 1), rule, ...rules.slice(position + 1)]
1609
+ }
1610
+
1611
+ // 批量移除(组删除)
1612
+ function removeRulesAt(rules, indexes) {
1613
+ const hit = new Set(indexes)
1614
+ return rules.filter((_, i) => !hit.has(i))
1615
+ }
1616
+
1405
1617
 
1406
1618
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1407
1619
  window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-usage-dash', factory })
@@ -1536,6 +1748,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1536
1748
  model: ['M5 5h14v14H5Z', 'M9 9h6v6H9Z', 'M9 2v3', 'M15 2v3', 'M9 19v3', 'M15 19v3', 'M2 9h3', 'M2 15h3', 'M19 9h3', 'M19 15h3'],
1537
1749
  rate: ['M22 12h-4l-3 9L9 3l-3 9H2'],
1538
1750
  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'],
1751
+ 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'],
1539
1752
  }
1540
1753
 
1541
1754
  function Icon({ paths }) {
@@ -1546,12 +1759,35 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1546
1759
  }, paths.map((d, index) => h('path', { key: index, d })))
1547
1760
  }
1548
1761
 
1762
+ // 危险删除按钮:首点进武装态(垃圾桶变"确认删除"文字),再点才执行,超时自动解除
1763
+ function DeleteArmedButton({ labelKey, confirmKey, onConfirm, t = defaultT }) {
1764
+ const [armed, setArmed] = useState(false)
1765
+ const armedTimerRef = useRef(null)
1766
+ useEffect(() => () => {
1767
+ if (armedTimerRef.current) clearTimeout(armedTimerRef.current)
1768
+ }, [])
1769
+ const click = () => {
1770
+ if (!armed) {
1771
+ setArmed(true)
1772
+ armedTimerRef.current = setTimeout(() => setArmed(false), REBUILD_CONFIRM_MS)
1773
+ return
1774
+ }
1775
+ if (armedTimerRef.current) clearTimeout(armedTimerRef.current)
1776
+ setArmed(false)
1777
+ onConfirm()
1778
+ }
1779
+ return h('button', {
1780
+ className: cx('ud-btn ud-btn--text', armed && 'ud-delete-armed'), type: 'button', onClick: click,
1781
+ 'aria-label': t(labelKey), title: t(labelKey),
1782
+ }, armed ? t(confirmKey) : h(Icon, { paths: ICONS.trash }))
1783
+ }
1784
+
1549
1785
  const STYLE_CSS = `
1550
1786
  .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1551
1787
  --ud-chart-1:color-mix(in srgb,#0576ff 70%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 70%,white);--ud-chart-3:color-mix(in srgb,#c46212 70%,white);--ud-chart-4:color-mix(in srgb,#975bf1 70%,white);--ud-chart-5:color-mix(in srgb,#d34591 70%,white);--ud-chart-other:color-mix(in srgb,#576270 70%,white);
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}
1788
+ --dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff;--ud-trend-speed:#0ca678;--ud-trend-ttft:#e8590c}
1553
1789
  body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 65%,white);--ud-chart-3:color-mix(in srgb,#c46212 65%,white);--ud-chart-4:color-mix(in srgb,#975bf1 65%,white);--ud-chart-5:color-mix(in srgb,#d34591 65%,white);--ud-chart-other:color-mix(in srgb,#576270 65%,white);
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}
1790
+ --dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe;--ud-trend-speed:#2fbf8f;--ud-trend-ttft:#ffa94d}
1555
1791
  .ud-toolbar{display:flex;align-items:flex-start;gap:8px}
1556
1792
  .ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
1557
1793
  .ud-group{display:flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
@@ -1569,6 +1805,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1569
1805
  .ud-icon-btn{padding:0 12px}
1570
1806
  .ud-icon{display:inline-flex;align-items:center;justify-content:center}
1571
1807
  .ud-btn--text{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);padding:2px 4px}
1808
+ .ud-delete-armed{color:var(--dsw-alias-state-error-primary);font-size:12px;white-space:nowrap}
1572
1809
  .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}
1573
1810
  .ud-loading{color:var(--dsw-alias-label-tertiary);text-align:center;padding:32px 0}
1574
1811
  .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}
@@ -1636,10 +1873,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1636
1873
  .ud-bar-hit{fill:transparent;pointer-events:all}
1637
1874
  .ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
1638
1875
  .ud-trend--speed{stroke:var(--ud-trend-speed)}
1876
+ .ud-trend--ttft{stroke:var(--ud-trend-ttft)}
1639
1877
  .ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
1640
1878
  .ud-trend-dot--speed{fill:var(--ud-trend-speed)}
1879
+ .ud-trend-dot--ttft{fill:var(--ud-trend-ttft)}
1641
1880
  .ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
1642
1881
  .ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
1882
+ .ud-legend-swatch--line--ttft{background:var(--ud-trend-ttft)}
1643
1883
  .ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
1644
1884
  .ud-donut-wrap{flex:0 0 auto}
1645
1885
  .ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
@@ -1680,14 +1920,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1680
1920
  .ud-pref-title{font-size:13px;color:var(--dsw-alias-label-primary)}
1681
1921
  .ud-pref-desc{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1682
1922
  .ud-rule{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px 12px}
1923
+ .ud-rule-group{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px}
1924
+ .ud-rule-group-head{display:flex;align-items:flex-end;gap:8px}
1925
+ .ud-rule-group-head .ud-field{flex:1}
1926
+ .ud-rule-group-slots{display:flex;flex-direction:column;gap:8px}
1927
+ .ud-rule-group-slots .ud-rule{background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 40%,transparent)}
1928
+ .ud-rule-default{border-style:dashed}
1929
+ .ud-slot-head{display:flex;align-items:flex-end;gap:8px}
1930
+ .ud-slot-head .ud-price-grid{flex:1}
1683
1931
  .ud-rule-head{display:flex;align-items:flex-end;gap:8px}
1684
1932
  .ud-rule-head .ud-field{flex:1}
1685
1933
  .ud-rule-cond{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1686
1934
  .ud-rule-conds{display:flex;flex-direction:column;gap:6px}
1687
- .ud-cond{display:flex;align-items:flex-start;gap:6px;flex-wrap:wrap}
1688
- .ud-cond-kind{width:auto;min-width:88px}
1689
- .ud-cond-fields{display:flex;align-items:flex-end;gap:6px;flex-wrap:wrap;flex:1;min-width:0}
1690
- .ud-cond-fields .ud-field{flex:0 1 auto}
1935
+ .ud-cond{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
1936
+ /* specificity 须高于 .ud-input 的 width:100%,否则类型下拉撑满整行把字段区挤到下一行 */
1937
+ .ud-cond .ud-cond-kind{width:auto;min-width:88px;flex:0 0 auto}
1938
+ .ud-cond-fields{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1;min-width:0}
1939
+ .ud-cond-fields .ud-field{flex-direction:row;align-items:center;gap:4px;flex:0 1 auto}
1691
1940
  .ud-cond-fields .ud-input{width:auto}
1692
1941
  .ud-cond-add{display:flex;gap:6px;flex-wrap:wrap}
1693
1942
  .ud-field{display:flex;flex-direction:column;gap:3px;min-width:0}
@@ -1705,8 +1954,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1705
1954
  .ud-rule-add:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1706
1955
  .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}
1707
1956
  .ud-statsline-sep{color:var(--dsw-alias-separator-primary);margin:0 10px}
1708
- .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}
1709
- @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}}
1957
+ /* 芯片 portal 至动作行末尾,历史轮悬停/焦点显隐随父级 data-actions-reveal,无需自绘规则 */
1958
+ .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}
1710
1959
  `
1711
1960
 
1712
1961
  function ensureStyle(document) {
@@ -1781,7 +2030,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1781
2030
  h(FitText, null, String(stats.activeDays))))
1782
2031
  }
1783
2032
 
1784
- function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
2033
+ function Legend({ models, colorFor, speedEnabled = false, ttftEnabled = false, isVisible, onItem, t = defaultT }) {
1785
2034
  const itemProps = (key) => ({
1786
2035
  className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
1787
2036
  onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
@@ -1796,7 +2045,10 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1796
2045
  h('span', null, t('hitRateLegend'))),
1797
2046
  speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
1798
2047
  h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
1799
- h('span', null, t('speedLegend'))) : null)
2048
+ h('span', null, t('speedLegend'))) : null,
2049
+ ttftEnabled ? h('span', { key: 'ttft', title: t('ttftLegend'), ...itemProps(LEGEND_KEY_TTFT) },
2050
+ h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--ttft' }),
2051
+ h('span', null, t('ttftLegend'))) : null)
1800
2052
  }
1801
2053
 
1802
2054
  const colorForModel = (models) => (model) => {
@@ -1826,22 +2078,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1826
2078
  return () => observer.disconnect()
1827
2079
  }, [])
1828
2080
  const hasSpeed = slots.some((slot) => slot.speed !== undefined)
1829
- const visibleSet = visibleKeys ?? new Set([...modelOrder, LEGEND_KEY_RATE, ...(hasSpeed ? [LEGEND_KEY_SPEED] : [])])
2081
+ const hasTtft = slots.some((slot) => slot.ttft !== undefined)
2082
+ const visibleSet = visibleKeys ?? new Set([
2083
+ ...modelOrder, LEGEND_KEY_RATE,
2084
+ ...(hasSpeed ? [LEGEND_KEY_SPEED] : []),
2085
+ ...(hasTtft ? [LEGEND_KEY_TTFT] : []),
2086
+ ])
1830
2087
  const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
1831
2088
  const showRate = visibleSet.has(LEGEND_KEY_RATE)
1832
2089
  const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
2090
+ const showTtft = hasTtft && visibleSet.has(LEGEND_KEY_TTFT)
1833
2091
  const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
1834
2092
  const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
1835
2093
  const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
1836
2094
  const speedMax = speedScaleMax(slots)
1837
2095
  const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
1838
2096
  const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
2097
+ const ttftMax = ttftScaleMax(slots)
2098
+ const ttftAxisMax = ttftMax * AXIS_SCALE_HEADROOM
2099
+ const ttftPoints = showTtft ? trendTtftPoints(slots, layout.bars, layout.plotHeight, ttftAxisMax) : []
1839
2100
  // 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
2101
+ // (ttft 不设轴,读数走 tooltip,毫秒域与速度轴不同单位不混轴)
1840
2102
  const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
1841
2103
  const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
1842
2104
  const hoverSlot = hover ? slots[hover.index] : null
1843
2105
  const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1844
2106
  const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
2107
+ const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
1845
2108
  const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
1846
2109
  const clear = () => setHover(null)
1847
2110
  const otherEntries = hoverSlot
@@ -1884,11 +2147,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1884
2147
  ? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
1885
2148
  : null),
1886
2149
  showRate ? h('path', {
1887
- className: 'ud-trend', d: smoothPath(ratePoints),
2150
+ className: 'ud-trend', d: smoothPath(ratePoints, layout.step * TREND_LINE_GAP_SLOTS),
1888
2151
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1889
2152
  }) : null,
1890
2153
  showSpeed ? h('path', {
1891
- className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
2154
+ className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints, layout.step * TREND_LINE_GAP_SLOTS),
2155
+ strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2156
+ }) : null,
2157
+ showTtft ? h('path', {
2158
+ className: cx('ud-trend', 'ud-trend--ttft'), d: smoothPath(ttftPoints, layout.step * TREND_LINE_GAP_SLOTS),
1892
2159
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1893
2160
  }) : null,
1894
2161
  hoverRatePoint
@@ -1897,13 +2164,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1897
2164
  hoverSpeedPoint
1898
2165
  ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
1899
2166
  : null,
2167
+ hoverTtftPoint
2168
+ ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--ttft'), cx: hoverTtftPoint.x, cy: hoverTtftPoint.y, r: TREND_DOT_RADIUS })
2169
+ : null,
1900
2170
  slots.map((slot, index) => h('rect', {
1901
2171
  key: `hit-${slot.day}`, className: 'ud-bar-hit',
1902
2172
  x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
1903
2173
  onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
1904
2174
  })))),
1905
2175
  h(Legend, {
1906
- models: legendModels, colorFor, speedEnabled: hasSpeed,
2176
+ models: legendModels, colorFor, speedEnabled: hasSpeed, ttftEnabled: hasTtft,
1907
2177
  isVisible: (key) => visibleSet.has(key),
1908
2178
  onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
1909
2179
  }),
@@ -1919,6 +2189,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1919
2189
  `${model}: ${formatTokens(tokens)}`)) : []),
1920
2190
  showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
1921
2191
  showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
2192
+ showTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
1922
2193
  costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
1923
2194
  ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
1924
2195
  : null,
@@ -2079,6 +2350,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2079
2350
  models.map((item) => {
2080
2351
  const isOther = item.model === OTHER_MODEL
2081
2352
  const speedText = modelSpeedText(item.speed)
2353
+ const ttftText = modelTtftText(item.ttft)
2354
+ const metaParts = [
2355
+ item.cost !== undefined ? `≈ ${formatCost(item.cost, costCurrency)}` : null,
2356
+ ttftText,
2357
+ speedText,
2358
+ ].filter(Boolean)
2082
2359
  return h(React.Fragment, { key: item.model },
2083
2360
  h('div', {
2084
2361
  className: cx('ud-model-row', isOther && 'ud-model-row--expand'),
@@ -2100,11 +2377,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2100
2377
  }, '›')
2101
2378
  : null,
2102
2379
  h('div', { className: 'ud-model-values' },
2103
- h('span', { className: 'ud-model-tokens' }, formatTokens(item.tokens)),
2104
- h('span', { className: 'ud-model-pct' },
2105
- item.cost !== undefined ? h('span', { className: 'ud-model-cost' }, `≈ ${formatCost(item.cost, costCurrency)} · `) : null,
2106
- formatPercent((item.tokens / total) * PERCENT_SCALE),
2107
- speedText ? ` · ${speedText}` : null))),
2380
+ h('span', { className: 'ud-model-tokens' },
2381
+ `${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`),
2382
+ h('span', { className: 'ud-model-pct' }, metaParts.join(' · ')))),
2108
2383
  isOther
2109
2384
  ? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
2110
2385
  h('div', { className: 'ud-model-other-list' },
@@ -2193,10 +2468,18 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2193
2468
  entry.title ? h('span', { title: entry.title }, entry.text) : entry.text)))
2194
2469
  })
2195
2470
 
2196
- // 注入点B 组件:Turn 尾部单轮用量行,chain matched 即 TurnTokenUsage,渲染于动作行之前;
2197
- // 无独立费用开关(plan 决策),显隐随容器 data-actions-reveal;价格未载期间费用为占位符
2198
- const CostTail = React.memo(function CostTail({ matched, useChat, t = defaultT }) {
2199
- if (!matched || typeof useChat !== 'function') return null
2471
+ // 注入点B 组件:回合费用芯片,官方动作行内渲染(复制与分支图标之间,赞踩/上下文跳转同排);
2472
+ // 受费用显示开关;价格异步首帧未回不渲染,回包后补渲染;显隐节奏随官方 data-actions-reveal
2473
+ // 官方槽容器固定在用量/用时芯片之前,末位排布由 SlotTailPortal 移交实现
2474
+ // 响应性:useChat selector 返回用量源稳定引用(null→引用 / 引用→引用),回合结束数据发布即触发重渲染;
2475
+ // 返回仓库本体则引用恒定,快照比对恒等,新回合永不重渲染(刷新才出现的根因)
2476
+ const CostChip = React.memo(function CostChip({ messageId, useChat, t = defaultT }) {
2477
+ if (typeof useChat !== 'function') return null
2478
+ const usageSource = typeof messageId === 'string' && messageId !== ''
2479
+ ? useChat((state) => turnUsageSourceOfMessage(state?.nodes, messageId))
2480
+ : null
2481
+ const [prefs, setPrefs] = useState(() => statsLineState.get())
2482
+ useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
2200
2483
  const [pricingRules, setPricingRules] = useState(null)
2201
2484
  useEffect(() => {
2202
2485
  let alive = true
@@ -2205,14 +2488,31 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2205
2488
  })
2206
2489
  return () => { alive = false }
2207
2490
  }, [])
2208
- const model = turnModelOf(matched)
2209
- const now = new Date()
2210
- const price = pricingRules ? matchPrice(pricingRules, model, now) : null
2211
- const currency = pricingRules ? aggregateCurrencyOf(pricingRules) : ''
2212
- return h('div', { className: 'ud-turn-cost', title: turnCostTitleText(t, matched) },
2213
- buildTurnCostLine(t, matched, price, currency))
2491
+ const matched = usageSourceBuckets(usageSource)
2492
+ const price = matched && prefs.costDisplay && pricingRules !== null
2493
+ ? matchPrice(pricingRules, turnModelOf(matched), new Date())
2494
+ : null
2495
+ // 0 (全零价/全零桶)与价未命中同不渲染,无占位符
2496
+ const cost = matched && price ? turnCostAmountOf(matched, price) : null
2497
+ const chip = cost === null ? null : h('span', { className: 'ud-turn-cost', title: turnCostTitleText(t, matched) },
2498
+ t('turnCostChip', { cost: formatCost(cost, aggregateCurrencyOf(pricingRules)) }))
2499
+ return h(SlotTailPortal, null, chip)
2214
2500
  })
2215
2501
 
2502
+ // 槽内容末位移交:锚点藏于槽容器内,portal 目标取槽容器(display:contents)的父级即官方动作行 div,
2503
+ // portal 子树 append 到动作行末尾(官方用量/用时芯片与结束时钟之后);portal 缺席降级锚点原位
2504
+ function SlotTailPortal({ children }) {
2505
+ const anchorRef = useRef(null)
2506
+ const [container, setContainer] = useState(null)
2507
+ useEffect(() => {
2508
+ const anchor = anchorRef.current
2509
+ const slotHost = anchor?.closest('[data-slot="conversation.chat.assistant-actions"]') ?? anchor?.parentElement
2510
+ setContainer(slotHost?.parentElement ?? null)
2511
+ }, [])
2512
+ const ported = container && createPortal ? createPortal(children, container) : null
2513
+ return h('span', { ref: anchorRef, style: ported ? { display: 'none' } : undefined }, ported ?? children)
2514
+ }
2515
+
2216
2516
  // 偏好卡行:说明文案承担 aria-describedby 目标
2217
2517
  function StatsLineOptionRow({ labelKey, descKey, checked, onToggle, t = defaultT }) {
2218
2518
  const describeId = React.useId()
@@ -2256,8 +2556,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2256
2556
  const PRICING_STATE_READY = 'ready'
2257
2557
  const PRICING_STATE_UNAVAILABLE = 'unavailable'
2258
2558
 
2259
- // 条件行:类型下拉 + 按类型字段区 + 删除;类型切换重置为该类型默认值(字段结构互不相通)
2260
- function PricingRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove }) {
2559
+ // 价格四桶 grid:默认价槽与附加规则卡共用;错误路径按规则平面索引寻址
2560
+ function PricingPriceGrid({ rule, currency, errors, pathPrefix, t, onPricePatch }) {
2261
2561
  const errorTextOf = (path) => {
2262
2562
  const key = errors.get(path)
2263
2563
  return key ? h('span', { className: 'ud-field-error' }, t(key)) : null
@@ -2269,9 +2569,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2269
2569
  h('input', {
2270
2570
  type: 'number', className: 'ud-input', min: 0, step: 'any',
2271
2571
  value: rule.price?.[key] ?? '',
2272
- onChange: (event) => onPatch({ price: { ...rule.price, [key]: event.target.value } }),
2572
+ onChange: (event) => onPricePatch({ price: { ...rule.price, [key]: event.target.value } }),
2273
2573
  })),
2274
2574
  errorTextOf(`${pathPrefix}price.${key}`))
2575
+ return h('div', { className: 'ud-price-grid' },
2576
+ priceField('input', 'priceInput'),
2577
+ priceField('output', 'priceOutput'),
2578
+ priceField('cacheRead', 'priceCacheRead'),
2579
+ priceField('cacheWrite', 'priceCacheWrite'))
2580
+ }
2581
+
2582
+ // 附加计费规则卡:头行 = 价格四桶 + 上移/下移/删除;下方为条件组合区(组内从上到下首个命中者生效)。
2583
+ // 默认价在组内独立成槽,附加规则不再承担兜底语义
2584
+ function PricingExtraRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove, onMove, canMoveUp, canMoveDown }) {
2585
+ const errorTextOf = (path) => {
2586
+ const key = errors.get(path)
2587
+ return key ? h('span', { className: 'ud-field-error' }, t(key)) : null
2588
+ }
2275
2589
  const patchConditions = (conditions) => onPatch({ conditions })
2276
2590
  const patchConditionAt = (conditionIndex, patch) => patchConditions(patchItemAt(rule.conditions, conditionIndex, patch))
2277
2591
  const removeConditionAt = (conditionIndex) => patchConditions(rule.conditions.filter((_, i) => i !== conditionIndex))
@@ -2285,10 +2599,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2285
2599
  onChange: (event) => patchCondition({ [field]: event.target.value }),
2286
2600
  }),
2287
2601
  errorTextOf(`${condPath}.${field}`))
2602
+ // 号段 from/to 同域 1~31(双闭),统一上限
2288
2603
  const numberInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
2289
2604
  h('span', { className: 'ud-field-label' }, t(labelKey)),
2290
2605
  h('input', {
2291
- type: 'number', className: 'ud-input', min: MONTH_DAY_MIN, max: MONTH_DAY_MAX, step: 1,
2606
+ type: 'number', className: 'ud-input', min: MONTH_DAY_MIN,
2607
+ max: MONTH_DAY_MAX, step: 1,
2292
2608
  value: condition[field] ?? '',
2293
2609
  onChange: (event) => patchCondition({ [field]: event.target.value }),
2294
2610
  }),
@@ -2325,32 +2641,74 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2325
2641
  }, '×'))
2326
2642
  }
2327
2643
  return h('div', { className: 'ud-rule' },
2328
- h('div', { className: 'ud-rule-head' },
2644
+ h('div', { className: 'ud-slot-head' },
2645
+ h(PricingPriceGrid, {
2646
+ rule, currency, errors, pathPrefix, t,
2647
+ onPricePatch: (part) => onPatch(part),
2648
+ }),
2649
+ canMoveUp ? h('button', {
2650
+ className: 'ud-btn ud-btn--text', type: 'button', onClick: () => onMove(-1),
2651
+ 'aria-label': t('moveUp'), title: t('moveUp'),
2652
+ }, '↑') : null,
2653
+ canMoveDown ? h('button', {
2654
+ className: 'ud-btn ud-btn--text', type: 'button', onClick: () => onMove(1),
2655
+ 'aria-label': t('moveDown'), title: t('moveDown'),
2656
+ }, '↓') : null,
2657
+ h(DeleteArmedButton, { labelKey: 'deleteRule', confirmKey: 'confirmDelete', onConfirm: onRemove, t })),
2658
+ h('div', { className: 'ud-rule-conds' },
2659
+ (rule.conditions ?? []).map((condition, conditionIndex) => conditionRow(condition, conditionIndex)),
2660
+ h('div', { className: 'ud-cond-add' },
2661
+ h('button', {
2662
+ type: 'button', className: 'ud-btn ud-btn--text',
2663
+ onClick: () => patchConditions([...(rule.conditions ?? []), defaultCondition(CONDITION_KIND_OPTIONS[0])]),
2664
+ }, `+${t('addCondition')}`))))
2665
+ }
2666
+
2667
+ // 模型组卡:组头 = 模型键输入(整组一次改名)+ 删除整组;组内 = 默认价槽(禁条件/排序/删除,恒兜底)
2668
+ // + 附加计费规则列表(从上到下首个命中生效,全不命中落默认价)。
2669
+ // 组件 key 由调用方锚定首槽位平面索引,改名引发的重新聚合不会丢焦点
2670
+ function PricingModelGroup({ group, currency, errors, t, onModelChange, onGroupRemove, onSlotPatch, onSlotRemove, onSlotMove, onSlotAdd }) {
2671
+ const modelError = group.slots.map((slot) => errors.get(`${slot.index}.model`)).find(Boolean)
2672
+ const { defaultSlot, extras } = partitionGroupOf(group)
2673
+ // 新附加规则插到默认价平面位之前(无默认价则组末),保证默认价恒居组末
2674
+ const insertPosition = defaultSlot ? defaultSlot.index - 1 : group.slots[group.slots.length - 1].index
2675
+ return h('div', { className: 'ud-rule-group' },
2676
+ h('div', { className: 'ud-rule-group-head' },
2329
2677
  h('label', { className: 'ud-field' },
2330
2678
  h('span', { className: 'ud-field-label' }, t('pricingModel')),
2331
2679
  h('input', {
2332
- type: 'text', className: 'ud-input', value: rule.model,
2680
+ type: 'text', className: 'ud-input', value: group.model,
2333
2681
  placeholder: t('pricingModelPlaceholder'),
2334
- onChange: (event) => onPatch({ model: event.target.value }),
2682
+ onChange: (event) => onModelChange(event.target.value),
2335
2683
  }),
2336
- errorTextOf(`${pathPrefix}model`)),
2337
- h('button', {
2338
- className: 'ud-btn ud-btn--text', type: 'button', onClick: onRemove,
2339
- 'aria-label': t('deleteRule'), title: t('deleteRule'),
2340
- }, '×')),
2341
- h('div', { className: 'ud-price-grid' },
2342
- priceField('input', 'priceInput'),
2343
- priceField('output', 'priceOutput'),
2344
- priceField('cacheRead', 'priceCacheRead'),
2345
- priceField('cacheWrite', 'priceCacheWrite')),
2346
- h('div', { className: 'ud-rule-conds' },
2347
- (rule.conditions ?? []).length === 0 ? h('span', { className: 'ud-rule-cond' }, t('noCondition')) : null,
2348
- (rule.conditions ?? []).map((condition, conditionIndex) => conditionRow(condition, conditionIndex)),
2349
- h('div', { className: 'ud-cond-add' },
2350
- CONDITION_KIND_OPTIONS.map((kind) => h('button', {
2351
- key: kind, type: 'button', className: 'ud-btn ud-btn--text',
2352
- onClick: () => patchConditions([...(rule.conditions ?? []), defaultCondition(kind)]),
2353
- }, `+${t(CONDITION_KIND_LABEL_KEYS[kind])}`)))))
2684
+ modelError ? h('span', { className: 'ud-field-error' }, t(modelError)) : null),
2685
+ h(DeleteArmedButton, { labelKey: 'deleteGroup', confirmKey: 'confirmDelete', onConfirm: onGroupRemove, t })),
2686
+ h('div', { className: 'ud-rule-group-slots' },
2687
+ defaultSlot ? h('div', { className: 'ud-rule ud-rule-default' },
2688
+ h('div', { className: 'ud-slot-head' },
2689
+ h(PricingPriceGrid, {
2690
+ rule: defaultSlot.rule, currency, errors,
2691
+ pathPrefix: `${defaultSlot.index}.`, t,
2692
+ onPricePatch: (part) => onSlotPatch(defaultSlot.index, part),
2693
+ })),
2694
+ h('span', { className: 'ud-rule-cond' }, t('defaultPriceHint'))) : null,
2695
+ extras.map((slot, position) => h(PricingExtraRuleCard, {
2696
+ key: slot.index,
2697
+ rule: slot.rule,
2698
+ currency,
2699
+ errors,
2700
+ pathPrefix: `${slot.index}.`,
2701
+ t,
2702
+ onPatch: (part) => onSlotPatch(slot.index, part),
2703
+ onRemove: () => onSlotRemove(slot.index),
2704
+ onMove: (delta) => onSlotMove(slot.index, extras[position + delta].index),
2705
+ canMoveUp: position > 0,
2706
+ canMoveDown: position < extras.length - 1,
2707
+ }))),
2708
+ h('button', {
2709
+ className: 'ud-rule-add', type: 'button',
2710
+ onClick: () => onSlotAdd(insertPosition, group.model),
2711
+ }, defaultSlot ? t('addRule') : t('addDefaultPrice')))
2354
2712
  }
2355
2713
 
2356
2714
  function PricingEditor({ t = defaultT }) {
@@ -2433,20 +2791,30 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2433
2791
  }, symbol))),
2434
2792
  h('button', { className: 'ud-btn', type: 'button', disabled: saving, onClick: save }, t('save')))),
2435
2793
  saveError ? h('div', { className: 'ud-error' }, saveError) : null,
2436
- rules.map((rule, index) => h(PricingRuleCard, {
2437
- key: index,
2438
- rule,
2794
+ h('span', { className: 'ud-rule-cond' }, t('ruleOrderHint')),
2795
+ groupRulesOf(rules).map((group) => h(PricingModelGroup, {
2796
+ // key 锚定首槽位平面索引:改名重聚合不重建组件,输入焦点不丢
2797
+ key: String(group.slots[0].index),
2798
+ group,
2439
2799
  currency,
2440
2800
  errors,
2441
- pathPrefix: `${index}.`,
2442
2801
  t,
2443
- onPatch: (part) => setRules((prev) => patchItemAt(prev, index, part)),
2444
- onRemove: () => setRules((prev) => prev.filter((_, i) => i !== index)),
2802
+ onModelChange: (model) => setRules((prev) => renameRulesAt(prev, group.slots.map((slot) => slot.index), model)),
2803
+ onGroupRemove: () => setRules((prev) => removeRulesAt(prev, group.slots.map((slot) => slot.index))),
2804
+ onSlotPatch: (index, part) => setRules((prev) => patchItemAt(prev, index, part)),
2805
+ onSlotRemove: (index) => setRules((prev) => prev.filter((_, i) => i !== index)),
2806
+ onSlotMove: (fromIndex, toIndex) => setRules((prev) => moveRuleTo(prev, fromIndex, toIndex)),
2807
+ onSlotAdd: (position, model) => setRules((prev) => insertRuleAt(prev, position, {
2808
+ ...defaultPricingRule(currency),
2809
+ model,
2810
+ // 附加规则默认带全天时段条件:默认价已独立成槽,附加规则应以可编辑条件呈现
2811
+ conditions: [defaultCondition(CONDITION_KIND_OPTIONS[0])],
2812
+ })),
2445
2813
  })),
2446
2814
  h('button', {
2447
2815
  className: 'ud-rule-add', type: 'button',
2448
2816
  onClick: () => setRules((prev) => [...prev, defaultPricingRule(currency)]),
2449
- }, t('addRule')))
2817
+ }, t('addGroup')))
2450
2818
  }
2451
2819
 
2452
2820
  // 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示
@@ -2477,14 +2845,22 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2477
2845
  status.error ? h('span', { className: 'ud-status-err' }, status.error) : null)
2478
2846
  }
2479
2847
 
2480
- // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容)
2848
+ // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容);
2849
+ // skipped 细分归因(宿主拒读/损坏/旧格式)挂在汇总行 title,供跨宿主边界诊断
2481
2850
  function AnomalyLog({ status, t = defaultT }) {
2482
2851
  const lines = status.log ?? []
2483
2852
  if (lines.length === 0) return null
2853
+ const breakdown = status.skippedBreakdown ?? {}
2854
+ const breakdownText = t('skipBreakdown', {
2855
+ d: breakdown.descriptor ?? 0,
2856
+ c: breakdown.corrupt ?? 0,
2857
+ l: breakdown.legacy ?? 0,
2858
+ o: breakdown.other ?? 0,
2859
+ })
2484
2860
  return h('div', { className: 'ud-log' },
2485
2861
  h('div', { className: 'ud-log-summary' },
2486
2862
  (status.skippedSessions ?? 0) > 0
2487
- ? h('span', null, t('skippedSessions', { n: status.skippedSessions }))
2863
+ ? h('span', { title: breakdownText }, t('skippedSessions', { n: status.skippedSessions }))
2488
2864
  : null,
2489
2865
  (status.recordFailures ?? 0) > 0
2490
2866
  ? h('span', { className: 'ud-status-err' }, t('recordFailures', { n: status.recordFailures }))
@@ -2523,6 +2899,40 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2523
2899
  armed ? t('rebuildConfirm') : t('rebuild'))
2524
2900
  }
2525
2901
 
2902
+ // 回退按钮:仅当存在重建快照(status.backup.available)时可点;
2903
+ // 恢复动作本身也会被重新快照覆盖,回退链不断
2904
+ function RestoreButton({ machineRef, busy, backup, onError, t = defaultT }) {
2905
+ const [armed, setArmed] = useState(false)
2906
+ const armedTimerRef = useRef(null)
2907
+
2908
+ useEffect(() => () => {
2909
+ if (armedTimerRef.current) clearTimeout(armedTimerRef.current)
2910
+ }, [])
2911
+
2912
+ if (!backup?.available) {
2913
+ return h('button', { className: 'ud-btn ud-btn--text', disabled: true, title: t('restoreMissing') },
2914
+ t('restore'))
2915
+ }
2916
+
2917
+ const restore = async () => {
2918
+ if (!armed) {
2919
+ setArmed(true)
2920
+ armedTimerRef.current = setTimeout(() => setArmed(false), REBUILD_CONFIRM_MS)
2921
+ return
2922
+ }
2923
+ setArmed(false)
2924
+ const result = await requestPost(ENDPOINTS.restore)
2925
+ if (!result.ok) {
2926
+ onError(result.message)
2927
+ return
2928
+ }
2929
+ machineRef.current?.restart()
2930
+ }
2931
+
2932
+ return h('button', { className: 'ud-btn ud-btn--text', disabled: busy, onClick: restore },
2933
+ armed ? t('restoreConfirm') : t('restore'))
2934
+ }
2935
+
2526
2936
  const viewLabel = (t, id) => (id === 'day' ? t('viewDay') : id === 'hour' ? t('viewHour') : t('viewMinute'))
2527
2937
  const trendTitle = (t, id) => (id === 'day' ? t('dailyTrend') : id === 'hour' ? t('hourTrend') : t('minuteTrend'))
2528
2938
  const trendLimitedText = (t, id, count) => (id === 'day'
@@ -2769,7 +3179,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2769
3179
  ? h('div', { className: 'ud-detail' },
2770
3180
  h(AnomalyLog, { status, t }),
2771
3181
  h('div', { className: 'ud-detail-foot' },
2772
- h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t })))
3182
+ h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t }),
3183
+ h(RestoreButton, { machineRef: statusMachineRef, busy: status?.running === true, backup: status?.backup, onError: setError, t })))
2773
3184
  : null,
2774
3185
  error ? h('div', { className: 'ud-error' }, error) : null,
2775
3186
  loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
@@ -2823,15 +3234,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2823
3234
  } catch (error) {
2824
3235
  console.warn('[usage-dash] 底部信息栏未注册(宿主无 conversation.composer.dock 插槽)', error)
2825
3236
  }
2826
- // 注入点B:turnTail chain 条目,同款两段式与降级;旧宿主无该插槽仅告警禁用
3237
+ // 注入点B:回合费用芯片走官方动作行槽,回合结束随 messageId 出现;旧宿主无该插槽仅告警禁用
2827
3238
  try {
2828
- ctx.slots.inject('conversation.chat.turnTail', () =>
3239
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
2829
3240
  ctx.slots.register(
2830
- { name: 'conversation.chat.turnTail', select: selectTurnTokenUsage, priority: TURN_TAIL_PRIORITY, locale: LOCALE_NS },
2831
- CostTail,
3241
+ { name: 'conversation.chat.assistant-actions', id: 'usage-dash-turn-cost', order: TURN_COST_CHIP_ORDER, locale: LOCALE_NS },
3242
+ CostChip,
2832
3243
  ))
2833
3244
  } catch (error) {
2834
- console.warn('[usage-dash] 会话尾部用量行未注册(宿主无 conversation.chat.turnTail 插槽)', error)
3245
+ console.warn('[usage-dash] 回合费用芯片未注册(宿主无 conversation.chat.assistant-actions 插槽)', error)
2835
3246
  }
2836
3247
  // 跨实例同步:其他实例写开关经 storage 事件触发重读(同实例写入不触发该事件)
2837
3248
  window.addEventListener('storage', (event) => {