@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/README.md +49 -0
- package/cordis.patch.yml +9 -0
- package/package.json +46 -0
- package/src/client.js +2416 -0
- package/src/collector.js +327 -0
- package/src/index.js +104 -0
- package/src/pricing.js +119 -0
- package/src/query.js +193 -0
- package/src/routes.js +323 -0
- package/src/store.js +333 -0
- package/test/client.test.mjs +614 -0
- package/test/collector.test.mjs +603 -0
- package/test/pricing-parity.test.mjs +231 -0
- package/test/pricing.test.mjs +285 -0
- package/test/query.test.mjs +427 -0
- package/test/routes.test.mjs +566 -0
- package/test/stats-line.test.mjs +407 -0
- package/test/store.test.mjs +397 -0
- package/test/switch-guard.test.mjs +32 -0
- package/test/turn-tail.test.mjs +120 -0
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
|
+
}
|