@mzzsfy/dsh-usage-dash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js ADDED
@@ -0,0 +1,2416 @@
1
+ // 用量统计面板 client 半区:设置页 settings.section 注入与底部信息栏接管,en/zh 双语。
2
+ // 无构建:createElement + 一次性样式注入;协议与渲染形态见 docs/feat-usage-dash/client-design.md。
3
+ // 单文件自包含:client-modules bundle 以非模块 script 求值,禁止 import/export(整捆语法共担);纯函数区经测试整源求值收集。
4
+
5
+ const DAY_PRESETS = ['7', '14', '30', '90']
6
+ const HOUR_PRESETS = ['24h', '48h', '72h']
7
+ const MINUTE_PRESETS = ['60m', '180m', '360m']
8
+
9
+ const HOUR_PRESET_HOURS = { '24h': 24, '48h': 48, '72h': 72 }
10
+ const MINUTE_PRESET_MINUTES = { '60m': 60, '180m': 180, '360m': 360 }
11
+
12
+ const DEFAULT_RANGE = '30'
13
+ const DEFAULT_HOUR_PRESET = '24h'
14
+ const DEFAULT_MINUTE_PRESET = '60m'
15
+
16
+ // 天视图渲染上限;时/分上限 = 闭区间桶数(hour N+1 槽,minute N/10+1 槽)
17
+ const DAY_MAX_SLOTS = 180
18
+ const API_PREFIX = '/api/usage-dash/'
19
+ const ENDPOINTS = { range: 'range', hours: 'hours', minutes: 'minutes', status: 'status', reset: 'reset', pricing: 'pricing' }
20
+
21
+ // 宿主语义 token 之外的插件本地模型色板容量与哨兵
22
+ const GROUP_TOP_COUNT = 5
23
+ const OTHER_MODEL = '\u0000other'
24
+
25
+ const PAD_WIDTH = 2
26
+ const MS_PER_HOUR = 60 * 60 * 1000
27
+ const MS_PER_MINUTE = 60 * 1000
28
+
29
+ const pad = (value) => String(value).padStart(PAD_WIDTH, '0')
30
+ const formatDate = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
31
+
32
+ function dayBucket(date) {
33
+ return formatDate(date)
34
+ }
35
+ function hourBucket(date) {
36
+ return `${formatDate(date)}T${pad(date.getHours())}`
37
+ }
38
+ function minuteBucket(date) {
39
+ return `${hourBucket(date)}:${pad(date.getMinutes())}`
40
+ }
41
+
42
+ function localDay(offsetDays, now = new Date()) {
43
+ const shifted = new Date(now)
44
+ shifted.setDate(shifted.getDate() + offsetDays)
45
+ return formatDate(shifted)
46
+ }
47
+
48
+ const hourValueOf = (presetId) => HOUR_PRESET_HOURS[presetId]
49
+ const minuteValueOf = (presetId) => MINUTE_PRESET_MINUTES[presetId]
50
+
51
+ function resolveDayRange(presetId, now = new Date()) {
52
+ const days = Number(presetId)
53
+ if (!Number.isInteger(days) || days <= 0) return null
54
+ return { from: localDay(-(days - 1), now), to: formatDate(now) }
55
+ }
56
+
57
+ function resolveHourRange(presetId, now = new Date()) {
58
+ const hours = hourValueOf(presetId)
59
+ if (!hours) return null
60
+ return { from: hourBucket(new Date(now.getTime() - hours * MS_PER_HOUR)), to: hourBucket(now) }
61
+ }
62
+
63
+ // 分钟桶对齐 10 分钟粒度:窗口起点落到桶边界,to 保持原始分钟(闭区间上界)
64
+ const MINUTE_BUCKET_SPAN_MINUTES = 10
65
+ const MINUTE_BUCKET_SPAN_MS = MINUTE_BUCKET_SPAN_MINUTES * MS_PER_MINUTE
66
+
67
+ function minuteBucketFloor(ts) {
68
+ return Math.floor(ts / MINUTE_BUCKET_SPAN_MS) * MINUTE_BUCKET_SPAN_MS
69
+ }
70
+
71
+ function resolveMinuteRange(presetId, now = new Date()) {
72
+ const minutes = minuteValueOf(presetId)
73
+ if (!minutes) return null
74
+ const from = minuteBucket(new Date(minuteBucketFloor(now.getTime()) - minutes * MS_PER_MINUTE))
75
+ return { from, to: minuteBucket(now) }
76
+ }
77
+
78
+ function maxSlotsFor(view, presetId) {
79
+ if (view === 'hour') return hourValueOf(presetId) + 1
80
+ if (view === 'minute') return minuteValueOf(presetId) / MINUTE_BUCKET_SPAN_MINUTES + 1
81
+ return DAY_MAX_SLOTS
82
+ }
83
+
84
+ function trimSlots(slots, max) {
85
+ return slots.length > max ? slots.slice(-max) : slots
86
+ }
87
+
88
+ const DEFAULT_ERROR_CODE = 'error'
89
+ const DEFAULT_ERROR_MESSAGE = 'usage api error'
90
+ const envelopeFailure = (code, message) => ({ ok: false, code, message })
91
+
92
+ function parseEnvelope(json) {
93
+ if (!json || typeof json !== 'object') return envelopeFailure(DEFAULT_ERROR_CODE, DEFAULT_ERROR_MESSAGE)
94
+ if (json.ok === true) return { ok: true, value: json.value }
95
+ if (json.ok === false) {
96
+ const error = json.error ?? {}
97
+ return envelopeFailure(error.code ?? DEFAULT_ERROR_CODE, error.message ?? DEFAULT_ERROR_MESSAGE)
98
+ }
99
+ return envelopeFailure(DEFAULT_ERROR_CODE, DEFAULT_ERROR_MESSAGE)
100
+ }
101
+
102
+ const MESSAGES_ZH = {
103
+ nav: '使用统计',
104
+ range: '时间范围',
105
+ 'rangePreset.7': '最近 7 天',
106
+ 'rangePreset.14': '最近 14 天',
107
+ 'rangePreset.30': '最近 30 天',
108
+ 'rangePreset.90': '最近 90 天',
109
+ rangeCustom: '自定义',
110
+ from: '开始日期',
111
+ to: '结束日期',
112
+ refresh: '刷新',
113
+ loading: '加载中',
114
+ tokens: 'Tokens 用量',
115
+ tokensHint: '服务商总口径:未缓存输入 + 输出 + 缓存命中 token',
116
+ sessions: '会话数量',
117
+ requests: '请求数量',
118
+ activeDays: '活跃天数',
119
+ cacheRate: '平均缓存命中率',
120
+ cacheRateHint: '时间段内缓存命中 token 占输入 token 的比例',
121
+ cacheHitRate: '缓存命中率',
122
+ hitRateLegend: '缓存命中率',
123
+ topModel: '最常用模型',
124
+ topModelHint: '按 token 用量排序,非调用次数',
125
+ heatmap: '活跃热力图',
126
+ heatLess: '较少',
127
+ heatMore: '较多',
128
+ dailyTrend: '按天 Token 趋势',
129
+ trendLimited: '仅显示最近 {n} 天',
130
+ modelUsage: '模型用量',
131
+ other: '其他',
132
+ total: '总用量',
133
+ percent: '占比',
134
+ asOf: '统计截至',
135
+ empty: '当前时间范围内暂无用量数据。Token 用量从本面板启用后开始累计,并会一次性回扫已有的历史会话。',
136
+ viewDay: '按天',
137
+ viewHour: '按小时',
138
+ viewMinute: '按分钟',
139
+ viewGroup: '统计粒度',
140
+ 'status.running': '回扫中 {done}/{total}',
141
+ rebuild: '重建',
142
+ rebuildConfirm: '确认重建',
143
+ hourTrend: '按小时 Token 趋势',
144
+ minuteTrend: '按分钟 Token 趋势',
145
+ hourPreset: '最近 {n} 小时',
146
+ minutePreset: '最近 {n} 分钟',
147
+ trendLimitedHour: '数据量过大,仅显示最近 {n} 小时',
148
+ trendLimitedMinute: '数据量过大,仅显示最近 {n} 分钟',
149
+ trendTruncated: '数据量过大,仅显示最近部分',
150
+ recordFailures: '{n} 条记录写入失败',
151
+ skippedSessions: '跳过 {n} 个无法读取的会话',
152
+ 'stats.counts': '{turns} 轮 · {steps} 步',
153
+ 'stats.llm': 'LLM {duration}',
154
+ 'stats.toolCall': '工具调用 {duration}',
155
+ 'stats.ttftAverage': '首 token 平均 {duration}',
156
+ 'stats.tokensPerSecond': '{throughput} tok/s',
157
+ 'stats.cacheHit': '缓存命中 {percent}%',
158
+ 'stats.tokens': '输入 {input} tok · 输出 {output} tok',
159
+ 'stats.tokensDetail': '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok',
160
+ cachePrecision: '精确缓存命中率',
161
+ cachePrecisionDesc: '在会话底部信息栏以两位小数显示缓存命中率。',
162
+ tokenDetail: '会话 Token 明细',
163
+ tokenDetailDesc: '在会话底部信息栏显示总 Token、命中/未命中缓存与输出明细。',
164
+ costDisplay: '费用显示',
165
+ costDisplayDesc: '在信息栏与趋势悬浮中显示按当前费率估算的费用。',
166
+ costTitle: '按当前费率对历史用量估算,精度为小时级',
167
+ costUnpriced: '{n} 个小时桶未计价',
168
+ statsCostTitle: '按当前费率对会话累计 token 估算',
169
+ 'stats.cost': '费用 ≈ {cost}',
170
+ 'stats.turnCost': '{summary} · 费用 ≈ {cost}',
171
+ turnCostTitle: '单轮用量按当前费率估算',
172
+ turnTokensUnreported: '该提供商未上报此桶',
173
+ pricing: '定价规则',
174
+ pricingUnavailable: '定价规则不可用',
175
+ pricingModel: '模型',
176
+ pricingModelPlaceholder: 'provider/model 或 *',
177
+ pricingCurrency: '货币',
178
+ pricingUnit: '每百万 token 定价',
179
+ priceInput: '输入',
180
+ priceOutput: '输出',
181
+ priceCacheRead: '缓存读',
182
+ priceCacheWrite: '缓存写',
183
+ noCondition: '无条件 = 恒生效',
184
+ conditionsPreserved: '已有 {n} 条条件,本编辑器暂不支持修改,保存时原样保留',
185
+ deleteRule: '删除规则',
186
+ addRule: '添加规则',
187
+ save: '保存',
188
+ saved: '已保存',
189
+ required: '必填',
190
+ priceInvalid: '不能为负',
191
+ 'duration.compactSeconds': '{seconds}秒',
192
+ 'duration.compactMinutes': '{minutes}分{seconds}秒',
193
+ 'number.thousand': '{value}K',
194
+ 'number.million': '{value}M',
195
+ }
196
+
197
+ // en 词典:stats/duration/number 族逐字节取官方 chat 值,其余为本插件自有文案
198
+ const MESSAGES_EN = {
199
+ nav: 'Usage',
200
+ range: 'Time range',
201
+ 'rangePreset.7': 'Last 7 days',
202
+ 'rangePreset.14': 'Last 14 days',
203
+ 'rangePreset.30': 'Last 30 days',
204
+ 'rangePreset.90': 'Last 90 days',
205
+ rangeCustom: 'Custom',
206
+ from: 'From',
207
+ to: 'To',
208
+ refresh: 'Refresh',
209
+ loading: 'Loading',
210
+ tokens: 'Token usage',
211
+ tokensHint: 'Provider total: uncached input + output + cache-read tokens',
212
+ sessions: 'Sessions',
213
+ requests: 'Requests',
214
+ activeDays: 'Active days',
215
+ cacheRate: 'Avg cache-hit rate',
216
+ cacheRateHint: 'Cache-hit tokens as a share of input tokens within the range',
217
+ cacheHitRate: 'Cache-hit rate',
218
+ hitRateLegend: 'Cache-hit rate',
219
+ topModel: 'Top model',
220
+ topModelHint: 'Ranked by token usage, not call count',
221
+ heatmap: 'Activity heatmap',
222
+ heatLess: 'Less',
223
+ heatMore: 'More',
224
+ dailyTrend: 'Daily token trend',
225
+ trendLimited: 'Showing only the last {n} days',
226
+ modelUsage: 'Model usage',
227
+ other: 'Other',
228
+ total: 'Total',
229
+ percent: 'Share',
230
+ asOf: 'Stats as of',
231
+ empty: 'No usage data in this range yet. Token usage accumulates from when this panel is enabled, and existing sessions are scanned once.',
232
+ viewDay: 'Daily',
233
+ viewHour: 'Hourly',
234
+ viewMinute: 'Per-minute',
235
+ viewGroup: 'Granularity',
236
+ 'status.running': 'Rescanning {done}/{total}',
237
+ rebuild: 'Rebuild',
238
+ rebuildConfirm: 'Confirm rebuild',
239
+ hourTrend: 'Hourly token trend',
240
+ minuteTrend: 'Per-minute token trend',
241
+ hourPreset: 'Last {n} hours',
242
+ minutePreset: 'Last {n} minutes',
243
+ trendLimitedHour: 'Too much data, showing only the last {n} hours',
244
+ trendLimitedMinute: 'Too much data, showing only the last {n} minutes',
245
+ trendTruncated: 'Too much data, showing only the latest part',
246
+ recordFailures: '{n} records failed to write',
247
+ skippedSessions: '{n} unreadable sessions skipped',
248
+ 'stats.counts': '{turns} turns · {steps} steps',
249
+ 'stats.llm': 'LLM {duration}',
250
+ 'stats.toolCall': 'Tool call {duration}',
251
+ 'stats.ttftAverage': 'TTFT avg {duration}',
252
+ 'stats.tokensPerSecond': '{throughput} tok/s',
253
+ 'stats.cacheHit': 'Cache hit {percent}%',
254
+ 'stats.tokens': 'Input {input} tok · Output {output} tok',
255
+ 'stats.tokensDetail': 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok',
256
+ cachePrecision: 'Precise cache-hit rate',
257
+ cachePrecisionDesc: 'Show the cache-hit rate with two decimals in the session stats line.',
258
+ tokenDetail: 'Session token detail',
259
+ tokenDetailDesc: 'Show total, cache hit/miss and output tokens in the session stats line.',
260
+ costDisplay: 'Cost display',
261
+ costDisplayDesc: 'Show costs estimated at current rates in the stats line and trend tooltips.',
262
+ costTitle: 'Estimated at current rates over historical usage, hourly precision',
263
+ costUnpriced: '{n} hour buckets unpriced',
264
+ statsCostTitle: 'Estimated at current rates over session token totals',
265
+ 'stats.cost': 'Cost ≈ {cost}',
266
+ 'stats.turnCost': '{summary} · Cost ≈ {cost}',
267
+ turnCostTitle: 'Per-turn usage estimated at current rates',
268
+ turnTokensUnreported: 'Not reported by this provider',
269
+ pricing: 'Pricing rules',
270
+ pricingUnavailable: 'Pricing rules unavailable',
271
+ pricingModel: 'Model',
272
+ pricingModelPlaceholder: 'provider/model or *',
273
+ pricingCurrency: 'Currency',
274
+ pricingUnit: 'per million tokens pricing',
275
+ priceInput: 'Input',
276
+ priceOutput: 'Output',
277
+ priceCacheRead: 'Cache read',
278
+ priceCacheWrite: 'Cache write',
279
+ noCondition: 'No condition = always applies',
280
+ conditionsPreserved: '{n} existing conditions are kept as-is; editing them is not supported yet',
281
+ deleteRule: 'Remove rule',
282
+ addRule: 'Add rule',
283
+ save: 'Save',
284
+ saved: 'Saved',
285
+ required: 'Required',
286
+ priceInvalid: 'Must not be negative',
287
+ 'duration.compactSeconds': '{seconds}s',
288
+ 'duration.compactMinutes': '{minutes}m{seconds}s',
289
+ 'number.thousand': '{value}K',
290
+ 'number.million': '{value}M',
291
+ }
292
+
293
+ const PLACEHOLDER_PATTERN = /\{(\w+)\}/g
294
+
295
+ // 纯查表翻译:缺键回退键名,占位 {k} 插值
296
+ function translateWith(dict, key, params) {
297
+ const text = dict[key] ?? key
298
+ if (!params) return text
299
+ return text.replace(PLACEHOLDER_PATTERN, (raw, name) => (name in params ? String(params[name]) : raw))
300
+ }
301
+
302
+ // 词典绑定翻译器:与宿主 t 座同构,locale 缺席时的本地回退形态
303
+ function createTranslator(dict) {
304
+ return (key, params) => translateWith(dict, key, params)
305
+ }
306
+
307
+ const COMPACT_BASE = 1000
308
+ const DECIMAL_DIGITS = 1
309
+ function formatTokens(value) {
310
+ return value.toLocaleString('en-US')
311
+ }
312
+ function formatCompact(value) {
313
+ if (value >= COMPACT_BASE ** 3) return (value / COMPACT_BASE ** 3).toFixed(DECIMAL_DIGITS) + 'B'
314
+ if (value >= COMPACT_BASE ** 2) return (value / COMPACT_BASE ** 2).toFixed(DECIMAL_DIGITS) + 'M'
315
+ if (value >= COMPACT_BASE) return (value / COMPACT_BASE).toFixed(DECIMAL_DIGITS) + 'k'
316
+ return String(value)
317
+ }
318
+ function formatPercent(value) {
319
+ return (Math.round(value * 10) / 10).toFixed(1) + '%'
320
+ }
321
+ function cacheRate(hit, miss) {
322
+ const total = hit + miss
323
+ return total <= 0 ? null : (hit / total) * 100
324
+ }
325
+ function cacheRateText(hit, miss) {
326
+ const rate = cacheRate(hit, miss)
327
+ return rate === null ? '—' : formatPercent(rate)
328
+ }
329
+
330
+ const REF_SPLIT_LIMIT = 2
331
+ function modelNameOf(ref) {
332
+ const parts = ref.split('/')
333
+ return parts.length < REF_SPLIT_LIMIT ? ref : parts.slice(1).join('/')
334
+ }
335
+ function providerOf(ref) {
336
+ const parts = ref.split('/')
337
+ return parts.length < REF_SPLIT_LIMIT ? 'default' : parts[0]
338
+ }
339
+
340
+ function shortDay(day) {
341
+ const parts = day.split('-')
342
+ return `${Number(parts[1])}/${Number(parts[2])}`
343
+ }
344
+
345
+ const DAY_KEY_LENGTH = 'YYYY-MM-DD'.length
346
+ const MIDNIGHT_HOUR = '00'
347
+ const MIDNIGHT_TIME = '00:00'
348
+ function hourTickLabel(key) {
349
+ const hour = key.slice(DAY_KEY_LENGTH + 1)
350
+ return hour === MIDNIGHT_HOUR ? `${shortDay(key.slice(0, DAY_KEY_LENGTH))} ${hour}:00` : `${hour}:00`
351
+ }
352
+ function minuteTickLabel(key) {
353
+ const time = key.slice(DAY_KEY_LENGTH + 1)
354
+ return time === MIDNIGHT_TIME ? `${shortDay(key.slice(0, DAY_KEY_LENGTH))} ${time}` : time
355
+ }
356
+
357
+ function isEmptyRange(value) {
358
+ return value.tokens === 0 && value.cacheHit === 0 && value.requests === 0 && value.turns === 0
359
+ }
360
+
361
+ // 状态行仅在回扫进行或异常存在时可见,空闲干净态不占版面
362
+ function statusLineActive(status) {
363
+ if (!status) return false
364
+ return Boolean(status.running)
365
+ || Boolean(status.error)
366
+ || (status.skippedSessions ?? 0) > 0
367
+ || (status.recordFailures ?? 0) > 0
368
+ }
369
+
370
+ const toRankedModels = (totals) =>
371
+ [...totals.entries()].map(([model, tokens]) => ({ model, tokens })).sort((a, b) => b.tokens - a.tokens)
372
+
373
+ // 逐槽 byModel 把非 top 模型并入哨兵桶,原明细留 otherByModel 供 tooltip;模型顺序 = 图例序(哨兵恒最后)
374
+ const foldSlotsByTop = (slots, topModels) => {
375
+ const top = new Set(topModels)
376
+ return slots.map((slot) => {
377
+ const byModel = {}
378
+ const otherByModel = {}
379
+ for (const [model, tokens] of Object.entries(slot.byModel)) {
380
+ if (top.has(model)) {
381
+ byModel[model] = (byModel[model] ?? 0) + tokens
382
+ continue
383
+ }
384
+ byModel[OTHER_MODEL] = (byModel[OTHER_MODEL] ?? 0) + tokens
385
+ otherByModel[model] = (otherByModel[model] ?? 0) + tokens
386
+ }
387
+ return { ...slot, byModel, otherByModel }
388
+ })
389
+ }
390
+
391
+ const topWithOther = (ranked) => {
392
+ const models = ranked.slice(0, GROUP_TOP_COUNT)
393
+ if (ranked.length > GROUP_TOP_COUNT) {
394
+ const rest = ranked.slice(GROUP_TOP_COUNT)
395
+ models.push({
396
+ model: OTHER_MODEL,
397
+ tokens: rest.reduce((sum, item) => sum + item.tokens, 0),
398
+ cost: rest.reduce((sum, item) => sum + (item.cost ?? 0), 0),
399
+ items: rest,
400
+ })
401
+ }
402
+ return models
403
+ }
404
+
405
+ function groupStats(stats) {
406
+ const models = topWithOther(stats.models)
407
+ const topModels = models.filter((item) => item.model !== OTHER_MODEL).map((item) => item.model)
408
+ return { models, daily: foldSlotsByTop(stats.daily, topModels) }
409
+ }
410
+
411
+ function groupPointSlots(slots) {
412
+ const totals = new Map()
413
+ for (const slot of slots) {
414
+ for (const [model, tokens] of Object.entries(slot.byModel)) {
415
+ totals.set(model, (totals.get(model) ?? 0) + tokens)
416
+ }
417
+ }
418
+ const models = topWithOther(toRankedModels(totals))
419
+ const topModels = models.filter((item) => item.model !== OTHER_MODEL).map((item) => item.model)
420
+ return { models, daily: foldSlotsByTop(slots, topModels) }
421
+ }
422
+
423
+ // 趋势图视口常量
424
+ const CHART_HEIGHT = 220
425
+ const CHART_PAD = { left: 46, right: 65, top: 10, bottom: 26 }
426
+ const BAR_WIDTH_RATIO = 0.62
427
+ const BAR_MIN_WIDTH = 3
428
+ const BAR_MAX_WIDTH = 30
429
+ const AXIS_TICK_COUNT = 4
430
+
431
+ function niceTicks(max, count) {
432
+ if (max <= 0 || count <= 0) return []
433
+ const raw = max / count
434
+ const magnitude = 10 ** Math.floor(Math.log10(raw))
435
+ const norm = raw / magnitude
436
+ const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * magnitude
437
+ const ticks = []
438
+ for (let value = step; value <= max; value += step) ticks.push(value)
439
+ return ticks
440
+ }
441
+
442
+ // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度
443
+ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
444
+ const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
445
+ const innerWidth = Math.max(1, avail - CHART_PAD.left - CHART_PAD.right)
446
+ const count = slots.length
447
+ const step = count > 1 ? innerWidth / (count - 1) : innerWidth
448
+ const barWidth = Math.max(BAR_MIN_WIDTH, Math.min(BAR_MAX_WIDTH, step * BAR_WIDTH_RATIO))
449
+ const maxTotal = Math.max(1, ...slots.map((slot) => slot.total))
450
+ const bars = slots.map((slot, index) => {
451
+ const centerX = CHART_PAD.left + barWidth / 2 + index * step
452
+ let yBottom = CHART_PAD.top + plotHeight
453
+ const segments = []
454
+ for (const model of modelOrder) {
455
+ const tokens = slot.byModel[model] ?? 0
456
+ if (tokens === 0) continue
457
+ const height = (tokens / maxTotal) * plotHeight
458
+ yBottom -= height
459
+ segments.push({ model, y: yBottom, height })
460
+ }
461
+ return { key: slot.day, x: centerX, segments }
462
+ })
463
+ return {
464
+ width: avail,
465
+ height: CHART_HEIGHT,
466
+ plotHeight,
467
+ step,
468
+ barWidth,
469
+ maxTotal,
470
+ ticks: niceTicks(maxTotal, AXIS_TICK_COUNT),
471
+ labelEvery: Math.max(1, Math.ceil(labelMinPitch / step)),
472
+ bars,
473
+ }
474
+ }
475
+
476
+ // 命中率副轴与曲线
477
+ const PERCENT_SCALE = 100
478
+ const RATE_AXIS_STEPS = 4
479
+ const TREND_LINE_WIDTH = 2
480
+ const TREND_DOT_RADIUS = 4
481
+ const TREND_DOT_RING = 2
482
+ const AXIS_RATE_GAP = 8
483
+ const BAR_HOVER_GROW = 3
484
+
485
+ const rateAxisTicks = () =>
486
+ Array.from({ length: RATE_AXIS_STEPS + 1 }, (_, index) => (index / RATE_AXIS_STEPS) * PERCENT_SCALE)
487
+
488
+ function trendRatePoints(slots, bars, plotHeight) {
489
+ const points = []
490
+ slots.forEach((slot, index) => {
491
+ const rate = cacheRate(slot.cacheHit, slot.cacheMiss)
492
+ if (rate === null) return
493
+ points.push({
494
+ day: slot.day,
495
+ x: bars[index].x,
496
+ y: CHART_PAD.top + plotHeight - (rate / PERCENT_SCALE) * plotHeight,
497
+ })
498
+ })
499
+ return points
500
+ }
501
+
502
+ // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
503
+ function smoothPath(points) {
504
+ if (points.length === 0) return ''
505
+ if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
506
+ let d = `M ${points[0].x} ${points[0].y}`
507
+ for (let i = 0; i < points.length - 1; i++) {
508
+ const p0 = points[i - 1] ?? points[i]
509
+ const p1 = points[i]
510
+ const p2 = points[i + 1]
511
+ const p3 = points[i + 2] ?? p2
512
+ const c1x = p1.x + (p2.x - p0.x) / 6
513
+ const c1y = p1.y + (p2.y - p0.y) / 6
514
+ const c2x = p2.x - (p3.x - p1.x) / 6
515
+ const c2y = p2.y - (p3.y - p1.y) / 6
516
+ d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`
517
+ }
518
+ return d
519
+ }
520
+
521
+ // donut:circle+stroke-dash 几何,offset 渲染序累加
522
+ const DONUT_VIEWBOX_SIZE = 200
523
+ const DONUT_CENTER_XY = 100
524
+ const DONUT_OUTER_RADIUS = 95
525
+ const DONUT_STROKE_WIDTH = 30
526
+ const DONUT_ACTIVE_STROKE_GROW = 5
527
+ const DONUT_DIM_OPACITY = 0.35
528
+ const DONUT_RADIUS = DONUT_OUTER_RADIUS - DONUT_STROKE_WIDTH / 2
529
+ const DONUT_CIRCUMFERENCE = 2 * Math.PI * DONUT_RADIUS
530
+ const DONUT_TOTAL_FLOOR = 1
531
+ const DONUT_CENTER_VALUE_OFFSET = 8
532
+ const DONUT_CENTER_LABEL_OFFSET = 26
533
+
534
+ function donutSegments(models, total) {
535
+ const denom = Math.max(DONUT_TOTAL_FLOOR, total)
536
+ let offset = 0
537
+ return models.map((item) => {
538
+ const dash = (item.tokens / denom) * DONUT_CIRCUMFERENCE
539
+ const segment = { ...item, dash, offset, percent: (item.tokens / denom) * PERCENT_SCALE }
540
+ offset += dash
541
+ return segment
542
+ })
543
+ }
544
+
545
+ // 列表手风琴明细与分段可访问标签
546
+ function otherDetailItems(models) {
547
+ const other = models.find((item) => item.model === OTHER_MODEL)
548
+ return other ? other.items ?? [] : []
549
+ }
550
+
551
+ function modelSegmentLabel(name, tokens, percent) {
552
+ return `${name}: ${formatTokens(tokens)} (${formatPercent(percent)})`
553
+ }
554
+
555
+ // 热力图:窗口固定 26 周,与所选范围无关
556
+ const HEAT_WEEKS = 26
557
+ const HEAT_ROW_COUNT = 7
558
+ const HEAT_WINDOW_DAYS = HEAT_WEEKS * HEAT_ROW_COUNT
559
+ const HEAT_BASE = 14
560
+ const HEAT_GAP = 3
561
+ const HEAT_RX = 3
562
+ const HEAT_WIDTH_EPSILON = 1
563
+ const HEAT_LEVELS = 6
564
+ const HEAT_LEVEL_BANDS = 4
565
+ const HEAT_EDGE_TRIM = 2
566
+
567
+ function indexOfDay(day) {
568
+ return (new Date(`${day}T00:00:00`).getDay() + HEAT_ROW_COUNT - 1) % HEAT_ROW_COUNT
569
+ }
570
+
571
+ function daysInRange(from, to) {
572
+ const days = []
573
+ const cursor = new Date(`${from}T00:00:00`)
574
+ const end = new Date(`${to}T00:00:00`)
575
+ while (cursor.getTime() <= end.getTime()) {
576
+ days.push(formatDate(cursor))
577
+ cursor.setDate(cursor.getDate() + 1)
578
+ }
579
+ return days
580
+ }
581
+
582
+ // 够宽格子连续生长铺满窗口,过窄保最新列裁最早
583
+ function heatLayout(width, firstDay) {
584
+ const avail = Math.max(1, width - HEAT_EDGE_TRIM)
585
+ const baseCols = Math.max(1, Math.floor((avail + HEAT_GAP) / (HEAT_BASE + HEAT_GAP)))
586
+ if (baseCols < HEAT_WEEKS || !firstDay) return { size: HEAT_BASE, cols: baseCols }
587
+ const totalWeeks = Math.ceil((HEAT_WINDOW_DAYS + (indexOfDay(firstDay) + 1) % HEAT_ROW_COUNT) / HEAT_ROW_COUNT)
588
+ return { size: Math.max(HEAT_BASE, avail / totalWeeks - HEAT_GAP), cols: HEAT_WEEKS }
589
+ }
590
+
591
+ function heatDisplayDays(allDays, cols) {
592
+ return allDays.slice(-Math.min(cols * HEAT_ROW_COUNT, allDays.length))
593
+ }
594
+
595
+ // 行序位移:indexOfDay 周一为零,+1 取模后周日..周六落行首
596
+ function heatGrid(rows, size) {
597
+ const startOffset = (indexOfDay(rows[0].day) + 1) % HEAT_ROW_COUNT
598
+ const weeks = Math.max(1, Math.ceil((rows.length + startOffset) / HEAT_ROW_COUNT))
599
+ const pitch = size + HEAT_GAP
600
+ const cells = rows.map((row, index) => {
601
+ const col = Math.floor((index + startOffset) / HEAT_ROW_COUNT)
602
+ const cellRow = (index + startOffset) % HEAT_ROW_COUNT
603
+ return { ...row, x: HEAT_GAP + col * pitch, y: HEAT_GAP + cellRow * pitch }
604
+ })
605
+ return { weeks, cells, width: weeks * pitch + HEAT_GAP, height: HEAT_ROW_COUNT * pitch + HEAT_GAP }
606
+ }
607
+
608
+ function heatLevel(tokens, max) {
609
+ return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
610
+ }
611
+
612
+ // ChartTip 定位:锚定格子矩形,边界内 clamp,下方优先放不下翻上方
613
+ const TIP_GAP_PX = 8
614
+ const TIP_MARGIN_PX = 8
615
+
616
+ function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
617
+ if (!tip || tip.width <= 0 || tip.height <= 0) return null
618
+ if (!anchor || (anchor.left === 0 && anchor.top === 0 && anchor.right === 0 && anchor.bottom === 0)) return null
619
+ const minX = bounds.left + margin
620
+ const minY = bounds.top + margin
621
+ const maxX = bounds.right - margin
622
+ const maxY = bounds.bottom - margin
623
+ const left = Math.max(minX, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, maxX - tip.width))
624
+ const below = anchor.bottom + gap
625
+ const above = anchor.top - gap - tip.height
626
+ let top
627
+ if (below + tip.height <= maxY) top = below
628
+ else if (above >= minY) top = above
629
+ else top = minY
630
+ return { left, top }
631
+ }
632
+
633
+ // ===== 底部信息栏:官方 StatsLine 口径(dsh-client-ui-chat/lib/client.js 同构)+ usp 双开关 =====
634
+ const STATS_LINE_STORAGE_KEY = 'dsh-usage-dash:stats-line'
635
+ const LOCALE_NS = 'usage-dash'
636
+ const STATS_ITEM_SEPARATOR = ' · '
637
+ const MS_PER_SECOND = 1000
638
+ const SECONDS_PER_MINUTE = 60
639
+ const NUMBER_ONE_DECIMAL = 10
640
+ const NUMBER_COMPACT_INT_THRESHOLD = 100
641
+ const PERCENT_TENTH_SCALE = 10
642
+ const PERCENT_GAP_DOUBLE_SCALE = 2 * PERCENT_SCALE
643
+ const PERCENT_LOSS_BASE = 10
644
+ const PERCENT_LOSS_CAP = 4
645
+ const PERCENT_LOSS_DEFAULT = 5
646
+ const PERCENT_PRECISE_CAP = 99.99
647
+ const DURATION_MINUTE_SECONDS = 60
648
+ const TPS_INTEGER_THRESHOLD = 10
649
+
650
+ // 官方 billing 分母:三个互斥的 prompt 侧计费桶
651
+ function billedInputTokens(usage) {
652
+ return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
653
+ }
654
+
655
+ // 官方二分取整:求最大 units 使 hit ≥ (2u-1)q + ceil((2u-1)r/2s),Math.round(hit·scale/D) 的保守复刻
656
+ function roundedPercentUnits(cacheReadTokens, denominator, decimalPlaces) {
657
+ const scale = (decimalPlaces === 0 ? 1 : PERCENT_TENTH_SCALE) * PERCENT_SCALE
658
+ const doubledScale = scale * 2
659
+ const quotient = Math.floor(denominator / doubledScale)
660
+ const remainder = denominator % doubledScale
661
+ const holds = (candidate) => {
662
+ const doubled = candidate * 2 - 1
663
+ return cacheReadTokens >= doubled * quotient + Math.ceil((doubled * remainder) / doubledScale)
664
+ }
665
+ let low = 0
666
+ let high = scale
667
+ while (low < high) {
668
+ const mid = Math.floor((low + high + 1) / 2)
669
+ if (holds(mid)) low = mid
670
+ else high = mid - 1
671
+ }
672
+ return low
673
+ }
674
+
675
+ function displayPercentUnits(units, decimalPlaces) {
676
+ if (decimalPlaces === 0) return String(units)
677
+ const whole = Math.floor(units / PERCENT_TENTH_SCALE)
678
+ const tenths = units % PERCENT_TENTH_SCALE
679
+ return tenths === 0 ? String(whole) : `${whole}.${tenths}`
680
+ }
681
+
682
+ // 官方整数百分比:0.5 进位;舍入溢出 100 而仍有 miss 时以 99.x 闭式诚实呈现
683
+ function formatCacheHitPercent(cacheReadTokens, promptTokens, decimalPlaces = 0) {
684
+ if (promptTokens === 0) return null
685
+ const missed = promptTokens - cacheReadTokens
686
+ if (missed === 0) return '100'
687
+ const roundedUnits = roundedPercentUnits(cacheReadTokens, promptTokens, decimalPlaces)
688
+ const unitsCeiling = decimalPlaces === 0 ? PERCENT_SCALE : PERCENT_SCALE * PERCENT_TENTH_SCALE
689
+ if (roundedUnits < unitsCeiling) return displayPercentUnits(roundedUnits, decimalPlaces)
690
+ let distinguishingPlaces = 1
691
+ let scaledDoubleGap = missed * PERCENT_GAP_DOUBLE_SCALE
692
+ const denominatorTens = Math.floor(promptTokens / PERCENT_TENTH_SCALE)
693
+ while (scaledDoubleGap <= denominatorTens) {
694
+ scaledDoubleGap *= PERCENT_TENTH_SCALE
695
+ distinguishingPlaces += 1
696
+ }
697
+ const denominatorOnes = promptTokens % PERCENT_TENTH_SCALE
698
+ let roundedLoss = PERCENT_LOSS_DEFAULT
699
+ for (let loss = 1; loss <= PERCENT_LOSS_CAP; loss++) {
700
+ const factor = loss * 2 + 1
701
+ const threshold = factor * denominatorTens + Math.floor((factor * denominatorOnes) / PERCENT_TENTH_SCALE)
702
+ if (scaledDoubleGap <= threshold) {
703
+ roundedLoss = loss
704
+ break
705
+ }
706
+ }
707
+ return `99.${'9'.repeat(distinguishingPlaces - 1)}${PERCENT_LOSS_BASE - roundedLoss}`
708
+ }
709
+
710
+ function cacheHitPercent(usage) {
711
+ return formatCacheHitPercent(usage.cacheReadTokens, billedInputTokens(usage), 0)
712
+ }
713
+
714
+ // usp 增强:恒两位小数,有 miss 即 99.99 封顶(镜像官方整数路径的诚实性)
715
+ function cacheHitPercentPrecise(usage) {
716
+ const billed = billedInputTokens(usage)
717
+ if (billed === 0) return null
718
+ const missed = usage.uncachedInputTokens + usage.cacheWriteTokens
719
+ if (missed === 0) return '100.00'
720
+ const percent = Math.min(
721
+ Math.round((usage.cacheReadTokens * PERCENT_SCALE * PERCENT_SCALE) / billed) / PERCENT_SCALE,
722
+ PERCENT_PRECISE_CAP,
723
+ )
724
+ return percent.toFixed(2)
725
+ }
726
+
727
+ // 官方 compact 数字族:number.thousand='{value}K'、million='{value}M'(官方同构签名)
728
+ function formatTokensCompact(value, t) {
729
+ const scaled = (count) => (count >= NUMBER_COMPACT_INT_THRESHOLD
730
+ ? String(Math.round(count))
731
+ : String(Math.round(count * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL))
732
+ if (value < COMPACT_BASE) return String(value)
733
+ if (value < COMPACT_BASE ** 2) return t('number.thousand', { value: scaled(value / COMPACT_BASE) })
734
+ return t('number.million', { value: scaled(value / COMPACT_BASE ** 2) })
735
+ }
736
+
737
+ // 官方时长:60 秒内保留一位小数,以上整秒折分秒(官方同构签名)
738
+ function formatDuration(ms, t) {
739
+ const seconds = ms / MS_PER_SECOND
740
+ if (seconds < DURATION_MINUTE_SECONDS) return t('duration.compactSeconds', { seconds: Math.round(seconds * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL })
741
+ const whole = Math.round(seconds)
742
+ return t('duration.compactMinutes', { minutes: Math.floor(whole / SECONDS_PER_MINUTE), seconds: whole % SECONDS_PER_MINUTE })
743
+ }
744
+
745
+ // 官方吞吐:钳负值,阈值上取整、下一位小数
746
+ function formatTokensPerSecond(tps) {
747
+ const clamped = Math.max(0, tps)
748
+ if (clamped >= TPS_INTEGER_THRESHOLD) return String(Math.round(clamped))
749
+ return String(Math.round(clamped * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL)
750
+ }
751
+
752
+ // 官方节点读数:TTFT/decode 仅在对应时间戳齐全时产出,usage 须为非负有限数
753
+ function assistantStepReading(node) {
754
+ const timing = node.timing
755
+ const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
756
+ ? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
757
+ : null
758
+ const decodeMs = timing !== undefined && timing.firstTokenTime !== null
759
+ ? Math.max(0, timing.completedTime - timing.firstTokenTime)
760
+ : null
761
+ const outputTokens = typeof node.usage === 'number' && Number.isFinite(node.usage) && node.usage >= 0
762
+ ? node.usage
763
+ : null
764
+ return { ttftMs, decodeMs, outputTokens }
765
+ }
766
+
767
+ // 官方窗口折叠:tool-result 累计工具时长,assistant 计轮次/步数/LLM 时长并聚合读数
768
+ function deriveStats(nodes) {
769
+ const turns = new Set()
770
+ const stats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
771
+ for (const node of nodes) {
772
+ if (node.kind === 'tool-result') {
773
+ if (node.callTime !== null) stats.toolMs += Math.max(0, node.time - node.callTime)
774
+ continue
775
+ }
776
+ if (node.kind !== 'assistant') continue
777
+ turns.add(node.turn)
778
+ stats.steps += 1
779
+ const timing = node.timing
780
+ if (timing !== undefined && timing.stepStartTime !== null) {
781
+ stats.llmMs += Math.max(0, timing.completedTime - timing.stepStartTime)
782
+ }
783
+ const reading = assistantStepReading(node)
784
+ if (reading.ttftMs !== null) {
785
+ stats.ttftMs += reading.ttftMs
786
+ stats.ttftSteps += 1
787
+ }
788
+ if (reading.decodeMs !== null && reading.outputTokens !== null) {
789
+ stats.decodeMs += reading.decodeMs
790
+ stats.decodeTokens += reading.outputTokens
791
+ }
792
+ }
793
+ stats.turns = turns.size
794
+ return stats
795
+ }
796
+
797
+ // 分组装配:官方 StatsLine 分组序 + usp 双开关(精确命中率/Token 明细)+ 费用开关;
798
+ // 费用组在 Token 组后追加:开关关/无用量/价格未加载不渲染,规则已载无命中价显示占位符
799
+ function buildStatsGroups(stats, usage, prefs, t, pricingRules = null) {
800
+ const groups = []
801
+ if (stats.steps > 0) {
802
+ groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
803
+ const durations = []
804
+ if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) }))
805
+ if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) }))
806
+ if (durations.length > 0) groups.push(durations.join(STATS_ITEM_SEPARATOR))
807
+ const speeds = []
808
+ if (stats.ttftSteps > 0) speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) }))
809
+ if (stats.decodeMs > 0) speeds.push(t('stats.tokensPerSecond', {
810
+ throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / MS_PER_SECOND)),
811
+ }))
812
+ if (speeds.length > 0) groups.push(speeds.join(STATS_ITEM_SEPARATOR))
813
+ }
814
+ if (usage !== undefined && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
815
+ const percent = prefs.cachePrecision ? cacheHitPercentPrecise(usage) : cacheHitPercent(usage)
816
+ if (percent !== null) groups.push(t('stats.cacheHit', { percent }))
817
+ if (prefs.tokenDetail) {
818
+ const billed = billedInputTokens(usage)
819
+ groups.push(t('stats.tokensDetail', {
820
+ total: formatTokensCompact(billed + usage.outputTokens, t),
821
+ input: formatTokensCompact(billed, t),
822
+ hit: formatTokensCompact(usage.cacheReadTokens, t),
823
+ miss: formatTokensCompact(usage.uncachedInputTokens + usage.cacheWriteTokens, t),
824
+ output: formatTokensCompact(usage.outputTokens, t),
825
+ }))
826
+ } else {
827
+ groups.push(t('stats.tokens', {
828
+ input: formatTokensCompact(billedInputTokens(usage), t),
829
+ output: formatTokensCompact(usage.outputTokens, t),
830
+ }))
831
+ }
832
+ const costItem = buildCostItem(usage, pricingRules, prefs, t)
833
+ if (costItem !== null) groups.push(costItem)
834
+ }
835
+ return groups
836
+ }
837
+
838
+ // usp stats-line 偏好状态工厂:storage 注入便于 Node 测试,读写全防御,内存值始终生效
839
+ // 默认三开:存储缺键/非法值一律按开,仅显式 false 视为用户关闭
840
+ function createStatsLineState(storage) {
841
+ const DEFAULT_PREFS = { cachePrecision: true, tokenDetail: true, costDisplay: true }
842
+ const listeners = new Set()
843
+ const read = () => {
844
+ if (!storage) return { ...DEFAULT_PREFS }
845
+ try {
846
+ const parsed = JSON.parse(storage.getItem(STATS_LINE_STORAGE_KEY))
847
+ if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_PREFS }
848
+ return {
849
+ cachePrecision: parsed.cachePrecision !== false,
850
+ tokenDetail: parsed.tokenDetail !== false,
851
+ costDisplay: parsed.costDisplay !== false,
852
+ }
853
+ } catch {
854
+ return { ...DEFAULT_PREFS }
855
+ }
856
+ }
857
+ let state = read()
858
+ const notify = () => {
859
+ for (const listener of listeners) listener()
860
+ }
861
+ return {
862
+ get: () => state,
863
+ set(patch) {
864
+ state = { ...state, ...patch }
865
+ if (storage) {
866
+ try {
867
+ storage.setItem(STATS_LINE_STORAGE_KEY, JSON.stringify(state))
868
+ } catch {
869
+ // 写失败仅丢持久化
870
+ }
871
+ }
872
+ notify()
873
+ },
874
+ subscribe(listener) {
875
+ listeners.add(listener)
876
+ return () => listeners.delete(listener)
877
+ },
878
+ reload() {
879
+ state = read()
880
+ notify()
881
+ },
882
+ }
883
+ }
884
+
885
+
886
+ // ===== 定价镜像:官方无此物,宿主 pricing.js 镜像(双实现同源,parity 测试锁定) =====
887
+ // 语义逐条对齐宿主模块:本地时区取 Date 本地分量,匹配只读遍历入参规则;
888
+ // 零填充与 ISO 日串格式化复用本文件既有同义部件(pad/formatDate)
889
+ const UNIT_PER_MILLION = 'perMillion'
890
+ const CURRENCIES = ['¥', '$']
891
+ const CONDITION_KINDS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
892
+ const TOKENS_PER_MILLION = 1000 * 1000
893
+
894
+ const MODEL_WILDCARD = '*'
895
+ const MINUTES_PER_HOUR = 60
896
+
897
+ const minutesOfDay = (date) => date.getHours() * MINUTES_PER_HOUR + date.getMinutes()
898
+
899
+ const toMinutesOfDay = (hhmm) => {
900
+ if (typeof hhmm !== 'string') return Number.NaN
901
+ const [hours, minutes] = hhmm.split(':')
902
+ const h = Number(hours)
903
+ const m = Number(minutes)
904
+ return Number.isFinite(h) && Number.isFinite(m) ? h * MINUTES_PER_HOUR + m : Number.NaN
905
+ }
906
+
907
+ // from<to 含头不含尾;from>to 跨午夜;from===to 全天生效
908
+ function dailyWindowMatches(condition, date) {
909
+ const from = toMinutesOfDay(condition.from)
910
+ const to = toMinutesOfDay(condition.to)
911
+ if (Number.isNaN(from) || Number.isNaN(to)) return false
912
+ const m = minutesOfDay(date)
913
+ if (from < to) return m >= from && m < to
914
+ if (from > to) return m >= from || m < to
915
+ return true
916
+ }
917
+
918
+ // days 空数组不成立;0=周日,取 getDay()
919
+ function weekdaysMatches(condition, date) {
920
+ const { days } = condition
921
+ return Array.isArray(days) && days.length > 0 && days.includes(date.getDay())
922
+ }
923
+
924
+ // 号段双闭;from>to 跨月环绕;日号必须整数,2 月无 31 号自然不触发
925
+ function monthDaysMatches(condition, date) {
926
+ const { from, to } = condition
927
+ if (!Number.isInteger(from) || !Number.isInteger(to)) return false
928
+ const d = date.getDate()
929
+ return from <= to ? d >= from && d <= to : d >= from || d <= to
930
+ }
931
+
932
+ // 要求零填充 YYYY-MM-DD 字典序双闭;from>to 属配置错误不成立,非规范串同样不成立
933
+ const ISO_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
934
+ function dateRangeMatches(condition, date) {
935
+ const { from, to } = condition
936
+ if (typeof from !== 'string' || typeof to !== 'string') return false
937
+ if (!ISO_DAY_PATTERN.test(from) || !ISO_DAY_PATTERN.test(to) || from > to) return false
938
+ const iso = formatDate(date)
939
+ return iso >= from && iso <= to
940
+ }
941
+
942
+ const CONDITION_MATCHERS = {
943
+ dailyWindow: dailyWindowMatches,
944
+ weekdays: weekdaysMatches,
945
+ monthDays: monthDaysMatches,
946
+ dateRange: dateRangeMatches,
947
+ }
948
+
949
+ // 单条件判定;未知 kind、形状残缺或非法 Date 一律不成立
950
+ function conditionMatches(condition, date) {
951
+ const matcher = condition && CONDITION_MATCHERS[condition.kind]
952
+ if (!matcher || !(date instanceof Date) || Number.isNaN(date.getTime())) return false
953
+ return matcher(condition, date)
954
+ }
955
+
956
+ // 形状残缺规则跳过:缺 model/price、unit 非 perMillion、conditions 非数组(含缺失)
957
+ function isRuleShaped(rule) {
958
+ return !!rule && typeof rule === 'object' && typeof rule.model === 'string'
959
+ && (rule.unit === undefined || rule.unit === UNIT_PER_MILLION)
960
+ && !!rule.price && typeof rule.price === 'object' && !Array.isArray(rule.price)
961
+ && Array.isArray(rule.conditions)
962
+ }
963
+
964
+ const firstMatchingPrice = (rules, date, modelFilter) => {
965
+ for (const rule of rules) {
966
+ if (!isRuleShaped(rule) || !modelFilter(rule)) continue
967
+ if (rule.conditions.every((condition) => conditionMatches(condition, date))) return rule.price
968
+ }
969
+ return null
970
+ }
971
+
972
+ const toLocalDate = (timestamp) => {
973
+ const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
974
+ return Number.isNaN(date.getTime()) ? null : date
975
+ }
976
+
977
+ // 精确子集按数组序取首个命中;无精确子集或全不命中回落 '*' 子集;仍无命中为 null
978
+ function matchPrice(rules, model, timestamp) {
979
+ const date = toLocalDate(timestamp)
980
+ if (!Array.isArray(rules) || !date || typeof model !== 'string') return null
981
+ return firstMatchingPrice(rules, date, (rule) => rule.model === model)
982
+ ?? firstMatchingPrice(rules, date, (rule) => rule.model === MODEL_WILDCARD)
983
+ }
984
+
985
+ const BUCKET_PRICE_KEYS = [
986
+ { tokens: 'inputTokens', price: 'input' },
987
+ { tokens: 'outputTokens', price: 'output' },
988
+ { tokens: 'cacheReadTokens', price: 'cacheRead' },
989
+ { tokens: 'cacheWriteTokens', price: 'cacheWrite' },
990
+ ]
991
+
992
+ const toFiniteNumber = (value) => (Number.isFinite(value) ? value : 0)
993
+
994
+ // 费用 = Σ(桶 token × 桶单价) / 每百万;缺桶或非法值按 0,原始浮点不圆整(展示层负责)
995
+ function costOf(price, buckets) {
996
+ let raw = 0
997
+ for (const { tokens, price: priceKey } of BUCKET_PRICE_KEYS) {
998
+ raw += toFiniteNumber(buckets?.[tokens]) * toFiniteNumber(price?.[priceKey])
999
+ }
1000
+ return raw / TOKENS_PER_MILLION
1001
+ }
1002
+
1003
+ // ===== 费用展示辅助(展示层专用,非镜像) =====
1004
+ const COST_DECIMALS = 2
1005
+ const COST_MICRO_DECIMALS = 4
1006
+ const COST_MICRO_THRESHOLD = 0.01
1007
+ const COST_PLACEHOLDER = '—'
1008
+ const THOUSANDS_PATTERN = /(\d)(?=(\d{3})+(?!\d))/g
1009
+
1010
+ // 千分位 + 至少两位小数,正值小于 0.01 时四位;货币空串不加符号,「≈」前缀由调用方拼
1011
+ function formatCost(value, currency) {
1012
+ const decimals = value > 0 && value < COST_MICRO_THRESHOLD ? COST_MICRO_DECIMALS : COST_DECIMALS
1013
+ const [whole, fraction] = value.toFixed(decimals).split('.')
1014
+ return `${currency}${whole.replace(THOUSANDS_PATTERN, '$1,')}.${fraction}`
1015
+ }
1016
+
1017
+ // 全局显示货币:规则表首个非空 currency,所有费用显示点统一取此值(全局价格定位);
1018
+ // 无则空串即不带符号。数值仍按命中规则单价计算,符号不随命中规则变化
1019
+ function aggregateCurrencyOf(rules) {
1020
+ if (!Array.isArray(rules)) return ''
1021
+ const found = rules.find((rule) => typeof rule?.currency === 'string' && rule.currency !== '')
1022
+ return found ? found.currency : ''
1023
+ }
1024
+
1025
+ // 投影四桶 → 计价桶形:投影的 uncachedInputTokens 即计价 inputTokens(host 存储行同口径)
1026
+ const pricingBucketsOf = (usage) => ({
1027
+ inputTokens: usage.uncachedInputTokens,
1028
+ outputTokens: usage.outputTokens,
1029
+ cacheReadTokens: usage.cacheReadTokens,
1030
+ cacheWriteTokens: usage.cacheWriteTokens,
1031
+ })
1032
+
1033
+ // 注入点A 费用组装配:开关关/无用量/价格未加载不渲染;规则已载无命中价显示占位符;
1034
+ // routes 缺席时 model 取通配,只匹配通配规则;时间条件按当前时刻评估(估算口径)
1035
+ function buildCostItem(usage, rules, prefs, t, now = new Date()) {
1036
+ if (!prefs?.costDisplay || !usage || !Array.isArray(rules)) return null
1037
+ const model = usage.routes?.[0]?.model ?? MODEL_WILDCARD
1038
+ const price = matchPrice(rules, model, now)
1039
+ if (!price) return COST_PLACEHOLDER
1040
+ return t('stats.cost', { cost: formatCost(costOf(price, pricingBucketsOf(usage)), aggregateCurrencyOf(rules)) })
1041
+ }
1042
+
1043
+ // 历史费用口径标注:估算说明,未计价小时桶计数为正时追加后缀
1044
+ function costTitleText(t, unpriced) {
1045
+ const base = t('costTitle')
1046
+ return unpriced > 0 ? `${base},${t('costUnpriced', { n: unpriced })}` : base
1047
+ }
1048
+
1049
+ // ===== 注入点B:turnTail 单轮用量行(chain 条目,官方 TurnTailNodeView 容器内渲染) =====
1050
+ // chain 尝试顺序 = priority 升序:deliverables 产物行(默认 0)先试,本条目后试让位
1051
+ const TURN_TAIL_DATA_KEY = 'turn-tail'
1052
+ const TURN_TAIL_PRIORITY = 1
1053
+ const TURN_COST_REVEAL_MS = 80
1054
+ const TURN_TAIL_ACTIONS_INSET_PX = -6
1055
+
1056
+ // Turn 位置数据读取:owner 形状残缺一律 null 不抛;tokenUsage 缺失(证据不完整)即放弃渲染
1057
+ function selectTurnTokenUsage(owner) {
1058
+ return owner?.turn?.data?.get
1059
+ ? (owner.turn.data.get(TURN_TAIL_DATA_KEY)?.tokenUsage ?? null)
1060
+ : null
1061
+ }
1062
+
1063
+ // 计价模型键:routes 首个 route.model,缺席回退通配(与注入点A 同款)
1064
+ const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ?? MODEL_WILDCARD
1065
+
1066
+ // 可选桶(cacheRead/cacheWrite)仅部分 provider 上报,缺失按 0 计入摘要与费用
1067
+ const turnReportedBucket = (value) => (value ?? 0)
1068
+
1069
+ // 摘要计费输入 = prompt 侧三桶(官方 billing 分母口径)
1070
+ function turnBilledInputTokens(tokenUsage) {
1071
+ return tokenUsage.uncachedInputTokens
1072
+ + turnReportedBucket(tokenUsage.cacheReadTokens)
1073
+ + turnReportedBucket(tokenUsage.cacheWriteTokens)
1074
+ }
1075
+
1076
+ // 可选桶未上报判定:与取不到价的「—」是两种降级,仅在 title 标注
1077
+ function turnOptionalUnreported(tokenUsage) {
1078
+ return tokenUsage?.cacheReadTokens === undefined || tokenUsage?.cacheWriteTokens === undefined
1079
+ }
1080
+
1081
+ // 单轮行文本:摘要 + 费用;token 复用官方 compact 族,价未命中为占位符
1082
+ function buildTurnCostLine(t, tokenUsage, price, currency) {
1083
+ const summary = t('stats.tokens', {
1084
+ input: formatTokensCompact(turnBilledInputTokens(tokenUsage), t),
1085
+ output: formatTokensCompact(tokenUsage.outputTokens, t),
1086
+ })
1087
+ const cost = price ? formatCost(costOf(price, pricingBucketsOf(tokenUsage)), currency) : COST_PLACEHOLDER
1088
+ return t('stats.turnCost', { summary, cost })
1089
+ }
1090
+
1091
+ // 行 title:估算口径说明,可选桶未上报时追加标注
1092
+ function turnCostTitleText(t, tokenUsage) {
1093
+ const base = t('turnCostTitle')
1094
+ return turnOptionalUnreported(tokenUsage) ? `${base},${t('turnTokensUnreported')}` : base
1095
+ }
1096
+
1097
+ // ===== 定价编辑器纯函数(校验/规整/默认值) =====
1098
+ const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
1099
+ const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
1100
+
1101
+ // 就地校验:字段路径 → 文案键;仅覆盖编辑器可编辑字段(模型与四桶价格)
1102
+ function validatePricingRules(rules) {
1103
+ const errors = new Map()
1104
+ if (!Array.isArray(rules)) return errors
1105
+ rules.forEach((rule, ruleIndex) => {
1106
+ if (typeof rule.model !== 'string' || rule.model.trim().length === 0) errors.set(`${ruleIndex}.model`, 'required')
1107
+ PRICE_KEYS.forEach((key) => {
1108
+ const value = rule.price?.[key]
1109
+ const path = `${ruleIndex}.price.${key}`
1110
+ if (value === '' || value === null || value === undefined) errors.set(path, 'required')
1111
+ else if (!Number.isFinite(Number(value)) || Number(value) < 0) errors.set(path, 'priceInvalid')
1112
+ })
1113
+ })
1114
+ return errors
1115
+ }
1116
+
1117
+ // POST 前规整:输入框字符串值转数值;模型原样(校验已确保非空)
1118
+ function coercePricingRules(rules) {
1119
+ return rules.map((rule) => ({
1120
+ ...rule,
1121
+ price: PRICE_KEYS.reduce((price, key) => ({ ...price, [key]: Number(rule.price[key]) }), {}),
1122
+ }))
1123
+ }
1124
+
1125
+ // 服务端规则为纯 JSON,编辑副本深拷贝与缓存脱钩
1126
+ const copyRules = (rules) => JSON.parse(JSON.stringify(rules))
1127
+
1128
+ // 货币为编辑器级全局设置:整表统一,规则内 currency 由切换值派生(wire 形态不变)
1129
+ const applyCurrencyToRules = (rules, currency) => rules.map((rule) => ({ ...rule, currency }))
1130
+
1131
+ // 新增规则默认:空模型 + 承接全局货币 + 四桶零价 + 无条件(恒生效);已有条件整条保留原样
1132
+ const defaultPricingRule = (currency = CURRENCIES[0]) => ({
1133
+ model: '',
1134
+ currency,
1135
+ price: PRICE_KEYS.reduce((price, key) => ({ ...price, [key]: 0 }), {}),
1136
+ conditions: [],
1137
+ })
1138
+
1139
+ const updateRuleAt = (rules, index, patch) => rules.map((rule, i) => (i === index ? { ...rule, ...patch } : rule))
1140
+
1141
+
1142
+ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1143
+ window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-usage-dash', factory })
1144
+
1145
+ function factory(require) {
1146
+ let React = null
1147
+ try {
1148
+ React = require('react')
1149
+ } catch {
1150
+ return { inject: [], apply() {} }
1151
+ }
1152
+ const { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } = React
1153
+
1154
+ let createPortal = null
1155
+ try {
1156
+ createPortal = require('react-dom').createPortal
1157
+ } catch {
1158
+ createPortal = null
1159
+ }
1160
+
1161
+ const h = (type, props, ...children) => React.createElement(type, props ?? null, ...children)
1162
+ const cx = (...values) => values.filter(Boolean).join(' ')
1163
+
1164
+ // locale 服务缺席时的回退翻译器(zh 固定);槽组件以 props.t 缺省参数承接宿主响应式 t
1165
+ const defaultT = createTranslator(MESSAGES_ZH)
1166
+
1167
+ // stats-line 偏好单例:模块表内同实例,面板偏好卡与底部信息栏订阅互通
1168
+ const statsLineState = createStatsLineState(typeof localStorage !== 'undefined' ? localStorage : null)
1169
+
1170
+ // 价格规则内存缓存单例:TTL 内直读,失败回退旧值待下次重试;禁 localStorage 持久化价格
1171
+ const PRICING_CACHE_TTL_MS = 5 * 60 * 1000
1172
+ const PRICING_SAVED_NOTICE_MS = 3 * 1000
1173
+ let cached = null
1174
+ const applyPricingValue = (value) => {
1175
+ cached = { revision: value.revision, rules: value.rules, fetchedAt: Date.now() }
1176
+ return cached
1177
+ }
1178
+
1179
+ const requestGet = async (endpoint) => {
1180
+ let response
1181
+ try {
1182
+ response = await fetch(API_PREFIX + endpoint)
1183
+ } catch (error) {
1184
+ return { ok: false, code: 'network', message: String(error?.message ?? error) }
1185
+ }
1186
+ let json = null
1187
+ try {
1188
+ json = await response.json()
1189
+ } catch {
1190
+ json = null
1191
+ }
1192
+ return parseEnvelope(json)
1193
+ }
1194
+
1195
+ // value 缺形状按失败处理:回退旧值,无旧值为 null(费用显示占位)
1196
+ const fetchPricing = async (force = false) => {
1197
+ if (!force && cached && Date.now() - cached.fetchedAt < PRICING_CACHE_TTL_MS) return cached
1198
+ const result = await requestGet(ENDPOINTS.pricing)
1199
+ const value = result.ok ? result.value : null
1200
+ if (value && Number.isFinite(value.revision) && Array.isArray(value.rules)) return applyPricingValue(value)
1201
+ return cached
1202
+ }
1203
+
1204
+ // 交互与尺寸常量
1205
+ const STATUS_POLL_FAST_MS = 1000
1206
+ const STATUS_POLL_SLOW_MS = 5000
1207
+ const REBUILD_CONFIRM_MS = 3000
1208
+ const STATUS_REFRESH_DEBOUNCE_MS = 800
1209
+ const FIT_MAX_SIZE = 22
1210
+ const FIT_MIN_SIZE = 11
1211
+ const FIT_NAME_MAX_SIZE = 20
1212
+ const FIT_STEP_SIZE = 0.5
1213
+ const FIT_OVERFLOW_TOLERANCE = 1
1214
+ const CHART_NOMINAL_WIDTH = 720
1215
+ const CHART_WIDTH_EPSILON = 1
1216
+ const CHART_BUSY_OPACITY = 0.5
1217
+ const LABEL_PITCH_DAY = 46
1218
+ const LABEL_PITCH_TIME = 60
1219
+ const AXIS_LABEL_GAP = 6
1220
+ const AXIS_LABEL_BASELINE = 3
1221
+ const X_LABEL_OFFSET = 8
1222
+ const PROGRESS_FULL_PERCENT = 100
1223
+ const ICON_SIZE = 14
1224
+ const ICON_STROKE_WIDTH = 2
1225
+ const NOTE_SEPARATOR = ' · '
1226
+ const TIP_Z_INDEX = 1100
1227
+ const STYLE_ID = 'dsh-usage-dash'
1228
+ // 开关视觉常量(规约形态:隐藏 checkbox + track 胶囊 + thumb 圆点)
1229
+ const SWITCH_TRACK_WIDTH = 40
1230
+ const SWITCH_TRACK_HEIGHT = 22
1231
+ const SWITCH_THUMB_SIZE = 18
1232
+ const SWITCH_EDGE_INSET = 2
1233
+ const SWITCH_THUMB_TRAVEL = SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - SWITCH_EDGE_INSET * 2
1234
+ const SWITCH_TRANSITION_MS = 120
1235
+ // 遮蔽语义:同 id 同 priority 属注册冲突(注册表抛错),需取更低值压过
1236
+ // 同格竞争者;usp 同款接管注册用 -1,本插件取次低值,官方无显式 priority(默认 0)
1237
+ const STATS_SLOT_PRIORITY = -2
1238
+ const STATS_LINE_TITLE_SEPARATOR = ' | '
1239
+ const VIEW_TABS = [
1240
+ { id: 'day', labelKey: 'viewDay' },
1241
+ { id: 'hour', labelKey: 'viewHour' },
1242
+ { id: 'minute', labelKey: 'viewMinute' },
1243
+ ]
1244
+
1245
+ const requestPost = async (endpoint, body) => {
1246
+ let response
1247
+ try {
1248
+ response = await fetch(API_PREFIX + endpoint, {
1249
+ method: 'POST',
1250
+ headers: { 'content-type': 'application/json' },
1251
+ body: body === undefined ? '{}' : JSON.stringify(body),
1252
+ })
1253
+ } catch (error) {
1254
+ return { ok: false, code: 'network', message: String(error?.message ?? error) }
1255
+ }
1256
+ let json = null
1257
+ try {
1258
+ json = await response.json()
1259
+ } catch {
1260
+ json = null
1261
+ }
1262
+ return parseEnvelope(json)
1263
+ }
1264
+
1265
+ // lucide 同风格简笔图标(纯装饰)
1266
+ const ICONS = {
1267
+ coins: ['M15.5 9.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0Z', 'M20.5 13.5a5.5 5.5 0 1 1-7 7'],
1268
+ sessions: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z'],
1269
+ requests: ['M14 9a2 2 0 0 1-2 2H6l-3 3V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2Z', 'M17 7h2a2 2 0 0 1 2 2v10l-3-3h-5'],
1270
+ model: ['M5 5h14v14H5Z', 'M9 9h6v6H9Z', 'M9 2v3', 'M15 2v3', 'M9 19v3', 'M15 19v3', 'M2 9h3', 'M2 15h3', 'M19 9h3', 'M19 15h3'],
1271
+ rate: ['M22 12h-4l-3 9L9 3l-3 9H2'],
1272
+ 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'],
1273
+ }
1274
+
1275
+ function Icon({ paths }) {
1276
+ return h('svg', {
1277
+ viewBox: '0 0 24 24', width: ICON_SIZE, height: ICON_SIZE, fill: 'none',
1278
+ stroke: 'currentColor', strokeWidth: ICON_STROKE_WIDTH,
1279
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
1280
+ }, paths.map((d, index) => h('path', { key: index, d })))
1281
+ }
1282
+
1283
+ const STYLE_CSS = `
1284
+ .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1285
+ --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);
1286
+ --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}
1287
+ 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);
1288
+ --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}
1289
+ .ud-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1290
+ .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)}
1291
+ .ud-seg-item{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1;padding:5px 10px;border-radius:6px;cursor:pointer;white-space:nowrap}
1292
+ .ud-seg-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1293
+ .ud-seg-item--on{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}
1294
+ .ud-custom-range{display:flex;align-items:center;gap:6px}
1295
+ .ud-date-input{border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;padding:4px 6px}
1296
+ .ud-custom-sep{color:var(--dsw-alias-label-tertiary)}
1297
+ .ud-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:5px 14px;font-size:12px;line-height:1;cursor:pointer}
1298
+ .ud-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1299
+ .ud-btn:disabled{opacity:.5;cursor:default}
1300
+ .ud-refresh{margin-left:auto}
1301
+ .ud-btn--text{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);padding:2px 4px}
1302
+ .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}
1303
+ .ud-loading{color:var(--dsw-alias-label-tertiary);text-align:center;padding:32px 0}
1304
+ .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}
1305
+ .ud-foot{color:var(--dsw-alias-label-tertiary);font-size:11px}
1306
+ .ud-status{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary)}
1307
+ .ud-status-track{display:inline-block;width:120px;height:2px;border-radius:1px;background:var(--dsw-alias-border-l1);overflow:hidden}
1308
+ .ud-status-fill{display:block;height:100%;background:var(--dsw-alias-state-business-primary)}
1309
+ .ud-status-err{color:var(--dsw-alias-state-error-primary)}
1310
+ .ud-cards{display:grid;grid-template-columns:1.35fr 1fr 1fr;gap:10px}
1311
+ @media (max-width:560px){.ud-cards{grid-template-columns:1fr 1fr}}
1312
+ @media (max-width:380px){.ud-cards{grid-template-columns:1fr}}
1313
+ .ud-card{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:12px 14px;min-width:0}
1314
+ .ud-card-head{display:flex;align-items:center;gap:6px}
1315
+ .ud-card-cost{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1316
+ .ud-card-icon{display:inline-flex;color:var(--dsw-alias-label-tertiary)}
1317
+ .ud-card-label{font-size:13px;color:var(--dsw-alias-label-secondary)}
1318
+ .ud-card-value{font-size:${FIT_MAX_SIZE}px;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden}
1319
+ .ud-card-lines{display:flex;flex-direction:column;gap:2px;min-width:0}
1320
+ .ud-card-name{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1321
+ .ud-card-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1322
+ .ud-section{display:flex;flex-direction:column;gap:8px;min-width:0}
1323
+ .ud-section-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
1324
+ .ud-section-title{font-size:15px;font-weight:600}
1325
+ .ud-trend-note{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1326
+ .ud-chart-wrap{width:100%;min-width:0}
1327
+ .ud-chart{display:block;width:100%}
1328
+ .ud-grid{stroke:var(--dsw-alias-border-l1);stroke-width:1}
1329
+ .ud-axis{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
1330
+ .ud-legend{display:flex;flex-wrap:wrap;gap:4px 12px}
1331
+ .ud-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0}
1332
+ .ud-legend-item span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1333
+ .ud-legend-swatch{width:8px;height:8px;border-radius:2px;flex:none}
1334
+ .ud-heat-wrap{width:100%;min-width:0;overflow:hidden}
1335
+ .ud-heat{display:block}
1336
+ .ud-heat-cell{stroke:none}
1337
+ .ud-heat-l0{fill:var(--dsw-heat-0);background:var(--dsw-heat-0)}
1338
+ .ud-heat-l1{fill:var(--dsw-heat-1);background:var(--dsw-heat-1)}
1339
+ .ud-heat-l2{fill:var(--dsw-heat-2);background:var(--dsw-heat-2)}
1340
+ .ud-heat-l3{fill:var(--dsw-heat-3);background:var(--dsw-heat-3)}
1341
+ .ud-heat-l4{fill:var(--dsw-heat-4);background:var(--dsw-heat-4)}
1342
+ .ud-heat-l5{fill:var(--dsw-heat-5);background:var(--dsw-heat-5)}
1343
+ .ud-heat-legend{display:inline-flex;align-items:center;gap:${HEAT_GAP}px;margin-left:auto;flex:none;font-size:11px;color:var(--dsw-alias-label-secondary)}
1344
+ .ud-heat-legend i{display:inline-block;width:${HEAT_BASE}px;height:${HEAT_BASE}px;border-radius:${HEAT_RX}px;flex:none}
1345
+ .ud-tip{position:fixed;z-index:${TIP_Z_INDEX};visibility:hidden;pointer-events:none;white-space:nowrap;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-overlay);box-shadow:0 4px 12px var(--dsw-alias-bg-mask-2);color:var(--dsw-alias-label-primary);font-size:13px;padding:8px 10px}
1346
+ .ud-tip-title{font-weight:600}
1347
+ .ud-tip-row{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}
1348
+ .ud-tip-row--sub{padding-left:12px;color:var(--dsw-alias-label-tertiary);font-size:11px}
1349
+ .ud-tip-breakdown{display:flex;flex-direction:column;gap:2px;border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}
1350
+ .ud-axis-rate{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
1351
+ .ud-bar{transform-box:fill-box;transform-origin:center}
1352
+ .ud-bar-hit{fill:transparent;pointer-events:all}
1353
+ .ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
1354
+ .ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
1355
+ .ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
1356
+ .ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
1357
+ .ud-donut-wrap{flex:0 0 auto}
1358
+ .ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
1359
+ .ud-donut-seg--dim{opacity:${DONUT_DIM_OPACITY}}
1360
+ .ud-donut-center{font-size:18px;font-weight:600;fill:var(--dsw-alias-label-primary)}
1361
+ .ud-donut-label{font-size:11px;fill:var(--dsw-alias-label-tertiary)}
1362
+ .ud-models{flex:1 1 260px;min-width:240px;display:flex;flex-direction:column}
1363
+ .ud-model-row{display:flex;align-items:center;gap:8px;min-height:44px;padding:2px 4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
1364
+ .ud-model-row--expand{cursor:pointer}
1365
+ .ud-model-row--expand:hover{background:var(--dsw-alias-interactive-bg-hover)}
1366
+ .ud-model-swatch{width:10px;height:10px;border-radius:2px;flex:none}
1367
+ .ud-model-id{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
1368
+ .ud-model-name{font-size:13px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1369
+ .ud-model-provider{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1370
+ .ud-model-values{display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-variant-numeric:tabular-nums;flex:none}
1371
+ .ud-model-tokens{font-size:12px;color:var(--dsw-alias-label-secondary)}
1372
+ .ud-model-pct{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1373
+ .ud-model-cost{font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
1374
+ .ud-model-toggle{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px 6px;font-size:14px;line-height:1;transition:transform .2s ease}
1375
+ .ud-model-toggle[aria-expanded="true"]{transform:rotate(90deg)}
1376
+ .ud-model-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);border-radius:4px}
1377
+ .ud-model-other{display:grid;grid-template-rows:0fr;transition:grid-template-rows .25s ease}
1378
+ .ud-model-other--open{grid-template-rows:1fr}
1379
+ .ud-model-other-list{overflow:hidden;min-height:0}
1380
+ .ud-model-row--sub{min-height:0;padding:4px 4px 4px 28px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 55%,transparent);border-bottom:none}
1381
+ .ud-switch{display:inline-flex;align-items:center;cursor:pointer}
1382
+ .ud-switch input[type="checkbox"] { position:absolute; opacity:0; width:1px; height:1px; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); }
1383
+ .ud-switch__track{position:relative;width:${SWITCH_TRACK_WIDTH}px;height:${SWITCH_TRACK_HEIGHT}px;border-radius:999px;box-sizing:border-box;flex:none;background:var(--dsw-alias-border-l2);transition:background ${SWITCH_TRANSITION_MS}ms var(--ds-ease-in-out)}
1384
+ .ud-switch__thumb{position:absolute;top:${SWITCH_EDGE_INSET}px;left:${SWITCH_EDGE_INSET}px;width:${SWITCH_THUMB_SIZE}px;height:${SWITCH_THUMB_SIZE}px;border-radius:50%;background:var(--dsw-alias-bg-layer-1);transition:transform ${SWITCH_TRANSITION_MS}ms var(--ds-ease-in-out)}
1385
+ .ud-switch:not(:has(input[type="checkbox"]:disabled)):hover .ud-switch__track{background:color-mix(in srgb,var(--dsw-alias-border-l2) 85%,var(--dsw-alias-label-tertiary))}
1386
+ .ud-switch input[type="checkbox"]:checked + .ud-switch__track{background:var(--dsw-alias-state-business-primary)}
1387
+ .ud-switch input[type="checkbox"]:checked + .ud-switch__track .ud-switch__thumb{transform:translateX(${SWITCH_THUMB_TRAVEL}px)}
1388
+ .ud-switch input[type="checkbox"]:focus-visible + .ud-switch__track{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
1389
+ .ud-switch input[type="checkbox"]:disabled + .ud-switch__track{opacity:.45;cursor:default}
1390
+ .ud-pref-group{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:12px 16px}
1391
+ .ud-pref-row{display:flex;align-items:center;justify-content:space-between;gap:16px}
1392
+ .ud-pref-text{display:flex;flex-direction:column;gap:2px;min-width:0}
1393
+ .ud-pref-title{font-size:13px;color:var(--dsw-alias-label-primary)}
1394
+ .ud-pref-desc{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1395
+ .ud-rule{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px 12px}
1396
+ .ud-rule-head{display:flex;align-items:flex-end;gap:8px}
1397
+ .ud-rule-head .ud-field{flex:1}
1398
+ .ud-rule-cond{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1399
+ .ud-field{display:flex;flex-direction:column;gap:3px;min-width:0}
1400
+ .ud-field-label{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1401
+ .ud-field-error{font-size:11px;color:var(--dsw-alias-state-error-primary)}
1402
+ .ud-input{border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;padding:4px 6px;min-width:0;width:100%;box-sizing:border-box}
1403
+ .ud-price-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}
1404
+ .ud-price-grid .ud-input{text-align:right}
1405
+ .ud-price-input{position:relative;display:block}
1406
+ .ud-price-input .ud-input{padding-left:20px}
1407
+ .ud-price-currency{position:absolute;left:7px;top:50%;transform:translateY(-50%);font-size:11px;color:var(--dsw-alias-label-tertiary);pointer-events:none}
1408
+ .ud-unit-note{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1409
+ .ud-pricing-actions{display:flex;align-items:center;gap:8px}
1410
+ .ud-rule-add{border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;cursor:pointer}
1411
+ .ud-rule-add:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1412
+ .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}
1413
+ .ud-statsline-sep{color:var(--dsw-alias-separator-primary);margin:0 10px}
1414
+ .ud-turn-cost{font-size:var(--dsh-content-font-size-secondary,13px);color:var(--dsw-alias-label-tertiary);margin-left:${TURN_TAIL_ACTIONS_INSET_PX}px;font-variant-numeric:tabular-nums;white-space:nowrap}
1415
+ @media (hover:hover){[data-actions-reveal=hover] .ud-turn-cost{opacity:0;transition:opacity ${TURN_COST_REVEAL_MS}ms}[data-actions-reveal=hover]:hover .ud-turn-cost,[data-actions-reveal=hover]:focus-within .ud-turn-cost{opacity:1}}
1416
+ `
1417
+
1418
+ function ensureStyle(document) {
1419
+ if (document.querySelector(`style[data-plugin="${STYLE_ID}"]`)) return
1420
+ const element = document.createElement('style')
1421
+ element.setAttribute('data-plugin', STYLE_ID)
1422
+ element.textContent = STYLE_CSS
1423
+ document.head.appendChild(element)
1424
+ }
1425
+
1426
+ const fitFontSize = (element, maxSize = FIT_MAX_SIZE, minSize = FIT_MIN_SIZE) => {
1427
+ let size = maxSize
1428
+ element.style.fontSize = `${size}px`
1429
+ while (element.scrollWidth > element.clientWidth + FIT_OVERFLOW_TOLERANCE && size > minSize) {
1430
+ size -= FIT_STEP_SIZE
1431
+ element.style.fontSize = `${size}px`
1432
+ }
1433
+ }
1434
+
1435
+ function FitText({ children, className = 'ud-card-value', maxSize = FIT_MAX_SIZE, minSize = FIT_MIN_SIZE }) {
1436
+ const ref = useRef(null)
1437
+ useLayoutEffect(() => {
1438
+ fitFontSize(ref.current, maxSize, minSize)
1439
+ }, [children, maxSize, minSize])
1440
+ useEffect(() => {
1441
+ const element = ref.current
1442
+ let lastWidth = element.clientWidth
1443
+ const observer = new ResizeObserver(() => {
1444
+ const width = element.clientWidth
1445
+ if (width === lastWidth) return
1446
+ lastWidth = width
1447
+ fitFontSize(element, maxSize, minSize)
1448
+ })
1449
+ observer.observe(element)
1450
+ return () => observer.disconnect()
1451
+ }, [maxSize, minSize])
1452
+ return h('div', { className, ref }, children)
1453
+ }
1454
+
1455
+ function Card({ icon, label, hint, head, children }) {
1456
+ const lines = Array.isArray(children) ? children : [children]
1457
+ return h('div', { className: 'ud-card', title: hint },
1458
+ h('div', { className: 'ud-card-head' },
1459
+ h('span', { className: 'ud-card-icon' }, h(Icon, { paths: icon })),
1460
+ h('span', { className: 'ud-card-label' }, label),
1461
+ head ?? null),
1462
+ ...lines)
1463
+ }
1464
+
1465
+ function StatCards({ stats, costCurrency = '', t = defaultT }) {
1466
+ return h('div', { className: 'ud-cards' },
1467
+ h(Card, { key: 'tokens', icon: ICONS.coins, label: t('tokens'), hint: t('tokensHint'),
1468
+ head: stats.cost !== undefined
1469
+ ? h('span', {
1470
+ className: 'ud-card-cost',
1471
+ title: costTitleText(t, stats.unpriced ?? 0),
1472
+ }, `≈ ${formatCost(stats.cost, costCurrency)}`)
1473
+ : null },
1474
+ h(FitText, null, formatTokens(stats.tokens))),
1475
+ h(Card, { key: 'turns', icon: ICONS.sessions, label: t('sessions') },
1476
+ h(FitText, null, String(stats.turns))),
1477
+ h(Card, { key: 'requests', icon: ICONS.requests, label: t('requests') },
1478
+ h(FitText, null, String(stats.requests))),
1479
+ h(Card, { key: 'model', icon: ICONS.model, label: t('topModel'), hint: t('topModelHint') },
1480
+ stats.topModel
1481
+ ? h(FitText, { className: 'ud-card-name', maxSize: FIT_NAME_MAX_SIZE },
1482
+ `${providerOf(stats.topModel)} / ${modelNameOf(stats.topModel)}`)
1483
+ : h('div', { className: 'ud-card-value' }, '—')),
1484
+ h(Card, { key: 'cache', icon: ICONS.rate, label: t('cacheRate'), hint: t('cacheRateHint') },
1485
+ h(FitText, null, cacheRateText(stats.cacheHit, stats.cacheMiss))),
1486
+ h(Card, { key: 'days', icon: ICONS.days, label: t('activeDays') },
1487
+ h(FitText, null, String(stats.activeDays))))
1488
+ }
1489
+
1490
+ function Legend({ models, colorFor, t = defaultT }) {
1491
+ return h('div', { className: 'ud-legend' },
1492
+ models.map((item) => h('span', { key: item.model, className: 'ud-legend-item', title: item.model === OTHER_MODEL ? t('other') : item.model },
1493
+ h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1494
+ h('span', null, item.model === OTHER_MODEL ? t('other') : item.model))),
1495
+ h('span', { key: 'hit-rate', className: 'ud-legend-item', title: t('hitRateLegend') },
1496
+ h('i', { className: 'ud-legend-swatch ud-legend-swatch--line' }),
1497
+ h('span', null, t('hitRateLegend'))))
1498
+ }
1499
+
1500
+ const colorForModel = (models) => (model) => {
1501
+ if (model === OTHER_MODEL) return 'var(--ud-chart-other)'
1502
+ const slot = models.findIndex((item) => item.model === model)
1503
+ const rank = Math.min(slot < 0 ? 0 : slot, GROUP_TOP_COUNT - 1) + 1
1504
+ return `var(--ud-chart-${rank})`
1505
+ }
1506
+
1507
+ function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
1508
+ const wrapRef = useRef(null)
1509
+ const [avail, setAvail] = useState(CHART_NOMINAL_WIDTH)
1510
+ const [hover, setHover] = useState(null)
1511
+ // prefs 仅用于 hover tooltip 的费用显隐,经 ref 读取:开关切换不触发本组件重渲染,
1512
+ // 避免宿主设置弹窗滚动锚定被重渲染扰动而跳变
1513
+ const prefsRef = useRef(statsLineState.get())
1514
+ useEffect(() => statsLineState.subscribe(() => { prefsRef.current = statsLineState.get() }), [])
1515
+ useEffect(() => {
1516
+ const element = wrapRef.current
1517
+ const observer = new ResizeObserver((entries) => {
1518
+ const width = entries[0].contentRect.width
1519
+ setAvail((prev) => (Math.abs(prev - width) < CHART_WIDTH_EPSILON ? prev : width))
1520
+ })
1521
+ observer.observe(element)
1522
+ return () => observer.disconnect()
1523
+ }, [])
1524
+ const layout = trendLayout(slots, modelOrder, avail, labelMinPitch)
1525
+ const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
1526
+ const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
1527
+ const hoverSlot = hover ? slots[hover.index] : null
1528
+ const hoverRatePoint = hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1529
+ const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
1530
+ const clear = () => setHover(null)
1531
+ const otherEntries = hoverSlot
1532
+ ? Object.entries(hoverSlot.otherByModel ?? {}).sort((a, b) => b[1] - a[1])
1533
+ : []
1534
+ return h('div', { className: 'ud-section' },
1535
+ h('div', { className: 'ud-section-head' },
1536
+ h('span', { className: 'ud-section-title' }, title),
1537
+ notes.length > 0 ? h('span', { className: 'ud-trend-note' }, notes.join(NOTE_SEPARATOR)) : null),
1538
+ h('div', { className: 'ud-chart-wrap', ref: wrapRef, style: busy ? { opacity: CHART_BUSY_OPACITY } : undefined },
1539
+ h('svg', {
1540
+ className: 'ud-chart', viewBox: `0 0 ${avail} ${CHART_HEIGHT}`, width: '100%', role: 'img',
1541
+ 'aria-label': title, onMouseLeave: clear,
1542
+ },
1543
+ layout.ticks.map((tick) => {
1544
+ const y = CHART_PAD.top + layout.plotHeight - (tick / layout.maxTotal) * layout.plotHeight
1545
+ return h('g', { key: tick },
1546
+ h('line', { className: 'ud-grid', x1: CHART_PAD.left, x2: plotRight, y1: y, y2: y }),
1547
+ h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, formatCompact(tick)))
1548
+ }),
1549
+ rateAxisTicks().map((tick) => {
1550
+ const y = CHART_PAD.top + layout.plotHeight - (tick / PERCENT_SCALE) * layout.plotHeight
1551
+ return h('text', {
1552
+ key: `rate-${tick}`, className: 'ud-axis-rate',
1553
+ x: plotRight + AXIS_RATE_GAP, y: y + AXIS_LABEL_BASELINE,
1554
+ }, String(tick))
1555
+ }),
1556
+ layout.bars.flatMap((bar, barIndex) => bar.segments.map((segment) => h('rect', {
1557
+ key: `${bar.key}/${segment.model}`, className: 'ud-bar',
1558
+ x: bar.x - layout.barWidth / 2, y: segment.y, width: layout.barWidth, height: segment.height,
1559
+ fill: colorFor(segment.model),
1560
+ style: hover?.index === barIndex
1561
+ ? { transform: `scaleX(${(layout.barWidth + BAR_HOVER_GROW) / layout.barWidth})` }
1562
+ : undefined,
1563
+ }))),
1564
+ slots.map((slot, index) => (index % layout.labelEvery === 0 || index === slots.length - 1)
1565
+ ? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
1566
+ : null),
1567
+ h('path', {
1568
+ className: 'ud-trend', d: smoothPath(ratePoints),
1569
+ strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1570
+ }),
1571
+ hoverRatePoint
1572
+ ? h('circle', { className: 'ud-trend-dot', cx: hoverRatePoint.x, cy: hoverRatePoint.y, r: TREND_DOT_RADIUS })
1573
+ : null,
1574
+ slots.map((slot, index) => h('rect', {
1575
+ key: `hit-${slot.day}`, className: 'ud-bar-hit',
1576
+ x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
1577
+ onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
1578
+ })))),
1579
+ h(Legend, { models: legendModels, colorFor }),
1580
+ h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
1581
+ hoverSlot
1582
+ ? [
1583
+ h('div', { key: 'title', className: 'ud-tip-title' }, hoverSlot.day),
1584
+ h('div', { key: 'total', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(hoverSlot.total)}`),
1585
+ ...legendModels.map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
1586
+ h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1587
+ `${item.model === OTHER_MODEL ? t('other') : item.model}: ${formatTokens(hoverSlot.byModel[item.model] ?? 0)}`)),
1588
+ ...otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
1589
+ `${model}: ${formatTokens(tokens)}`)),
1590
+ h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`),
1591
+ costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
1592
+ ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
1593
+ : null,
1594
+ ]
1595
+ : null))
1596
+ }
1597
+
1598
+ // 浮层 tooltip:portal 到 body,模块表缺失时降级面板内 fixed
1599
+ function ChartTip({ anchor, panelRef, children }) {
1600
+ const tipRef = useRef(null)
1601
+ const [pos, setPos] = useState(null)
1602
+ const place = useCallback(() => {
1603
+ const tip = tipRef.current
1604
+ if (!tip) return
1605
+ const panel = panelRef ? panelRef.current : null
1606
+ const bounds = panel
1607
+ ? panel.getBoundingClientRect()
1608
+ : { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
1609
+ const next = tipPlace(
1610
+ anchor ? anchor.getBoundingClientRect() : null,
1611
+ { width: tip.offsetWidth, height: tip.offsetHeight },
1612
+ bounds,
1613
+ )
1614
+ setPos((prev) => (prev && next && prev.left === next.left && prev.top === next.top ? prev : next))
1615
+ }, [anchor, panelRef])
1616
+ useLayoutEffect(() => {
1617
+ place()
1618
+ }, [place])
1619
+ useEffect(() => {
1620
+ window.addEventListener('scroll', place, true)
1621
+ window.addEventListener('resize', place)
1622
+ const observer = new ResizeObserver(() => place())
1623
+ if (tipRef.current) observer.observe(tipRef.current)
1624
+ if (panelRef && panelRef.current) observer.observe(panelRef.current)
1625
+ return () => {
1626
+ window.removeEventListener('scroll', place, true)
1627
+ window.removeEventListener('resize', place)
1628
+ observer.disconnect()
1629
+ }
1630
+ }, [place])
1631
+ const element = h('div', {
1632
+ className: 'ud-tip',
1633
+ ref: tipRef,
1634
+ style: {
1635
+ left: pos ? pos.left : 0,
1636
+ top: pos ? pos.top : 0,
1637
+ visibility: pos && anchor ? 'visible' : 'hidden',
1638
+ },
1639
+ }, anchor ? children : null)
1640
+ return createPortal && typeof document !== 'undefined' && document.body
1641
+ ? createPortal(element, document.body)
1642
+ : element
1643
+ }
1644
+
1645
+ function HeatSection({ days, panelRef, t = defaultT }) {
1646
+ const wrapRef = useRef(null)
1647
+ const [width, setWidth] = useState(0)
1648
+ const [hover, setHover] = useState(null)
1649
+ useEffect(() => {
1650
+ const element = wrapRef.current
1651
+ const observer = new ResizeObserver((entries) => {
1652
+ const next = entries[0].contentRect.width
1653
+ setWidth((prev) => (Math.abs(prev - next) < HEAT_WIDTH_EPSILON ? prev : next))
1654
+ })
1655
+ observer.observe(element)
1656
+ return () => observer.disconnect()
1657
+ }, [])
1658
+ const geom = width > 0 && days ? heatLayout(width, days[0].day) : null
1659
+ const display = geom ? heatDisplayDays(days, geom.cols) : null
1660
+ const grid = display ? heatGrid(display, geom.size) : null
1661
+ const peak = display ? Math.max(1, ...display.map((slot) => slot.tokens)) : 1
1662
+ const pick = (cell) => (event) => setHover({ ...cell, anchor: event.currentTarget })
1663
+ const clear = () => setHover(null)
1664
+ return h('div', { className: 'ud-section' },
1665
+ h('div', { className: 'ud-section-head' },
1666
+ h('span', { className: 'ud-section-title' }, t('heatmap')),
1667
+ h('div', { className: 'ud-heat-legend' },
1668
+ h('span', null, t('heatLess')),
1669
+ Array.from({ length: HEAT_LEVELS - 1 }, (_, index) => h('i', {
1670
+ key: index,
1671
+ className: `ud-heat-l${index + 1}`,
1672
+ style: geom ? { width: geom.size, height: geom.size } : undefined,
1673
+ })),
1674
+ h('span', null, t('heatMore')))),
1675
+ h('div', { className: 'ud-heat-wrap', ref: wrapRef },
1676
+ grid
1677
+ ? h('svg', { className: 'ud-heat', width: grid.width, height: grid.height, role: 'img', 'aria-label': t('heatmap') },
1678
+ grid.cells.map((cell) => {
1679
+ const level = heatLevel(cell.tokens, peak)
1680
+ return h('rect', {
1681
+ key: cell.day,
1682
+ className: `ud-heat-cell ud-heat-l${level}`,
1683
+ x: cell.x, y: cell.y, width: geom.size, height: geom.size, rx: HEAT_RX,
1684
+ 'aria-hidden': level === 0,
1685
+ onMouseEnter: pick(cell), onFocus: pick(cell),
1686
+ onMouseLeave: clear, onBlur: clear,
1687
+ })
1688
+ }))
1689
+ : null),
1690
+ h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
1691
+ hover
1692
+ ? [
1693
+ h('div', { key: 'title', className: 'ud-tip-title' }, hover.day),
1694
+ h('div', { key: 'tokens', className: 'ud-tip-row' }, `${t('tokens')}: ${formatTokens(hover.tokens)}`),
1695
+ h('div', { key: 'requests', className: 'ud-tip-row' }, `${t('requests')}: ${hover.requests}`),
1696
+ h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hover.cacheHit, hover.cacheMiss)}`),
1697
+ ]
1698
+ : null))
1699
+ }
1700
+
1701
+ // 模型用量:donut + 列表,恒按天口径,不随视图切换
1702
+ function ModelUsage({ models, colorFor, panelRef, costCurrency = '', t = defaultT }) {
1703
+ const [hover, setHover] = useState(null)
1704
+ const [tip, setTip] = useState(null)
1705
+ const [expandedOther, setExpandedOther] = useState(false)
1706
+ const total = Math.max(DONUT_TOTAL_FLOOR, models.reduce((sum, item) => sum + item.tokens, 0))
1707
+ const segments = donutSegments(models, total)
1708
+ const displayName = (model) => (model === OTHER_MODEL ? t('other') : model)
1709
+ const pick = (model) => (event) => {
1710
+ setHover(model)
1711
+ setTip({ model, anchor: event.currentTarget })
1712
+ }
1713
+ const clear = () => {
1714
+ setHover(null)
1715
+ setTip(null)
1716
+ }
1717
+ const toggleOther = () => setExpandedOther((value) => !value)
1718
+ const tipSegment = tip ? segments.find((segment) => segment.model === tip.model) : null
1719
+ return h('div', { className: 'ud-section' },
1720
+ h('div', { className: 'ud-section-head' },
1721
+ h('span', { className: 'ud-section-title' }, t('modelUsage'))),
1722
+ h('div', { className: 'ud-model-usage' },
1723
+ h('svg', {
1724
+ className: 'ud-donut-wrap', viewBox: `0 0 ${DONUT_VIEWBOX_SIZE} ${DONUT_VIEWBOX_SIZE}`,
1725
+ width: DONUT_VIEWBOX_SIZE, height: DONUT_VIEWBOX_SIZE,
1726
+ },
1727
+ h('circle', {
1728
+ className: 'ud-donut-track', cx: DONUT_CENTER_XY, cy: DONUT_CENTER_XY, r: DONUT_RADIUS,
1729
+ fill: 'none', strokeWidth: DONUT_STROKE_WIDTH, stroke: 'var(--dsw-alias-bg-mask-1)',
1730
+ }),
1731
+ segments.map((segment) => h('circle', {
1732
+ key: segment.model,
1733
+ className: cx('ud-donut-seg', hover && hover !== segment.model && 'ud-donut-seg--dim'),
1734
+ cx: DONUT_CENTER_XY, cy: DONUT_CENTER_XY, r: DONUT_RADIUS, fill: 'none',
1735
+ stroke: colorFor(segment.model),
1736
+ strokeWidth: hover === segment.model ? DONUT_STROKE_WIDTH + DONUT_ACTIVE_STROKE_GROW : DONUT_STROKE_WIDTH,
1737
+ strokeDasharray: `${segment.dash} ${DONUT_CIRCUMFERENCE - segment.dash}`,
1738
+ strokeDashoffset: -segment.offset,
1739
+ transform: `rotate(-90 ${DONUT_CENTER_XY} ${DONUT_CENTER_XY})`,
1740
+ tabIndex: 0, role: 'button',
1741
+ 'aria-label': modelSegmentLabel(displayName(segment.model), segment.tokens, segment.percent),
1742
+ onMouseEnter: pick(segment.model), onFocus: pick(segment.model),
1743
+ onMouseLeave: clear, onBlur: clear,
1744
+ })),
1745
+ h('text', { className: 'ud-donut-center', x: DONUT_CENTER_XY, y: DONUT_CENTER_XY + DONUT_CENTER_VALUE_OFFSET, textAnchor: 'middle', 'aria-hidden': true }, formatCompact(total)),
1746
+ h('text', { className: 'ud-donut-label', x: DONUT_CENTER_XY, y: DONUT_CENTER_XY + DONUT_CENTER_LABEL_OFFSET, textAnchor: 'middle', 'aria-hidden': true }, t('tokens'))),
1747
+ h('div', { className: 'ud-models' },
1748
+ models.map((item) => {
1749
+ const isOther = item.model === OTHER_MODEL
1750
+ return h(React.Fragment, { key: item.model },
1751
+ h('div', {
1752
+ className: cx('ud-model-row', isOther && 'ud-model-row--expand'),
1753
+ onClick: isOther ? toggleOther : undefined,
1754
+ onMouseEnter: () => setHover(item.model),
1755
+ onMouseLeave: clear,
1756
+ },
1757
+ h('i', { className: 'ud-model-swatch', style: { background: colorFor(item.model) } }),
1758
+ h('div', { className: 'ud-model-id' },
1759
+ h('span', { className: 'ud-model-name' }, displayName(item.model)),
1760
+ isOther ? null : h('span', { className: 'ud-model-provider' }, providerOf(item.model))),
1761
+ isOther
1762
+ ? h('button', {
1763
+ className: 'ud-model-toggle', 'aria-expanded': expandedOther, 'aria-label': t('other'),
1764
+ onClick: (event) => {
1765
+ event.stopPropagation()
1766
+ toggleOther()
1767
+ },
1768
+ }, '›')
1769
+ : null,
1770
+ h('div', { className: 'ud-model-values' },
1771
+ h('span', { className: 'ud-model-tokens' }, formatTokens(item.tokens)),
1772
+ h('span', { className: 'ud-model-pct' },
1773
+ item.cost !== undefined ? h('span', { className: 'ud-model-cost' }, `≈ ${formatCost(item.cost, costCurrency)} · `) : null,
1774
+ formatPercent((item.tokens / total) * PERCENT_SCALE)))),
1775
+ isOther
1776
+ ? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
1777
+ h('div', { className: 'ud-model-other-list' },
1778
+ otherDetailItems(models).map((detail) => h('div', {
1779
+ key: detail.model, className: 'ud-model-row ud-model-row--sub',
1780
+ onMouseEnter: () => setHover(OTHER_MODEL), onMouseLeave: clear,
1781
+ },
1782
+ h('span', { className: 'ud-model-name' }, detail.model),
1783
+ h('div', { className: 'ud-model-values' },
1784
+ h('span', { className: 'ud-model-tokens' }, formatTokens(detail.tokens)))))))
1785
+ : null)
1786
+ }))),
1787
+ h(ChartTip, { anchor: tip ? tip.anchor : null, panelRef },
1788
+ tipSegment
1789
+ ? [
1790
+ h('div', { key: 'title', className: 'ud-tip-title' }, displayName(tipSegment.model)),
1791
+ h('div', { key: 'tokens', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(tipSegment.tokens)}`),
1792
+ h('div', { key: 'pct', className: 'ud-tip-row' }, `${t('percent')}: ${formatPercent(tipSegment.percent)}`),
1793
+ tipSegment.model === OTHER_MODEL && (tipSegment.items ?? []).length > 0
1794
+ ? h('div', { key: 'breakdown', className: 'ud-tip-breakdown' },
1795
+ tipSegment.items.map((detail) => h('div', { key: detail.model, className: 'ud-tip-row ud-tip-row--sub' },
1796
+ h('i', { className: 'ud-legend-swatch', style: { background: 'var(--ud-chart-other)' } }),
1797
+ `${detail.model}: ${formatTokens(detail.tokens)}`)))
1798
+ : null,
1799
+ ]
1800
+ : null))
1801
+ }
1802
+
1803
+ // 规约形态开关:原生 checkbox 保语义并视觉隐藏,track/thumb 呈现选中态
1804
+ function Switch({ checked, onChange, disabled, describedbyId }) {
1805
+ return h('label', { className: 'ud-switch' },
1806
+ h('input', {
1807
+ type: 'checkbox',
1808
+ checked,
1809
+ disabled,
1810
+ 'aria-describedby': describedbyId,
1811
+ onChange: (event) => onChange(event.target.checked),
1812
+ }),
1813
+ h('span', { className: 'ud-switch__track' },
1814
+ h('span', { className: 'ud-switch__thumb' })))
1815
+ }
1816
+
1817
+ // 底部信息栏接管:与官方 StatsLine 同 id 'stats' 的槽条目,双开关控制增强项
1818
+ // 语言切换经槽的 locale 座以新 t 引用驱动 memo 重渲染
1819
+ const StatsLineEnhanced = React.memo(function StatsLineEnhanced({ useChat, useProjection, t = defaultT }) {
1820
+ if (typeof useProjection !== 'function' || typeof useChat !== 'function') return null
1821
+ const usage = useProjection('tokenUsage')
1822
+ const projected = useProjection('sessionStats')
1823
+ const settledNodes = useChat((state) => state.legacy.nodes)
1824
+ const stats = useMemo(() => projected ?? deriveStats(settledNodes ?? []), [projected, settledNodes])
1825
+ const [prefs, setPrefs] = useState(() => statsLineState.get())
1826
+ useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
1827
+ // 价格异步首帧可能未回:回包经状态刷新补渲染,未回期间费用组不渲染
1828
+ const [pricingRules, setPricingRules] = useState(null)
1829
+ useEffect(() => {
1830
+ let alive = true
1831
+ fetchPricing().then((value) => {
1832
+ if (alive && value) setPricingRules(value.rules)
1833
+ })
1834
+ return () => { alive = false }
1835
+ }, [])
1836
+ const groups = buildStatsGroups(stats, usage, prefs, t, pricingRules)
1837
+ const costItem = buildCostItem(usage, pricingRules, prefs, t)
1838
+ const entries = groups.map((text) => ({ text }))
1839
+ if (costItem !== null && groups[groups.length - 1] === costItem) {
1840
+ entries[entries.length - 1] = { text: costItem, title: t('statsCostTitle') }
1841
+ }
1842
+ const rootRef = useRef(null)
1843
+ const [truncated, setTruncated] = useState(false)
1844
+ useLayoutEffect(() => {
1845
+ const element = rootRef.current
1846
+ if (!element) return undefined
1847
+ const measure = () => setTruncated(element.scrollWidth > element.clientWidth)
1848
+ measure()
1849
+ const observer = new ResizeObserver(measure)
1850
+ observer.observe(element)
1851
+ return () => observer.disconnect()
1852
+ }, [groups])
1853
+ if (entries.length === 0) return null
1854
+ return h('div', {
1855
+ className: 'ud-statsline-root',
1856
+ ref: rootRef,
1857
+ title: truncated ? entries.map((entry) => entry.text).join(STATS_LINE_TITLE_SEPARATOR) : undefined,
1858
+ }, entries.map((entry, index) => h(React.Fragment, { key: index },
1859
+ index > 0 && h('span', { className: 'ud-statsline-sep', 'aria-hidden': true }, '|'),
1860
+ entry.title ? h('span', { title: entry.title }, entry.text) : entry.text)))
1861
+ })
1862
+
1863
+ // 注入点B 组件:Turn 尾部单轮用量行,chain matched 即 TurnTokenUsage,渲染于动作行之前;
1864
+ // 无独立费用开关(plan 决策),显隐随容器 data-actions-reveal;价格未载期间费用为占位符
1865
+ const CostTail = React.memo(function CostTail({ matched, useChat, t = defaultT }) {
1866
+ if (!matched || typeof useChat !== 'function') return null
1867
+ const [pricingRules, setPricingRules] = useState(null)
1868
+ useEffect(() => {
1869
+ let alive = true
1870
+ fetchPricing().then((value) => {
1871
+ if (alive && value) setPricingRules(value.rules)
1872
+ })
1873
+ return () => { alive = false }
1874
+ }, [])
1875
+ const model = turnModelOf(matched)
1876
+ const now = new Date()
1877
+ const price = pricingRules ? matchPrice(pricingRules, model, now) : null
1878
+ const currency = pricingRules ? aggregateCurrencyOf(pricingRules) : ''
1879
+ return h('div', { className: 'ud-turn-cost', title: turnCostTitleText(t, matched) },
1880
+ buildTurnCostLine(t, matched, price, currency))
1881
+ })
1882
+
1883
+ // 偏好卡行:说明文案承担 aria-describedby 目标
1884
+ function StatsLineOptionRow({ labelKey, descKey, checked, onToggle, t = defaultT }) {
1885
+ const describeId = React.useId()
1886
+ return h('div', { className: 'ud-pref-row' },
1887
+ h('div', { className: 'ud-pref-text' },
1888
+ h('span', { className: 'ud-pref-title' }, t(labelKey)),
1889
+ h('span', { className: 'ud-pref-desc', id: describeId }, t(descKey))),
1890
+ h(Switch, { checked, onChange: onToggle, describedbyId: describeId }))
1891
+ }
1892
+
1893
+ // 偏好卡:与底部信息栏同 store 实例,改动即时互通
1894
+ function StatsLineOptions({ t = defaultT }) {
1895
+ const [prefs, setPrefs] = useState(() => statsLineState.get())
1896
+ useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
1897
+ return h('div', { className: 'ud-pref-group' },
1898
+ h(StatsLineOptionRow, {
1899
+ labelKey: 'cachePrecision',
1900
+ descKey: 'cachePrecisionDesc',
1901
+ checked: prefs.cachePrecision,
1902
+ onToggle: (value) => statsLineState.set({ cachePrecision: value }),
1903
+ t,
1904
+ }),
1905
+ h(StatsLineOptionRow, {
1906
+ labelKey: 'tokenDetail',
1907
+ descKey: 'tokenDetailDesc',
1908
+ checked: prefs.tokenDetail,
1909
+ onToggle: (value) => statsLineState.set({ tokenDetail: value }),
1910
+ t,
1911
+ }),
1912
+ h(StatsLineOptionRow, {
1913
+ labelKey: 'costDisplay',
1914
+ descKey: 'costDisplayDesc',
1915
+ checked: prefs.costDisplay,
1916
+ onToggle: (value) => statsLineState.set({ costDisplay: value }),
1917
+ t,
1918
+ }))
1919
+ }
1920
+
1921
+ // 定价规则编辑器:货币为编辑器级全局设置(标题右侧切换,整表统一,不逐模型设置);
1922
+ // 条件行不支持编辑,已有条件整条保留原样,空条件即恒生效;打开面板时经 fetchPricing 初始化,未保存离开即弃
1923
+ const PRICING_STATE_READY = 'ready'
1924
+ const PRICING_STATE_UNAVAILABLE = 'unavailable'
1925
+
1926
+ function PricingRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove }) {
1927
+ const errorTextOf = (path) => {
1928
+ const key = errors.get(path)
1929
+ return key ? h('span', { className: 'ud-field-error' }, t(key)) : null
1930
+ }
1931
+ const priceField = (key, labelKey) => h('label', { key, className: 'ud-field' },
1932
+ h('span', { className: 'ud-field-label' }, t(labelKey)),
1933
+ h('span', { className: 'ud-price-input' },
1934
+ h('span', { className: 'ud-price-currency', 'aria-hidden': 'true' }, currency),
1935
+ h('input', {
1936
+ type: 'number', className: 'ud-input', min: 0, step: 'any',
1937
+ value: rule.price?.[key] ?? '',
1938
+ onChange: (event) => onPatch({ price: { ...rule.price, [key]: event.target.value } }),
1939
+ })),
1940
+ errorTextOf(`${pathPrefix}price.${key}`))
1941
+ return h('div', { className: 'ud-rule' },
1942
+ h('div', { className: 'ud-rule-head' },
1943
+ h('label', { className: 'ud-field' },
1944
+ h('span', { className: 'ud-field-label' }, t('pricingModel')),
1945
+ h('input', {
1946
+ type: 'text', className: 'ud-input', value: rule.model,
1947
+ placeholder: t('pricingModelPlaceholder'),
1948
+ onChange: (event) => onPatch({ model: event.target.value }),
1949
+ }),
1950
+ errorTextOf(`${pathPrefix}model`)),
1951
+ h('button', {
1952
+ className: 'ud-btn ud-btn--text', type: 'button', onClick: onRemove,
1953
+ 'aria-label': t('deleteRule'), title: t('deleteRule'),
1954
+ }, '×')),
1955
+ h('div', { className: 'ud-price-grid' },
1956
+ priceField('input', 'priceInput'),
1957
+ priceField('output', 'priceOutput'),
1958
+ priceField('cacheRead', 'priceCacheRead'),
1959
+ priceField('cacheWrite', 'priceCacheWrite')),
1960
+ h('span', { className: 'ud-rule-cond' },
1961
+ (rule.conditions ?? []).length === 0
1962
+ ? t('noCondition')
1963
+ : t('conditionsPreserved', { n: rule.conditions.length })))
1964
+ }
1965
+
1966
+ function PricingEditor({ t = defaultT }) {
1967
+ const [phase, setPhase] = useState(null)
1968
+ const [currency, setCurrency] = useState(CURRENCIES[0])
1969
+ const [rules, setRules] = useState(null)
1970
+ const [errors, setErrors] = useState(() => new Map())
1971
+ const [saveError, setSaveError] = useState('')
1972
+ const [saving, setSaving] = useState(false)
1973
+ const [saved, setSaved] = useState(false)
1974
+ const savedTimerRef = useRef(null)
1975
+
1976
+ useEffect(() => {
1977
+ let alive = true
1978
+ fetchPricing().then((value) => {
1979
+ if (!alive) return
1980
+ if (!value) {
1981
+ setPhase(PRICING_STATE_UNAVAILABLE)
1982
+ return
1983
+ }
1984
+ // 打开即按首个非空货币归一显示(无非空则回落首档),整表状态与全局货币恒一致
1985
+ const loaded = copyRules(value.rules)
1986
+ const unified = aggregateCurrencyOf(loaded) || CURRENCIES[0]
1987
+ setCurrency(unified)
1988
+ setRules(applyCurrencyToRules(loaded, unified))
1989
+ setPhase(PRICING_STATE_READY)
1990
+ })
1991
+ return () => { alive = false }
1992
+ }, [])
1993
+
1994
+ useEffect(() => () => {
1995
+ if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
1996
+ }, [])
1997
+
1998
+ const save = async () => {
1999
+ const found = validatePricingRules(rules)
2000
+ setErrors(found)
2001
+ setSaved(false)
2002
+ if (found.size > 0) return
2003
+ setSaving(true)
2004
+ const result = await requestPost(ENDPOINTS.pricing, { rules: coercePricingRules(rules) })
2005
+ setSaving(false)
2006
+ if (!result.ok) {
2007
+ setSaveError(result.message)
2008
+ return
2009
+ }
2010
+ // 保存即生效:响应 value 直接覆盖缓存,编辑副本同步为服务端规整后的规则
2011
+ applyPricingValue(result.value)
2012
+ setSaveError('')
2013
+ setErrors(new Map())
2014
+ setRules(copyRules(result.value.rules))
2015
+ setSaved(true)
2016
+ if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
2017
+ savedTimerRef.current = setTimeout(() => setSaved(false), PRICING_SAVED_NOTICE_MS)
2018
+ }
2019
+
2020
+ if (phase === PRICING_STATE_UNAVAILABLE) {
2021
+ return h('div', { className: 'ud-pref-group' },
2022
+ h('span', { className: 'ud-pref-title' }, t('pricing')),
2023
+ h('div', { className: 'ud-empty' }, t('pricingUnavailable')))
2024
+ }
2025
+ if (phase !== PRICING_STATE_READY) return null
2026
+
2027
+ return h('div', { className: 'ud-pref-group' },
2028
+ h('div', { className: 'ud-pref-row' },
2029
+ h('div', { className: 'ud-pref-text' },
2030
+ h('span', { className: 'ud-pref-title' }, t('pricing')),
2031
+ saved ? h('span', { className: 'ud-pref-desc' }, t('saved')) : null),
2032
+ h('div', { className: 'ud-pricing-actions' },
2033
+ h('span', { className: 'ud-unit-note' }, t('pricingUnit')),
2034
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('pricingCurrency') },
2035
+ CURRENCIES.map((symbol) => h('button', {
2036
+ key: symbol, type: 'button',
2037
+ className: cx('ud-seg-item', currency === symbol && 'ud-seg-item--on'),
2038
+ 'aria-pressed': currency === symbol,
2039
+ onClick: () => {
2040
+ setCurrency(symbol)
2041
+ setRules((prev) => applyCurrencyToRules(prev, symbol))
2042
+ },
2043
+ }, symbol))),
2044
+ h('button', { className: 'ud-btn', type: 'button', disabled: saving, onClick: save }, t('save')))),
2045
+ saveError ? h('div', { className: 'ud-error' }, saveError) : null,
2046
+ rules.map((rule, index) => h(PricingRuleCard, {
2047
+ key: index,
2048
+ rule,
2049
+ currency,
2050
+ errors,
2051
+ pathPrefix: `${index}.`,
2052
+ t,
2053
+ onPatch: (part) => setRules((prev) => updateRuleAt(prev, index, part)),
2054
+ onRemove: () => setRules((prev) => prev.filter((_, i) => i !== index)),
2055
+ })),
2056
+ h('button', {
2057
+ className: 'ud-rule-add', type: 'button',
2058
+ onClick: () => setRules((prev) => [...prev, defaultPricingRule(currency)]),
2059
+ }, t('addRule')))
2060
+ }
2061
+
2062
+ function StatusLine({ status, t = defaultT }) {
2063
+ if (!statusLineActive(status)) return null
2064
+ const running = status.running === true
2065
+ const progress = running && status.total > 0
2066
+ ? Math.min(PROGRESS_FULL_PERCENT, (status.done / status.total) * PROGRESS_FULL_PERCENT)
2067
+ : 0
2068
+ return h('div', { className: 'ud-status' },
2069
+ running
2070
+ ? h(React.Fragment, null,
2071
+ h('span', null, t('status.running', { done: status.done, total: status.total })),
2072
+ h('span', { className: 'ud-status-track' },
2073
+ h('span', { className: 'ud-status-fill', style: { width: `${progress}%` } })))
2074
+ : null,
2075
+ status.error ? h('span', { className: 'ud-status-err' }, status.error) : null,
2076
+ // 无法读取是日志固有损伤,常态提示用中性色,不算错误
2077
+ (status.skippedSessions ?? 0) > 0
2078
+ ? h('span', null, t('skippedSessions', { n: status.skippedSessions }))
2079
+ : null,
2080
+ (status.recordFailures ?? 0) > 0
2081
+ ? h('span', { className: 'ud-status-err' }, t('recordFailures', { n: status.recordFailures }))
2082
+ : null)
2083
+ }
2084
+
2085
+ function RebuildButton({ machineRef, busy, onError, t = defaultT }) {
2086
+ const [armed, setArmed] = useState(false)
2087
+ const armedTimerRef = useRef(null)
2088
+
2089
+ useEffect(() => () => {
2090
+ if (armedTimerRef.current) clearTimeout(armedTimerRef.current)
2091
+ }, [])
2092
+
2093
+ const rebuild = async () => {
2094
+ if (!armed) {
2095
+ setArmed(true)
2096
+ armedTimerRef.current = setTimeout(() => setArmed(false), REBUILD_CONFIRM_MS)
2097
+ return
2098
+ }
2099
+ setArmed(false)
2100
+ const result = await requestPost(ENDPOINTS.reset)
2101
+ if (!result.ok) {
2102
+ onError(result.message)
2103
+ return
2104
+ }
2105
+ machineRef.current?.restart()
2106
+ }
2107
+
2108
+ return h('button', { className: 'ud-btn ud-btn--text', disabled: busy, onClick: rebuild },
2109
+ armed ? t('rebuildConfirm') : t('rebuild'))
2110
+ }
2111
+
2112
+ const viewLabel = (t, id) => (id === 'day' ? t('viewDay') : id === 'hour' ? t('viewHour') : t('viewMinute'))
2113
+ const trendTitle = (t, id) => (id === 'day' ? t('dailyTrend') : id === 'hour' ? t('hourTrend') : t('minuteTrend'))
2114
+ const trendLimitedText = (t, id, count) => (id === 'day'
2115
+ ? t('trendLimited', { n: count })
2116
+ : id === 'hour' ? t('trendLimitedHour', { n: count }) : t('trendLimitedMinute', { n: count }))
2117
+ const presetLabel = (t, view, id) => (view === 'hour'
2118
+ ? t('hourPreset', { n: parseInt(id, 10) })
2119
+ : t('minutePreset', { n: parseInt(id, 10) }))
2120
+ const tickLabelFor = (view) => (view === 'day' ? shortDay : view === 'hour' ? hourTickLabel : minuteTickLabel)
2121
+
2122
+ function UsageDashPanel({ t = defaultT }) {
2123
+ const [view, setView] = useState('day')
2124
+ const [range, setRange] = useState(DEFAULT_RANGE)
2125
+ const [customFrom, setCustomFrom] = useState('')
2126
+ const [customTo, setCustomTo] = useState('')
2127
+ const [stats, setStats] = useState(null)
2128
+ const [loading, setLoading] = useState(true)
2129
+ const [error, setError] = useState('')
2130
+ const [hourPreset, setHourPreset] = useState(DEFAULT_HOUR_PRESET)
2131
+ const [minutePreset, setMinutePreset] = useState(DEFAULT_MINUTE_PRESET)
2132
+ const [pointStats, setPointStats] = useState(null)
2133
+ const [pointStatus, setPointStatus] = useState('idle')
2134
+ const [fetchTick, setFetchTick] = useState(0)
2135
+ const [status, setStatus] = useState(null)
2136
+ const statusMachineRef = useRef(null)
2137
+ const generationRef = useRef(0)
2138
+ const pointGenerationRef = useRef(0)
2139
+ const heatGenerationRef = useRef(0)
2140
+ const panelRef = useRef(null)
2141
+ const [heatDays, setHeatDays] = useState(null)
2142
+ // 费用展示货币来源:价格规则异步首帧未回时空串即不带符号
2143
+ const [pricingRules, setPricingRules] = useState(null)
2144
+
2145
+ useEffect(() => {
2146
+ let alive = true
2147
+ fetchPricing().then((value) => {
2148
+ if (alive && value) setPricingRules(value.rules)
2149
+ })
2150
+ return () => { alive = false }
2151
+ }, [])
2152
+
2153
+ const costCurrency = aggregateCurrencyOf(pricingRules)
2154
+
2155
+ // 热力图独立请求:与所选范围无关,失败静默留空,过期响应丢弃
2156
+ useEffect(() => {
2157
+ const generation = ++heatGenerationRef.current
2158
+ const request = { from: localDay(-(HEAT_WINDOW_DAYS - 1)), to: localDay(0) }
2159
+ requestPost(ENDPOINTS.range, request).then((result) => {
2160
+ if (heatGenerationRef.current !== generation || !result.ok) return
2161
+ const byDay = new Map(result.value.daily.map((slot) => [slot.day, slot]))
2162
+ setHeatDays(daysInRange(request.from, request.to).map((day) => {
2163
+ const slot = byDay.get(day)
2164
+ return {
2165
+ day,
2166
+ tokens: slot ? slot.total : 0,
2167
+ requests: slot ? slot.requests : 0,
2168
+ cacheHit: slot ? slot.cacheHit : 0,
2169
+ cacheMiss: slot ? slot.cacheMiss : 0,
2170
+ }
2171
+ }))
2172
+ })
2173
+ }, [])
2174
+
2175
+ const load = useCallback(async () => {
2176
+ const request = range === 'custom'
2177
+ ? { from: customFrom, to: customTo }
2178
+ : resolveDayRange(range, new Date())
2179
+ if (!request || !request.from || !request.to) return
2180
+ const generation = ++generationRef.current
2181
+ setLoading(true)
2182
+ const result = await requestPost(ENDPOINTS.range, request)
2183
+ if (generationRef.current !== generation) return
2184
+ setLoading(false)
2185
+ if (!result.ok) {
2186
+ setError(result.message)
2187
+ return
2188
+ }
2189
+ setError('')
2190
+ setStats(result.value)
2191
+ }, [range, customFrom, customTo])
2192
+
2193
+ useEffect(() => {
2194
+ load()
2195
+ }, [load])
2196
+
2197
+ const presetId = view === 'hour' ? hourPreset : view === 'minute' ? minutePreset : null
2198
+
2199
+ useEffect(() => {
2200
+ if (view === 'day') return
2201
+ if (pointStats && pointStats.preset === presetId) return
2202
+ const request = view === 'hour'
2203
+ ? resolveHourRange(presetId, new Date())
2204
+ : resolveMinuteRange(presetId, new Date())
2205
+ const generation = ++pointGenerationRef.current
2206
+ setPointStatus('loading')
2207
+ requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, request).then((result) => {
2208
+ if (pointGenerationRef.current !== generation) return
2209
+ if (!result.ok) {
2210
+ setPointStatus('error')
2211
+ setError(result.message)
2212
+ return
2213
+ }
2214
+ setError('')
2215
+ setPointStatus('ok')
2216
+ setPointStats({ preset: presetId, value: result.value })
2217
+ })
2218
+ }, [view, presetId, fetchTick])
2219
+
2220
+ const refreshPoints = useCallback(() => {
2221
+ setPointStats(null)
2222
+ setFetchTick((value) => value + 1)
2223
+ }, [])
2224
+
2225
+ const refreshLatestRef = useRef(null)
2226
+ refreshLatestRef.current = () => {
2227
+ load()
2228
+ if (view !== 'day') refreshPoints()
2229
+ }
2230
+ const refreshTimerRef = useRef(null)
2231
+ const scheduleRefresh = useMemo(() => () => {
2232
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
2233
+ refreshTimerRef.current = setTimeout(() => refreshLatestRef.current(), STATUS_REFRESH_DEBOUNCE_MS)
2234
+ }, [])
2235
+ useEffect(() => () => {
2236
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
2237
+ }, [])
2238
+
2239
+ const refresh = () => {
2240
+ setError('')
2241
+ if (view === 'day') load()
2242
+ else refreshPoints()
2243
+ }
2244
+
2245
+ // 回扫状态轮询:运行中快轮,推进时联动数据刷新;restart 供重建按钮重置基线快轮
2246
+ useEffect(() => {
2247
+ let timer = null
2248
+ let alive = true
2249
+ let baseDone = null
2250
+ const schedule = (delay) => {
2251
+ if (!alive) return
2252
+ if (timer) clearTimeout(timer)
2253
+ timer = setTimeout(tick, delay)
2254
+ }
2255
+ const tick = async () => {
2256
+ timer = null
2257
+ const result = await requestPost(ENDPOINTS.status)
2258
+ if (!alive) return
2259
+ if (!result.ok) {
2260
+ schedule(STATUS_POLL_SLOW_MS)
2261
+ return
2262
+ }
2263
+ const value = result.value
2264
+ setStatus(value)
2265
+ if (value.running) {
2266
+ if (baseDone === null || value.done < baseDone) baseDone = value.done
2267
+ else if (value.done > baseDone) {
2268
+ baseDone = value.done
2269
+ scheduleRefresh()
2270
+ }
2271
+ schedule(STATUS_POLL_FAST_MS)
2272
+ }
2273
+ }
2274
+ tick()
2275
+ statusMachineRef.current = { restart: () => { baseDone = 0; schedule(STATUS_POLL_FAST_MS) } }
2276
+ return () => {
2277
+ alive = false
2278
+ if (timer) clearTimeout(timer)
2279
+ statusMachineRef.current = null
2280
+ }
2281
+ }, [])
2282
+
2283
+ const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
2284
+ const pointView = pointStats && pointStats.preset === presetId ? pointStats.value : null
2285
+ const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
2286
+ const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
2287
+
2288
+ const pointActive = view !== 'day'
2289
+ const trendSource = pointActive
2290
+ ? (pointGrouped ? { slots: pointGrouped.daily, models: pointGrouped.models, value: pointView } : null)
2291
+ : (grouped ? { slots: grouped.daily, models: grouped.models, value: stats } : null)
2292
+ const maxSlots = pointActive ? maxSlotsFor(view, presetId) : DAY_MAX_SLOTS
2293
+ const trimmedSlots = trendSource ? trimSlots(trendSource.slots, maxSlots) : null
2294
+ const notes = []
2295
+ if (trendSource && trendSource.slots.length > trimmedSlots.length) notes.push(trendLimitedText(t, view, trimmedSlots.length))
2296
+ if (trendSource?.value?.truncated) notes.push(t('trendTruncated'))
2297
+
2298
+ const busy = pointActive ? pointStatus === 'loading' : loading
2299
+ const loadingVisible = pointActive ? pointStatus === 'loading' && !pointView : loading && !stats
2300
+ const emptyVisible = !error && (pointActive ? pointView && isEmptyRange(pointView) : stats && isEmptyRange(stats))
2301
+
2302
+ return h('div', { className: 'ud-panel', ref: panelRef },
2303
+ h('div', { className: 'ud-toolbar' },
2304
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('viewGroup') },
2305
+ VIEW_TABS.map((tab) => h('button', {
2306
+ key: tab.id,
2307
+ className: cx('ud-seg-item', view === tab.id && 'ud-seg-item--on'),
2308
+ 'aria-pressed': view === tab.id,
2309
+ onClick: () => setView(tab.id),
2310
+ }, viewLabel(t, tab.id)))),
2311
+ pointActive
2312
+ ? h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2313
+ (view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
2314
+ key: id,
2315
+ className: cx('ud-seg-item', presetId === id && 'ud-seg-item--on'),
2316
+ 'aria-pressed': presetId === id,
2317
+ onClick: () => (view === 'hour' ? setHourPreset(id) : setMinutePreset(id)),
2318
+ }, presetLabel(t, view, id))))
2319
+ : h(React.Fragment, null,
2320
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2321
+ DAY_PRESETS.map((id) => h('button', {
2322
+ key: id,
2323
+ className: cx('ud-seg-item', range === id && 'ud-seg-item--on'),
2324
+ 'aria-pressed': range === id,
2325
+ onClick: () => setRange(id),
2326
+ }, t(`rangePreset.${id}`))),
2327
+ h('button', {
2328
+ className: cx('ud-seg-item', range === 'custom' && 'ud-seg-item--on'),
2329
+ 'aria-pressed': range === 'custom',
2330
+ onClick: () => setRange('custom'),
2331
+ }, t('rangeCustom'))),
2332
+ range === 'custom'
2333
+ ? h('div', { className: 'ud-custom-range' },
2334
+ h('input', {
2335
+ type: 'date', className: 'ud-date-input', 'aria-label': t('from'),
2336
+ value: customFrom, max: customTo || undefined,
2337
+ onChange: (event) => setCustomFrom(event.target.value),
2338
+ }),
2339
+ h('span', { className: 'ud-custom-sep' }, '–'),
2340
+ h('input', {
2341
+ type: 'date', className: 'ud-date-input', 'aria-label': t('to'),
2342
+ value: customTo, min: customFrom || undefined, max: dayBucket(new Date()),
2343
+ onChange: (event) => setCustomTo(event.target.value),
2344
+ }))
2345
+ : null),
2346
+ h(StatusLine, { status, t }),
2347
+ h('button', { className: 'ud-btn ud-btn--text ud-refresh', disabled: busy, onClick: refresh }, t('refresh')),
2348
+ h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t })),
2349
+ error ? h('div', { className: 'ud-error' }, error) : null,
2350
+ loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
2351
+ stats ? h(StatCards, { key: 'cards', stats, costCurrency, t }) : null,
2352
+ h(HeatSection, { key: 'heat', days: heatDays, panelRef, t }),
2353
+ trimmedSlots
2354
+ ? h(TrendChart, {
2355
+ key: 'trend',
2356
+ title: trendTitle(t, view),
2357
+ notes,
2358
+ slots: trimmedSlots,
2359
+ modelOrder: trendSource.models.map((item) => item.model),
2360
+ colorFor,
2361
+ labelFor: tickLabelFor(view),
2362
+ labelMinPitch: pointActive ? LABEL_PITCH_TIME : LABEL_PITCH_DAY,
2363
+ busy,
2364
+ legendModels: trendSource.models,
2365
+ panelRef,
2366
+ costCurrency,
2367
+ costEnabled: view === 'day',
2368
+ t,
2369
+ })
2370
+ : null,
2371
+ grouped ? h(ModelUsage, { key: 'models', models: grouped.models, colorFor, panelRef, costCurrency, t }) : null,
2372
+ stats?.to ? h('div', { className: 'ud-foot' }, `${t('asOf')} ${stats.to}`) : null,
2373
+ emptyVisible ? h('div', { className: 'ud-empty' }, t('empty')) : null,
2374
+ h(StatsLineOptions, { key: 'prefs', t }),
2375
+ h(PricingEditor, { key: 'pricing', t }))
2376
+ }
2377
+
2378
+ return {
2379
+ inject: ['slots', 'locale'],
2380
+ apply(ctx) {
2381
+ ensureStyle(document)
2382
+ // locale 座随槽声明,语言切换经新 t 引用驱动重渲染;旧宿主无 locale 服务时由 cordis 门控整体未激活
2383
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh: MESSAGES_ZH, en: MESSAGES_EN }), 'usage-dash: dictionaries')
2384
+ ctx.slots.inject('settings.section', () =>
2385
+ ctx.slots.register(
2386
+ { name: 'settings.section', id: 'usage-dash', order: 45, label: () => ctx.locale.bind(LOCALE_NS)('nav'), locale: LOCALE_NS },
2387
+ UsageDashPanel,
2388
+ ))
2389
+ // 两段式接管官方 stats 格:宿主缺该插槽时注册抛错即禁用本功能
2390
+ try {
2391
+ ctx.slots.inject('conversation.composer.dock', () =>
2392
+ ctx.slots.register(
2393
+ { name: 'conversation.composer.dock', id: 'stats', order: 0, priority: STATS_SLOT_PRIORITY, locale: LOCALE_NS },
2394
+ StatsLineEnhanced,
2395
+ ))
2396
+ } catch (error) {
2397
+ console.warn('[usage-dash] 底部信息栏未注册(宿主无 conversation.composer.dock 插槽)', error)
2398
+ }
2399
+ // 注入点B:turnTail chain 条目,同款两段式与降级;旧宿主无该插槽仅告警禁用
2400
+ try {
2401
+ ctx.slots.inject('conversation.chat.turnTail', () =>
2402
+ ctx.slots.register(
2403
+ { name: 'conversation.chat.turnTail', select: selectTurnTokenUsage, priority: TURN_TAIL_PRIORITY, locale: LOCALE_NS },
2404
+ CostTail,
2405
+ ))
2406
+ } catch (error) {
2407
+ console.warn('[usage-dash] 会话尾部用量行未注册(宿主无 conversation.chat.turnTail 插槽)', error)
2408
+ }
2409
+ // 跨实例同步:其他实例写开关经 storage 事件触发重读(同实例写入不触发该事件)
2410
+ window.addEventListener('storage', (event) => {
2411
+ if (event.key === STATS_LINE_STORAGE_KEY) statsLineState.reload()
2412
+ })
2413
+ },
2414
+ }
2415
+ }
2416
+ }