@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/README.md +52 -11
- package/package.json +2 -2
- package/src/archive-reader.js +138 -0
- package/src/client.js +400 -112
- package/src/collector.js +58 -12
- package/src/direct-log-reader.js +126 -0
- package/src/pricing.js +5 -6
- package/src/routes.js +14 -1
- package/src/store.js +70 -2
- package/test/archive-reader.test.mjs +365 -0
- package/test/client.test.mjs +153 -12
- package/test/collector.test.mjs +152 -8
- package/test/direct-log-reader.test.mjs +104 -0
- package/test/pricing-parity.test.mjs +38 -4
- package/test/pricing.test.mjs +50 -18
- package/test/query.test.mjs +2 -1
- package/test/routes.test.mjs +9 -2
- package/test/stats-line.test.mjs +6 -6
- package/test/store.test.mjs +58 -1
- package/test/turn-tail.test.mjs +95 -41
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
|
-
|
|
223
|
-
|
|
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
|
|
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: '
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
1062
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
1233
|
-
//
|
|
1234
|
-
const
|
|
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
|
-
//
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
|
1247
|
-
|
|
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
|
-
//
|
|
1265
|
-
function
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
//
|
|
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
|
|
1277
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
1375
|
+
// 各条件类型合法默认即填即用:时段全天、周几空、号段全月、日期段当天单日
|
|
1311
1376
|
const defaultCondition = (kind, now = new Date()) => {
|
|
1312
1377
|
const today = formatDate(now)
|
|
1313
1378
|
const defaults = {
|
|
1314
|
-
dailyWindow: { kind: 'dailyWindow',
|
|
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
|
-
// 条件字段级校验:路径前缀 + 键 →
|
|
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
|
-
|
|
1330
|
-
|
|
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:
|
|
1688
|
-
.ud-
|
|
1689
|
-
.ud-cond-
|
|
1690
|
-
.ud-cond-fields
|
|
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
|
-
|
|
1709
|
-
|
|
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
|
|
2197
|
-
//
|
|
2198
|
-
|
|
2199
|
-
|
|
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
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
const
|
|
2212
|
-
|
|
2213
|
-
|
|
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
|
|
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) =>
|
|
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,
|
|
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-
|
|
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:
|
|
2557
|
+
type: 'text', className: 'ud-input', value: group.model,
|
|
2333
2558
|
placeholder: t('pricingModelPlaceholder'),
|
|
2334
|
-
onChange: (event) =>
|
|
2559
|
+
onChange: (event) => onModelChange(event.target.value),
|
|
2335
2560
|
}),
|
|
2336
|
-
|
|
2337
|
-
h('
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
(
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
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
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
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
|
-
|
|
2444
|
-
|
|
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('
|
|
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',
|
|
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
|
|
3114
|
+
// 注入点B:回合费用芯片走官方动作行槽,回合结束随 messageId 出现;旧宿主无该插槽仅告警禁用
|
|
2827
3115
|
try {
|
|
2828
|
-
ctx.slots.inject('conversation.chat.
|
|
3116
|
+
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
2829
3117
|
ctx.slots.register(
|
|
2830
|
-
{ name: 'conversation.chat.
|
|
2831
|
-
|
|
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]
|
|
3122
|
+
console.warn('[usage-dash] 回合费用芯片未注册(宿主无 conversation.chat.assistant-actions 插槽)', error)
|
|
2835
3123
|
}
|
|
2836
3124
|
// 跨实例同步:其他实例写开关经 storage 事件触发重读(同实例写入不触发该事件)
|
|
2837
3125
|
window.addEventListener('storage', (event) => {
|