@mzzsfy/dsh-usage-dash 0.3.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
@@ -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
@@ -148,6 +148,9 @@ const MESSAGES_ZH = {
148
148
  'status.running': '回扫中 {done}/{total}',
149
149
  rebuild: '重建',
150
150
  rebuildConfirm: '确认重建',
151
+ restore: '回退数据',
152
+ restoreConfirm: '确认回退',
153
+ restoreMissing: '无可用回退点(重建时自动生成)',
151
154
  hourTrend: '按小时 Token 趋势',
152
155
  minuteTrend: '按分钟 Token 趋势',
153
156
  'hourPreset.24h': '24 小时',
@@ -163,6 +166,7 @@ const MESSAGES_ZH = {
163
166
  trendTruncated: '数据量过大,仅显示最近部分',
164
167
  recordFailures: '{n} 条记录写入失败',
165
168
  skippedSessions: '跳过 {n} 个无法读取的会话',
169
+ skipBreakdown: '宿主拒读 {d} / 存档损坏 {c} / 旧格式 {l} / 其他 {o}——宿主修复后下轮回扫自动补齐',
166
170
  anomalyLog: '扫描异常日志',
167
171
  logKindSkipped: '跳过会话',
168
172
  logKindRecord: '写入失败',
@@ -173,18 +177,18 @@ const MESSAGES_ZH = {
173
177
  'stats.tokensPerSecond': '{throughput} tok/s',
174
178
  'stats.cacheHit': '缓存命中 {percent}%',
175
179
  'stats.tokens': '输入 {input} tok · 输出 {output} tok',
180
+ 'turnCostChip': '{cost}',
176
181
  'stats.tokensDetail': '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok',
177
182
  cachePrecision: '精确缓存命中率',
178
183
  cachePrecisionDesc: '在会话底部信息栏以两位小数显示缓存命中率。',
179
184
  tokenDetail: '会话 Token 明细',
180
185
  tokenDetailDesc: '在会话底部信息栏显示总 Token、命中/未命中缓存与输出明细。',
181
186
  costDisplay: '费用显示',
182
- costDisplayDesc: '在信息栏与趋势悬浮中显示按当前费率估算的费用。',
187
+ costDisplayDesc: '在信息栏、趋势悬浮与回合费用芯片中显示按当前费率估算的费用。',
183
188
  costTitle: '按当前费率对历史用量估算,精度为小时级',
184
189
  costUnpriced: '{n} 个小时桶未计价',
185
190
  statsCostTitle: '按当前费率对会话累计 token 估算',
186
191
  'stats.cost': '费用 ≈ {cost}',
187
- 'stats.turnCost': '{summary} · 费用 ≈ {cost}',
188
192
  turnCostTitle: '单轮用量按当前费率估算',
189
193
  turnTokensUnreported: '该提供商未上报此桶',
190
194
  pricing: '定价规则',
@@ -197,8 +201,8 @@ const MESSAGES_ZH = {
197
201
  priceOutput: '输出',
198
202
  priceCacheRead: '缓存读',
199
203
  priceCacheWrite: '缓存写',
200
- noCondition: '无条件 = 恒生效',
201
204
  addCondition: '添加条件',
205
+ confirmDelete: '确认删除',
202
206
  deleteCondition: '删除条件',
203
207
  condKind: '条件类型',
204
208
  condDailyWindow: '每日时段',
@@ -218,9 +222,17 @@ const MESSAGES_ZH = {
218
222
  condWeekday: '需 0-6 整数',
219
223
  condMonthDay: '需 1-31 整数',
220
224
  condDate: '需 YYYY-MM-DD',
221
- condRange: '起始不得晚于结束',
222
- deleteRule: '删除规则',
223
- addRule: '添加规则',
225
+ condRange: '结束不得早于起始(两端均含)',
226
+ ruleOrderHint: '附加计费规则从上到下匹配,首个命中生效;全不命中落默认价',
227
+ moveUp: '上移',
228
+ moveDown: '下移',
229
+ addGroup: '添加模型',
230
+ addRule: '添加额外计费规则',
231
+ addDefaultPrice: '添加默认价',
232
+ deleteGroup: '删除模型及其全部计费规则',
233
+ deleteRule: '删除计费规则',
234
+ addCondition: '添加条件',
235
+ defaultPriceHint: '默认价:附加规则全不命中时生效,不设条件',
224
236
  save: '保存',
225
237
  saved: '已保存',
226
238
  required: '必填',
@@ -275,6 +287,9 @@ const MESSAGES_EN = {
275
287
  'status.running': 'Rescanning {done}/{total}',
276
288
  rebuild: 'Rebuild',
277
289
  rebuildConfirm: 'Confirm rebuild',
290
+ restore: 'Restore data',
291
+ restoreConfirm: 'Confirm restore',
292
+ restoreMissing: 'No restore point (created automatically on rebuild)',
278
293
  hourTrend: 'Hourly token trend',
279
294
  minuteTrend: 'Per-minute token trend',
280
295
  'hourPreset.24h': '24 hours',
@@ -290,6 +305,7 @@ const MESSAGES_EN = {
290
305
  trendTruncated: 'Too much data, showing only the latest part',
291
306
  recordFailures: '{n} records failed to write',
292
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',
293
309
  anomalyLog: 'Scan anomaly log',
294
310
  logKindSkipped: 'skipped',
295
311
  logKindRecord: 'write failed',
@@ -300,18 +316,18 @@ const MESSAGES_EN = {
300
316
  'stats.tokensPerSecond': '{throughput} tok/s',
301
317
  'stats.cacheHit': 'Cache hit {percent}%',
302
318
  'stats.tokens': 'Input {input} tok · Output {output} tok',
319
+ 'turnCostChip': '{cost}',
303
320
  'stats.tokensDetail': 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok',
304
321
  cachePrecision: 'Precise cache-hit rate',
305
322
  cachePrecisionDesc: 'Show the cache-hit rate with two decimals in the session stats line.',
306
323
  tokenDetail: 'Session token detail',
307
324
  tokenDetailDesc: 'Show total, cache hit/miss and output tokens in the session stats line.',
308
325
  costDisplay: 'Cost display',
309
- 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.',
310
327
  costTitle: 'Estimated at current rates over historical usage, hourly precision',
311
328
  costUnpriced: '{n} hour buckets unpriced',
312
329
  statsCostTitle: 'Estimated at current rates over session token totals',
313
330
  'stats.cost': 'Cost ≈ {cost}',
314
- 'stats.turnCost': '{summary} · Cost ≈ {cost}',
315
331
  turnCostTitle: 'Per-turn usage estimated at current rates',
316
332
  turnTokensUnreported: 'Not reported by this provider',
317
333
  pricing: 'Pricing rules',
@@ -324,8 +340,8 @@ const MESSAGES_EN = {
324
340
  priceOutput: 'Output',
325
341
  priceCacheRead: 'Cache read',
326
342
  priceCacheWrite: 'Cache write',
327
- noCondition: 'No condition = always applies',
328
343
  addCondition: 'Add condition',
344
+ confirmDelete: 'Confirm delete',
329
345
  deleteCondition: 'Remove condition',
330
346
  condKind: 'Condition kind',
331
347
  condDailyWindow: 'Daily window',
@@ -345,9 +361,17 @@ const MESSAGES_EN = {
345
361
  condWeekday: 'Requires integer 0-6',
346
362
  condMonthDay: 'Requires integer 1-31',
347
363
  condDate: 'Requires YYYY-MM-DD',
348
- condRange: 'From must not be after to',
349
- deleteRule: 'Remove rule',
350
- 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',
351
375
  save: 'Save',
352
376
  saved: 'Saved',
353
377
  required: 'Required',
@@ -752,10 +776,13 @@ function heatLevel(tokens, max) {
752
776
  return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
753
777
  }
754
778
 
755
- // ChartTip 定位:锚定格子矩形,边界内 clamp,下方优先放不下翻上方
779
+ // ChartTip 定位:锚定区装得下贴内顶(趋势图悬停区即绘图区,悬浮窗留在图表内),装不下上翻、再下翻、末了钳边界顶
756
780
  const TIP_GAP_PX = 8
757
781
  const TIP_MARGIN_PX = 8
758
782
 
783
+ // 回合费用芯片与前置官方芯片(结束时钟)的间距
784
+ const TURN_COST_GAP_PX = 8
785
+
759
786
  function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
760
787
  if (!tip || tip.width <= 0 || tip.height <= 0) return null
761
788
  if (!anchor || (anchor.left === 0 && anchor.top === 0 && anchor.right === 0 && anchor.bottom === 0)) return null
@@ -764,11 +791,13 @@ function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX)
764
791
  const maxX = bounds.right - margin
765
792
  const maxY = bounds.bottom - margin
766
793
  const left = Math.max(minX, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, maxX - tip.width))
767
- const below = anchor.bottom + gap
794
+ const inside = anchor.top + gap
768
795
  const above = anchor.top - gap - tip.height
796
+ const below = anchor.bottom + gap
769
797
  let top
770
- if (below + tip.height <= maxY) top = below
798
+ if (inside + tip.height <= anchor.bottom) top = inside
771
799
  else if (above >= minY) top = above
800
+ else if (below + tip.height <= maxY) top = below
772
801
  else top = minY
773
802
  return { left, top }
774
803
  }
@@ -1052,15 +1081,14 @@ const toMinutesOfDay = (hhmm) => {
1052
1081
  return Number.isFinite(h) && Number.isFinite(m) ? h * MINUTES_PER_HOUR + m : Number.NaN
1053
1082
  }
1054
1083
 
1055
- // from<to 含头不含尾;from>to 跨午夜;from===to 全天生效
1084
+ // 所有范围条件统一双侧包含;from>to 跨午夜/跨月环绕;from===to 单点/单日(与宿主 pricing.js 镜像,parity 测试锁定)
1056
1085
  function dailyWindowMatches(condition, date) {
1057
1086
  const from = toMinutesOfDay(condition.from)
1058
1087
  const to = toMinutesOfDay(condition.to)
1059
1088
  if (Number.isNaN(from) || Number.isNaN(to)) return false
1060
1089
  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
1090
+ if (from <= to) return m >= from && m <= to
1091
+ return m >= from || m <= to
1064
1092
  }
1065
1093
 
1066
1094
  // days 空数组不成立;0=周日,取 getDay()
@@ -1069,7 +1097,7 @@ function weekdaysMatches(condition, date) {
1069
1097
  return Array.isArray(days) && days.length > 0 && days.includes(date.getDay())
1070
1098
  }
1071
1099
 
1072
- // 号段双闭;from>to 跨月环绕;日号必须整数,2 月无 31 号自然不触发
1100
+ // 号段双侧包含;from>to 跨月环绕;from===to 单日,2 月无 31 号自然不触发
1073
1101
  function monthDaysMatches(condition, date) {
1074
1102
  const { from, to } = condition
1075
1103
  if (!Number.isInteger(from) || !Number.isInteger(to)) return false
@@ -1077,7 +1105,7 @@ function monthDaysMatches(condition, date) {
1077
1105
  return from <= to ? d >= from && d <= to : d >= from || d <= to
1078
1106
  }
1079
1107
 
1080
- // 要求零填充 YYYY-MM-DD 字典序双闭;from>to 属配置错误不成立,非规范串同样不成立
1108
+ // 零填充 YYYY-MM-DD 字典序双侧包含;from>to 配置错误不成立(编辑器校验拦截)
1081
1109
  const ISO_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
1082
1110
  function dateRangeMatches(condition, date) {
1083
1111
  const { from, to } = condition
@@ -1229,29 +1257,55 @@ function costTitleText(t, unpriced) {
1229
1257
  return unpriced > 0 ? `${base},${t('costUnpriced', { n: unpriced })}` : base
1230
1258
  }
1231
1259
 
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
1260
+ // ===== 注入点B:回合费用芯片(官方动作行 assistant-actions 槽条目,赞踩/上下文跳转同排) =====
1261
+ // 排序取上下文插件条目(20)之后,贴近尾部用量/用时芯片一侧
1262
+ const TURN_COST_CHIP_ORDER = 20 + 10
1238
1263
 
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
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
1244
1291
  }
1245
1292
 
1246
- // 计价模型键:routes 首个 route.model,缺席回退全通配键(与注入点A 同款)
1247
- 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
+ }
1248
1302
 
1249
1303
  // 可选桶(cacheRead/cacheWrite)仅部分 provider 上报,缺失按 0 计入摘要与费用
1250
1304
  const turnReportedBucket = (value) => (value ?? 0)
1251
1305
 
1252
- // 摘要计费输入 = prompt 侧三桶(官方 billing 分母口径)
1306
+ // 摘要计费输入 = prompt 侧三桶(官方 billing 分母口径),核心桶缺省同按 0 保证降级形态对称
1253
1307
  function turnBilledInputTokens(tokenUsage) {
1254
- return tokenUsage.uncachedInputTokens
1308
+ return turnReportedBucket(tokenUsage.uncachedInputTokens)
1255
1309
  + turnReportedBucket(tokenUsage.cacheReadTokens)
1256
1310
  + turnReportedBucket(tokenUsage.cacheWriteTokens)
1257
1311
  }
@@ -1261,33 +1315,44 @@ function turnOptionalUnreported(tokenUsage) {
1261
1315
  return tokenUsage?.cacheReadTokens === undefined || tokenUsage?.cacheWriteTokens === undefined
1262
1316
  }
1263
1317
 
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 })
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
1272
1323
  }
1273
1324
 
1274
- // 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 摘要 + 估算口径,可选桶未上报时追加标注
1275
1332
  function turnCostTitleText(t, tokenUsage) {
1276
- const base = t('turnCostTitle')
1277
- 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(',')
1278
1342
  }
1279
1343
 
1280
1344
  // ===== 定价编辑器纯函数(校验/规整/默认值) =====
1281
1345
  const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
1282
1346
  const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
1283
1347
  const ISO_DAY_PATTERN_CLIENT = /^\d{4}-\d{2}-\d{2}$/
1284
- // 时刻分量界:小时含头不含尾,分钟双闭
1285
- const HOUR_MAX = 24
1348
+ // 时刻分量界:小时/分钟均双闭
1349
+ const HOUR_MAX = 23
1286
1350
  const MINUTE_MAX = 59
1287
1351
  const WEEKDAY_MIN = 0
1288
1352
  const WEEKDAY_MAX = 6
1289
1353
  const MONTH_DAY_MIN = 1
1290
1354
  const MONTH_DAY_MAX = 31
1355
+ const FULL_DAY_WINDOW = { from: '00:00', to: '23:59' }
1291
1356
  const CONDITION_KIND_OPTIONS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
1292
1357
  const CONDITION_KIND_LABEL_KEYS = {
1293
1358
  dailyWindow: 'condDailyWindow',
@@ -1300,18 +1365,18 @@ const WEEKDAY_COUNT = 7
1300
1365
  const parseHHMM = (value) => {
1301
1366
  if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) return null
1302
1367
  const [hours, minutes] = value.split(':').map(Number)
1303
- 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
1304
1369
  }
1305
1370
 
1306
1371
  const isValidMonthDay = (value) => Number.isInteger(value) && value >= MONTH_DAY_MIN && value <= MONTH_DAY_MAX
1307
1372
 
1308
1373
  const isValidWeekday = (value) => Number.isInteger(value) && value >= WEEKDAY_MIN && value <= WEEKDAY_MAX
1309
1374
 
1310
- // 各条件类型合法默认:时段全天(from===to)、周几空、号段全月(短月高位号自然不触发)、日期段当天
1375
+ // 各条件类型合法默认即填即用:时段全天、周几空、号段全月、日期段当天单日
1311
1376
  const defaultCondition = (kind, now = new Date()) => {
1312
1377
  const today = formatDate(now)
1313
1378
  const defaults = {
1314
- dailyWindow: { kind: 'dailyWindow', from: '00:00', to: '00:00' },
1379
+ dailyWindow: { kind: 'dailyWindow', ...FULL_DAY_WINDOW },
1315
1380
  weekdays: { kind: 'weekdays', days: [] },
1316
1381
  monthDays: { kind: 'monthDays', from: MONTH_DAY_MIN, to: MONTH_DAY_MAX },
1317
1382
  dateRange: { kind: 'dateRange', from: today, to: today },
@@ -1319,15 +1384,18 @@ const defaultCondition = (kind, now = new Date()) => {
1319
1384
  return defaults[kind] ? { ...defaults[kind] } : null
1320
1385
  }
1321
1386
 
1322
- // 条件字段级校验:路径前缀 + 键 → 错误键;倒序仅 dateRange 非法(时段/号段倒序为跨午夜/跨月语义)
1387
+ // 条件字段级校验:路径前缀 + 键 → 错误键;时段/号段倒序为跨午夜/跨月语义;
1388
+ // 所有范围双侧包含,from===to 单点/单日合法;仅日期段倒序属配置错误(condRange)
1323
1389
  const validateCondition = (condition, path, errors) => {
1324
1390
  if (!condition || typeof condition !== 'object') {
1325
1391
  errors.set(path, 'required')
1326
1392
  return
1327
1393
  }
1328
1394
  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')
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')
1331
1399
  return
1332
1400
  }
1333
1401
  if (condition.kind === 'weekdays') {
@@ -1402,6 +1470,58 @@ const defaultPricingRule = (currency = CURRENCIES[0]) => ({
1402
1470
 
1403
1471
  const patchItemAt = (array, index, patch) => array.map((item, i) => (i === index ? { ...item, ...patch } : item))
1404
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
+
1405
1525
 
1406
1526
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1407
1527
  window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-usage-dash', factory })
@@ -1536,6 +1656,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1536
1656
  model: ['M5 5h14v14H5Z', 'M9 9h6v6H9Z', 'M9 2v3', 'M15 2v3', 'M9 19v3', 'M15 19v3', 'M2 9h3', 'M2 15h3', 'M19 9h3', 'M19 15h3'],
1537
1657
  rate: ['M22 12h-4l-3 9L9 3l-3 9H2'],
1538
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'],
1539
1660
  }
1540
1661
 
1541
1662
  function Icon({ paths }) {
@@ -1546,6 +1667,29 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1546
1667
  }, paths.map((d, index) => h('path', { key: index, d })))
1547
1668
  }
1548
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
+
1549
1693
  const STYLE_CSS = `
1550
1694
  .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1551
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);
@@ -1569,6 +1713,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1569
1713
  .ud-icon-btn{padding:0 12px}
1570
1714
  .ud-icon{display:inline-flex;align-items:center;justify-content:center}
1571
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}
1572
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}
1573
1718
  .ud-loading{color:var(--dsw-alias-label-tertiary);text-align:center;padding:32px 0}
1574
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}
@@ -1680,14 +1825,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1680
1825
  .ud-pref-title{font-size:13px;color:var(--dsw-alias-label-primary)}
1681
1826
  .ud-pref-desc{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1682
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}
1683
1836
  .ud-rule-head{display:flex;align-items:flex-end;gap:8px}
1684
1837
  .ud-rule-head .ud-field{flex:1}
1685
1838
  .ud-rule-cond{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1686
1839
  .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}
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}
1691
1845
  .ud-cond-fields .ud-input{width:auto}
1692
1846
  .ud-cond-add{display:flex;gap:6px;flex-wrap:wrap}
1693
1847
  .ud-field{display:flex;flex-direction:column;gap:3px;min-width:0}
@@ -1705,8 +1859,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1705
1859
  .ud-rule-add:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1706
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}
1707
1861
  .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}}
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}
1710
1864
  `
1711
1865
 
1712
1866
  function ensureStyle(document) {
@@ -2193,10 +2347,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2193
2347
  entry.title ? h('span', { title: entry.title }, entry.text) : entry.text)))
2194
2348
  })
2195
2349
 
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
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())), [])
2200
2358
  const [pricingRules, setPricingRules] = useState(null)
2201
2359
  useEffect(() => {
2202
2360
  let alive = true
@@ -2205,14 +2363,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2205
2363
  })
2206
2364
  return () => { alive = false }
2207
2365
  }, [])
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))
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)
2214
2377
  })
2215
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
+
2216
2393
  // 偏好卡行:说明文案承担 aria-describedby 目标
2217
2394
  function StatsLineOptionRow({ labelKey, descKey, checked, onToggle, t = defaultT }) {
2218
2395
  const describeId = React.useId()
@@ -2256,8 +2433,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2256
2433
  const PRICING_STATE_READY = 'ready'
2257
2434
  const PRICING_STATE_UNAVAILABLE = 'unavailable'
2258
2435
 
2259
- // 条件行:类型下拉 + 按类型字段区 + 删除;类型切换重置为该类型默认值(字段结构互不相通)
2260
- function PricingRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove }) {
2436
+ // 价格四桶 grid:默认价槽与附加规则卡共用;错误路径按规则平面索引寻址
2437
+ function PricingPriceGrid({ rule, currency, errors, pathPrefix, t, onPricePatch }) {
2261
2438
  const errorTextOf = (path) => {
2262
2439
  const key = errors.get(path)
2263
2440
  return key ? h('span', { className: 'ud-field-error' }, t(key)) : null
@@ -2269,9 +2446,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2269
2446
  h('input', {
2270
2447
  type: 'number', className: 'ud-input', min: 0, step: 'any',
2271
2448
  value: rule.price?.[key] ?? '',
2272
- onChange: (event) => onPatch({ price: { ...rule.price, [key]: event.target.value } }),
2449
+ onChange: (event) => onPricePatch({ price: { ...rule.price, [key]: event.target.value } }),
2273
2450
  })),
2274
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
+ }
2275
2466
  const patchConditions = (conditions) => onPatch({ conditions })
2276
2467
  const patchConditionAt = (conditionIndex, patch) => patchConditions(patchItemAt(rule.conditions, conditionIndex, patch))
2277
2468
  const removeConditionAt = (conditionIndex) => patchConditions(rule.conditions.filter((_, i) => i !== conditionIndex))
@@ -2285,10 +2476,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2285
2476
  onChange: (event) => patchCondition({ [field]: event.target.value }),
2286
2477
  }),
2287
2478
  errorTextOf(`${condPath}.${field}`))
2479
+ // 号段 from/to 同域 1~31(双闭),统一上限
2288
2480
  const numberInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
2289
2481
  h('span', { className: 'ud-field-label' }, t(labelKey)),
2290
2482
  h('input', {
2291
- 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,
2292
2485
  value: condition[field] ?? '',
2293
2486
  onChange: (event) => patchCondition({ [field]: event.target.value }),
2294
2487
  }),
@@ -2325,32 +2518,74 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2325
2518
  }, '×'))
2326
2519
  }
2327
2520
  return h('div', { className: 'ud-rule' },
2328
- 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' },
2329
2554
  h('label', { className: 'ud-field' },
2330
2555
  h('span', { className: 'ud-field-label' }, t('pricingModel')),
2331
2556
  h('input', {
2332
- type: 'text', className: 'ud-input', value: rule.model,
2557
+ type: 'text', className: 'ud-input', value: group.model,
2333
2558
  placeholder: t('pricingModelPlaceholder'),
2334
- onChange: (event) => onPatch({ model: event.target.value }),
2559
+ onChange: (event) => onModelChange(event.target.value),
2335
2560
  }),
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])}`)))))
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')))
2354
2589
  }
2355
2590
 
2356
2591
  function PricingEditor({ t = defaultT }) {
@@ -2433,20 +2668,30 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2433
2668
  }, symbol))),
2434
2669
  h('button', { className: 'ud-btn', type: 'button', disabled: saving, onClick: save }, t('save')))),
2435
2670
  saveError ? h('div', { className: 'ud-error' }, saveError) : null,
2436
- rules.map((rule, index) => h(PricingRuleCard, {
2437
- key: index,
2438
- 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,
2439
2676
  currency,
2440
2677
  errors,
2441
- pathPrefix: `${index}.`,
2442
2678
  t,
2443
- onPatch: (part) => setRules((prev) => patchItemAt(prev, index, part)),
2444
- 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
+ })),
2445
2690
  })),
2446
2691
  h('button', {
2447
2692
  className: 'ud-rule-add', type: 'button',
2448
2693
  onClick: () => setRules((prev) => [...prev, defaultPricingRule(currency)]),
2449
- }, t('addRule')))
2694
+ }, t('addGroup')))
2450
2695
  }
2451
2696
 
2452
2697
  // 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示
@@ -2477,14 +2722,22 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2477
2722
  status.error ? h('span', { className: 'ud-status-err' }, status.error) : null)
2478
2723
  }
2479
2724
 
2480
- // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容)
2725
+ // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容);
2726
+ // skipped 细分归因(宿主拒读/损坏/旧格式)挂在汇总行 title,供跨宿主边界诊断
2481
2727
  function AnomalyLog({ status, t = defaultT }) {
2482
2728
  const lines = status.log ?? []
2483
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
+ })
2484
2737
  return h('div', { className: 'ud-log' },
2485
2738
  h('div', { className: 'ud-log-summary' },
2486
2739
  (status.skippedSessions ?? 0) > 0
2487
- ? h('span', null, t('skippedSessions', { n: status.skippedSessions }))
2740
+ ? h('span', { title: breakdownText }, t('skippedSessions', { n: status.skippedSessions }))
2488
2741
  : null,
2489
2742
  (status.recordFailures ?? 0) > 0
2490
2743
  ? h('span', { className: 'ud-status-err' }, t('recordFailures', { n: status.recordFailures }))
@@ -2523,6 +2776,40 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2523
2776
  armed ? t('rebuildConfirm') : t('rebuild'))
2524
2777
  }
2525
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
+
2526
2813
  const viewLabel = (t, id) => (id === 'day' ? t('viewDay') : id === 'hour' ? t('viewHour') : t('viewMinute'))
2527
2814
  const trendTitle = (t, id) => (id === 'day' ? t('dailyTrend') : id === 'hour' ? t('hourTrend') : t('minuteTrend'))
2528
2815
  const trendLimitedText = (t, id, count) => (id === 'day'
@@ -2769,7 +3056,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2769
3056
  ? h('div', { className: 'ud-detail' },
2770
3057
  h(AnomalyLog, { status, t }),
2771
3058
  h('div', { className: 'ud-detail-foot' },
2772
- 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 })))
2773
3061
  : null,
2774
3062
  error ? h('div', { className: 'ud-error' }, error) : null,
2775
3063
  loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
@@ -2823,15 +3111,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2823
3111
  } catch (error) {
2824
3112
  console.warn('[usage-dash] 底部信息栏未注册(宿主无 conversation.composer.dock 插槽)', error)
2825
3113
  }
2826
- // 注入点B:turnTail chain 条目,同款两段式与降级;旧宿主无该插槽仅告警禁用
3114
+ // 注入点B:回合费用芯片走官方动作行槽,回合结束随 messageId 出现;旧宿主无该插槽仅告警禁用
2827
3115
  try {
2828
- ctx.slots.inject('conversation.chat.turnTail', () =>
3116
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
2829
3117
  ctx.slots.register(
2830
- { name: 'conversation.chat.turnTail', select: selectTurnTokenUsage, priority: TURN_TAIL_PRIORITY, locale: LOCALE_NS },
2831
- CostTail,
3118
+ { name: 'conversation.chat.assistant-actions', id: 'usage-dash-turn-cost', order: TURN_COST_CHIP_ORDER, locale: LOCALE_NS },
3119
+ CostChip,
2832
3120
  ))
2833
3121
  } catch (error) {
2834
- console.warn('[usage-dash] 会话尾部用量行未注册(宿主无 conversation.chat.turnTail 插槽)', error)
3122
+ console.warn('[usage-dash] 回合费用芯片未注册(宿主无 conversation.chat.assistant-actions 插槽)', error)
2835
3123
  }
2836
3124
  // 跨实例同步:其他实例写开关经 storage 事件触发重读(同实例写入不触发该事件)
2837
3125
  window.addEventListener('storage', (event) => {