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