dsh-model-billing 0.1.1 → 0.2.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/client.js CHANGED
@@ -1,1524 +1,2270 @@
1
- window.__ModuleLoader__.load({
2
- id: 'dsh-model-billing',
3
- factory(require) {
4
- const React = require('react')
5
- const ReactDOM = require('react-dom')
6
- const h = React.createElement
7
- const {
8
- Fragment,
9
- useEffect,
10
- useId,
11
- useLayoutEffect,
12
- useMemo,
13
- useRef,
14
- useState,
15
- useSyncExternalStore,
16
- } = React
17
-
18
- // ---------------------------------------------------------------------
19
- // Locale
20
- // ---------------------------------------------------------------------
21
-
22
- const STRINGS = {
23
- zh: {
24
- nav: '模型与费用',
25
- title: '模型与费用',
26
- intro: '调整已配置模型的顺序,并填写每百万 tokens 的价格。金额根据会话日志估算所有模型;DeepSeek 使用内置峰谷价。',
27
- loading: '正在加载模型目录…',
28
- loadFailed: '模型目录加载失败',
29
- retry: '重试',
30
- modelsCount: '{count} 个模型',
31
- save: '保存',
32
- saving: '保存中…',
33
- saved: '已保存',
34
- unsaved: '有未保存的修改',
35
- undo: '撤销修改',
36
- orderHint: '用 ↑ / ↓ 调整顺序;顺序直接决定对话框右下角模型选择器中的排列,也用于费用明细。',
37
- noModels: '当前没有可用的已配置模型。',
38
- up: '上移',
39
- down: '下移',
40
- cacheHitPrice: '输入 · 缓存命中',
41
- cacheMissPrice: '输入 · 缓存未命中',
42
- outputPrice: '输出',
43
- perMillion: '元 / 百万 tokens',
44
- deepseekTag: 'DeepSeek',
45
- deepseekNote: '官方峰谷价只读;DeepSeek 已按事件发生时间自动计入估算。',
46
- officialPrice: '官方定价(只读)',
47
- peak: '高峰',
48
- offPeak: '空闲',
49
- deepseekSchedule: '高峰为北京时间周一至周五 9:00–12:00、14:00–18:00(法定节假日除外);其余时段为空闲。',
50
- pricePlaceholder: '未设置',
51
- priceUnset: '未定价',
52
- priceHitShort: '命中',
53
- priceMissShort: '未命中',
54
- priceOutShort: '输出',
55
- priceUnit: '元/M',
56
- invalidPrice: '价格必须是非负数字,或留空表示未设置。',
57
- saveFailed: '保存失败:{message}',
58
- savedAt: '已保存于 {time}',
59
- formula: '估算公式:缓存命中输入 × 命中价 +(未缓存输入 + 缓存写入)× 未命中价 + 输出 × 输出价。',
60
- formulaNote: '价格按每百万 tokens 计。缓存写入按缓存未命中价计算;未配置价格的模型不产生金额。',
61
- singlePrice: '价格',
62
- settingsOpen: '在设置中编辑',
63
- costEstimate: '费用估算',
64
- costDetails: '费用明细',
65
- close: '关闭',
66
- thisTurn: '本轮',
67
- sessionTotal: '本会话',
68
- cacheHit: '缓存命中率',
69
- tokens: '输入 / 输出',
70
- turn: '轮次',
71
- content: '内容',
72
- time: '时间',
73
- model: '使用模型',
74
- cost: '费用',
75
- byModel: '按模型',
76
- excludedShort: '不计入',
77
- partial: '部分未定价',
78
- partialShort: '部分未定价',
79
- partialNote: '有非 DeepSeek 调用尚未配置完整价格,合计只包含已定价部分。',
80
- estimateNote: '金额由会话日志中的 token 用量和缓存命中估算;DeepSeek 按事件时间使用内置峰谷价,实际账单以提供方为准。',
81
- turnModelTitle: '本轮实际调用的模型(多个模型按名称排序)',
82
- listSep: '、',
83
- turnCost: '费用',
84
- turnCostTitle: '根据本轮 token 用量、缓存命中与价格估算',
85
- chooseModel: '选择模型',
86
- loadingModels: '加载模型…',
87
- searchModel: '搜索模型',
88
- noMatch: '没有匹配的模型',
89
- pickerEmpty: '暂无可用模型',
90
- reasoningEffort: '推理强度',
91
- providerDefault: '提供方默认',
92
- switching: '切换中…',
93
- pickerRetry: '重新加载',
94
- pickerAria: '选择模型与推理强度',
95
- pickerUnavailable: '模型目录尚未就绪,请刷新页面',
96
- },
97
- en: {
98
- nav: 'Models & Cost',
99
- title: 'Models & Cost',
100
- intro: 'Reorder configured models and enter prices per million tokens. Amounts estimate every model from the session log; DeepSeek uses its built-in peak/off-peak prices.',
101
- loading: 'Loading the model catalog…',
102
- loadFailed: 'Could not load the model catalog',
103
- retry: 'Retry',
104
- modelsCount: '{count} models',
105
- save: 'Save',
106
- saving: 'Saving…',
107
- saved: 'Saved',
108
- unsaved: 'Unsaved changes',
109
- undo: 'Discard changes',
110
- orderHint: 'Use ↑ / ↓ to reorder. This order is used directly by the model picker at the bottom-right of the composer, and by the cost details.',
111
- noModels: 'No configured models are available.',
112
- up: 'Move up',
113
- down: 'Move down',
114
- cacheHitPrice: 'Input · cache hit',
115
- cacheMissPrice: 'Input · cache miss',
116
- outputPrice: 'Output',
117
- perMillion: 'CNY / million tokens',
118
- deepseekTag: 'DeepSeek',
119
- deepseekNote: 'Official peak/off-peak prices are read-only; DeepSeek is now billed automatically at the event time.',
120
- officialPrice: 'Official prices (read-only)',
121
- peak: 'Peak',
122
- offPeak: 'Off-peak',
123
- deepseekSchedule: 'Peak: Beijing time Mon–Fri 09:00–12:00 and 14:00–18:00, excluding Chinese statutory holidays; all other times are off-peak.',
124
- pricePlaceholder: 'Not set',
125
- priceUnset: 'Not priced',
126
- priceHitShort: 'hit',
127
- priceMissShort: 'miss',
128
- priceOutShort: 'out',
129
- priceUnit: 'CNY/M',
130
- invalidPrice: 'Prices must be non-negative numbers, or blank when unset.',
131
- saveFailed: 'Save failed: {message}',
132
- savedAt: 'Saved at {time}',
133
- formula: 'Estimate: cached input × hit price + (uncached input + cache write) × miss price + output × output price.',
134
- formulaNote: 'Prices are per million tokens. Cache writes use the cache-miss price; models without a full price set produce no amount.',
135
- singlePrice: 'Prices',
136
- settingsOpen: 'Edit in Settings',
137
- costEstimate: 'Cost estimate',
138
- costDetails: 'Cost details',
139
- close: 'Close',
140
- thisTurn: 'Turn',
141
- sessionTotal: 'Session',
142
- cacheHit: 'Cache hit rate',
143
- tokens: 'Input / output',
144
- turn: 'Turn',
145
- content: 'Content',
146
- time: 'Time',
147
- model: 'Model',
148
- cost: 'Cost',
149
- byModel: 'By model',
150
- excludedShort: 'Excluded',
151
- partial: 'partly unpriced',
152
- partialShort: 'partly unpriced',
153
- partialNote: 'Some non-DeepSeek calls do not have a complete price set; the total includes only priced calls.',
154
- estimateNote: 'Amounts are estimated from token usage and cache hits in the session log; DeepSeek uses built-in peak/off-peak prices at the event time, and the provider bill is authoritative.',
155
- turnModelTitle: 'Models actually called this turn (multiple models sorted by name)',
156
- listSep: ', ',
157
- turnCost: 'Cost',
158
- turnCostTitle: 'Estimated from this turn’s tokens, cache hits and prices',
159
- chooseModel: 'Select model',
160
- loadingModels: 'Loading models…',
161
- searchModel: 'Search models',
162
- noMatch: 'No matching model',
163
- pickerEmpty: 'No model available',
164
- reasoningEffort: 'Reasoning effort',
165
- providerDefault: 'Provider default',
166
- switching: 'Switching…',
167
- pickerRetry: 'Reload',
168
- pickerAria: 'Select model and reasoning effort',
169
- pickerUnavailable: 'The model directory is not ready; reload the page',
170
- },
171
- }
172
-
173
- const t = (key, values) => {
174
- const active = localeService?.getSnapshot?.().active ?? 'zh'
175
- const dict = active.toLowerCase().startsWith('en') ? STRINGS.en : STRINGS.zh
176
- const template = dict[key] ?? STRINGS.zh[key] ?? key
177
- return values === undefined
178
- ? template
179
- : template.replace(/\{(\w+)\}/g, (match, name) => values[name] === undefined ? match : String(values[name]))
180
- }
181
-
182
- let localeService
183
- let modelDirectories
184
- let modelDirectoryRevision = 0
185
- const modelDirectoryListeners = new Set()
186
-
187
- function publishModelDirectories(next) {
188
- if (next === undefined || next === modelDirectories) return
189
- modelDirectories = next
190
- modelDirectoryRevision += 1
191
- for (const listener of [...modelDirectoryListeners]) listener()
192
- }
193
-
194
- function subscribeModelDirectories(listener) {
195
- modelDirectoryListeners.add(listener)
196
- return () => modelDirectoryListeners.delete(listener)
197
- }
198
-
199
- function useModelDirectoriesRevision() {
200
- return useSyncExternalStore(
201
- subscribeModelDirectories,
202
- () => modelDirectoryRevision,
203
- () => 0,
204
- )
205
- }
206
-
207
- function subscribeLocale(listener) {
208
- return localeService?.subscribe?.(listener) ?? (() => {})
209
- }
210
-
211
- function localeRevision() {
212
- return localeService?.getSnapshot?.().revision ?? 0
213
- }
214
-
215
- function useT() {
216
- useSyncExternalStore(subscribeLocale, localeRevision, () => 0)
217
- return t
218
- }
219
-
220
- // ---------------------------------------------------------------------
221
- // Shared catalog state
222
- // ---------------------------------------------------------------------
223
-
224
- const catalog = {
225
- snap: { status: 'idle', data: null, error: null },
226
- listeners: new Set(),
227
- inflight: null,
228
- generation: 0,
229
- }
230
-
231
- async function requestJson(url, options) {
232
- const response = await fetch(url, {
233
- credentials: 'same-origin',
234
- cache: 'no-store',
235
- ...options,
236
- headers: {
237
- accept: 'application/json',
238
- ...(options?.headers ?? {}),
239
- },
240
- })
241
- let body
242
- try {
243
- body = await response.json()
244
- } catch {
245
- throw new Error(`HTTP ${response.status}`)
246
- }
247
- if (!response.ok || body?.ok === false) {
248
- throw new Error(body?.message ?? `HTTP ${response.status}`)
249
- }
250
- return body
251
- }
252
-
253
- function emitCatalog() {
254
- for (const listener of [...catalog.listeners]) listener()
255
- }
256
-
257
- function setCatalog(draft) {
258
- catalog.snap = { ...catalog.snap, ...draft }
259
- emitCatalog()
260
- }
261
-
262
- async function ensureCatalog(force = false) {
263
- if (!force && catalog.snap.status === 'ready') return catalog.snap.data
264
- if (catalog.inflight !== null && !force) return catalog.inflight
265
- catalog.generation += 1
266
- const generation = catalog.generation
267
- setCatalog({ status: 'loading', error: null })
268
- const operation = requestJson('/model-billing/catalog')
269
- .then((data) => {
270
- if (generation !== catalog.generation) return data
271
- setCatalog({ status: 'ready', data, error: null })
272
- return data
273
- })
274
- .catch((error) => {
275
- if (generation === catalog.generation) {
276
- setCatalog({ status: 'error', error: error instanceof Error ? error.message : String(error) })
277
- }
278
- return null
279
- })
280
- .finally(() => {
281
- if (catalog.inflight === operation) catalog.inflight = null
282
- })
283
- catalog.inflight = operation
284
- return operation
285
- }
286
-
287
- function subscribeCatalog(listener) {
288
- catalog.listeners.add(listener)
289
- if (catalog.snap.status === 'idle') void ensureCatalog()
290
- return () => catalog.listeners.delete(listener)
291
- }
292
-
293
- function catalogSnapshot() {
294
- return catalog.snap
295
- }
296
-
297
- function useCatalog() {
298
- return useSyncExternalStore(subscribeCatalog, catalogSnapshot, catalogSnapshot)
299
- }
300
-
301
- // ---------------------------------------------------------------------
302
- // Shared session usage state
303
- // ---------------------------------------------------------------------
304
-
305
- const usageSlots = new Map()
306
-
307
- function usageSlot(sessionId) {
308
- let slot = usageSlots.get(sessionId)
309
- if (slot === undefined) {
310
- slot = {
311
- snap: { status: 'idle', data: null, error: null },
312
- listeners: new Set(),
313
- inflight: null,
314
- generation: 0,
315
- revision: null,
316
- }
317
- usageSlots.set(sessionId, slot)
318
- }
319
- return slot
320
- }
321
-
322
- function emitUsage(slot) {
323
- for (const listener of [...slot.listeners]) listener()
324
- }
325
-
326
- function ensureUsage(sessionId, revision, force = false) {
327
- const slot = usageSlot(sessionId)
328
- if (!force && slot.revision === revision && slot.snap.status !== 'error') return
329
- if (slot.inflight !== null && slot.inflight === revision && !force) return
330
- slot.revision = revision
331
- slot.generation += 1
332
- const generation = slot.generation
333
- slot.snap = { ...slot.snap, status: 'loading', error: null }
334
- emitUsage(slot)
335
- const operation = requestJson(`/model-billing/usage?sessionId=${encodeURIComponent(sessionId)}`)
336
- .then((data) => {
337
- if (generation !== slot.generation) return
338
- slot.snap = { status: 'ready', data, error: null }
339
- emitUsage(slot)
340
- })
341
- .catch((error) => {
342
- if (generation !== slot.generation) return
343
- slot.snap = {
344
- status: 'error',
345
- data: slot.snap.data,
346
- error: error instanceof Error ? error.message : String(error),
347
- }
348
- emitUsage(slot)
349
- })
350
- .finally(() => {
351
- if (slot.inflight === operation) slot.inflight = null
352
- })
353
- slot.inflight = operation
354
- return operation
355
- }
356
-
357
- function useSessionUsage(sessionId, revision) {
358
- const slot = usageSlot(sessionId)
359
- const snap = useSyncExternalStore(
360
- (listener) => {
361
- slot.listeners.add(listener)
362
- return () => slot.listeners.delete(listener)
363
- },
364
- () => slot.snap,
365
- () => slot.snap,
366
- )
367
- useEffect(() => {
368
- if (sessionId !== undefined && revision !== null) ensureUsage(sessionId, revision)
369
- }, [sessionId, revision])
370
- return snap
371
- }
372
-
373
- function tokenUsageRevision(tokenUsage) {
374
- if (tokenUsage === undefined || tokenUsage === null) return 'none'
375
- return [
376
- tokenUsage.uncachedInputTokens ?? '',
377
- tokenUsage.outputTokens ?? '',
378
- tokenUsage.cacheReadTokens ?? '',
379
- tokenUsage.cacheWriteTokens ?? '',
380
- ].join(':')
381
- }
382
-
383
- // ---------------------------------------------------------------------
384
- // Formatting
385
- // ---------------------------------------------------------------------
386
-
387
- function formatMoney(value) {
388
- if (value === null || value === undefined || !Number.isFinite(value)) return '—'
389
- if (value === 0) return '¥0'
390
- const fixed = value.toFixed(2)
391
- return fixed === '0.00' ? '<¥0.01' : `¥${fixed}`
392
- }
393
-
394
- function formatTokens(value) {
395
- if (!Number.isFinite(value)) return '—'
396
- if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
397
- if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
398
- return String(Math.round(value))
399
- }
400
-
401
- function formatPercent(value) {
402
- return value === null || value === undefined ? '—' : `${(value * 100).toFixed(1)}%`
403
- }
404
-
405
- function formatPrice(value) {
406
- if (value === null || value === undefined) return '—'
407
- return String(Number(value.toFixed(6)))
408
- }
409
-
410
- function PriceLines({ pricing, translate }) {
411
- if (pricing?.builtin === true) {
412
- const line = (label, prices) =>
413
- `${label} ${translate('priceHitShort')} ${formatPrice(prices?.cacheHit)} · ${translate('priceMissShort')} ${formatPrice(prices?.cacheMiss)} · ${translate('priceOutShort')} ${formatPrice(prices?.output)} ${translate('priceUnit')}`
414
- return h('div', { className: 'mb-picker-prices' },
415
- h('div', { className: 'mb-picker-price-line' }, line(translate('peak'), pricing.peak)),
416
- h('div', { className: 'mb-picker-price-line' }, line(translate('offPeak'), pricing.offPeak)))
417
- }
418
- const hasAny = pricing !== null && pricing !== undefined
419
- && [pricing.cacheHit, pricing.cacheMiss, pricing.output].some((value) => typeof value === 'number')
420
- if (!hasAny) {
421
- return h('div', { className: 'mb-picker-prices' }, translate('priceUnset'))
422
- }
423
- return h('div', { className: 'mb-picker-prices' },
424
- h('div', { className: 'mb-picker-price-line' },
425
- `${translate('priceHitShort')} ${formatPrice(pricing.cacheHit)} · ${translate('priceMissShort')} ${formatPrice(pricing.cacheMiss)} · ${translate('priceOutShort')} ${formatPrice(pricing.output)} ${translate('priceUnit')}`))
426
- }
427
-
428
- function timeText(timestamp) {
429
- if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
430
- try {
431
- return new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
432
- } catch {
433
- return ''
434
- }
435
- }
436
-
437
- // ---------------------------------------------------------------------
438
- // Styles
439
- // ---------------------------------------------------------------------
440
-
441
- const CSS = `
442
- .mb-page { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; padding: 4px 0 28px; }
443
- .mb-page * { box-sizing: border-box; }
444
- .mb-title { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
445
- .mb-intro { margin: 0 0 14px; color: var(--dsw-alias-label-secondary); max-width: 760px; }
446
- .mb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 14px 0; }
447
- .mb-muted { color: var(--dsw-alias-label-secondary); }
448
- .mb-spacer { flex: 1; }
449
- .mb-btn { appearance: none; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 7px; height: 30px; padding: 0 12px; font: inherit; cursor: pointer; }
450
- .mb-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
451
- .mb-btn:disabled { opacity: .45; cursor: default; }
452
- .mb-btn-primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-base); font-weight: 600; }
453
- .mb-btn-primary:hover:not(:disabled) { background: var(--dsw-alias-brand-primary); opacity: .9; }
454
- .mb-btn-icon { width: 28px; height: 28px; padding: 0; font-size: 13px; }
455
- .mb-status { color: var(--dsw-alias-label-secondary); min-height: 18px; }
456
- .mb-status[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
457
- .mb-status[data-tone="success"] { color: var(--dsw-alias-state-success-primary); }
458
- .mb-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; max-width: 980px; }
459
- .mb-card { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 10px; padding: 12px 14px; }
460
- .mb-card-head { display: flex; align-items: flex-start; gap: 12px; }
461
- .mb-index { width: 26px; height: 26px; border-radius: 7px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); display: grid; place-items: center; font-variant-numeric: tabular-nums; flex: none; }
462
- .mb-name { font-weight: 600; font-size: 14px; }
463
- .mb-sub { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; }
464
- .mb-head-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
465
- .mb-tag { display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 500; vertical-align: 1px; }
466
- .mb-tag-excluded { color: var(--dsw-alias-state-warn-primary); }
467
- .mb-prices { display: grid; gap: 10px; margin-top: 12px; }
468
- .mb-price-group { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px 12px 12px; }
469
- .mb-price-group-title { display: flex; gap: 8px; align-items: baseline; margin-bottom: 8px; font-weight: 600; }
470
- .mb-price-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
471
- .mb-field { display: grid; gap: 4px; min-width: 0; }
472
- .mb-field-label { color: var(--dsw-alias-label-secondary); font-size: 11px; }
473
- .mb-input { width: 100%; height: 30px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); padding: 0 8px; font: inherit; font-variant-numeric: tabular-nums; }
474
- .mb-input:focus { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -1px; }
475
- .mb-note { margin: 8px 0 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
476
- .mb-warn { margin: 6px 0 0; color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
477
- .mb-empty { border: 1px dashed var(--dsw-alias-border-l2); border-radius: 10px; padding: 28px; text-align: center; color: var(--dsw-alias-label-secondary); }
478
- .mb-footer-note { max-width: 980px; margin-top: 14px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
479
- .mb-dock { align-self: center; display: flex; flex-direction: column; align-items: flex-start; color: var(--dsw-alias-label-tertiary); font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); }
480
- .mb-dock-trigger { appearance: none; border: 0; background: transparent; color: inherit; font: inherit; line-height: inherit; padding: 1px 8px; border-radius: var(--dsw-radius-sm); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; text-align: left; max-width: 100%; flex-wrap: nowrap; }
481
- .mb-dock-trigger:hover, .mb-dock-trigger[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
482
- .mb-dock-amount { color: var(--dsw-alias-label-secondary); font-weight: 500; font-variant-numeric: tabular-nums; }
483
- .mb-dock-sep { opacity: .55; }
484
- .mb-dock-icon { flex: none; width: 14px; height: 14px; }
485
- .mb-dock-caret { font-size: 10px; opacity: .7; }
486
- .mb-panel { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1)); border-radius: var(--dsw-radius-lg, 10px); padding: 10px 12px; color: var(--dsw-alias-label-primary); }
487
- .mb-panel-floating { position: fixed; z-index: 1200; max-width: calc(100vw - 24px); max-height: min(430px, calc(100vh - 96px)); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-scrollbar-bg-l2, var(--dsw-alias-border-l2)) transparent; box-shadow: var(--dsw-elevation-prominent); backdrop-filter: var(--dsw-menu-backdrop-filter, none); }
488
- .mb-panel-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
489
- .mb-panel-title { font-weight: 600; }
490
- .mb-panel-close { margin-left: auto; width: 24px; height: 24px; border: 0; border-radius: var(--dsw-radius-sm); background: 0 0; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 18px; line-height: 20px; cursor: pointer; }
491
- .mb-panel-close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
492
- .mb-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
493
- .mb-summary-item { background: var(--dsw-alias-bg-layer-2); border-radius: 7px; padding: 7px 9px; min-width: 0; }
494
- .mb-summary-key { color: var(--dsw-alias-label-secondary); font-size: 11px; }
495
- .mb-summary-value { font-variant-numeric: tabular-nums; font-weight: 600; overflow-wrap: anywhere; }
496
- .mb-table { width: 100%; border-collapse: collapse; font-size: 12px; }
497
- .mb-table th, .mb-table td { text-align: left; padding: 4px 5px; border-bottom: 1px solid var(--dsw-alias-border-l1); vertical-align: top; }
498
- .mb-table th { color: var(--dsw-alias-label-secondary); font-weight: 500; font-size: 11px; }
499
- .mb-table td.num, .mb-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
500
- .mb-table tr:last-child td { border-bottom: 0; }
501
- .mb-turn-table { table-layout: fixed; }
502
- .mb-turn-table th, .mb-turn-table td, .mb-turn-table th.num, .mb-turn-table td.num { text-align: left; }
503
- .mb-turn-table th:nth-child(1), .mb-turn-table td:nth-child(1) { width: 5%; }
504
- .mb-turn-table th:nth-child(2), .mb-turn-table td:nth-child(2) { width: 26.5%; }
505
- .mb-turn-table th:nth-child(3), .mb-turn-table td:nth-child(3) { width: 12.5%; }
506
- .mb-turn-table th:nth-child(4), .mb-turn-table td:nth-child(4) { width: 20%; }
507
- .mb-turn-table th:nth-child(5), .mb-turn-table td:nth-child(5) { width: 10%; }
508
- .mb-turn-table th:nth-child(6), .mb-turn-table td:nth-child(6) { width: 16%; }
509
- .mb-turn-table th:nth-child(7), .mb-turn-table td:nth-child(7) { width: 10%; }
510
- .mb-turn-preview, .mb-turn-model { color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
511
- .mb-turn-model { color: var(--dsw-alias-label-primary); }
512
- .mb-panel-note { margin: 9px 0 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
513
- .mb-turn { color: var(--dsw-alias-label-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
514
- .mb-turn strong { color: var(--dsw-alias-label-primary); font-weight: 600; }
515
- .mb-picker { position: relative; display: flex; min-width: 0; }
516
- .mb-picker-trigger { border-radius: var(--dsw-radius-sm); min-width: 0; max-width: min(360px, 45cqw); height: 28px; color: var(--dsw-alias-label-secondary); cursor: pointer; background: 0 0; border: none; outline: none; align-items: center; gap: 4px; padding: 0 4px 0 8px; font-size: 13px; font-weight: 400; line-height: 20px; display: flex; }
517
- .mb-picker-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
518
- .mb-picker-trigger:focus-visible { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
519
- .mb-picker-trigger:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary)); cursor: default; }
520
- .mb-picker-icon { display: var(--dsh-composer-model-icon-display, none); flex: none; }
521
- .mb-picker-name { color: inherit; text-overflow: ellipsis; white-space: nowrap; min-width: 0; overflow: hidden; display: var(--dsh-composer-model-text-display, block); }
522
- .mb-picker-trigger-effort { text-overflow: ellipsis; white-space: nowrap; min-width: 0; color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); flex-shrink: 1000; overflow: hidden; display: var(--dsh-composer-model-text-display, block); }
523
- .mb-picker-chevron { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); flex: none; transition: transform .12s; }
524
- .mb-picker-chevron-open { transform: rotate(180deg); }
525
- .mb-picker-menu { box-sizing: border-box; z-index: 1100; width: min(360px, calc(100vw - 32px)); max-width: min(360px, calc(100vw - 32px)); max-height: min(480px, calc(100vh - 96px)); box-shadow: var(--dsw-elevation-prominent); background: var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1)); backdrop-filter: var(--dsw-menu-backdrop-filter, none); color: var(--dsw-alias-label-primary); border: 0; border-radius: var(--dsw-radius-lg, 10px); flex-direction: column; padding: 10px; display: flex; position: fixed; overflow: hidden; }
526
- .mb-picker-search { box-sizing: border-box; border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); height: 32px; margin: 0 0 6px; padding: 0 9px; font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary); width: 100%; flex: none; }
527
- .mb-picker-search::placeholder { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); }
528
- .mb-picker-search:focus { border-color: var(--dsw-alias-state-business-primary); outline: none; }
529
- .mb-picker-list { min-height: 0; overflow-y: auto; padding: 0 2px; display: flex; flex-direction: column; }
530
- .mb-picker-group { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); background: var(--dsw-alias-menu-group-header-fill, var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1))); position: sticky; top: 0; z-index: 1; margin-top: 2px; padding: 10px 10px 6px; font-size: 11px; font-weight: 500; line-height: 16px; }
531
- .mb-picker-row { box-sizing: border-box; border-radius: var(--dsw-radius-md); width: auto; min-width: 100%; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: none; outline: none; align-items: center; gap: 8px; padding: 10px; font: inherit; display: flex; }
532
- .mb-picker-row + .mb-picker-row { margin-top: 2px; }
533
- .mb-picker-row:hover:not(:disabled), .mb-picker-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
534
- .mb-picker-row:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary)); cursor: default; }
535
- .mb-picker-option-copy { flex-direction: column; flex: 1; min-width: 0; gap: 4px; display: flex; }
536
- .mb-picker-model { color: inherit; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 400; line-height: 20px; overflow: hidden; }
537
- .mb-picker-prices { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); font-size: 11px; line-height: 17px; font-variant-numeric: tabular-nums; }
538
- .mb-picker-price-line { white-space: normal; overflow-wrap: anywhere; }
539
- .mb-picker-check { color: var(--dsw-alias-label-primary); flex: 0 0 14px; place-items: center; display: grid; font-size: 12px; }
540
- .mb-picker-check svg { width: 14px; height: 14px; }
541
- .mb-picker-empty { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); padding: 8px; font-size: 12px; line-height: 18px; }
542
- .mb-picker-divider { border-top: 1px solid var(--dsw-alias-border-l1); margin: 3px 0; }
543
- .mb-picker-efforts { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 2px 4px 4px; }
544
- .mb-picker-efforts-label { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); font-size: 11px; margin-right: 2px; }
545
- .mb-picker-chip { appearance: none; border: 0; background: 0 0; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; height: 24px; padding: 0 7px; border-radius: var(--dsw-radius-sm); cursor: pointer; }
546
- .mb-picker-chip:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
547
- .mb-picker-chip[aria-pressed="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
548
- .mb-picker-error { border-radius: var(--dsw-radius-md); background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); margin-bottom: 3px; padding: 6px 7px; font-size: 11px; line-height: 16px; }
549
- .mb-picker-retry { align-self: flex-start; margin: 0 4px 2px; }
550
- .mb-picker-status { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); padding: 4px 7px; font-size: 11px; line-height: 16px; }
551
- .mb-fatal { border: 1px solid var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); border-radius: 8px; padding: 12px; }
552
- @media (max-width: 720px) {
553
- .mb-price-grid { grid-template-columns: 1fr; }
554
- .mb-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
555
- .mb-head-actions { width: 100%; margin-left: 38px; justify-content: flex-start; }
556
- }
557
- `
558
-
559
- function Style() {
560
- return h('style', null, CSS)
561
- }
562
-
563
- // ---------------------------------------------------------------------
564
- // Settings section
565
- // ---------------------------------------------------------------------
566
-
567
- const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
568
- const PRICE_LABEL_KEYS = {
569
- cacheHit: 'cacheHitPrice',
570
- cacheMiss: 'cacheMissPrice',
571
- output: 'outputPrice',
572
- }
573
-
574
- function toDraftPricing(pricing) {
575
- if (pricing?.builtin === true) return { ...pricing, peak: { ...pricing.peak }, offPeak: { ...pricing.offPeak } }
576
- const source = pricing ?? {}
577
- return Object.fromEntries(PRICE_FIELDS.map((field) => [
578
- field,
579
- source[field] === null || source[field] === undefined ? '' : String(source[field]),
580
- ]))
581
- }
582
-
583
- function toDraft(models) {
584
- return models.map((model) => ({
585
- provider: model.provider,
586
- providerName: model.providerName,
587
- model: model.model,
588
- name: model.name,
589
- isDeepSeek: model.isDeepSeek,
590
- pricing: toDraftPricing(model.pricing),
591
- }))
592
- }
593
-
594
- function parsePriceInput(value) {
595
- if (typeof value === 'string' && value.trim() === '') return null
596
- const number = Number(value)
597
- if (!Number.isFinite(number) || number < 0 || number > 1e12) return undefined
598
- return Math.round(number * 1e8) / 1e8
599
- }
600
-
601
- function SettingsSection() {
602
- const translate = useT()
603
- const snap = useCatalog()
604
- const [draft, setDraft] = useState(null)
605
- const [dirty, setDirty] = useState(false)
606
- const [saving, setSaving] = useState(false)
607
- const [status, setStatus] = useState(null)
608
-
609
- useEffect(() => {
610
- if (snap.data === null) return
611
- setDraft(toDraft(snap.data.models))
612
- setDirty(false)
613
- }, [snap.data])
614
-
615
- const models = draft ?? []
616
- const update = (index, change) => {
617
- setDraft((current) => current === null ? current : current.map((entry, at) => at === index ? change(entry) : entry))
618
- setDirty(true)
619
- setStatus(null)
620
- }
621
-
622
- const updatePrice = (index, field, value) => {
623
- update(index, (entry) => ({
624
- ...entry,
625
- pricing: { ...entry.pricing, [field]: value },
626
- }))
627
- }
628
-
629
- const move = (index, delta) => {
630
- const target = index + delta
631
- if (target < 0 || target >= models.length) return
632
- setDraft((current) => {
633
- if (current === null) return current
634
- const next = [...current]
635
- const [item] = next.splice(index, 1)
636
- next.splice(target, 0, item)
637
- return next
638
- })
639
- setDirty(true)
640
- setStatus(null)
641
- }
642
-
643
- const reset = () => {
644
- if (snap.data !== null) setDraft(toDraft(snap.data.models))
645
- setDirty(false)
646
- setStatus(null)
647
- }
648
-
649
- const save = async () => {
650
- if (draft === null) return
651
- const payload = []
652
- for (const entry of draft) {
653
- if (entry.pricing.builtin === true) {
654
- payload.push({ provider: entry.provider, model: entry.model, pricing: {} })
655
- continue
656
- }
657
- const pricing = {}
658
- for (const field of PRICE_FIELDS) {
659
- const parsed = parsePriceInput(entry.pricing[field])
660
- if (parsed === undefined) {
661
- setStatus({ tone: 'error', text: translate('invalidPrice') })
662
- return
663
- }
664
- pricing[field] = parsed
665
- }
666
- payload.push({ provider: entry.provider, model: entry.model, pricing })
667
- }
668
- setSaving(true)
669
- setStatus(null)
670
- try {
671
- await requestJson('/model-billing/save', {
672
- method: 'POST',
673
- headers: { 'content-type': 'application/json' },
674
- body: JSON.stringify({ models: payload }),
675
- })
676
- setDirty(false)
677
- setStatus({ tone: 'success', text: translate('saved') })
678
- await ensureCatalog(true)
679
- } catch (error) {
680
- setStatus({ tone: 'error', text: translate('saveFailed', { message: error instanceof Error ? error.message : String(error) }) })
681
- } finally {
682
- setSaving(false)
683
- }
684
- }
685
-
686
- if (snap.data === null) {
687
- return h(Fragment, null,
688
- h(Style),
689
- h('section', { className: 'mb-page' },
690
- h('h2', { className: 'mb-title' }, translate('title')),
691
- snap.status === 'error'
692
- ? h('div', { className: 'mb-fatal' },
693
- h('div', null, translate('loadFailed')),
694
- h('p', { className: 'mb-note' }, snap.error),
695
- h('button', { type: 'button', className: 'mb-btn', onClick: () => void ensureCatalog(true) }, translate('retry')))
696
- : h('p', { className: 'mb-muted' }, translate('loading'))))
697
- }
698
-
699
- if (draft === null) {
700
- return h(Fragment, null,
701
- h(Style),
702
- h('section', { className: 'mb-page' },
703
- h('h2', { className: 'mb-title' }, translate('title')),
704
- h('p', { className: 'mb-muted' }, translate('loading'))))
705
- }
706
-
707
- if (models.length === 0) {
708
- return h(Fragment, null,
709
- h(Style),
710
- h('section', { className: 'mb-page' },
711
- h('h2', { className: 'mb-title' }, translate('title')),
712
- h('p', { className: 'mb-intro' }, translate('intro')),
713
- h('div', { className: 'mb-empty' }, translate('noModels'))))
714
- }
715
-
716
- return h(Fragment, null,
717
- h(Style),
718
- h('section', { className: 'mb-page' },
719
- h('h2', { className: 'mb-title' }, translate('title')),
720
- h('p', { className: 'mb-intro' }, translate('intro')),
721
- h('div', { className: 'mb-toolbar' },
722
- h('span', { className: 'mb-muted' }, translate('modelsCount', { count: models.length })),
723
- h('span', { className: 'mb-spacer' }),
724
- dirty ? h('span', { className: 'mb-muted' }, translate('unsaved')) : null,
725
- h('button', { type: 'button', className: 'mb-btn', disabled: !dirty || saving, onClick: reset }, translate('undo')),
726
- h('button', { type: 'button', className: 'mb-btn mb-btn-primary', disabled: !dirty || saving, onClick: () => void save() }, saving ? translate('saving') : translate('save'))),
727
- status === null ? null : h('div', { className: 'mb-status', 'data-tone': status.tone, role: 'status' }, status.text),
728
- h('p', { className: 'mb-note' }, translate('orderHint')),
729
- h('ul', { className: 'mb-list' },
730
- models.map((entry, index) => h(SettingsModelCard, {
731
- key: `${entry.provider}\u0000${entry.model}`,
732
- entry,
733
- index,
734
- count: models.length,
735
- translate,
736
- onMove: move,
737
- onUpdatePrice: updatePrice,
738
- }))),
739
- h('p', { className: 'mb-footer-note' }, translate('formula')),
740
- h('p', { className: 'mb-footer-note' }, translate('formulaNote'))))
741
- }
742
-
743
- function SettingsModelCard({ entry, index, count, translate, onMove, onUpdatePrice }) {
744
- const id = useId()
745
- const pricing = entry.pricing
746
- const builtin = pricing.builtin === true
747
- const fields = builtin ? null : PRICE_FIELDS.map((field) => {
748
- const inputId = `${id}-${field}`
749
- return h('label', { className: 'mb-field', htmlFor: inputId, key: field },
750
- h('span', { className: 'mb-field-label' }, translate(PRICE_LABEL_KEYS[field])),
751
- h('input', {
752
- id: inputId,
753
- className: 'mb-input',
754
- type: 'text',
755
- inputMode: 'decimal',
756
- autoComplete: 'off',
757
- placeholder: translate('pricePlaceholder'),
758
- value: pricing[field],
759
- onChange: (event) => onUpdatePrice(index, field, event.target.value),
760
- }))
761
- })
762
-
763
- return h('li', { className: 'mb-card' },
764
- h('div', { className: 'mb-card-head' },
765
- h('div', { className: 'mb-index' }, index + 1),
766
- h('div', { style: { minWidth: 0 } },
767
- h('div', { className: 'mb-name' },
768
- entry.name,
769
- entry.isDeepSeek ? h('span', { className: 'mb-tag' }, translate('deepseekTag')) : null),
770
- h('div', { className: 'mb-sub' }, `${entry.providerName} · ${entry.provider} / ${entry.model}`)),
771
- h('div', { className: 'mb-head-actions' },
772
- h('button', {
773
- type: 'button',
774
- className: 'mb-btn mb-btn-icon',
775
- disabled: index === 0,
776
- title: translate('up'),
777
- 'aria-label': translate('up'),
778
- onClick: () => onMove(index, -1),
779
- }, '↑'),
780
- h('button', {
781
- type: 'button',
782
- className: 'mb-btn mb-btn-icon',
783
- disabled: index === count - 1,
784
- title: translate('down'),
785
- 'aria-label': translate('down'),
786
- onClick: () => onMove(index, 1),
787
- }, '↓'))),
788
- entry.isDeepSeek ? h('p', { className: 'mb-note' }, translate('deepseekNote')) : null,
789
- h('div', { className: 'mb-prices' },
790
- h('div', { className: 'mb-price-group' },
791
- h('div', { className: 'mb-price-group-title' },
792
- h('span', null, builtin ? translate('officialPrice') : translate('singlePrice')),
793
- h('span', { className: 'mb-muted', style: { fontWeight: 400, fontSize: '11px' } }, translate('perMillion'))),
794
- builtin
795
- ? h(Fragment, null,
796
- h(PriceLines, { pricing, translate }),
797
- h('p', { className: 'mb-note' }, translate('deepseekSchedule')))
798
- : h('div', { className: 'mb-price-grid' }, fields))))
799
- }
800
-
801
- // ---------------------------------------------------------------------
802
- // Composer dock cost summary
803
- // ---------------------------------------------------------------------
804
-
805
- function CostDock({ sessionId, useProjection }) {
806
- const translate = useT()
807
- const tokenUsage = useProjection('tokenUsage')
808
- const revision = tokenUsageRevision(tokenUsage)
809
- const usage = useSessionUsage(sessionId, revision)
810
- const catalogSnap = useCatalog()
811
- const [open, setOpen] = useState(false)
812
- const [panelPos, setPanelPos] = useState(null)
813
- const triggerRef = useRef(null)
814
- const panelRef = useRef(null)
815
-
816
- useLayoutEffect(() => {
817
- if (!open) {
818
- setPanelPos(null)
819
- return undefined
820
- }
821
- const place = () => {
822
- const rect = triggerRef.current?.getBoundingClientRect()
823
- if (rect === undefined) return
824
- const width = Math.min(660, window.innerWidth - 24)
825
- const naturalLeft = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))
826
- // The dock sits near the right edge; shift the whole detail panel left so
827
- // the bottom-right whale widget cannot cover it.
828
- const left = Math.max(12, naturalLeft - 120)
829
- const spaceAbove = rect.top - 12
830
- const spaceBelow = window.innerHeight - rect.bottom - 12
831
- const above = spaceAbove > spaceBelow
832
- const maxHeight = Math.min(430, Math.max(180, above ? spaceAbove : spaceBelow))
833
- setPanelPos(above
834
- ? { left, width, bottom: window.innerHeight - rect.top + 8, maxHeight }
835
- : { left, width, top: rect.bottom + 8, maxHeight })
836
- }
837
- place()
838
- window.addEventListener('resize', place)
839
- window.addEventListener('scroll', place, true)
840
- return () => {
841
- window.removeEventListener('resize', place)
842
- window.removeEventListener('scroll', place, true)
843
- }
844
- }, [open])
845
-
846
- useEffect(() => {
847
- if (!open) return undefined
848
- const closeOutside = (event) => {
849
- if (triggerRef.current?.contains(event.target) === true) return
850
- if (panelRef.current?.contains(event.target) === true) return
851
- setOpen(false)
852
- }
853
- const closeOnEscape = (event) => {
854
- if (event.key !== 'Escape') return
855
- event.preventDefault()
856
- setOpen(false)
857
- triggerRef.current?.focus()
858
- }
859
- document.addEventListener('mousedown', closeOutside)
860
- document.addEventListener('keydown', closeOnEscape)
861
- return () => {
862
- document.removeEventListener('mousedown', closeOutside)
863
- document.removeEventListener('keydown', closeOnEscape)
864
- }
865
- }, [open])
866
-
867
- const data = usage.data
868
- if (data === null || data === undefined || data.totals?.hasBillable !== true) {
869
- return null
870
- }
871
- const totals = data.totals
872
- const last = [...(data.turns ?? [])].reverse().find((entry) => entry.hasBillable) ?? null
873
- const totalText = totals.costKnown
874
- ? `≈ ${formatMoney(totals.cost)}`
875
- : totals.pricedAttempts > 0 ? `≈ ${formatMoney(totals.cost)}*` : translate('pricePlaceholder')
876
- const lastText = last === null || last === undefined || !last.hasBillable
877
- ? null
878
- : last.costKnown ? `≈ ${formatMoney(last.cost)}` : last.pricedAttempts > 0 ? `≈ ${formatMoney(last.cost)}*` : translate('pricePlaceholder')
879
- const orderIndex = new Map((catalogSnap.data?.models ?? []).map((model) => [`${model.provider}\u0000${model.model}`, model.order]))
880
- const byModel = [...(data.byModel ?? [])].sort((left, right) => {
881
- const leftKey = left.provider === null ? '' : `${left.provider}\u0000${left.model}`
882
- const rightKey = right.provider === null ? '' : `${right.provider}\u0000${right.model}`
883
- return (orderIndex.get(leftKey) ?? Number.MAX_SAFE_INTEGER) - (orderIndex.get(rightKey) ?? Number.MAX_SAFE_INTEGER)
884
- })
885
-
886
- const modelNameOf = (model) => {
887
- const catalogRow = catalogSnap.data?.models?.find((entry) => entry.provider === model?.provider && entry.model === model?.model)
888
- return catalogRow?.name ?? model?.name ?? model?.model ?? '—'
889
- }
890
- const turnModelNames = (turn) => {
891
- const names = []
892
- for (const model of turn.models ?? []) {
893
- const name = modelNameOf(model)
894
- if (!names.includes(name)) names.push(name)
895
- }
896
- return names
897
- }
898
-
899
- const header = h('thead', null,
900
- h('tr', null,
901
- h('th', null, translate('turn')),
902
- h('th', null, translate('content')),
903
- h('th', null, translate('time')),
904
- h('th', null, translate('model')),
905
- h('th', { className: 'num' }, translate('cost')),
906
- h('th', { className: 'num' }, translate('tokens')),
907
- h('th', { className: 'num' }, translate('cacheHit'))))
908
-
909
- const turnRows = [...(data.turns ?? [])].reverse().slice(0, 20).map((turn) => {
910
- const costText = turn.costKnown
911
- ? `≈ ${formatMoney(turn.cost)}`
912
- : turn.pricedAttempts > 0 ? `≈ ${formatMoney(turn.cost)}*` : translate('pricePlaceholder')
913
- const modelNames = turnModelNames(turn)
914
- const modelText = modelNames.length === 0
915
- ? '—'
916
- : modelNames.length === 1 ? modelNames[0] : `${modelNames[0]} +${modelNames.length - 1}`
917
- return h('tr', { key: turn.turn },
918
- h('td', null, `#${turn.turn}`),
919
- h('td', { className: 'mb-turn-preview', title: turn.preview }, turn.preview || '—'),
920
- h('td', null, `${timeText(turn.startTime)}–${timeText(turn.endTime)}`),
921
- h('td', { className: 'mb-turn-model', title: modelNames.join(' / ') }, modelText),
922
- h('td', { className: 'num' }, costText),
923
- h('td', { className: 'num' }, `${formatTokens(turn.promptTokens)} / ${formatTokens(turn.outputTokens)}`),
924
- h('td', { className: 'num' }, formatPercent(turn.cacheHit)))
925
- })
926
-
927
- const turnTable = h('div', { style: { overflowX: 'auto' } },
928
- h('table', { className: 'mb-table mb-turn-table' }, header, h('tbody', null, turnRows)))
929
-
930
- const modelRows = byModel.map((model) => {
931
- const catalogRow = catalogSnap.data?.models?.find((entry) => entry.provider === model.provider && entry.model === model.model)
932
- const modelName = catalogRow === undefined ? model.name : `${catalogRow.providerName} / ${catalogRow.name}`
933
- return h('tr', { key: `${model.provider}\u0000${model.model}\u0000${model.name}` },
934
- h('td', null,
935
- modelName,
936
- model.isDeepSeek ? h('span', { className: 'mb-tag' }, translate('deepseekTag')) : null),
937
- h('td', { className: 'num' },
938
- model.pricedAttempts > 0 ? `≈ ${formatMoney(model.cost)}` : translate('pricePlaceholder')),
939
- h('td', { className: 'num', style: { color: 'var(--dsw-alias-label-secondary)' } },
940
- `${formatTokens(model.promptTokens)} / ${formatTokens(model.outputTokens)}`))
941
- })
942
-
943
- const modelTable = byModel.length === 0
944
- ? null
945
- : h('div', { style: { marginTop: '10px' } },
946
- h('div', { className: 'mb-panel-title', style: { fontSize: '12px', marginBottom: '4px' } }, translate('byModel')),
947
- h('div', { style: { overflowX: 'auto' } },
948
- h('table', { className: 'mb-table' }, h('tbody', null, modelRows))))
949
-
950
- const panelStyle = panelPos === null
951
- ? { left: 0, top: 0, width: 660, visibility: 'hidden' }
952
- : {
953
- left: panelPos.left,
954
- width: panelPos.width,
955
- maxHeight: panelPos.maxHeight,
956
- ...panelPos.top === undefined ? { bottom: panelPos.bottom } : { top: panelPos.top },
957
- }
958
-
959
- const panel = h('div', {
960
- ref: panelRef,
961
- className: 'mb-panel mb-panel-floating',
962
- style: panelStyle,
963
- role: 'dialog',
964
- 'aria-label': translate('costDetails'),
965
- tabIndex: -1,
966
- },
967
- h('div', { className: 'mb-panel-head' },
968
- h('span', { className: 'mb-panel-title' }, translate('costDetails')),
969
- h('span', { className: 'mb-muted' }, translate('sessionTotal')),
970
- usage.status === 'error'
971
- ? h('button', {
972
- type: 'button',
973
- className: 'mb-btn',
974
- onClick: () => void ensureUsage(sessionId, revision, true),
975
- }, translate('retry'))
976
- : null,
977
- h('button', {
978
- type: 'button',
979
- className: 'mb-panel-close',
980
- 'aria-label': translate('close'),
981
- title: translate('close'),
982
- onClick: () => {
983
- setOpen(false)
984
- triggerRef.current?.focus()
985
- },
986
- }, '×')),
987
- h('div', { className: 'mb-summary' },
988
- summaryItem(translate('sessionTotal'), totalText),
989
- summaryItem(translate('thisTurn'), lastText ?? '—'),
990
- summaryItem(translate('cacheHit'), formatPercent(totals.cacheHit)),
991
- summaryItem(translate('tokens'), `${formatTokens(totals.promptTokens)} / ${formatTokens(totals.outputTokens)}`)),
992
- turnTable,
993
- modelTable,
994
- h('p', { className: 'mb-panel-note' }, translate('estimateNote')),
995
- totals.partial ? h('p', { className: 'mb-warn' }, translate('partialNote')) : null)
996
-
997
- return h('div', { className: 'mb-dock' },
998
- h(Style),
999
- h('button', {
1000
- ref: triggerRef,
1001
- type: 'button',
1002
- className: 'mb-dock-trigger',
1003
- 'aria-expanded': open ? 'true' : 'false',
1004
- 'aria-haspopup': 'dialog',
1005
- onClick: () => setOpen((value) => !value),
1006
- title: translate('costEstimate'),
1007
- },
1008
- h('svg', {
1009
- className: 'mb-dock-icon',
1010
- viewBox: '0 0 16 16',
1011
- fill: 'none',
1012
- stroke: 'currentColor',
1013
- strokeWidth: 1.2,
1014
- 'aria-hidden': true,
1015
- },
1016
- h('circle', { cx: 8, cy: 8, r: 5.5 }),
1017
- h('path', { d: 'M8 5v6M6.2 6.8h3.6M6.2 9.2h3.6' })),
1018
- h('span', null, translate('costEstimate')),
1019
- h('span', { className: 'mb-dock-amount' }, totalText),
1020
- lastText === null ? null : h(Fragment, null,
1021
- h('span', { className: 'mb-dock-sep' }, '·'),
1022
- h('span', null, `${translate('thisTurn')} ${lastText}`)),
1023
- totals.partial ? h('span', { className: 'mb-warn', style: { margin: 0 } }, translate('partial')) : null),
1024
- open ? ReactDOM.createPortal(panel, document.body) : null)
1025
- }
1026
-
1027
- function summaryItem(label, value) {
1028
- return h('div', { className: 'mb-summary-item', key: label },
1029
- h('div', { className: 'mb-summary-key' }, label),
1030
- h('div', { className: 'mb-summary-value' }, value))
1031
- }
1032
-
1033
- // ---------------------------------------------------------------------
1034
- // Per-turn cost chip
1035
- // ---------------------------------------------------------------------
1036
-
1037
- function turnModelText(models, translate) {
1038
- const names = (models ?? []).map((entry) => {
1039
- if (entry === undefined || entry === null) return null
1040
- if (typeof entry.name === 'string' && entry.name !== '') return entry.name
1041
- return entry.provider !== undefined && entry.model !== undefined
1042
- ? `${entry.provider} / ${entry.model}`
1043
- : null
1044
- }).filter((name) => name !== null)
1045
- if (names.length === 0) return null
1046
- return names.join(translate('listSep'))
1047
- }
1048
-
1049
- function TurnCost(props) {
1050
- const { turn } = props
1051
- // The turnTail owner hands over the whole folded turn life cycle, not just its number.
1052
- const turnNumber = turn === null || turn === undefined
1053
- ? undefined
1054
- : typeof turn === 'number' ? turn : turn.turn
1055
- const sessionId = props.sessionId
1056
- const useProjection = props.useProjection
1057
- const translate = useT()
1058
- const tokenUsage = typeof useProjection === 'function' ? useProjection('tokenUsage') : undefined
1059
- const revision = tokenUsageRevision(tokenUsage)
1060
- const usage = useSessionUsage(sessionId, revision)
1061
- const rows = usage.data?.turns
1062
- const row = rows?.find((entry) => entry.turn === turnNumber)
1063
- if (row === undefined) return null
1064
- const costText = row.costKnown
1065
- ? `≈ ${formatMoney(row.cost)}`
1066
- : row.pricedAttempts > 0 ? `≈ ${formatMoney(row.cost)}*` : translate('pricePlaceholder')
1067
- const modelText = turnModelText(row.models, translate)
1068
- return h('span', {
1069
- className: 'mb-turn',
1070
- title: `${translate('turnModelTitle')}\n${translate('turnCostTitle')}`,
1071
- },
1072
- modelText === null ? null : h(Fragment, null, translate('model'), ' ', h('strong', null, modelText), ' · '),
1073
- translate('turnCost'), ' ', h('strong', null, costText),
1074
- row.cacheHit === null ? null : ` · ${translate('cacheHit')} ${formatPercent(row.cacheHit)}`,
1075
- row.partial ? ` · ${translate('partialShort')}` : '')
1076
- }
1077
-
1078
- // ---------------------------------------------------------------------
1079
- // Custom-ordered model picker in the composer seat
1080
- // ---------------------------------------------------------------------
1081
-
1082
- const MODEL_KEY_SEPARATOR = '\u0000'
1083
-
1084
- function OrderedModelPicker({ locked, sessionId, useProjection }) {
1085
- const translate = useT()
1086
- const catalogSnap = useCatalog()
1087
- const selectionProjection = useProjection('modelSelection')
1088
- const directoryRevision = useModelDirectoriesRevision()
1089
- const [directoryTick, setDirectoryTick] = useState(0)
1090
- const directory = useMemo(() => {
1091
- if (modelDirectories === undefined || sessionId === undefined) return null
1092
- try {
1093
- return modelDirectories.directoryFor(sessionId)
1094
- } catch {
1095
- return null
1096
- }
1097
- }, [sessionId, directoryRevision, directoryTick])
1098
-
1099
- const [state, setState] = useState(null)
1100
- const [open, setOpen] = useState(false)
1101
- const [query, setQuery] = useState('')
1102
- const [selecting, setSelecting] = useState(false)
1103
- const [failure, setFailure] = useState(null)
1104
- const [menuPos, setMenuPos] = useState(null)
1105
- const rootRef = useRef(null)
1106
- const triggerRef = useRef(null)
1107
- const menuRef = useRef(null)
1108
- const searchRef = useRef(null)
1109
- const id = useId()
1110
-
1111
- useEffect(() => {
1112
- if (directory === null) {
1113
- setState(null)
1114
- return undefined
1115
- }
1116
- const sync = () => setState(directory.store.getSnapshot())
1117
- sync()
1118
- const unsubscribe = directory.store.subscribe(sync)
1119
- directory.load().catch(() => {})
1120
- return unsubscribe
1121
- }, [directory])
1122
-
1123
- useEffect(() => {
1124
- if (directory !== null || directoryTick >= 20) return undefined
1125
- const timer = setTimeout(() => setDirectoryTick((value) => value + 1), 300)
1126
- return () => clearTimeout(timer)
1127
- }, [directory, directoryTick])
1128
-
1129
- useEffect(() => {
1130
- if (!open) return undefined
1131
- const closeOutside = (event) => {
1132
- if (rootRef.current?.contains(event.target) === true) return
1133
- if (menuRef.current?.contains(event.target) === true) return
1134
- setOpen(false)
1135
- }
1136
- document.addEventListener('mousedown', closeOutside)
1137
- return () => document.removeEventListener('mousedown', closeOutside)
1138
- }, [open])
1139
-
1140
- useEffect(() => {
1141
- if (!open) {
1142
- setQuery('')
1143
- return undefined
1144
- }
1145
- const closeOnEscape = (event) => {
1146
- if (event.key !== 'Escape') return
1147
- event.preventDefault()
1148
- setOpen(false)
1149
- queueMicrotask(() => triggerRef.current?.focus())
1150
- }
1151
- document.addEventListener('keydown', closeOnEscape)
1152
- return () => document.removeEventListener('keydown', closeOnEscape)
1153
- }, [open])
1154
-
1155
- useEffect(() => {
1156
- if (locked && open) setOpen(false)
1157
- }, [locked, open])
1158
-
1159
- const rank = useMemo(() => {
1160
- const map = new Map()
1161
- for (const model of catalogSnap.data?.models ?? []) {
1162
- map.set(`${model.provider}${MODEL_KEY_SEPARATOR}${model.model}`, model.order)
1163
- }
1164
- return map
1165
- }, [catalogSnap.data])
1166
-
1167
- const priceByKey = useMemo(() => {
1168
- const map = new Map()
1169
- for (const model of catalogSnap.data?.models ?? []) {
1170
- map.set(`${model.provider}${MODEL_KEY_SEPARATOR}${model.model}`, model.pricing)
1171
- }
1172
- return map
1173
- }, [catalogSnap.data])
1174
-
1175
- const groups = state?.groups
1176
- const catalogModels = catalogSnap.data?.models
1177
- const entries = useMemo(() => {
1178
- if (Array.isArray(groups) && groups.length > 0) {
1179
- const list = []
1180
- for (const [groupIndex, group] of groups.entries()) {
1181
- for (const [modelIndex, model] of group.models.entries()) {
1182
- list.push({ group, model, groupIndex, modelIndex })
1183
- }
1184
- }
1185
- list.sort((left, right) => {
1186
- const leftRank = rank.get(`${left.group.id}${MODEL_KEY_SEPARATOR}${left.model.id}`) ?? Number.MAX_SAFE_INTEGER
1187
- const rightRank = rank.get(`${right.group.id}${MODEL_KEY_SEPARATOR}${right.model.id}`) ?? Number.MAX_SAFE_INTEGER
1188
- return leftRank - rightRank || left.groupIndex - right.groupIndex || left.modelIndex - right.modelIndex
1189
- })
1190
- return list
1191
- }
1192
- return (catalogModels ?? []).map((model, index) => ({
1193
- group: { id: model.provider, name: model.providerName },
1194
- model: {
1195
- id: model.model,
1196
- name: model.name,
1197
- ...(model.reasoning === undefined ? {} : { reasoning: model.reasoning }),
1198
- },
1199
- groupIndex: model.order ?? index,
1200
- modelIndex: index,
1201
- }))
1202
- }, [groups, catalogModels, rank])
1203
-
1204
- const projected = selectionProjection?.next ?? selectionProjection?.lastUsed ?? null
1205
- const current = state?.current ?? projected
1206
- const currentEntry = current === null
1207
- ? undefined
1208
- : entries.find((entry) => entry.group.id === current.provider && entry.model.id === current.model)
1209
- const currentCatalog = current === null
1210
- ? undefined
1211
- : (catalogModels ?? []).find((model) => model.provider === current.provider && model.model === current.model)
1212
- const currentReasoning = currentEntry?.model.reasoning
1213
- const effectiveEffort = current?.reasoningEffort ?? currentReasoning?.defaultEffort
1214
- const effortLabel = current === null
1215
- ? undefined
1216
- : currentReasoning === undefined
1217
- ? state?.retainedEffort
1218
- : effectiveEffort === undefined
1219
- ? translate('providerDefault')
1220
- : currentReasoning.efforts.find((effort) => effort.id === effectiveEffort)?.name ?? effectiveEffort
1221
- const effortChoices = currentReasoning === undefined
1222
- ? []
1223
- : [
1224
- ...(currentReasoning.defaultEffort === undefined
1225
- ? [{ key: 'provider-default', effort: undefined, label: translate('providerDefault') }]
1226
- : []),
1227
- ...currentReasoning.efforts.map((effort) => ({
1228
- key: `effort:${effort.id}`,
1229
- effort: effort.id,
1230
- label: effort.name,
1231
- })),
1232
- ]
1233
-
1234
- const showSearch = entries.length > 4
1235
- const term = query.trim().toLowerCase()
1236
- const filtered = useMemo(() => {
1237
- if (term === '') return entries
1238
- return entries.filter((entry) => `${entry.model.name} ${entry.group.name}`.toLowerCase().includes(term))
1239
- }, [entries, term])
1240
-
1241
- useLayoutEffect(() => {
1242
- if (!open) {
1243
- setMenuPos(null)
1244
- return undefined
1245
- }
1246
- const place = () => {
1247
- const rect = triggerRef.current?.getBoundingClientRect()
1248
- if (rect === undefined) return
1249
- const width = Math.min(360, window.innerWidth - 24)
1250
- const left = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))
1251
- const spaceAbove = rect.top - 12
1252
- const spaceBelow = window.innerHeight - rect.bottom - 12
1253
- const openBelow = spaceBelow > spaceAbove
1254
- const maxHeight = Math.min(480, Math.max(160, openBelow ? spaceBelow : spaceAbove))
1255
- setMenuPos(openBelow
1256
- ? { left, top: rect.bottom + 8, width, maxHeight }
1257
- : { left, bottom: window.innerHeight - rect.top + 8, width, maxHeight })
1258
- }
1259
- place()
1260
- window.addEventListener('scroll', place, true)
1261
- window.addEventListener('resize', place)
1262
- return () => {
1263
- window.removeEventListener('scroll', place, true)
1264
- window.removeEventListener('resize', place)
1265
- }
1266
- }, [open, filtered.length, state?.status])
1267
-
1268
- useEffect(() => {
1269
- if (!open) return undefined
1270
- const timer = setTimeout(() => {
1271
- if (showSearch) {
1272
- searchRef.current?.focus()
1273
- return
1274
- }
1275
- const first = menuRef.current?.querySelector('.mb-picker-row:not(:disabled)')
1276
- if (first instanceof HTMLElement) first.focus()
1277
- }, 0)
1278
- return () => clearTimeout(timer)
1279
- }, [open, showSearch])
1280
-
1281
- const loading = catalogSnap.data === null && (state === null || state.status === 'loading')
1282
- const busy = selecting || state?.status === 'selecting'
1283
- const currentName = currentEntry?.model.name ?? currentCatalog?.name
1284
- const triggerModel = currentName ?? (loading ? translate('loadingModels') : translate('chooseModel'))
1285
- const triggerAria = effortLabel === undefined ? triggerModel : `${triggerModel} · ${effortLabel}`
1286
-
1287
- const close = (restoreFocus) => {
1288
- setOpen(false)
1289
- setQuery('')
1290
- setFailure(null)
1291
- if (restoreFocus) queueMicrotask(() => triggerRef.current?.focus())
1292
- }
1293
-
1294
- const submit = async (selection) => {
1295
- if (busy) return
1296
- setSelecting(true)
1297
- setFailure(null)
1298
- try {
1299
- const result = directory !== null
1300
- ? await directory.select(selection)
1301
- : await requestJson('/model-billing/select', {
1302
- method: 'POST',
1303
- headers: { 'content-type': 'application/json' },
1304
- body: JSON.stringify({ sessionId, ...selection }),
1305
- })
1306
- if (result?.ok === false) {
1307
- setFailure(result.error?.message ?? translate('pickerEmpty'))
1308
- return
1309
- }
1310
- close(true)
1311
- } catch (error) {
1312
- setFailure(error instanceof Error ? error.message : String(error))
1313
- } finally {
1314
- setSelecting(false)
1315
- }
1316
- }
1317
-
1318
- const chooseModel = (entry) => {
1319
- if (current?.provider === entry.group.id && current?.model === entry.model.id) {
1320
- close(true)
1321
- return
1322
- }
1323
- void submit({ provider: entry.group.id, model: entry.model.id })
1324
- }
1325
-
1326
- const chooseEffort = (effort) => {
1327
- if (current === null) return
1328
- if (effectiveEffort === effort) {
1329
- close(true)
1330
- return
1331
- }
1332
- void submit({
1333
- provider: current.provider,
1334
- model: current.model,
1335
- ...effort === undefined ? {} : { reasoningEffort: effort },
1336
- })
1337
- }
1338
-
1339
- const toggle = () => {
1340
- if (open) {
1341
- close(true)
1342
- return
1343
- }
1344
- setFailure(null)
1345
- setOpen(true)
1346
- if (directory !== null) directory.load().catch(() => {})
1347
- }
1348
-
1349
- const menuStyle = menuPos === null
1350
- ? { left: 0, top: 0, width: 360, visibility: 'hidden' }
1351
- : {
1352
- left: menuPos.left,
1353
- width: menuPos.width,
1354
- maxHeight: menuPos.maxHeight,
1355
- ...menuPos.top === undefined ? { bottom: menuPos.bottom } : { top: menuPos.top },
1356
- }
1357
- const errorText = failure ?? (state?.status === 'error' ? state.error : null)
1358
-
1359
- const menu = h('div', {
1360
- ref: menuRef,
1361
- id: `${id}-menu`,
1362
- className: 'mb-picker-menu',
1363
- style: menuStyle,
1364
- role: 'dialog',
1365
- 'aria-label': translate('pickerAria'),
1366
- },
1367
- showSearch ? h('input', {
1368
- ref: searchRef,
1369
- className: 'mb-picker-search',
1370
- type: 'search',
1371
- value: query,
1372
- placeholder: translate('searchModel'),
1373
- 'aria-label': translate('searchModel'),
1374
- onChange: (event) => setQuery(event.target.value),
1375
- onKeyDown: (event) => {
1376
- if (event.key !== 'Enter' || filtered.length === 0 || busy) return
1377
- event.preventDefault()
1378
- chooseModel(filtered[0])
1379
- },
1380
- }) : null,
1381
- h('div', { className: 'mb-picker-list', role: 'menu' },
1382
- filtered.length === 0
1383
- ? h('div', { className: 'mb-picker-empty' }, term === '' ? translate('pickerEmpty') : translate('noMatch'))
1384
- : filtered.map((entry, index) => {
1385
- const selected = current?.provider === entry.group.id && current?.model === entry.model.id
1386
- const previous = filtered[index - 1]
1387
- const showGroup = previous === undefined || previous.group.id !== entry.group.id
1388
- const price = priceByKey.get(`${entry.group.id}${MODEL_KEY_SEPARATOR}${entry.model.id}`)
1389
- return h(Fragment, { key: `${entry.group.id}${MODEL_KEY_SEPARATOR}${entry.model.id}` },
1390
- showGroup ? h('div', { className: 'mb-picker-group' }, entry.group.name) : null,
1391
- h('button', {
1392
- type: 'button',
1393
- role: 'menuitemradio',
1394
- className: 'mb-picker-row',
1395
- 'aria-checked': selected ? 'true' : 'false',
1396
- title: entry.model.name,
1397
- disabled: busy,
1398
- onClick: () => chooseModel(entry),
1399
- },
1400
- h('span', { className: 'mb-picker-option-copy' },
1401
- h('span', { className: 'mb-picker-model' }, entry.model.name),
1402
- h(PriceLines, { pricing: price, translate })),
1403
- h('span', { className: 'mb-picker-check' },
1404
- selected
1405
- ? h('svg', {
1406
- viewBox: '0 0 16 16',
1407
- width: 14,
1408
- height: 14,
1409
- fill: 'none',
1410
- stroke: 'currentColor',
1411
- strokeWidth: 1.7,
1412
- 'aria-hidden': true,
1413
- },
1414
- h('path', { d: 'M3.5 8.5l3 3 6-7' }))
1415
- : null)))
1416
- })),
1417
- effortChoices.length === 0 ? null : h(Fragment, null,
1418
- h('div', { className: 'mb-picker-divider' }),
1419
- h('div', { className: 'mb-picker-efforts' },
1420
- h('span', { className: 'mb-picker-efforts-label' }, translate('reasoningEffort')),
1421
- effortChoices.map((choice) => h('button', {
1422
- type: 'button',
1423
- key: choice.key,
1424
- className: 'mb-picker-chip',
1425
- 'aria-pressed': effectiveEffort === choice.effort ? 'true' : 'false',
1426
- disabled: busy,
1427
- onClick: () => chooseEffort(choice.effort),
1428
- }, choice.label)))),
1429
- busy ? h('div', { className: 'mb-picker-status' }, translate('switching')) : null,
1430
- errorText === null ? null : h('div', { className: 'mb-picker-error' }, errorText),
1431
- state?.status === 'error'
1432
- ? h('button', {
1433
- type: 'button',
1434
- className: 'mb-btn mb-picker-retry',
1435
- onClick: () => {
1436
- setFailure(null)
1437
- directory.load().catch(() => {})
1438
- },
1439
- }, translate('pickerRetry'))
1440
- : null)
1441
-
1442
- return h('div', {
1443
- className: 'mb-picker',
1444
- ref: rootRef,
1445
- },
1446
- h(Style),
1447
- h('button', {
1448
- ref: triggerRef,
1449
- type: 'button',
1450
- className: 'mb-picker-trigger',
1451
- disabled: locked,
1452
- 'aria-label': triggerAria,
1453
- 'aria-haspopup': 'menu',
1454
- 'aria-controls': open ? `${id}-menu` : undefined,
1455
- 'aria-expanded': open ? 'true' : 'false',
1456
- 'aria-busy': busy ? 'true' : undefined,
1457
- title: triggerAria,
1458
- onClick: toggle,
1459
- },
1460
- h('svg', {
1461
- viewBox: '0 0 16 16',
1462
- width: 14,
1463
- height: 14,
1464
- fill: 'none',
1465
- stroke: 'currentColor',
1466
- strokeWidth: 1.3,
1467
- 'aria-hidden': true,
1468
- className: 'mb-picker-icon',
1469
- },
1470
- h('rect', { x: 2, y: 3, width: 12, height: 10, rx: 2 }),
1471
- h('path', { d: 'M2 6.5h12M6 6.5V13' })),
1472
- h('span', { className: 'mb-picker-name' }, triggerModel),
1473
- effortLabel === undefined ? null : h('span', { className: 'mb-picker-trigger-effort' }, effortLabel),
1474
- h('svg', {
1475
- className: open ? 'mb-picker-chevron mb-picker-chevron-open' : 'mb-picker-chevron',
1476
- viewBox: '0 0 16 16',
1477
- width: 14,
1478
- height: 14,
1479
- fill: 'none',
1480
- stroke: 'currentColor',
1481
- strokeWidth: 1.5,
1482
- 'aria-hidden': true,
1483
- },
1484
- h('path', { d: 'M4 6l4 4 4-4' }))),
1485
- open ? ReactDOM.createPortal(menu, document.body) : null)
1486
- }
1487
-
1488
- return {
1489
- inject: ['slots', 'locale'],
1490
- apply(ctx) {
1491
- localeService = ctx.get('locale')
1492
- publishModelDirectories(ctx.get('modelDirectories'))
1493
- ctx.inject(['modelDirectories'], (scope) => {
1494
- publishModelDirectories(scope.modelDirectories)
1495
- scope.slots.inject('conversation.input.model', () => scope.slots.register({
1496
- name: 'conversation.input.model',
1497
- priority: -1,
1498
- }, OrderedModelPicker))
1499
- })
1500
-
1501
- void ensureCatalog()
1502
-
1503
- ctx.slots.inject('settings.section', () => ctx.slots.register({
1504
- name: 'settings.section',
1505
- id: 'model-billing',
1506
- order: 12,
1507
- label: () => t('nav'),
1508
- }, SettingsSection))
1509
-
1510
- ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
1511
- name: 'conversation.composer.dock',
1512
- id: 'model-billing-cost',
1513
- order: 2,
1514
- }, CostDock))
1515
-
1516
- ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
1517
- name: 'conversation.chat.turnTail',
1518
- id: 'model-billing-turn',
1519
- order: 30,
1520
- }, TurnCost))
1521
- },
1522
- }
1523
- },
1524
- })
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-model-billing',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const ReactDOM = require('react-dom')
6
+ const h = React.createElement
7
+ const {
8
+ Fragment,
9
+ useEffect,
10
+ useId,
11
+ useLayoutEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ useSyncExternalStore,
16
+ } = React
17
+
18
+ // ---------------------------------------------------------------------
19
+ // Locale
20
+ // ---------------------------------------------------------------------
21
+
22
+ const STRINGS = {
23
+ zh: {
24
+ nav: '模型与费用',
25
+ title: '模型与费用',
26
+ intro: '调整已配置模型的顺序,并填写每百万 tokens 的价格。金额根据会话日志估算所有模型;DeepSeek 使用内置峰谷价。',
27
+ loading: '正在加载模型目录…',
28
+ loadFailed: '模型目录加载失败',
29
+ retry: '重试',
30
+ modelsCount: '{count} 个模型',
31
+ save: '保存',
32
+ saving: '保存中…',
33
+ saved: '已保存',
34
+ unsaved: '有未保存的修改',
35
+ undo: '撤销修改',
36
+ orderHint: '用 ↑ / ↓ 调整顺序;顺序直接决定对话框右下角模型选择器中的排列,也用于费用估算。',
37
+ noModels: '当前没有可用的已配置模型。',
38
+ up: '上移',
39
+ down: '下移',
40
+ cacheHitPrice: '输入 · 缓存命中',
41
+ cacheMissPrice: '输入 · 缓存未命中',
42
+ outputPrice: '输出',
43
+ perMillion: '元 / 百万 tokens',
44
+ deepseekTag: 'DeepSeek',
45
+ deepseekNote: '默认使用官方峰谷价;填写完整的自定义价格后,将优先按自定义价格估算。',
46
+ officialPrice: '官方定价',
47
+ priceSourceOfficial: '官方价',
48
+ priceSourceCustom: '自定义',
49
+ customizePrice: '自定义价格',
50
+ useOfficialPrice: '恢复官方价',
51
+ customPriceNote: '自定义价格优先于官方峰谷价;三项留空并保存即可恢复官方价。',
52
+ peak: '高峰',
53
+ offPeak: '空闲',
54
+ deepseekSchedule: '高峰为北京时间周一至周五 9:00–12:00、14:00–18:00(法定节假日除外);其余时段为空闲。',
55
+ pricePlaceholder: '未设置',
56
+ priceUnset: '未定价',
57
+ priceHitShort: '命中',
58
+ priceMissShort: '未命中',
59
+ priceOutShort: '输出',
60
+ priceUnit: '元/M',
61
+ invalidPrice: '价格必须是非负数字,或留空表示未设置。',
62
+ saveFailed: '保存失败:{message}',
63
+ formula: '估算公式:缓存命中输入 × 命中价 +(未缓存输入 + 缓存写入)× 未命中价 + 输出 × 输出价。',
64
+ formulaNote: '价格按每百万 tokens 计。缓存写入按缓存未命中价计算;未配置价格的模型不产生金额。',
65
+ singlePrice: '价格',
66
+ settingsOpen: '在设置中编辑',
67
+ costEstimate: '费用估算',
68
+ costDetails: '输入框下方费用估算',
69
+ close: '关闭',
70
+ thisTurn: '本轮',
71
+ sessionTotal: '本会话',
72
+ cacheHit: '缓存命中率',
73
+ tokens: '输入 / 输出',
74
+ turn: '轮次',
75
+ content: '内容',
76
+ time: '时间',
77
+ model: '使用模型',
78
+ cost: '费用',
79
+ byModel: '按模型',
80
+ excludedShort: '不计入',
81
+ partial: '部分未定价',
82
+ partialShort: '部分未定价',
83
+ partialNote: '有非 DeepSeek 调用尚未配置完整价格,合计只包含已定价部分。',
84
+ showUnpricedNotice: '显示未定价提示',
85
+ unpricedRoutes: '未定价路由',
86
+ addPrice: '补充价格',
87
+ priceSaved: '价格已保存',
88
+ archiveSection: '历史价格',
89
+ archiveHint: '已移除模型的调用仍按这里保存的价格计价,不需要把它们加回模型列表。',
90
+ archiveEmpty: '暂无历史价格',
91
+ addArchive: '添加历史价格',
92
+ archiveProvider: '提供商',
93
+ archiveModel: '模型',
94
+ archiveConflict: '该路由仍在当前模型列表中,请直接编辑上方的模型价格。',
95
+ invalidRoute: '提供商和模型都不能为空。',
96
+ remove: '删除',
97
+ estimateNote: '金额由会话日志中的 token 用量和缓存命中估算;DeepSeek 按事件时间使用内置峰谷价,实际账单以提供方为准。',
98
+ listSep: '、',
99
+ turnCost: '费用',
100
+ turnTokens: '本轮 Token',
101
+ displaySection: '轮尾显示',
102
+ displayTurnTail: '启用轮尾显示',
103
+ displayDock: '显示输入框下方费用估算',
104
+ dockSection: '输入框下方费用估算',
105
+ dockHint: '控制输入框下方的费用条与费用估算弹层。',
106
+ displayHint: '控制每轮结束后在对话尾部显示的内容;修改后点“保存”生效。',
107
+ displayModels: '使用模型',
108
+ displayProvider: '模型提供商',
109
+ displayCost: '本轮费用',
110
+ displayTokens: 'Token 用量',
111
+ displayCacheHit: '缓存命中率',
112
+ displayLabels: '显示文字标签',
113
+ displayFontSize: '字号',
114
+ displayDensity: '间距',
115
+ displayWeight: '字重',
116
+ fontSizeSmall: '小',
117
+ fontSizeNormal: '标准',
118
+ fontSizeLarge: '大',
119
+ densityCompact: '紧凑',
120
+ densityComfortable: '舒适',
121
+ weightNormal: '常规',
122
+ weightBold: '加粗',
123
+ pickerSection: '模型选择器',
124
+ takeoverPicker: '接管模型选择器',
125
+ takeoverHint: '开启后使用本插件按自定义顺序排列的模型选择器;关闭后恢复 DSH 内置选择器。',
126
+ chooseModel: '选择模型',
127
+ loadingModels: '加载模型…',
128
+ searchModel: '搜索模型',
129
+ noMatch: '没有匹配的模型',
130
+ pickerEmpty: '暂无可用模型',
131
+ reasoningEffort: '推理强度',
132
+ providerDefault: '提供方默认',
133
+ switching: '切换中…',
134
+ pickerRetry: '重新加载',
135
+ pickerAria: '选择模型与推理强度',
136
+ pickerUnavailable: '模型目录尚未就绪,请刷新页面',
137
+ },
138
+ en: {
139
+ nav: 'Models & Cost',
140
+ title: 'Models & Cost',
141
+ intro: 'Reorder configured models and enter prices per million tokens. Amounts estimate every model from the session log; DeepSeek uses its built-in peak/off-peak prices.',
142
+ loading: 'Loading the model catalog…',
143
+ loadFailed: 'Could not load the model catalog',
144
+ retry: 'Retry',
145
+ modelsCount: '{count} models',
146
+ save: 'Save',
147
+ saving: 'Saving…',
148
+ saved: 'Saved',
149
+ unsaved: 'Unsaved changes',
150
+ undo: 'Discard changes',
151
+ orderHint: 'Use ↑ / ↓ to reorder. This order is used directly by the model picker at the bottom-right of the composer, and by the cost details.',
152
+ noModels: 'No configured models are available.',
153
+ up: 'Move up',
154
+ down: 'Move down',
155
+ cacheHitPrice: 'Input · cache hit',
156
+ cacheMissPrice: 'Input · cache miss',
157
+ outputPrice: 'Output',
158
+ perMillion: 'CNY / million tokens',
159
+ deepseekTag: 'DeepSeek',
160
+ deepseekNote: 'Official peak/off-peak prices are used by default; a complete custom price set takes precedence.',
161
+ officialPrice: 'Official prices',
162
+ priceSourceOfficial: 'Official',
163
+ priceSourceCustom: 'Custom',
164
+ customizePrice: 'Set custom prices',
165
+ useOfficialPrice: 'Restore official prices',
166
+ customPriceNote: 'Custom prices override the official peak/off-peak table. Leave all three blank and save to restore official prices.',
167
+ peak: 'Peak',
168
+ offPeak: 'Off-peak',
169
+ deepseekSchedule: 'Peak: Beijing time Mon–Fri 09:00–12:00 and 14:00–18:00, excluding Chinese statutory holidays; all other times are off-peak.',
170
+ pricePlaceholder: 'Not set',
171
+ priceUnset: 'Not priced',
172
+ priceHitShort: 'hit',
173
+ priceMissShort: 'miss',
174
+ priceOutShort: 'out',
175
+ priceUnit: 'CNY/M',
176
+ invalidPrice: 'Prices must be non-negative numbers, or blank when unset.',
177
+ saveFailed: 'Save failed: {message}',
178
+ formula: 'Estimate: cached input × hit price + (uncached input + cache write) × miss price + output × output price.',
179
+ formulaNote: 'Prices are per million tokens. Cache writes use the cache-miss price; models without a full price set produce no amount.',
180
+ singlePrice: 'Prices',
181
+ settingsOpen: 'Edit in Settings',
182
+ costEstimate: 'Cost estimate',
183
+ costDetails: 'Composer cost estimate',
184
+ close: 'Close',
185
+ thisTurn: 'Turn',
186
+ sessionTotal: 'Session',
187
+ cacheHit: 'Cache hit rate',
188
+ tokens: 'Input / output',
189
+ turn: 'Turn',
190
+ content: 'Content',
191
+ time: 'Time',
192
+ model: 'Model',
193
+ cost: 'Cost',
194
+ byModel: 'By model',
195
+ excludedShort: 'Excluded',
196
+ partial: 'partly unpriced',
197
+ partialShort: 'partly unpriced',
198
+ partialNote: 'Some non-DeepSeek calls do not have a complete price set; the total includes only priced calls.',
199
+ showUnpricedNotice: 'Show unpriced notices',
200
+ unpricedRoutes: 'Unpriced routes',
201
+ addPrice: 'Add price',
202
+ priceSaved: 'Price saved',
203
+ archiveSection: 'Historical prices',
204
+ archiveHint: 'Calls to removed models stay priced from this book; you do not need to add them back to the model list.',
205
+ archiveEmpty: 'No historical prices yet',
206
+ addArchive: 'Add historical price',
207
+ archiveProvider: 'Provider',
208
+ archiveModel: 'Model',
209
+ archiveConflict: 'This route is still in the configured list; edit its price above instead.',
210
+ invalidRoute: 'Provider and model are both required.',
211
+ remove: 'Remove',
212
+ estimateNote: 'Amounts are estimated from token usage and cache hits in the session log; DeepSeek uses built-in peak/off-peak prices at the event time, and the provider bill is authoritative.',
213
+ listSep: ', ',
214
+ turnCost: 'Cost',
215
+ turnTokens: 'Turn tokens',
216
+ displaySection: 'Turn-tail display',
217
+ displayTurnTail: 'Show the turn tail',
218
+ displayDock: 'Show the composer cost estimate',
219
+ dockSection: 'Composer cost estimate',
220
+ dockHint: 'Controls the cost bar below the composer and its detail panel.',
221
+ displayHint: 'Choose what appears below each completed turn; click Save to apply.',
222
+ displayModels: 'Models used',
223
+ displayProvider: 'Model provider',
224
+ displayCost: 'Turn cost',
225
+ displayTokens: 'Token usage',
226
+ displayCacheHit: 'Cache hit rate',
227
+ displayLabels: 'Show text labels',
228
+ displayFontSize: 'Font size',
229
+ displayDensity: 'Spacing',
230
+ displayWeight: 'Font weight',
231
+ fontSizeSmall: 'Small',
232
+ fontSizeNormal: 'Normal',
233
+ fontSizeLarge: 'Large',
234
+ densityCompact: 'Compact',
235
+ densityComfortable: 'Comfortable',
236
+ weightNormal: 'Regular',
237
+ weightBold: 'Bold',
238
+ pickerSection: 'Model picker',
239
+ takeoverPicker: 'Take over the model picker',
240
+ takeoverHint: 'On: use this plugin’s picker with your custom model order. Off: restore the built-in DSH picker.',
241
+ chooseModel: 'Select model',
242
+ loadingModels: 'Loading models…',
243
+ searchModel: 'Search models',
244
+ noMatch: 'No matching model',
245
+ pickerEmpty: 'No model available',
246
+ reasoningEffort: 'Reasoning effort',
247
+ providerDefault: 'Provider default',
248
+ switching: 'Switching…',
249
+ pickerRetry: 'Reload',
250
+ pickerAria: 'Select model and reasoning effort',
251
+ pickerUnavailable: 'The model directory is not ready; reload the page',
252
+ },
253
+ }
254
+
255
+ const t = (key, values) => {
256
+ const active = localeService?.getSnapshot?.().active ?? 'zh'
257
+ const dict = active.toLowerCase().startsWith('en') ? STRINGS.en : STRINGS.zh
258
+ const template = dict[key] ?? STRINGS.zh[key] ?? key
259
+ return values === undefined
260
+ ? template
261
+ : template.replace(/\{(\w+)\}/g, (match, name) => values[name] === undefined ? match : String(values[name]))
262
+ }
263
+
264
+ let localeService
265
+ let modelDirectories
266
+ let modelDirectoryRevision = 0
267
+ const modelDirectoryListeners = new Set()
268
+
269
+ function publishModelDirectories(next) {
270
+ if (next === undefined || next === modelDirectories) return
271
+ modelDirectories = next
272
+ modelDirectoryRevision += 1
273
+ for (const listener of [...modelDirectoryListeners]) listener()
274
+ }
275
+
276
+ function subscribeModelDirectories(listener) {
277
+ modelDirectoryListeners.add(listener)
278
+ return () => modelDirectoryListeners.delete(listener)
279
+ }
280
+
281
+ function useModelDirectoriesRevision() {
282
+ return useSyncExternalStore(
283
+ subscribeModelDirectories,
284
+ () => modelDirectoryRevision,
285
+ () => 0,
286
+ )
287
+ }
288
+
289
+ function subscribeLocale(listener) {
290
+ return localeService?.subscribe?.(listener) ?? (() => {})
291
+ }
292
+
293
+ function localeRevision() {
294
+ return localeService?.getSnapshot?.().revision ?? 0
295
+ }
296
+
297
+ function useT() {
298
+ useSyncExternalStore(subscribeLocale, localeRevision, () => 0)
299
+ return t
300
+ }
301
+
302
+ const DISPLAY_DEFAULTS = Object.freeze({
303
+ showTurnTail: true,
304
+ showDock: true,
305
+ showModels: true,
306
+ showProvider: false,
307
+ showCost: true,
308
+ showTokens: false,
309
+ showCacheHit: false,
310
+ showLabels: true,
311
+ showUnpricedNotice: true,
312
+ takeoverPicker: true,
313
+ fontSize: 'normal',
314
+ density: 'comfortable',
315
+ fontWeight: 'normal',
316
+ })
317
+
318
+ const DISPLAY_STORAGE_KEY = 'dsh-model-billing.display'
319
+
320
+ function clearStoredDisplay() {
321
+ try {
322
+ localStorage.removeItem(DISPLAY_STORAGE_KEY)
323
+ } catch {
324
+ // Ignore unavailable storage.
325
+ }
326
+ }
327
+
328
+ // Older builds mirrored display settings into localStorage, which made the
329
+ // browser a second source of truth that could never sync across devices.
330
+ // Clear the legacy key once; the Host state is now the only copy.
331
+ clearStoredDisplay()
332
+
333
+ function readDisplay(data) {
334
+ const source = data?.display
335
+ return source !== null && typeof source === 'object' && !Array.isArray(source)
336
+ ? { ...DISPLAY_DEFAULTS, ...source }
337
+ : { ...DISPLAY_DEFAULTS }
338
+ }
339
+
340
+ // ---------------------------------------------------------------------
341
+ // Shared catalog state
342
+ // ---------------------------------------------------------------------
343
+
344
+ const catalog = {
345
+ snap: { status: 'idle', data: null, error: null },
346
+ listeners: new Set(),
347
+ inflight: null,
348
+ generation: 0,
349
+ }
350
+
351
+ const REQUEST_TIMEOUT_MS = 8000
352
+
353
+ async function requestJson(url, options) {
354
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
355
+ const timeout = controller === null
356
+ ? null
357
+ : setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
358
+ let response
359
+ try {
360
+ response = await fetch(url, {
361
+ credentials: 'same-origin',
362
+ cache: 'no-store',
363
+ ...options,
364
+ signal: options?.signal ?? controller?.signal ?? null,
365
+ headers: {
366
+ accept: 'application/json',
367
+ ...(options?.headers ?? {}),
368
+ },
369
+ })
370
+ } catch (error) {
371
+ if (timeout !== null) clearTimeout(timeout)
372
+ throw controller !== null && error?.name === 'AbortError'
373
+ ? new Error(`timeout after ${REQUEST_TIMEOUT_MS}ms`)
374
+ : error
375
+ }
376
+ if (timeout !== null) clearTimeout(timeout)
377
+ let body
378
+ try {
379
+ body = await response.json()
380
+ } catch {
381
+ throw new Error(`HTTP ${response.status}`)
382
+ }
383
+ if (!response.ok || body?.ok === false) {
384
+ throw new Error(body?.message ?? `HTTP ${response.status}`)
385
+ }
386
+ return body
387
+ }
388
+
389
+ function emitCatalog() {
390
+ for (const listener of [...catalog.listeners]) listener()
391
+ }
392
+
393
+ function setCatalog(draft) {
394
+ catalog.snap = { ...catalog.snap, ...draft }
395
+ emitCatalog()
396
+ }
397
+
398
+ async function ensureCatalog(force = false) {
399
+ if (!force && catalog.snap.status === 'ready') return catalog.snap.data
400
+ if (catalog.inflight !== null && !force) return catalog.inflight
401
+ catalog.generation += 1
402
+ const generation = catalog.generation
403
+ setCatalog({ status: 'loading', error: null })
404
+ const operation = requestJson('/model-billing/catalog')
405
+ .then((data) => {
406
+ if (generation !== catalog.generation) return data
407
+ setCatalog({ status: 'ready', data, error: null })
408
+ return data
409
+ })
410
+ .catch((error) => {
411
+ if (generation === catalog.generation) {
412
+ setCatalog({ status: 'error', error: error instanceof Error ? error.message : String(error) })
413
+ }
414
+ return null
415
+ })
416
+ .finally(() => {
417
+ if (catalog.inflight === operation) catalog.inflight = null
418
+ })
419
+ catalog.inflight = operation
420
+ return operation
421
+ }
422
+
423
+ function subscribeCatalog(listener) {
424
+ catalog.listeners.add(listener)
425
+ if (catalog.snap.status === 'idle') void ensureCatalog()
426
+ return () => catalog.listeners.delete(listener)
427
+ }
428
+
429
+ function catalogSnapshot() {
430
+ return catalog.snap
431
+ }
432
+
433
+ function useCatalog() {
434
+ return useSyncExternalStore(subscribeCatalog, catalogSnapshot, catalogSnapshot)
435
+ }
436
+
437
+ // ---------------------------------------------------------------------
438
+ // Shared session usage state
439
+ // ---------------------------------------------------------------------
440
+
441
+ const usageSlots = new Map()
442
+ const USAGE_DEBOUNCE_MS = 300
443
+
444
+ function usageSlot(sessionId) {
445
+ let slot = usageSlots.get(sessionId)
446
+ if (slot === undefined) {
447
+ slot = {
448
+ snap: { status: 'idle', data: null, error: null },
449
+ listeners: new Set(),
450
+ inflight: null,
451
+ generation: 0,
452
+ revision: null,
453
+ pending: null,
454
+ refs: 0,
455
+ rowsByTurn: new Map(),
456
+ }
457
+ usageSlots.set(sessionId, slot)
458
+ }
459
+ slot.refs += 1
460
+ return slot
461
+ }
462
+
463
+ function releaseSlot(sessionId, slot) {
464
+ slot.refs = Math.max(0, slot.refs - 1)
465
+ if (slot.listeners.size > 0 || slot.refs > 0) return
466
+ if (slot.pending !== null) {
467
+ clearTimeout(slot.pending)
468
+ slot.pending = null
469
+ }
470
+ usageSlots.delete(sessionId)
471
+ }
472
+
473
+ function emitUsage(slot) {
474
+ for (const listener of [...slot.listeners]) listener()
475
+ }
476
+
477
+ function ensureUsage(sessionId, revision, force = false) {
478
+ const slot = usageSlot(sessionId)
479
+ if (!force && slot.revision === revision && slot.snap.status !== 'error') return
480
+ if (slot.inflight !== null && !force) return
481
+ slot.revision = revision
482
+ if (slot.pending !== null) clearTimeout(slot.pending)
483
+ // Collapse the token-counter updates a streaming reply produces into one
484
+ // trailing request instead of one request per token.
485
+ slot.pending = setTimeout(() => {
486
+ slot.pending = null
487
+ void fetchUsage(sessionId, revision)
488
+ }, USAGE_DEBOUNCE_MS)
489
+ }
490
+
491
+ function fetchUsage(sessionId, revision) {
492
+ const slot = usageSlots.get(sessionId)
493
+ if (slot === undefined) return
494
+ slot.generation += 1
495
+ const generation = slot.generation
496
+ slot.snap = { ...slot.snap, status: 'loading', error: null }
497
+ emitUsage(slot)
498
+ const operation = requestJson(`/model-billing/usage?sessionId=${encodeURIComponent(sessionId)}`)
499
+ .then((data) => {
500
+ if (generation !== slot.generation) return
501
+ // Index once per fetch rather than scanning the row list on every
502
+ // turn render.
503
+ slot.snap = { status: 'ready', data, rowsByTurn: indexRowsByTurn(data), error: null }
504
+ slot.rowsByTurn = slot.snap.rowsByTurn
505
+ emitUsage(slot)
506
+ })
507
+ .catch((error) => {
508
+ if (generation !== slot.generation) return
509
+ slot.snap = {
510
+ status: 'error',
511
+ data: slot.snap.data,
512
+ rowsByTurn: slot.snap.rowsByTurn,
513
+ error: error instanceof Error ? error.message : String(error),
514
+ }
515
+ emitUsage(slot)
516
+ })
517
+ .finally(() => {
518
+ if (slot.inflight === operation) slot.inflight = null
519
+ })
520
+ slot.inflight = operation
521
+ return operation
522
+ }
523
+
524
+ function indexRowsByTurn(data) {
525
+ const index = new Map()
526
+ if (Array.isArray(data?.usage?.turns)) {
527
+ for (const row of data.usage.turns) index.set(row.turn, row)
528
+ }
529
+ return index
530
+ }
531
+
532
+ function useSessionUsage(sessionId, revision) {
533
+ const slot = usageSlot(sessionId)
534
+ useEffect(() => () => releaseSlot(sessionId, slot), [sessionId, slot])
535
+ const snap = useSyncExternalStore(
536
+ (listener) => {
537
+ slot.listeners.add(listener)
538
+ return () => slot.listeners.delete(listener)
539
+ },
540
+ () => slot.snap,
541
+ () => slot.snap,
542
+ )
543
+ useEffect(() => {
544
+ if (sessionId !== undefined && revision !== null) ensureUsage(sessionId, revision)
545
+ }, [sessionId, revision])
546
+ return snap
547
+ }
548
+
549
+ function tokenUsageRevision(tokenUsage) {
550
+ if (tokenUsage === undefined || tokenUsage === null) return 'none'
551
+ return [
552
+ tokenUsage.uncachedInputTokens ?? '',
553
+ tokenUsage.outputTokens ?? '',
554
+ tokenUsage.cacheReadTokens ?? '',
555
+ tokenUsage.cacheWriteTokens ?? '',
556
+ ].join(':')
557
+ }
558
+
559
+ // ---------------------------------------------------------------------
560
+ // Formatting
561
+ // ---------------------------------------------------------------------
562
+
563
+ function formatMoney(value) {
564
+ if (value === null || value === undefined || !Number.isFinite(value)) return '—'
565
+ if (value === 0) return '¥0'
566
+ const fixed = value.toFixed(2)
567
+ return fixed === '0.00' ? '<¥0.01' : `¥${fixed}`
568
+ }
569
+
570
+ function formatTokens(value) {
571
+ if (!Number.isFinite(value)) return '—'
572
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
573
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
574
+ return String(Math.round(value))
575
+ }
576
+
577
+ function formatPercent(value) {
578
+ return value === null || value === undefined ? '—' : `${(value * 100).toFixed(1)}%`
579
+ }
580
+
581
+ function formatPrice(value) {
582
+ if (value === null || value === undefined) return '—'
583
+ return String(Number(value.toFixed(6)))
584
+ }
585
+
586
+ function PriceLines({ pricing, translate, showSource = true }) {
587
+ const source = showSource === false ? null : pricing?.source === 'official'
588
+ ? translate('priceSourceOfficial')
589
+ : pricing?.source === 'user' ? translate('priceSourceCustom') : null
590
+ const sourceLine = source === null ? null : h('div', { className: 'mb-picker-price-source' }, source)
591
+ if (pricing?.builtin === true) {
592
+ const line = (label, prices) =>
593
+ `${label} ${translate('priceHitShort')} ${formatPrice(prices?.cacheHit)} · ${translate('priceMissShort')} ${formatPrice(prices?.cacheMiss)} · ${translate('priceOutShort')} ${formatPrice(prices?.output)} ${translate('priceUnit')}`
594
+ return h('div', { className: 'mb-picker-prices' },
595
+ sourceLine,
596
+ h('div', { className: 'mb-picker-price-line' }, line(translate('peak'), pricing.peak)),
597
+ h('div', { className: 'mb-picker-price-line' }, line(translate('offPeak'), pricing.offPeak)))
598
+ }
599
+ const hasAny = pricing !== null && pricing !== undefined
600
+ && [pricing.cacheHit, pricing.cacheMiss, pricing.output].some((value) => typeof value === 'number')
601
+ if (!hasAny) {
602
+ return h('div', { className: 'mb-picker-prices' }, translate('priceUnset'))
603
+ }
604
+ return h('div', { className: 'mb-picker-prices' },
605
+ sourceLine,
606
+ h('div', { className: 'mb-picker-price-line' },
607
+ `${translate('priceHitShort')} ${formatPrice(pricing.cacheHit)} · ${translate('priceMissShort')} ${formatPrice(pricing.cacheMiss)} · ${translate('priceOutShort')} ${formatPrice(pricing.output)} ${translate('priceUnit')}`))
608
+ }
609
+
610
+ function timeText(timestamp) {
611
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
612
+ try {
613
+ return new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
614
+ } catch {
615
+ return ''
616
+ }
617
+ }
618
+
619
+ // ---------------------------------------------------------------------
620
+ // Styles
621
+ // ---------------------------------------------------------------------
622
+
623
+ const CSS = `
624
+ .mb-page { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; padding: 4px 0 28px; }
625
+ .mb-page * { box-sizing: border-box; }
626
+ .mb-title { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
627
+ .mb-intro { margin: 0 0 14px; color: var(--dsw-alias-label-secondary); max-width: 760px; }
628
+ .mb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 14px 0; }
629
+ .mb-muted { color: var(--dsw-alias-label-secondary); }
630
+ .mb-spacer { flex: 1; }
631
+ .mb-btn { appearance: none; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 7px; height: 30px; padding: 0 12px; font: inherit; cursor: pointer; }
632
+ .mb-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
633
+ .mb-btn:disabled { opacity: .45; cursor: default; }
634
+ .mb-btn-primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-base); font-weight: 600; }
635
+ .mb-btn-primary:hover:not(:disabled) { background: var(--dsw-alias-brand-primary); opacity: .9; }
636
+ .mb-btn-icon { width: 28px; height: 28px; padding: 0; font-size: 13px; }
637
+ .mb-status { color: var(--dsw-alias-label-secondary); min-height: 18px; }
638
+ .mb-status[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
639
+ .mb-status[data-tone="success"] { color: var(--dsw-alias-state-success-primary); }
640
+ .mb-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; max-width: 980px; }
641
+ .mb-card { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 10px; padding: 12px 14px; }
642
+ .mb-card-head { display: flex; align-items: flex-start; gap: 12px; }
643
+ .mb-index { width: 26px; height: 26px; border-radius: 7px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); display: grid; place-items: center; font-variant-numeric: tabular-nums; flex: none; }
644
+ .mb-name { font-weight: 600; font-size: 14px; }
645
+ .mb-sub { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; }
646
+ .mb-head-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
647
+ .mb-tag { display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 500; vertical-align: 1px; }
648
+ .mb-tag-excluded { color: var(--dsw-alias-state-warn-primary); }
649
+ .mb-prices { display: grid; gap: 10px; margin-top: 12px; }
650
+ .mb-price-group { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px 12px 12px; }
651
+ .mb-price-group-title { display: flex; gap: 8px; align-items: baseline; margin-bottom: 8px; font-weight: 600; }
652
+ .mb-price-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
653
+ .mb-card-actions { margin-top: 10px; display: flex; justify-content: flex-end; }
654
+ .mb-field { display: grid; gap: 4px; min-width: 0; }
655
+ .mb-field-label { color: var(--dsw-alias-label-secondary); font-size: 11px; }
656
+ .mb-input { width: 100%; height: 30px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); padding: 0 8px; font: inherit; font-variant-numeric: tabular-nums; }
657
+ .mb-input:focus { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -1px; }
658
+ .mb-note { margin: 8px 0 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
659
+ .mb-warn { margin: 6px 0 0; color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
660
+ .mb-empty { border: 1px dashed var(--dsw-alias-border-l2); border-radius: 10px; padding: 28px; text-align: center; color: var(--dsw-alias-label-secondary); }
661
+ .mb-footer-note { max-width: 980px; margin-top: 14px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
662
+ .mb-dock { align-self: center; display: flex; flex-direction: column; align-items: flex-start; color: var(--dsw-alias-label-tertiary); font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); }
663
+ .mb-dock-trigger { appearance: none; border: 0; background: transparent; color: inherit; font: inherit; line-height: inherit; padding: 1px 8px; border-radius: var(--dsw-radius-sm); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; text-align: left; max-width: 100%; flex-wrap: nowrap; }
664
+ .mb-dock-trigger:hover, .mb-dock-trigger[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
665
+ .mb-dock-amount { color: var(--dsw-alias-label-secondary); font-weight: 500; font-variant-numeric: tabular-nums; }
666
+ .mb-dock-sep { opacity: .55; }
667
+ .mb-dock-icon { flex: none; width: 14px; height: 14px; }
668
+ .mb-dock-caret { font-size: 10px; opacity: .7; }
669
+ .mb-panel { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1)); border-radius: var(--dsw-radius-lg, 10px); padding: 10px 12px; color: var(--dsw-alias-label-primary); }
670
+ .mb-panel-floating { position: fixed; z-index: 1200; max-width: calc(100vw - 24px); max-height: min(430px, calc(100vh - 96px)); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-scrollbar-bg-l2, var(--dsw-alias-border-l2)) transparent; box-shadow: var(--dsw-elevation-prominent); backdrop-filter: var(--dsw-menu-backdrop-filter, none); }
671
+ .mb-panel-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
672
+ .mb-panel-title { font-weight: 600; }
673
+ .mb-panel-close { margin-left: auto; width: 24px; height: 24px; border: 0; border-radius: var(--dsw-radius-sm); background: 0 0; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 18px; line-height: 20px; cursor: pointer; }
674
+ .mb-panel-close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
675
+ .mb-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
676
+ .mb-summary-item { background: var(--dsw-alias-bg-layer-2); border-radius: 7px; padding: 7px 9px; min-width: 0; }
677
+ .mb-summary-key { color: var(--dsw-alias-label-secondary); font-size: 11px; }
678
+ .mb-summary-value { font-variant-numeric: tabular-nums; font-weight: 600; overflow-wrap: anywhere; }
679
+ .mb-table { width: 100%; border-collapse: collapse; font-size: 12px; }
680
+ .mb-table th, .mb-table td { text-align: left; padding: 4px 5px; border-bottom: 1px solid var(--dsw-alias-border-l1); vertical-align: top; }
681
+ .mb-table th { color: var(--dsw-alias-label-secondary); font-weight: 500; font-size: 11px; }
682
+ .mb-table td.num, .mb-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
683
+ .mb-table tr:last-child td { border-bottom: 0; }
684
+ .mb-turn-table { table-layout: fixed; }
685
+ .mb-turn-table th, .mb-turn-table td, .mb-turn-table th.num, .mb-turn-table td.num { text-align: left; }
686
+ .mb-turn-table th:nth-child(1), .mb-turn-table td:nth-child(1) { width: 5%; }
687
+ .mb-turn-table th:nth-child(2), .mb-turn-table td:nth-child(2) { width: 25.5%; }
688
+ .mb-turn-table th:nth-child(3), .mb-turn-table td:nth-child(3) { width: 12.5%; }
689
+ .mb-turn-table th:nth-child(4), .mb-turn-table td:nth-child(4) { width: 21%; }
690
+ .mb-turn-table th:nth-child(5), .mb-turn-table td:nth-child(5) { width: 10%; }
691
+ .mb-turn-table th:nth-child(6), .mb-turn-table td:nth-child(6) { width: 16%; }
692
+ .mb-turn-table th:nth-child(7), .mb-turn-table td:nth-child(7) { width: 10%; }
693
+ .mb-turn-preview, .mb-turn-model { color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
694
+ .mb-turn-model { color: var(--dsw-alias-label-primary); }
695
+ .mb-panel-note { margin: 9px 0 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
696
+ .mb-turn { color: var(--dsw-alias-label-secondary); font-size: 12px; font-variant-numeric: tabular-nums; display: inline-flex; flex-wrap: wrap; align-items: baseline; column-gap: 10px; row-gap: 2px; max-width: 100%; }
697
+ .mb-turn[data-size="small"] { font-size: 11px; }
698
+ .mb-turn[data-size="large"] { font-size: 13px; }
699
+ .mb-turn[data-density="compact"] { column-gap: 6px; }
700
+ .mb-turn-item { white-space: nowrap; }
701
+ .mb-turn strong { color: inherit; font-weight: 600; }
702
+ .mb-turn[data-weight="normal"] { font-weight: 400; }
703
+ .mb-turn[data-weight="normal"] strong { font-weight: 400; }
704
+ .mb-turn[data-weight="bold"] { font-weight: 600; }
705
+ .mb-turn[data-weight="bold"] strong { font-weight: 600; }
706
+ .mb-turn-partial { color: var(--dsw-alias-state-warn-primary); }
707
+ .mb-display { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; padding: 12px 14px; margin: 10px 0 14px; background: var(--dsw-alias-bg-layer-2); }
708
+ .mb-display-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
709
+ .mb-display-title { font-weight: 600; }
710
+ .mb-display-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; }
711
+ .mb-check-grid { display: flex; flex-wrap: wrap; gap: 8px 18px; }
712
+ .mb-check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
713
+ .mb-check.is-disabled { opacity: .55; cursor: default; }
714
+ .mb-check input { width: 15px; height: 15px; accent-color: var(--dsw-alias-brand-primary); }
715
+ .mb-display-controls { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 12px; }
716
+ .mb-archive-list { display: flex; flex-direction: column; gap: 8px; }
717
+ .mb-archive-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
718
+ .mb-archive-row .mb-archive-provider { grid-column: 1; grid-row: 1; min-width: 0; }
719
+ .mb-archive-row .mb-archive-model { grid-column: 2; grid-row: 1; min-width: 0; }
720
+ .mb-archive-row > .mb-btn { grid-column: 3; grid-row: 1; }
721
+ .mb-archive-row .mb-archive-price { grid-row: 2; min-width: 0; }
722
+ .mb-route-editor { display: flex; flex-wrap: wrap; }
723
+ .mb-route-editor .mb-input { flex: 1 1 120px; min-width: 110px; }
724
+ .mb-route-editor .mb-btn { flex: 0 0 auto; }
725
+ .mb-archive-actions { display: flex; gap: 8px; margin-top: 10px; }
726
+ .mb-master { display: flex; align-items: center; gap: 10px; padding: 10px 0 4px; font-size: 15px; font-weight: 600; cursor: pointer; }
727
+ .mb-master input { width: 16px; height: 16px; flex: 0 0 auto; }
728
+ .mb-master-label { line-height: 1.4; }
729
+ .mb-master-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; font-weight: 400; }
730
+ .mb-subchecks { margin: 2px 0 0 26px; }
731
+ .mb-tag.is-muted { color: var(--dsw-alias-label-secondary); }
732
+ @media (max-width: 720px) {
733
+ .mb-subchecks { margin-left: 18px; }
734
+ }
735
+ .mb-select { height: 30px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); padding: 0 8px; font: inherit; }
736
+ .mb-select:focus { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -1px; }
737
+ .mb-picker { position: relative; display: flex; min-width: 0; }
738
+ .mb-picker-trigger { border-radius: var(--dsw-radius-sm); min-width: 0; max-width: min(360px, 45cqw); height: 28px; color: var(--dsw-alias-label-secondary); cursor: pointer; background: 0 0; border: none; outline: none; align-items: center; gap: 4px; padding: 0 4px 0 8px; font-size: 13px; font-weight: 400; line-height: 20px; display: flex; }
739
+ .mb-picker-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
740
+ .mb-picker-trigger:focus-visible { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
741
+ .mb-picker-trigger:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary)); cursor: default; }
742
+ .mb-picker-icon { display: var(--dsh-composer-model-icon-display, none); flex: none; }
743
+ .mb-picker-name { color: inherit; text-overflow: ellipsis; white-space: nowrap; min-width: 0; overflow: hidden; display: var(--dsh-composer-model-text-display, block); }
744
+ .mb-picker-trigger-effort { text-overflow: ellipsis; white-space: nowrap; min-width: 0; color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); flex-shrink: 1000; overflow: hidden; display: var(--dsh-composer-model-text-display, block); }
745
+ .mb-picker-chevron { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); flex: none; transition: transform .12s; }
746
+ .mb-picker-chevron-open { transform: rotate(180deg); }
747
+ .mb-picker-menu { box-sizing: border-box; z-index: 1100; width: min(360px, calc(100vw - 32px)); max-width: min(360px, calc(100vw - 32px)); max-height: min(480px, calc(100vh - 96px)); box-shadow: var(--dsw-elevation-prominent); background: var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1)); backdrop-filter: var(--dsw-menu-backdrop-filter, none); color: var(--dsw-alias-label-primary); border: 0; border-radius: var(--dsw-radius-lg, 10px); flex-direction: column; padding: 10px; display: flex; position: fixed; overflow: hidden; }
748
+ .mb-picker-search { box-sizing: border-box; border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); height: 32px; margin: 0 0 6px; padding: 0 9px; font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary); width: 100%; flex: none; }
749
+ .mb-picker-search::placeholder { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); }
750
+ .mb-picker-search:focus { border-color: var(--dsw-alias-state-business-primary); outline: none; }
751
+ .mb-picker-list { min-height: 0; overflow-y: auto; padding: 0 2px; display: flex; flex-direction: column; }
752
+ .mb-picker-group { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); background: var(--dsw-alias-menu-group-header-fill, var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1))); position: sticky; top: 0; z-index: 1; margin-top: 2px; padding: 10px 10px 6px; font-size: 11px; font-weight: 500; line-height: 16px; }
753
+ .mb-picker-row { box-sizing: border-box; border-radius: var(--dsw-radius-md); width: auto; min-width: 100%; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: none; outline: none; align-items: center; gap: 8px; padding: 10px; font: inherit; display: flex; }
754
+ .mb-picker-row + .mb-picker-row { margin-top: 2px; }
755
+ .mb-picker-row:hover:not(:disabled), .mb-picker-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
756
+ .mb-picker-row:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary)); cursor: default; }
757
+ .mb-picker-option-copy { flex-direction: column; flex: 1; min-width: 0; gap: 4px; display: flex; }
758
+ .mb-picker-model { color: inherit; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 400; line-height: 20px; overflow: hidden; }
759
+ .mb-picker-prices { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); font-size: 11px; line-height: 17px; font-variant-numeric: tabular-nums; }
760
+ .mb-picker-price-line { white-space: normal; overflow-wrap: anywhere; }
761
+ .mb-picker-price-source { margin-bottom: 2px; color: var(--dsw-alias-label-secondary); font-weight: 600; }
762
+ .mb-picker-check { color: var(--dsw-alias-label-primary); flex: 0 0 14px; place-items: center; display: grid; font-size: 12px; }
763
+ .mb-picker-check svg { width: 14px; height: 14px; }
764
+ .mb-picker-empty { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); padding: 8px; font-size: 12px; line-height: 18px; }
765
+ .mb-picker-divider { border-top: 1px solid var(--dsw-alias-border-l1); margin: 3px 0; }
766
+ .mb-picker-efforts { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 2px 4px 4px; }
767
+ .mb-picker-efforts-label { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); font-size: 11px; margin-right: 2px; }
768
+ .mb-picker-chip { appearance: none; border: 0; background: 0 0; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; height: 24px; padding: 0 7px; border-radius: var(--dsw-radius-sm); cursor: pointer; }
769
+ .mb-picker-chip:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
770
+ .mb-picker-chip[aria-pressed="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
771
+ .mb-picker-error { border-radius: var(--dsw-radius-md); background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); margin-bottom: 3px; padding: 6px 7px; font-size: 11px; line-height: 16px; }
772
+ .mb-picker-retry { align-self: flex-start; margin: 0 4px 2px; }
773
+ .mb-picker-status { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); padding: 4px 7px; font-size: 11px; line-height: 16px; }
774
+ .mb-fatal { border: 1px solid var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); border-radius: 8px; padding: 12px; }
775
+ @media (max-width: 720px) {
776
+ .mb-price-grid { grid-template-columns: 1fr; }
777
+ .mb-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
778
+ .mb-head-actions { width: 100%; margin-left: 38px; justify-content: flex-start; }
779
+ }
780
+ `
781
+
782
+ const STYLE_MARKER = 'data-dsh-model-billing'
783
+
784
+ // The stylesheet belongs to the plugin, not to any one surface, so inject it
785
+ // once into <head> instead of rendering a copy per settings page, dock,
786
+ // panel and picker.
787
+ function ensureStylesheet() {
788
+ if (typeof document === 'undefined') return
789
+ if (document.head.querySelector(`style[${STYLE_MARKER}]`) !== null) return
790
+ const style = document.createElement('style')
791
+ style.setAttribute(STYLE_MARKER, '')
792
+ style.textContent = CSS
793
+ document.head.appendChild(style)
794
+ }
795
+
796
+ function Style() {
797
+ ensureStylesheet()
798
+ return null
799
+ }
800
+
801
+ // ---------------------------------------------------------------------
802
+ // Settings section
803
+ // ---------------------------------------------------------------------
804
+
805
+ const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
806
+ const PRICE_LABEL_KEYS = {
807
+ cacheHit: 'cacheHitPrice',
808
+ cacheMiss: 'cacheMissPrice',
809
+ output: 'outputPrice',
810
+ }
811
+
812
+ function toDraftPricing(pricing) {
813
+ if (pricing?.builtin === true) return { ...pricing, peak: { ...pricing.peak }, offPeak: { ...pricing.offPeak } }
814
+ const source = pricing ?? {}
815
+ return Object.fromEntries(PRICE_FIELDS.map((field) => [
816
+ field,
817
+ source[field] === null || source[field] === undefined ? '' : String(source[field]),
818
+ ]))
819
+ }
820
+
821
+ function toDraft(models) {
822
+ return models.map((model) => ({
823
+ provider: model.provider,
824
+ providerName: model.providerName,
825
+ model: model.model,
826
+ name: model.name,
827
+ isDeepSeek: model.isDeepSeek,
828
+ pricing: toDraftPricing(model.pricing),
829
+ }))
830
+ }
831
+
832
+ function toDraftArchive(entries) {
833
+ return (entries ?? []).map((entry) => ({
834
+ provider: entry.provider,
835
+ model: entry.model,
836
+ pricing: toDraftPricing(entry.pricing),
837
+ }))
838
+ }
839
+
840
+ function parsePriceInput(value) {
841
+ if (typeof value === 'string' && value.trim() === '') return null
842
+ const number = Number(value)
843
+ if (!Number.isFinite(number) || number < 0 || number > 1e12) return undefined
844
+ return Math.round(number * 1e8) / 1e8
845
+ }
846
+
847
+ function SettingsSection() {
848
+ const translate = useT()
849
+ const snap = useCatalog()
850
+ const [draft, setDraft] = useState(null)
851
+ const [archive, setArchive] = useState(null)
852
+ const [display, setDisplay] = useState(null)
853
+ const [dirty, setDirty] = useState(false)
854
+ const [saving, setSaving] = useState(false)
855
+ const [status, setStatus] = useState(null)
856
+
857
+ useEffect(() => {
858
+ if (snap.data === null) return
859
+ setDraft(toDraft(snap.data.models))
860
+ setArchive(toDraftArchive(snap.data.pricingArchive))
861
+ setDisplay(readDisplay(snap.data))
862
+ setDirty(false)
863
+ }, [snap.data])
864
+
865
+ const models = draft ?? []
866
+ const archiveRows = archive ?? []
867
+ const update = (index, change) => {
868
+ setDraft((current) => current === null ? current : current.map((entry, at) => at === index ? change(entry) : entry))
869
+ setDirty(true)
870
+ setStatus(null)
871
+ }
872
+
873
+ const updatePrice = (index, field, value) => {
874
+ update(index, (entry) => ({
875
+ ...entry,
876
+ pricing: { ...entry.pricing, [field]: value },
877
+ }))
878
+ }
879
+
880
+ // "Custom prices" starts from blank fields; the user opts in explicitly.
881
+ const resetPricing = (index) => {
882
+ update(index, (entry) => ({
883
+ ...entry,
884
+ pricing: { cacheHit: '', cacheMiss: '', output: '' },
885
+ }))
886
+ }
887
+
888
+ // "Restore official prices" puts the built-in peak/off-peak card back
889
+ // immediately, so the user sees the official table before saving.
890
+ const restoreOfficialPricing = (index) => {
891
+ const entry = models[index]
892
+ if (entry === undefined) return
893
+ const catalogRow = (snap.data?.models ?? []).find((row) =>
894
+ row.provider === entry.provider && row.model === entry.model)
895
+ const official = catalogRow?.pricing?.official
896
+ ?? (catalogRow?.pricing?.builtin === true ? catalogRow.pricing : null)
897
+ const restored = official?.builtin === true
898
+ ? toDraftPricing(official)
899
+ : { cacheHit: '', cacheMiss: '', output: '' }
900
+ update(index, (current) => ({ ...current, pricing: restored }))
901
+ }
902
+
903
+ const move = (index, delta) => {
904
+ const target = index + delta
905
+ if (target < 0 || target >= models.length) return
906
+ setDraft((current) => {
907
+ if (current === null) return current
908
+ const next = [...current]
909
+ const [item] = next.splice(index, 1)
910
+ next.splice(target, 0, item)
911
+ return next
912
+ })
913
+ setDirty(true)
914
+ setStatus(null)
915
+ }
916
+
917
+ const updateArchive = (index, change) => {
918
+ setArchive((current) => current === null ? current : current.map((entry, at) => at === index ? change(entry) : entry))
919
+ setDirty(true)
920
+ setStatus(null)
921
+ }
922
+
923
+ const updateArchivePrice = (index, field, value) => {
924
+ updateArchive(index, (entry) => ({ ...entry, pricing: { ...entry.pricing, [field]: value } }))
925
+ }
926
+
927
+ const addArchive = () => {
928
+ setArchive((current) => [...(current ?? []), { provider: '', model: '', pricing: { cacheHit: '', cacheMiss: '', output: '' } }])
929
+ setDirty(true)
930
+ setStatus(null)
931
+ }
932
+
933
+ const removeArchive = (index) => {
934
+ setArchive((current) => current === null ? current : current.filter((entry, at) => at !== index))
935
+ setDirty(true)
936
+ setStatus(null)
937
+ }
938
+
939
+ const reset = () => {
940
+ emitCatalog()
941
+ if (snap.data !== null) {
942
+ setDraft(toDraft(snap.data.models))
943
+ setArchive(toDraftArchive(snap.data.pricingArchive))
944
+ setDisplay(readDisplay(snap.data))
945
+ }
946
+ setDirty(false)
947
+ setStatus(null)
948
+ }
949
+
950
+ const displayValue = display ?? readDisplay(snap.data)
951
+
952
+ const updateDisplay = (change) => {
953
+ setDisplay((current) => ({ ...(current ?? readDisplay(snap.data)), ...change }))
954
+ setDirty(true)
955
+ setStatus(null)
956
+ }
957
+
958
+ const masterToggle = (field, label, hint) => h('label', { className: 'mb-master' },
959
+ h('input', {
960
+ type: 'checkbox',
961
+ checked: displayValue[field] === true,
962
+ onChange: (event) => updateDisplay({ [field]: event.target.checked }),
963
+ }),
964
+ h('span', { className: 'mb-master-label' }, translate(label)),
965
+ hint === undefined ? null : h('span', { className: 'mb-master-hint' }, hint))
966
+
967
+ const renderDisplaySettings = () => h(Fragment, null,
968
+ h('div', { className: 'mb-display' },
969
+ h('div', { className: 'mb-display-head' },
970
+ h('div', { className: 'mb-display-title' }, translate('displaySection')),
971
+ h('div', { className: 'mb-display-hint' }, translate('displayHint'))),
972
+ masterToggle('showTurnTail', 'displayTurnTail'),
973
+ h('div', { className: 'mb-check-grid mb-subchecks' },
974
+ [
975
+ ['showProvider', 'displayProvider'],
976
+ ['showModels', 'displayModels'],
977
+ ['showCost', 'displayCost'],
978
+ ['showTokens', 'displayTokens'],
979
+ ['showCacheHit', 'displayCacheHit'],
980
+ ['showLabels', 'displayLabels'],
981
+ ].map(([field, label]) => {
982
+ const disabled = displayValue.showTurnTail !== true ||
983
+ (field === 'showProvider' && displayValue.showModels !== true)
984
+ return h('label', {
985
+ className: `mb-check${disabled ? ' is-disabled' : ''}`,
986
+ key: field,
987
+ },
988
+ h('input', {
989
+ type: 'checkbox',
990
+ checked: displayValue[field] === true,
991
+ disabled,
992
+ onChange: (event) => updateDisplay({ [field]: event.target.checked }),
993
+ }),
994
+ h('span', null, translate(label)))
995
+ })),
996
+ h('div', { className: 'mb-display-controls' },
997
+ h('label', { className: 'mb-field' },
998
+ h('span', { className: 'mb-field-label' }, translate('displayFontSize')),
999
+ h('select', {
1000
+ className: 'mb-select',
1001
+ value: displayValue.fontSize,
1002
+ onChange: (event) => updateDisplay({ fontSize: event.target.value }),
1003
+ },
1004
+ h('option', { value: 'small' }, translate('fontSizeSmall')),
1005
+ h('option', { value: 'normal' }, translate('fontSizeNormal')),
1006
+ h('option', { value: 'large' }, translate('fontSizeLarge')))),
1007
+ h('label', { className: 'mb-field' },
1008
+ h('span', { className: 'mb-field-label' }, translate('displayDensity')),
1009
+ h('select', {
1010
+ className: 'mb-select',
1011
+ value: displayValue.density,
1012
+ onChange: (event) => updateDisplay({ density: event.target.value }),
1013
+ },
1014
+ h('option', { value: 'compact' }, translate('densityCompact')),
1015
+ h('option', { value: 'comfortable' }, translate('densityComfortable')))),
1016
+ h('label', { className: 'mb-field' },
1017
+ h('span', { className: 'mb-field-label' }, translate('displayWeight')),
1018
+ h('select', {
1019
+ className: 'mb-select',
1020
+ value: displayValue.fontWeight,
1021
+ onChange: (event) => updateDisplay({ fontWeight: event.target.value }),
1022
+ },
1023
+ h('option', { value: 'normal' }, translate('weightNormal')),
1024
+ h('option', { value: 'bold' }, translate('weightBold')))))),
1025
+ h('div', { className: 'mb-display' },
1026
+ h('div', { className: 'mb-display-head' },
1027
+ h('div', { className: 'mb-display-title' }, translate('dockSection')),
1028
+ h('div', { className: 'mb-display-hint' }, translate('dockHint'))),
1029
+ masterToggle('showDock', 'displayDock'),
1030
+ h('div', { className: 'mb-check-grid mb-subchecks' },
1031
+ h('label', { className: `mb-check${displayValue.showDock === true ? '' : ' is-disabled'}` },
1032
+ h('input', {
1033
+ type: 'checkbox',
1034
+ checked: displayValue.showUnpricedNotice === true,
1035
+ disabled: displayValue.showDock !== true,
1036
+ onChange: (event) => updateDisplay({ showUnpricedNotice: event.target.checked }),
1037
+ }),
1038
+ h('span', null, translate('showUnpricedNotice'))))),
1039
+ h('div', { className: 'mb-display' },
1040
+ h('div', { className: 'mb-display-head' },
1041
+ h('div', { className: 'mb-display-title' }, translate('pickerSection')),
1042
+ h('div', { className: 'mb-display-hint' }, translate('takeoverHint'))),
1043
+ h('label', { className: 'mb-check' },
1044
+ h('input', {
1045
+ type: 'checkbox',
1046
+ checked: displayValue.takeoverPicker === true,
1047
+ onChange: (event) => updateDisplay({ takeoverPicker: event.target.checked }),
1048
+ }),
1049
+ h('span', null, translate('takeoverPicker')))))
1050
+
1051
+ const renderArchive = () => h('div', { className: 'mb-display' },
1052
+ h('div', { className: 'mb-display-head' },
1053
+ h('div', { className: 'mb-display-title' }, translate('archiveSection')),
1054
+ h('div', { className: 'mb-display-hint' }, translate('archiveHint'))),
1055
+ archiveRows.length === 0
1056
+ ? h('div', { className: 'mb-muted' }, translate('archiveEmpty'))
1057
+ : h('div', { className: 'mb-archive-list' },
1058
+ archiveRows.map((entry, index) => h('div', { className: 'mb-archive-row', key: index },
1059
+ h('input', {
1060
+ className: 'mb-input mb-archive-provider',
1061
+ placeholder: translate('archiveProvider'),
1062
+ value: entry.provider,
1063
+ onChange: (event) => updateArchive(index, (current) => ({ ...current, provider: event.target.value })),
1064
+ }),
1065
+ h('input', {
1066
+ className: 'mb-input mb-archive-model',
1067
+ placeholder: translate('archiveModel'),
1068
+ value: entry.model,
1069
+ onChange: (event) => updateArchive(index, (current) => ({ ...current, model: event.target.value })),
1070
+ }),
1071
+ ...PRICE_FIELDS.map((field) => h('input', {
1072
+ key: field,
1073
+ className: 'mb-input mb-archive-price',
1074
+ inputMode: 'decimal',
1075
+ placeholder: translate(PRICE_LABEL_KEYS[field]),
1076
+ 'aria-label': translate(PRICE_LABEL_KEYS[field]),
1077
+ value: entry.pricing[field] ?? '',
1078
+ onChange: (event) => updateArchivePrice(index, field, event.target.value),
1079
+ })),
1080
+ h('button', {
1081
+ type: 'button',
1082
+ className: 'mb-btn',
1083
+ title: translate('remove'),
1084
+ 'aria-label': translate('remove'),
1085
+ onClick: () => removeArchive(index),
1086
+ }, translate('remove'))))),
1087
+ h('div', { className: 'mb-archive-actions' },
1088
+ h('button', { type: 'button', className: 'mb-btn', onClick: addArchive }, translate('addArchive'))))
1089
+
1090
+ const save = async () => {
1091
+ if (draft === null) return
1092
+ const payload = []
1093
+ for (const entry of draft) {
1094
+ if (entry.pricing.builtin === true) {
1095
+ payload.push({ provider: entry.provider, model: entry.model, pricing: {} })
1096
+ continue
1097
+ }
1098
+ const pricing = {}
1099
+ const blank = PRICE_FIELDS.every((field) => String(entry.pricing[field] ?? '').trim() === '')
1100
+ if (blank) {
1101
+ // Three blank fields mean "drop the override", so the official table applies again.
1102
+ payload.push({ provider: entry.provider, model: entry.model, pricing: {} })
1103
+ continue
1104
+ }
1105
+ for (const field of PRICE_FIELDS) {
1106
+ const parsed = parsePriceInput(entry.pricing[field])
1107
+ if (parsed === undefined) {
1108
+ setStatus({ tone: 'error', text: translate('invalidPrice') })
1109
+ return
1110
+ }
1111
+ pricing[field] = parsed
1112
+ }
1113
+ payload.push({ provider: entry.provider, model: entry.model, pricing })
1114
+ }
1115
+ const archivePayload = []
1116
+ const currentKeys = new Set(draft.map((entry) => `${entry.provider}\u0000${entry.model}`))
1117
+ for (const entry of archiveRows) {
1118
+ const provider = entry.provider.trim()
1119
+ const model = entry.model.trim()
1120
+ if (provider === '' && model === '') continue
1121
+ if (provider === '' || model === '') {
1122
+ setStatus({ tone: 'error', text: translate('invalidRoute') })
1123
+ return
1124
+ }
1125
+ if (currentKeys.has(`${provider}\u0000${model}`)) {
1126
+ setStatus({ tone: 'error', text: translate('archiveConflict') })
1127
+ return
1128
+ }
1129
+ const pricing = {}
1130
+ const blank = PRICE_FIELDS.every((field) => String(entry.pricing[field] ?? '').trim() === '')
1131
+ if (blank) {
1132
+ archivePayload.push({ provider, model, pricing: {} })
1133
+ continue
1134
+ }
1135
+ for (const field of PRICE_FIELDS) {
1136
+ const parsed = parsePriceInput(entry.pricing[field])
1137
+ if (parsed === undefined) {
1138
+ setStatus({ tone: 'error', text: translate('invalidPrice') })
1139
+ return
1140
+ }
1141
+ pricing[field] = parsed
1142
+ }
1143
+ archivePayload.push({ provider, model, pricing })
1144
+ }
1145
+ setSaving(true)
1146
+ setStatus(null)
1147
+ try {
1148
+ await requestJson('/model-billing/save', {
1149
+ method: 'POST',
1150
+ headers: { 'content-type': 'application/json' },
1151
+ body: JSON.stringify({ models: payload, pricingArchive: archivePayload, display: displayValue }),
1152
+ })
1153
+ setDirty(false)
1154
+ setStatus({ tone: 'success', text: translate('saved') })
1155
+ await ensureCatalog(true)
1156
+ } catch (error) {
1157
+ setStatus({ tone: 'error', text: translate('saveFailed', { message: error instanceof Error ? error.message : String(error) }) })
1158
+ } finally {
1159
+ setSaving(false)
1160
+ }
1161
+ }
1162
+
1163
+ if (snap.data === null) {
1164
+ return h(Fragment, null,
1165
+ h(Style),
1166
+ h('section', { className: 'mb-page' },
1167
+ h('h2', { className: 'mb-title' }, translate('title')),
1168
+ snap.status === 'error'
1169
+ ? h('div', { className: 'mb-fatal' },
1170
+ h('div', null, translate('loadFailed')),
1171
+ h('p', { className: 'mb-note' }, snap.error),
1172
+ h('button', { type: 'button', className: 'mb-btn', onClick: () => void ensureCatalog(true) }, translate('retry')))
1173
+ : h('p', { className: 'mb-muted' }, translate('loading'))))
1174
+ }
1175
+
1176
+ if (draft === null) {
1177
+ return h(Fragment, null,
1178
+ h(Style),
1179
+ h('section', { className: 'mb-page' },
1180
+ h('h2', { className: 'mb-title' }, translate('title')),
1181
+ h('p', { className: 'mb-muted' }, translate('loading'))))
1182
+ }
1183
+
1184
+ const renderToolbar = () => h('div', { className: 'mb-toolbar' },
1185
+ h('span', { className: 'mb-muted' }, translate('modelsCount', { count: models.length })),
1186
+ h('span', { className: 'mb-spacer' }),
1187
+ dirty ? h('span', { className: 'mb-muted' }, translate('unsaved')) : null,
1188
+ h('button', { type: 'button', className: 'mb-btn', disabled: !dirty || saving, onClick: reset }, translate('undo')),
1189
+ h('button', { type: 'button', className: 'mb-btn mb-btn-primary', disabled: !dirty || saving, onClick: () => void save() }, saving ? translate('saving') : translate('save')))
1190
+
1191
+ if (models.length === 0) {
1192
+ // The display controls stay editable with no models configured, so the
1193
+ // toolbar (and its save button) must still render here.
1194
+ return h(Fragment, null,
1195
+ h(Style),
1196
+ h('section', { className: 'mb-page' },
1197
+ h('h2', { className: 'mb-title' }, translate('title')),
1198
+ h('p', { className: 'mb-intro' }, translate('intro')),
1199
+ renderToolbar(),
1200
+ status === null ? null : h('div', { className: 'mb-status', 'data-tone': status.tone, role: 'status' }, status.text),
1201
+ renderDisplaySettings(),
1202
+ renderArchive(),
1203
+ h('div', { className: 'mb-empty' }, translate('noModels'))))
1204
+ }
1205
+
1206
+ return h(Fragment, null,
1207
+ h(Style),
1208
+ h('section', { className: 'mb-page' },
1209
+ h('h2', { className: 'mb-title' }, translate('title')),
1210
+ h('p', { className: 'mb-intro' }, translate('intro')),
1211
+ renderToolbar(),
1212
+ status === null ? null : h('div', { className: 'mb-status', 'data-tone': status.tone, role: 'status' }, status.text),
1213
+ renderDisplaySettings(),
1214
+ h('p', { className: 'mb-note' }, translate('orderHint')),
1215
+ h('ul', { className: 'mb-list' },
1216
+ models.map((entry, index) => h(SettingsModelCard, {
1217
+ key: `${entry.provider}\u0000${entry.model}`,
1218
+ entry,
1219
+ index,
1220
+ count: models.length,
1221
+ translate,
1222
+ onMove: move,
1223
+ onUpdatePrice: updatePrice,
1224
+ onResetPricing: resetPricing,
1225
+ onRestoreOfficial: restoreOfficialPricing,
1226
+ }))),
1227
+ renderArchive(),
1228
+ h('p', { className: 'mb-footer-note' }, translate('formula')),
1229
+ h('p', { className: 'mb-footer-note' }, translate('formulaNote'))))
1230
+ }
1231
+
1232
+ function SettingsModelCard({ entry, index, count, translate, onMove, onUpdatePrice, onResetPricing, onRestoreOfficial }) {
1233
+ const id = useId()
1234
+ const pricing = entry.pricing
1235
+ const builtin = pricing.builtin === true
1236
+ const custom = entry.isDeepSeek && !builtin
1237
+ const fields = builtin ? null : PRICE_FIELDS.map((field) => {
1238
+ const inputId = `${id}-${field}`
1239
+ return h('label', { className: 'mb-field', htmlFor: inputId, key: field },
1240
+ h('span', { className: 'mb-field-label' }, translate(PRICE_LABEL_KEYS[field])),
1241
+ h('input', {
1242
+ id: inputId,
1243
+ className: 'mb-input',
1244
+ type: 'text',
1245
+ inputMode: 'decimal',
1246
+ autoComplete: 'off',
1247
+ placeholder: translate('pricePlaceholder'),
1248
+ value: pricing[field],
1249
+ onChange: (event) => onUpdatePrice(index, field, event.target.value),
1250
+ }))
1251
+ })
1252
+
1253
+ return h('li', { className: 'mb-card' },
1254
+ h('div', { className: 'mb-card-head' },
1255
+ h('div', { className: 'mb-index' }, index + 1),
1256
+ h('div', { style: { minWidth: 0 } },
1257
+ h('div', { className: 'mb-name' },
1258
+ entry.name,
1259
+ entry.isDeepSeek ? h('span', { className: 'mb-tag' }, translate('deepseekTag')) : null),
1260
+ h('div', { className: 'mb-sub' }, `${entry.providerName} · ${entry.provider} / ${entry.model}`)),
1261
+ h('div', { className: 'mb-head-actions' },
1262
+ h('button', {
1263
+ type: 'button',
1264
+ className: 'mb-btn mb-btn-icon',
1265
+ disabled: index === 0,
1266
+ title: translate('up'),
1267
+ 'aria-label': translate('up'),
1268
+ onClick: () => onMove(index, -1),
1269
+ }, '↑'),
1270
+ h('button', {
1271
+ type: 'button',
1272
+ className: 'mb-btn mb-btn-icon',
1273
+ disabled: index === count - 1,
1274
+ title: translate('down'),
1275
+ 'aria-label': translate('down'),
1276
+ onClick: () => onMove(index, 1),
1277
+ }, '↓'))),
1278
+ entry.isDeepSeek ? h('p', { className: 'mb-note' }, translate('deepseekNote')) : null,
1279
+ h('div', { className: 'mb-prices' },
1280
+ h('div', { className: 'mb-price-group' },
1281
+ h('div', { className: 'mb-price-group-title' },
1282
+ h('span', null, builtin ? translate('officialPrice') : translate('singlePrice')),
1283
+ custom ? h('span', { className: 'mb-tag' }, translate('priceSourceCustom')) : null,
1284
+ h('span', { className: 'mb-muted', style: { fontWeight: 400, fontSize: '11px' } }, translate('perMillion'))),
1285
+ builtin
1286
+ ? h(Fragment, null,
1287
+ h(PriceLines, { pricing, translate, showSource: false }),
1288
+ h('p', { className: 'mb-note' }, translate('deepseekSchedule')))
1289
+ : h(Fragment, null,
1290
+ h('div', { className: 'mb-price-grid' }, fields),
1291
+ custom ? h('p', { className: 'mb-note' }, translate('customPriceNote')) : null),
1292
+ entry.isDeepSeek
1293
+ ? h('div', { className: 'mb-card-actions' },
1294
+ h('button', {
1295
+ type: 'button',
1296
+ className: 'mb-btn',
1297
+ onClick: () => builtin ? onResetPricing(index) : onRestoreOfficial(index),
1298
+ }, builtin ? translate('customizePrice') : translate('useOfficialPrice')))
1299
+ : null)))
1300
+ }
1301
+
1302
+ // ---------------------------------------------------------------------
1303
+ // Composer dock cost summary
1304
+ // ---------------------------------------------------------------------
1305
+
1306
+ function CostDock({ sessionId, useProjection }) {
1307
+ const translate = useT()
1308
+ const tokenUsage = useProjection('tokenUsage')
1309
+ const revision = tokenUsageRevision(tokenUsage)
1310
+ const usage = useSessionUsage(sessionId, revision)
1311
+ const catalogSnap = useCatalog()
1312
+ const [open, setOpen] = useState(false)
1313
+ const [panelPos, setPanelPos] = useState(null)
1314
+ const [editingRoute, setEditingRoute] = useState(null)
1315
+ const [editPrices, setEditPrices] = useState({ cacheHit: '', cacheMiss: '', output: '' })
1316
+ const [savingPrice, setSavingPrice] = useState(false)
1317
+ const [priceStatus, setPriceStatus] = useState(null)
1318
+ const triggerRef = useRef(null)
1319
+ const panelRef = useRef(null)
1320
+
1321
+ useLayoutEffect(() => {
1322
+ if (!open) {
1323
+ setPanelPos(null)
1324
+ return undefined
1325
+ }
1326
+ const place = () => {
1327
+ const rect = triggerRef.current?.getBoundingClientRect()
1328
+ if (rect === undefined) return
1329
+ const width = Math.min(660, window.innerWidth - 24)
1330
+ const naturalLeft = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))
1331
+ // The dock sits near the right edge; shift the whole detail panel left so
1332
+ // the bottom-right whale widget cannot cover it.
1333
+ const left = Math.max(12, naturalLeft - 120)
1334
+ const spaceAbove = rect.top - 12
1335
+ const spaceBelow = window.innerHeight - rect.bottom - 12
1336
+ const above = spaceAbove > spaceBelow
1337
+ const maxHeight = Math.min(430, Math.max(180, above ? spaceAbove : spaceBelow))
1338
+ setPanelPos(above
1339
+ ? { left, width, bottom: window.innerHeight - rect.top + 8, maxHeight }
1340
+ : { left, width, top: rect.bottom + 8, maxHeight })
1341
+ }
1342
+ place()
1343
+ window.addEventListener('resize', place)
1344
+ window.addEventListener('scroll', place, true)
1345
+ return () => {
1346
+ window.removeEventListener('resize', place)
1347
+ window.removeEventListener('scroll', place, true)
1348
+ }
1349
+ }, [open])
1350
+
1351
+ useEffect(() => {
1352
+ if (!open) return undefined
1353
+ const closeOutside = (event) => {
1354
+ if (triggerRef.current?.contains(event.target) === true) return
1355
+ if (panelRef.current?.contains(event.target) === true) return
1356
+ setOpen(false)
1357
+ }
1358
+ const closeOnEscape = (event) => {
1359
+ if (event.key !== 'Escape') return
1360
+ event.preventDefault()
1361
+ setOpen(false)
1362
+ triggerRef.current?.focus()
1363
+ }
1364
+ document.addEventListener('mousedown', closeOutside)
1365
+ document.addEventListener('keydown', closeOnEscape)
1366
+ // A dialog must take focus when it opens so keyboard users land inside it.
1367
+ panelRef.current?.focus()
1368
+ return () => {
1369
+ document.removeEventListener('mousedown', closeOutside)
1370
+ document.removeEventListener('keydown', closeOnEscape)
1371
+ }
1372
+ }, [open])
1373
+
1374
+ // Hooks must run on every render, so derive the catalog indexes before any
1375
+ // early return; React rejects a different hook count between renders.
1376
+ const orderIndex = useMemo(() =>
1377
+ new Map((catalogSnap.data?.models ?? []).map((model) => [`${model.provider}\u0000${model.model}`, model.order])),
1378
+ [catalogSnap.data])
1379
+ const catalogByKey = useMemo(() =>
1380
+ new Map((catalogSnap.data?.models ?? []).map((model) => [`${model.provider}\u0000${model.model}`, model])),
1381
+ [catalogSnap.data])
1382
+
1383
+ const display = readDisplay(catalogSnap.data)
1384
+ if (display.showDock === false) return null
1385
+
1386
+ const data = usage.data
1387
+ if (data === null || data === undefined || data.totals?.hasBillable !== true) {
1388
+ return null
1389
+ }
1390
+ const totals = data.totals
1391
+ const unpricedRoutes = data.unpricedRoutes ?? []
1392
+ const beginRouteEdit = (route) => {
1393
+ setEditingRoute(`${route.provider}\u0000${route.model}`)
1394
+ setEditPrices({ cacheHit: '', cacheMiss: '', output: '' })
1395
+ setPriceStatus(null)
1396
+ }
1397
+ const saveRoutePrice = async (route) => {
1398
+ const pricing = {}
1399
+ for (const field of PRICE_FIELDS) {
1400
+ const parsed = parsePriceInput(editPrices[field])
1401
+ if (parsed === undefined || parsed === null) {
1402
+ setPriceStatus({ tone: 'error', text: translate('invalidPrice') })
1403
+ return
1404
+ }
1405
+ pricing[field] = parsed
1406
+ }
1407
+ setSavingPrice(true)
1408
+ setPriceStatus(null)
1409
+ try {
1410
+ await requestJson('/model-billing/save', {
1411
+ method: 'POST',
1412
+ headers: { 'content-type': 'application/json' },
1413
+ body: JSON.stringify({ pricingArchive: [{ provider: route.provider, model: route.model, pricing }] }),
1414
+ })
1415
+ await ensureCatalog(true)
1416
+ setEditingRoute(null)
1417
+ setPriceStatus({ tone: 'success', text: translate('priceSaved') })
1418
+ await ensureUsage(sessionId, revision, true)
1419
+ } catch (error) {
1420
+ setPriceStatus({ tone: 'error', text: translate('saveFailed', { message: error instanceof Error ? error.message : String(error) }) })
1421
+ } finally {
1422
+ setSavingPrice(false)
1423
+ }
1424
+ }
1425
+ const last = [...(data.turns ?? [])].reverse().find((entry) => entry.hasBillable) ?? null
1426
+ const totalText = totals.costKnown
1427
+ ? `≈ ${formatMoney(totals.cost)}`
1428
+ : totals.pricedAttempts > 0 ? `≈ ${formatMoney(totals.cost)}` : translate('pricePlaceholder')
1429
+ const lastText = last === null || last === undefined || !last.hasBillable
1430
+ ? null
1431
+ : last.costKnown ? `≈ ${formatMoney(last.cost)}` : last.pricedAttempts > 0 ? `≈ ${formatMoney(last.cost)}` : translate('pricePlaceholder')
1432
+ // Rebuilding the index and scanning the catalog on every render gets
1433
+ // expensive on a long session, so both indexes are memoized above.
1434
+ const byModel = [...(data.byModel ?? [])].sort((left, right) => {
1435
+ const leftKey = left.provider === null ? '' : `${left.provider}\u0000${left.model}`
1436
+ const rightKey = right.provider === null ? '' : `${right.provider}\u0000${right.model}`
1437
+ return (orderIndex.get(leftKey) ?? Number.MAX_SAFE_INTEGER) - (orderIndex.get(rightKey) ?? Number.MAX_SAFE_INTEGER)
1438
+ })
1439
+
1440
+ const modelNameOf = (model) => {
1441
+ const catalogRow = model?.provider === null || model?.provider === undefined
1442
+ ? undefined
1443
+ : catalogByKey.get(`${model.provider}\u0000${model.model}`)
1444
+ return catalogRow?.name ?? model?.name ?? model?.model ?? '—'
1445
+ }
1446
+ const turnModelNames = (turn) => {
1447
+ const names = []
1448
+ for (const model of turn.models ?? []) {
1449
+ const name = modelNameOf(model)
1450
+ if (!names.includes(name)) names.push(name)
1451
+ }
1452
+ return names
1453
+ }
1454
+
1455
+ const header = h('thead', null,
1456
+ h('tr', null,
1457
+ h('th', null, translate('turn')),
1458
+ h('th', null, translate('content')),
1459
+ h('th', null, translate('time')),
1460
+ h('th', null, translate('model')),
1461
+ h('th', { className: 'num' }, translate('cost')),
1462
+ h('th', { className: 'num' }, translate('tokens')),
1463
+ h('th', { className: 'num' }, translate('cacheHit'))))
1464
+
1465
+ const turnRows = [...(data.turns ?? [])].reverse().slice(0, 20).map((turn) => {
1466
+ const costText = turn.costKnown
1467
+ ? `≈ ${formatMoney(turn.cost)}`
1468
+ : turn.pricedAttempts > 0 ? `≈ ${formatMoney(turn.cost)}` : translate('pricePlaceholder')
1469
+ const modelNames = turnModelNames(turn)
1470
+ const modelText = modelNames.length === 0
1471
+ ? '—'
1472
+ : modelNames.length === 1 ? modelNames[0] : `${modelNames[0]} +${modelNames.length - 1}`
1473
+ return h('tr', { key: turn.turn },
1474
+ h('td', null, `#${turn.turn}`),
1475
+ h('td', { className: 'mb-turn-preview', title: turn.preview }, turn.preview || '—'),
1476
+ h('td', null, `${timeText(turn.startTime)}–${timeText(turn.endTime)}`),
1477
+ h('td', { className: 'mb-turn-model', title: modelNames.join(' / ') }, modelText),
1478
+ h('td', { className: 'num' }, costText),
1479
+ h('td', { className: 'num' }, `${formatTokens(turn.promptTokens)} / ${formatTokens(turn.outputTokens)}`),
1480
+ h('td', { className: 'num' }, formatPercent(turn.cacheHit)))
1481
+ })
1482
+
1483
+ const turnTable = h('div', { style: { overflowX: 'auto' } },
1484
+ h('table', { className: 'mb-table mb-turn-table' }, header, h('tbody', null, turnRows)))
1485
+
1486
+ const modelRows = byModel.map((model) => {
1487
+ const catalogRow = model.provider === null
1488
+ ? undefined
1489
+ : catalogByKey.get(`${model.provider}\u0000${model.model}`)
1490
+ const modelName = catalogRow === undefined ? model.name : `${catalogRow.providerName} / ${catalogRow.name}`
1491
+ return h('tr', { key: `${model.provider}\u0000${model.model}\u0000${model.name}` },
1492
+ h('td', null,
1493
+ modelName,
1494
+ model.isDeepSeek ? h('span', { className: 'mb-tag' }, translate('deepseekTag')) : null),
1495
+ h('td', { className: 'num' },
1496
+ model.pricedAttempts > 0 ? `≈ ${formatMoney(model.cost)}` : translate('pricePlaceholder')),
1497
+ h('td', { className: 'num', style: { color: 'var(--dsw-alias-label-secondary)' } },
1498
+ `${formatTokens(model.promptTokens)} / ${formatTokens(model.outputTokens)}`))
1499
+ })
1500
+
1501
+ const modelTable = byModel.length === 0
1502
+ ? null
1503
+ : h('div', { style: { marginTop: '10px' } },
1504
+ h('div', { className: 'mb-panel-title', style: { fontSize: '12px', marginBottom: '4px' } }, translate('byModel')),
1505
+ h('div', { style: { overflowX: 'auto' } },
1506
+ h('table', { className: 'mb-table' }, h('tbody', null, modelRows))))
1507
+
1508
+ const editingRouteRow = editingRoute === null
1509
+ ? null
1510
+ : unpricedRoutes.find((route) => `${route.provider}\u0000${route.model}` === editingRoute) ?? null
1511
+ const routeEditor = editingRouteRow === null
1512
+ ? null
1513
+ : h('div', { className: 'mb-archive-row mb-route-editor', style: { marginTop: '8px' } },
1514
+ h('span', { className: 'mb-muted' }, `${editingRouteRow.provider} / ${editingRouteRow.model}`),
1515
+ ...PRICE_FIELDS.map((field) => h('input', {
1516
+ key: field,
1517
+ className: 'mb-input',
1518
+ inputMode: 'decimal',
1519
+ placeholder: translate(PRICE_LABEL_KEYS[field]),
1520
+ 'aria-label': translate(PRICE_LABEL_KEYS[field]),
1521
+ value: editPrices[field],
1522
+ onChange: (event) => setEditPrices((current) => ({ ...current, [field]: event.target.value })),
1523
+ })),
1524
+ h('button', {
1525
+ type: 'button',
1526
+ className: 'mb-btn mb-btn-primary',
1527
+ disabled: savingPrice,
1528
+ onClick: () => void saveRoutePrice(editingRouteRow),
1529
+ }, savingPrice ? translate('saving') : translate('save')))
1530
+
1531
+ const unpricedTable = unpricedRoutes.length === 0 || display.showUnpricedNotice === false
1532
+ ? null
1533
+ : h('div', { style: { marginTop: '10px' } },
1534
+ h('div', { className: 'mb-panel-title', style: { fontSize: '12px', marginBottom: '4px' } }, translate('unpricedRoutes')),
1535
+ h('div', { style: { overflowX: 'auto' } },
1536
+ h('table', { className: 'mb-table' },
1537
+ h('tbody', null, unpricedRoutes.map((route) => h('tr', { key: `${route.provider}\u0000${route.model}` },
1538
+ h('td', null, `${route.provider} / ${route.model}`),
1539
+ h('td', { className: 'num' }, String(route.attempts)),
1540
+ h('td', { className: 'num', style: { color: 'var(--dsw-alias-label-secondary)' } },
1541
+ `${formatTokens(route.promptTokens)} / ${formatTokens(route.outputTokens)}`),
1542
+ h('td', { className: 'num' },
1543
+ h('button', {
1544
+ type: 'button',
1545
+ className: 'mb-btn',
1546
+ onClick: () => beginRouteEdit(route),
1547
+ }, translate('addPrice')))))))),
1548
+ routeEditor,
1549
+ priceStatus === null ? null : h('div', { className: 'mb-status', 'data-tone': priceStatus.tone, role: 'status' }, priceStatus.text))
1550
+
1551
+ const panelStyle = panelPos === null
1552
+ ? { left: 0, top: 0, width: 660, visibility: 'hidden' }
1553
+ : {
1554
+ left: panelPos.left,
1555
+ width: panelPos.width,
1556
+ maxHeight: panelPos.maxHeight,
1557
+ ...panelPos.top === undefined ? { bottom: panelPos.bottom } : { top: panelPos.top },
1558
+ }
1559
+
1560
+ const panel = h('div', {
1561
+ ref: panelRef,
1562
+ className: 'mb-panel mb-panel-floating',
1563
+ style: panelStyle,
1564
+ role: 'dialog',
1565
+ 'aria-modal': true,
1566
+ 'aria-label': translate('costDetails'),
1567
+ tabIndex: -1,
1568
+ },
1569
+ h('div', { className: 'mb-panel-head' },
1570
+ h('span', { className: 'mb-panel-title' }, translate('costDetails')),
1571
+ h('span', { className: 'mb-muted' }, translate('sessionTotal')),
1572
+ usage.status === 'error'
1573
+ ? h('button', {
1574
+ type: 'button',
1575
+ className: 'mb-btn',
1576
+ onClick: () => void ensureUsage(sessionId, revision, true),
1577
+ }, translate('retry'))
1578
+ : null,
1579
+ h('button', {
1580
+ type: 'button',
1581
+ className: 'mb-panel-close',
1582
+ 'aria-label': translate('close'),
1583
+ title: translate('close'),
1584
+ onClick: () => {
1585
+ setOpen(false)
1586
+ triggerRef.current?.focus()
1587
+ },
1588
+ }, '×')),
1589
+ h('div', { className: 'mb-summary' },
1590
+ summaryItem(translate('sessionTotal'), totalText),
1591
+ summaryItem(translate('thisTurn'), lastText ?? '—'),
1592
+ summaryItem(translate('cacheHit'), formatPercent(totals.cacheHit)),
1593
+ summaryItem(translate('tokens'), `${formatTokens(totals.promptTokens)} / ${formatTokens(totals.outputTokens)}`)),
1594
+ turnTable,
1595
+ modelTable,
1596
+ unpricedTable,
1597
+ h('p', { className: 'mb-panel-note' }, translate('estimateNote')),
1598
+ totals.partial && display.showUnpricedNotice
1599
+ ? h('p', { className: 'mb-warn' }, translate('partialNote'))
1600
+ : null)
1601
+
1602
+ return h('div', { className: 'mb-dock' },
1603
+ h(Style),
1604
+ h('button', {
1605
+ ref: triggerRef,
1606
+ type: 'button',
1607
+ className: 'mb-dock-trigger',
1608
+ 'aria-expanded': open ? 'true' : 'false',
1609
+ 'aria-haspopup': 'dialog',
1610
+ onClick: () => setOpen((value) => !value),
1611
+ title: translate('costEstimate'),
1612
+ },
1613
+ h('svg', {
1614
+ className: 'mb-dock-icon',
1615
+ viewBox: '0 0 16 16',
1616
+ fill: 'none',
1617
+ stroke: 'currentColor',
1618
+ strokeWidth: 1.2,
1619
+ 'aria-hidden': true,
1620
+ },
1621
+ h('circle', { cx: 8, cy: 8, r: 5.5 }),
1622
+ h('path', { d: 'M8 5v6M6.2 6.8h3.6M6.2 9.2h3.6' })),
1623
+ h('span', null, translate('costEstimate')),
1624
+ h('span', { className: 'mb-dock-amount' }, totalText),
1625
+ lastText === null ? null : h(Fragment, null,
1626
+ h('span', { className: 'mb-dock-sep' }, '·'),
1627
+ h('span', null, `${translate('thisTurn')} ${lastText}`)),
1628
+ totals.partial && display.showUnpricedNotice ? h('span', { className: 'mb-warn', style: { margin: 0 } }, translate('partial')) : null),
1629
+ open ? ReactDOM.createPortal(panel, document.body) : null)
1630
+ }
1631
+
1632
+ function summaryItem(label, value) {
1633
+ return h('div', { className: 'mb-summary-item', key: label },
1634
+ h('div', { className: 'mb-summary-key' }, label),
1635
+ h('div', { className: 'mb-summary-value' }, value))
1636
+ }
1637
+
1638
+ // ---------------------------------------------------------------------
1639
+ // Per-turn cost chip
1640
+ // ---------------------------------------------------------------------
1641
+
1642
+ function turnModelText(models, translate, showProvider, catalogModels) {
1643
+ const findModel = (entry) => {
1644
+ if (entry.provider === undefined || entry.model === undefined) return undefined
1645
+ return (catalogModels ?? []).find((candidate) => candidate.provider === entry.provider && candidate.model === entry.model)
1646
+ }
1647
+ const names = (models ?? []).map((entry) => {
1648
+ if (entry === undefined || entry === null) return null
1649
+ const matched = findModel(entry)
1650
+ const modelName = typeof matched?.name === 'string' && matched.name !== ''
1651
+ ? matched.name
1652
+ : typeof entry.name === 'string' && entry.name !== ''
1653
+ ? entry.name
1654
+ : typeof entry.model === 'string' && entry.model !== '' ? entry.model : null
1655
+ if (modelName === null) return null
1656
+ if (!showProvider) return modelName
1657
+ const providerName = typeof matched?.providerName === 'string' && matched.providerName !== ''
1658
+ ? matched.providerName
1659
+ : typeof entry.providerName === 'string' && entry.providerName !== ''
1660
+ ? entry.providerName
1661
+ : typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null
1662
+ return providerName === null ? modelName : `${providerName} / ${modelName}`
1663
+ }).filter((name) => name !== null)
1664
+ if (names.length === 0) return null
1665
+ return names.join(translate('listSep'))
1666
+ }
1667
+
1668
+ function TurnCost(props) {
1669
+ const { turn } = props
1670
+ // The turnTail owner hands over the whole folded turn life cycle, not just its number.
1671
+ const turnNumber = turn === null || turn === undefined
1672
+ ? undefined
1673
+ : typeof turn === 'number' ? turn : turn.turn
1674
+ const sessionId = props.sessionId
1675
+ const useProjection = props.useProjection
1676
+ const translate = useT()
1677
+ const catalogSnap = useCatalog()
1678
+ const display = readDisplay(catalogSnap.data)
1679
+ const tokenUsage = typeof useProjection === 'function' ? useProjection('tokenUsage') : undefined
1680
+ const revision = tokenUsageRevision(tokenUsage)
1681
+ const usage = useSessionUsage(sessionId, revision)
1682
+ // The master switch hides the whole chip while the per-field switches keep
1683
+ // their saved values, so turning it back on restores the previous layout.
1684
+ if (display.showTurnTail === false) return null
1685
+ const rows = usage.data?.turns
1686
+ const row = (usage.rowsByTurn instanceof Map ? usage.rowsByTurn.get(turnNumber) : undefined) ??
1687
+ rows?.find((entry) => entry.turn === turnNumber)
1688
+ if (row === undefined) return null
1689
+ const costText = row.costKnown
1690
+ ? `≈ ${formatMoney(row.cost)}`
1691
+ : row.pricedAttempts > 0 ? `≈ ${formatMoney(row.cost)}` : translate('pricePlaceholder')
1692
+ const modelText = turnModelText(row.models, translate, display.showProvider === true, catalogSnap.data?.models)
1693
+ const label = (key) => display.showLabels ? `${translate(key)} ` : ''
1694
+ const parts = []
1695
+ if (display.showModels && modelText !== null) {
1696
+ parts.push(h('span', { className: 'mb-turn-item', key: 'model' },
1697
+ label('model'), h('strong', null, modelText)))
1698
+ }
1699
+ if (display.showCost) {
1700
+ parts.push(h('span', { className: 'mb-turn-item', key: 'cost' },
1701
+ label('turnCost'), h('strong', null, costText)))
1702
+ }
1703
+ const promptTokens = Number.isFinite(row.promptTokens) ? row.promptTokens : 0
1704
+ const outputTokens = Number.isFinite(row.outputTokens) ? row.outputTokens : 0
1705
+ if (display.showTokens && (promptTokens > 0 || outputTokens > 0)) {
1706
+ parts.push(h('span', { className: 'mb-turn-item', key: 'tokens' },
1707
+ label('turnTokens'), h('strong', null, `${formatTokens(promptTokens)} / ${formatTokens(outputTokens)}`)))
1708
+ }
1709
+ if (display.showCacheHit && row.cacheHit !== null && row.cacheHit !== undefined) {
1710
+ parts.push(h('span', { className: 'mb-turn-item', key: 'cache' },
1711
+ label('cacheHit'), h('strong', null, formatPercent(row.cacheHit))))
1712
+ }
1713
+ if (row.partial) {
1714
+ parts.push(h('span', { className: 'mb-turn-item mb-turn-partial', key: 'partial' }, translate('partialShort')))
1715
+ }
1716
+ if (parts.length === 0) return null
1717
+ return h('span', {
1718
+ className: 'mb-turn',
1719
+ 'data-size': display.fontSize,
1720
+ 'data-density': display.density,
1721
+ 'data-weight': display.fontWeight,
1722
+ }, parts)
1723
+ }
1724
+
1725
+ // ---------------------------------------------------------------------
1726
+ // Custom-ordered model picker in the composer seat
1727
+ // ---------------------------------------------------------------------
1728
+
1729
+ const MODEL_KEY_SEPARATOR = '\u0000'
1730
+
1731
+ function OrderedModelPicker({ locked, sessionId, useProjection }) {
1732
+ const translate = useT()
1733
+ const catalogSnap = useCatalog()
1734
+ const selectionProjection = useProjection('modelSelection')
1735
+ const directoryRevision = useModelDirectoriesRevision()
1736
+ const [directoryTick, setDirectoryTick] = useState(0)
1737
+ const directory = useMemo(() => {
1738
+ if (modelDirectories === undefined || sessionId === undefined) return null
1739
+ try {
1740
+ return modelDirectories.directoryFor(sessionId)
1741
+ } catch {
1742
+ return null
1743
+ }
1744
+ }, [sessionId, directoryRevision, directoryTick])
1745
+
1746
+ const [state, setState] = useState(null)
1747
+ const [open, setOpen] = useState(false)
1748
+ const [query, setQuery] = useState('')
1749
+ const [selecting, setSelecting] = useState(false)
1750
+ const [failure, setFailure] = useState(null)
1751
+ const [menuPos, setMenuPos] = useState(null)
1752
+ // Roving cursor for arrow-key navigation inside the model list.
1753
+ const [activeIndex, setActiveIndex] = useState(0)
1754
+ const rootRef = useRef(null)
1755
+ const triggerRef = useRef(null)
1756
+ const menuRef = useRef(null)
1757
+ const searchRef = useRef(null)
1758
+ const id = useId()
1759
+
1760
+ useEffect(() => {
1761
+ if (directory === null) {
1762
+ setState(null)
1763
+ return undefined
1764
+ }
1765
+ const sync = () => setState(directory.store.getSnapshot())
1766
+ sync()
1767
+ const unsubscribe = directory.store.subscribe(sync)
1768
+ directory.load().catch(() => {})
1769
+ return unsubscribe
1770
+ }, [directory])
1771
+
1772
+ useEffect(() => {
1773
+ if (directory !== null || directoryTick >= 20) return undefined
1774
+ const timer = setTimeout(() => setDirectoryTick((value) => value + 1), 300)
1775
+ return () => clearTimeout(timer)
1776
+ }, [directory, directoryTick])
1777
+
1778
+ useEffect(() => {
1779
+ if (!open) return undefined
1780
+ const closeOutside = (event) => {
1781
+ if (rootRef.current?.contains(event.target) === true) return
1782
+ if (menuRef.current?.contains(event.target) === true) return
1783
+ setOpen(false)
1784
+ }
1785
+ document.addEventListener('mousedown', closeOutside)
1786
+ return () => document.removeEventListener('mousedown', closeOutside)
1787
+ }, [open])
1788
+
1789
+ useEffect(() => {
1790
+ if (!open) {
1791
+ setQuery('')
1792
+ return undefined
1793
+ }
1794
+ const closeOnEscape = (event) => {
1795
+ if (event.key !== 'Escape') return
1796
+ event.preventDefault()
1797
+ setOpen(false)
1798
+ queueMicrotask(() => triggerRef.current?.focus())
1799
+ }
1800
+ document.addEventListener('keydown', closeOnEscape)
1801
+ return () => document.removeEventListener('keydown', closeOnEscape)
1802
+ }, [open])
1803
+
1804
+ useEffect(() => {
1805
+ if (locked && open) setOpen(false)
1806
+ }, [locked, open])
1807
+
1808
+ const rank = useMemo(() => {
1809
+ const map = new Map()
1810
+ for (const model of catalogSnap.data?.models ?? []) {
1811
+ map.set(`${model.provider}${MODEL_KEY_SEPARATOR}${model.model}`, model.order)
1812
+ }
1813
+ return map
1814
+ }, [catalogSnap.data])
1815
+
1816
+ const priceByKey = useMemo(() => {
1817
+ const map = new Map()
1818
+ for (const model of catalogSnap.data?.models ?? []) {
1819
+ map.set(`${model.provider}${MODEL_KEY_SEPARATOR}${model.model}`, model.pricing)
1820
+ }
1821
+ return map
1822
+ }, [catalogSnap.data])
1823
+
1824
+ const groups = state?.groups
1825
+ const catalogModels = catalogSnap.data?.models
1826
+ const entries = useMemo(() => {
1827
+ if (Array.isArray(groups) && groups.length > 0) {
1828
+ const list = []
1829
+ for (const [groupIndex, group] of groups.entries()) {
1830
+ for (const [modelIndex, model] of group.models.entries()) {
1831
+ list.push({ group, model, groupIndex, modelIndex })
1832
+ }
1833
+ }
1834
+ list.sort((left, right) => {
1835
+ const leftRank = rank.get(`${left.group.id}${MODEL_KEY_SEPARATOR}${left.model.id}`) ?? Number.MAX_SAFE_INTEGER
1836
+ const rightRank = rank.get(`${right.group.id}${MODEL_KEY_SEPARATOR}${right.model.id}`) ?? Number.MAX_SAFE_INTEGER
1837
+ return leftRank - rightRank || left.groupIndex - right.groupIndex || left.modelIndex - right.modelIndex
1838
+ })
1839
+ return list
1840
+ }
1841
+ return (catalogModels ?? []).map((model, index) => ({
1842
+ group: { id: model.provider, name: model.providerName },
1843
+ model: {
1844
+ id: model.model,
1845
+ name: model.name,
1846
+ ...(model.reasoning === undefined ? {} : { reasoning: model.reasoning }),
1847
+ },
1848
+ groupIndex: model.order ?? index,
1849
+ modelIndex: index,
1850
+ }))
1851
+ }, [groups, catalogModels, rank])
1852
+
1853
+ const projected = selectionProjection?.next ?? selectionProjection?.lastUsed ?? null
1854
+ const current = state?.current ?? projected
1855
+ const currentEntry = current === null
1856
+ ? undefined
1857
+ : entries.find((entry) => entry.group.id === current.provider && entry.model.id === current.model)
1858
+ const currentCatalog = current === null
1859
+ ? undefined
1860
+ : (catalogModels ?? []).find((model) => model.provider === current.provider && model.model === current.model)
1861
+ const currentReasoning = currentEntry?.model.reasoning
1862
+ const effectiveEffort = current?.reasoningEffort ?? currentReasoning?.defaultEffort
1863
+ const effortLabel = current === null
1864
+ ? undefined
1865
+ : currentReasoning === undefined
1866
+ ? state?.retainedEffort
1867
+ : effectiveEffort === undefined
1868
+ ? translate('providerDefault')
1869
+ : currentReasoning.efforts.find((effort) => effort.id === effectiveEffort)?.name ?? effectiveEffort
1870
+ const effortChoices = currentReasoning === undefined
1871
+ ? []
1872
+ : [
1873
+ ...(currentReasoning.defaultEffort === undefined
1874
+ ? [{ key: 'provider-default', effort: undefined, label: translate('providerDefault') }]
1875
+ : []),
1876
+ ...currentReasoning.efforts.map((effort) => ({
1877
+ key: `effort:${effort.id}`,
1878
+ effort: effort.id,
1879
+ label: effort.name,
1880
+ })),
1881
+ ]
1882
+
1883
+ const showSearch = entries.length > 4
1884
+ const term = query.trim().toLowerCase()
1885
+ const filtered = useMemo(() => {
1886
+ if (term === '') return entries
1887
+ return entries.filter((entry) => `${entry.model.name} ${entry.group.name}`.toLowerCase().includes(term))
1888
+ }, [entries, term])
1889
+ // Keep the roving cursor inside the visible list; a shrunk search result
1890
+ // must not leave the index pointing past the end.
1891
+ const active = filtered.length === 0 ? -1 : Math.min(Math.max(activeIndex, 0), filtered.length - 1)
1892
+
1893
+ useLayoutEffect(() => {
1894
+ if (!open) {
1895
+ setMenuPos(null)
1896
+ return undefined
1897
+ }
1898
+ const place = () => {
1899
+ const rect = triggerRef.current?.getBoundingClientRect()
1900
+ if (rect === undefined) return
1901
+ const width = Math.min(360, window.innerWidth - 24)
1902
+ const left = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))
1903
+ const spaceAbove = rect.top - 12
1904
+ const spaceBelow = window.innerHeight - rect.bottom - 12
1905
+ const openBelow = spaceBelow > spaceAbove
1906
+ const maxHeight = Math.min(480, Math.max(160, openBelow ? spaceBelow : spaceAbove))
1907
+ setMenuPos(openBelow
1908
+ ? { left, top: rect.bottom + 8, width, maxHeight }
1909
+ : { left, bottom: window.innerHeight - rect.top + 8, width, maxHeight })
1910
+ }
1911
+ place()
1912
+ window.addEventListener('scroll', place, true)
1913
+ window.addEventListener('resize', place)
1914
+ return () => {
1915
+ window.removeEventListener('scroll', place, true)
1916
+ window.removeEventListener('resize', place)
1917
+ }
1918
+ }, [open, filtered.length, state?.status])
1919
+
1920
+ useEffect(() => {
1921
+ if (!open) return undefined
1922
+ const timer = setTimeout(() => {
1923
+ if (showSearch) {
1924
+ searchRef.current?.focus()
1925
+ return
1926
+ }
1927
+ const rows = menuRef.current?.querySelectorAll('.mb-picker-row')
1928
+ const target = rows?.[active]
1929
+ if (target instanceof HTMLElement) target.focus()
1930
+ }, 0)
1931
+ return () => clearTimeout(timer)
1932
+ // Focus follows the roving cursor so arrow keys keep focus on the active row.
1933
+ }, [open, showSearch, active])
1934
+
1935
+ const loading = catalogSnap.data === null && (state === null || state.status === 'loading')
1936
+ const busy = selecting || state?.status === 'selecting'
1937
+ const currentName = currentEntry?.model.name ?? currentCatalog?.name
1938
+ const triggerModel = currentName ?? (loading ? translate('loadingModels') : translate('chooseModel'))
1939
+ const triggerAria = effortLabel === undefined ? triggerModel : `${triggerModel} · ${effortLabel}`
1940
+
1941
+ const close = (restoreFocus) => {
1942
+ setOpen(false)
1943
+ setQuery('')
1944
+ setFailure(null)
1945
+ if (restoreFocus) queueMicrotask(() => triggerRef.current?.focus())
1946
+ }
1947
+
1948
+ const submit = async (selection) => {
1949
+ if (busy) return
1950
+ setSelecting(true)
1951
+ setFailure(null)
1952
+ try {
1953
+ const result = directory !== null
1954
+ ? await directory.select(selection)
1955
+ : await requestJson('/model-billing/select', {
1956
+ method: 'POST',
1957
+ headers: { 'content-type': 'application/json' },
1958
+ body: JSON.stringify({ sessionId, ...selection }),
1959
+ })
1960
+ if (result?.ok === false) {
1961
+ setFailure(result.error?.message ?? translate('pickerEmpty'))
1962
+ return
1963
+ }
1964
+ close(true)
1965
+ } catch (error) {
1966
+ setFailure(error instanceof Error ? error.message : String(error))
1967
+ } finally {
1968
+ setSelecting(false)
1969
+ }
1970
+ }
1971
+
1972
+ const chooseModel = (entry) => {
1973
+ if (current?.provider === entry.group.id && current?.model === entry.model.id) {
1974
+ close(true)
1975
+ return
1976
+ }
1977
+ void submit({ provider: entry.group.id, model: entry.model.id })
1978
+ }
1979
+
1980
+ const chooseEffort = (effort) => {
1981
+ if (current === null) return
1982
+ if (effectiveEffort === effort) {
1983
+ close(true)
1984
+ return
1985
+ }
1986
+ void submit({
1987
+ provider: current.provider,
1988
+ model: current.model,
1989
+ ...effort === undefined ? {} : { reasoningEffort: effort },
1990
+ })
1991
+ }
1992
+
1993
+ const toggle = () => {
1994
+ if (open) {
1995
+ close(true)
1996
+ return
1997
+ }
1998
+ setFailure(null)
1999
+ // Open the cursor on the row the user is already using, when there is one.
2000
+ const selectedAt = filtered.findIndex((entry) => entry.group.id === current?.provider && entry.model.id === current?.model)
2001
+ setActiveIndex(selectedAt < 0 ? 0 : selectedAt)
2002
+ setOpen(true)
2003
+ if (directory !== null) directory.load().catch(() => {})
2004
+ }
2005
+
2006
+ const menuStyle = menuPos === null
2007
+ ? { left: 0, top: 0, width: 360, visibility: 'hidden' }
2008
+ : {
2009
+ left: menuPos.left,
2010
+ width: menuPos.width,
2011
+ maxHeight: menuPos.maxHeight,
2012
+ ...menuPos.top === undefined ? { bottom: menuPos.bottom } : { top: menuPos.top },
2013
+ }
2014
+ const errorText = failure ?? (state?.status === 'error' ? state.error : null)
2015
+
2016
+ // Arrow keys move a roving cursor through the list. Enter and Space are
2017
+ // left to the focused row itself, so a <button> keeps its native
2018
+ // activation and the menu never fires the choice twice.
2019
+ const focusRow = (index) => {
2020
+ const rows = menuRef.current?.querySelectorAll('.mb-picker-row')
2021
+ const target = rows?.[index]
2022
+ if (target instanceof HTMLElement) target.focus()
2023
+ }
2024
+ const moveActive = (delta) => {
2025
+ if (filtered.length === 0) return
2026
+ const next = Math.min(Math.max(active + delta, 0), filtered.length - 1)
2027
+ setActiveIndex(next)
2028
+ focusRow(next)
2029
+ }
2030
+ const onMenuKeyDown = (event) => {
2031
+ if (event.key === 'ArrowDown') {
2032
+ event.preventDefault()
2033
+ moveActive(1)
2034
+ return
2035
+ }
2036
+ if (event.key === 'ArrowUp') {
2037
+ event.preventDefault()
2038
+ moveActive(-1)
2039
+ return
2040
+ }
2041
+ if (event.key === 'Home') {
2042
+ event.preventDefault()
2043
+ if (filtered.length === 0) return
2044
+ setActiveIndex(0)
2045
+ focusRow(0)
2046
+ return
2047
+ }
2048
+ if (event.key === 'End') {
2049
+ event.preventDefault()
2050
+ const last = filtered.length - 1
2051
+ if (last < 0) return
2052
+ setActiveIndex(last)
2053
+ focusRow(last)
2054
+ }
2055
+ }
2056
+
2057
+ const menu = h('div', {
2058
+ ref: menuRef,
2059
+ id: `${id}-menu`,
2060
+ className: 'mb-picker-menu',
2061
+ style: menuStyle,
2062
+ role: 'dialog',
2063
+ 'aria-label': translate('pickerAria'),
2064
+ onKeyDown: onMenuKeyDown,
2065
+ },
2066
+ showSearch ? h('input', {
2067
+ ref: searchRef,
2068
+ className: 'mb-picker-search',
2069
+ type: 'search',
2070
+ value: query,
2071
+ placeholder: translate('searchModel'),
2072
+ 'aria-label': translate('searchModel'),
2073
+ onChange: (event) => {
2074
+ setQuery(event.target.value)
2075
+ // A shorter result list must not leave the cursor on a removed row.
2076
+ setActiveIndex(0)
2077
+ },
2078
+ onKeyDown: (event) => {
2079
+ // Enter picks the highlighted row; a downward arrow steps into the list.
2080
+ if (event.key === 'ArrowDown') {
2081
+ event.preventDefault()
2082
+ moveActive(1)
2083
+ return
2084
+ }
2085
+ if (event.key !== 'Enter' || filtered.length === 0 || busy) return
2086
+ event.preventDefault()
2087
+ chooseModel(filtered[active])
2088
+ },
2089
+ }) : null,
2090
+ h('div', { className: 'mb-picker-list', role: 'menu' },
2091
+ filtered.length === 0
2092
+ ? h('div', { className: 'mb-picker-empty' }, term === '' ? translate('pickerEmpty') : translate('noMatch'))
2093
+ : filtered.map((entry, index) => {
2094
+ const selected = current?.provider === entry.group.id && current?.model === entry.model.id
2095
+ const previous = filtered[index - 1]
2096
+ const showGroup = previous === undefined || previous.group.id !== entry.group.id
2097
+ const price = priceByKey.get(`${entry.group.id}${MODEL_KEY_SEPARATOR}${entry.model.id}`)
2098
+ return h(Fragment, { key: `${entry.group.id}${MODEL_KEY_SEPARATOR}${entry.model.id}` },
2099
+ showGroup ? h('div', { className: 'mb-picker-group' }, entry.group.name) : null,
2100
+ h('button', {
2101
+ type: 'button',
2102
+ role: 'menuitemradio',
2103
+ className: 'mb-picker-row',
2104
+ 'aria-checked': selected ? 'true' : 'false',
2105
+ title: entry.model.name,
2106
+ // Roving tabindex: only the cursor row is reachable by Tab.
2107
+ tabIndex: index === active ? 0 : -1,
2108
+ disabled: busy,
2109
+ onClick: () => chooseModel(entry),
2110
+ },
2111
+ h('span', { className: 'mb-picker-option-copy' },
2112
+ h('span', { className: 'mb-picker-model' }, entry.model.name),
2113
+ h(PriceLines, { pricing: price, translate })),
2114
+ h('span', { className: 'mb-picker-check' },
2115
+ selected
2116
+ ? h('svg', {
2117
+ viewBox: '0 0 16 16',
2118
+ width: 14,
2119
+ height: 14,
2120
+ fill: 'none',
2121
+ stroke: 'currentColor',
2122
+ strokeWidth: 1.7,
2123
+ 'aria-hidden': true,
2124
+ },
2125
+ h('path', { d: 'M3.5 8.5l3 3 6-7' }))
2126
+ : null)))
2127
+ })),
2128
+ effortChoices.length === 0 ? null : h(Fragment, null,
2129
+ h('div', { className: 'mb-picker-divider' }),
2130
+ h('div', { className: 'mb-picker-efforts' },
2131
+ h('span', { className: 'mb-picker-efforts-label' }, translate('reasoningEffort')),
2132
+ effortChoices.map((choice) => h('button', {
2133
+ type: 'button',
2134
+ key: choice.key,
2135
+ className: 'mb-picker-chip',
2136
+ 'aria-pressed': effectiveEffort === choice.effort ? 'true' : 'false',
2137
+ disabled: busy,
2138
+ onClick: () => chooseEffort(choice.effort),
2139
+ }, choice.label)))),
2140
+ busy ? h('div', { className: 'mb-picker-status' }, translate('switching')) : null,
2141
+ errorText === null ? null : h('div', { className: 'mb-picker-error' }, errorText),
2142
+ state?.status === 'error'
2143
+ ? h('button', {
2144
+ type: 'button',
2145
+ className: 'mb-btn mb-picker-retry',
2146
+ onClick: () => {
2147
+ setFailure(null)
2148
+ directory.load().catch(() => {})
2149
+ },
2150
+ }, translate('pickerRetry'))
2151
+ : null)
2152
+
2153
+ return h('div', {
2154
+ className: 'mb-picker',
2155
+ ref: rootRef,
2156
+ },
2157
+ h(Style),
2158
+ h('button', {
2159
+ ref: triggerRef,
2160
+ type: 'button',
2161
+ className: 'mb-picker-trigger',
2162
+ disabled: locked,
2163
+ 'aria-label': triggerAria,
2164
+ 'aria-haspopup': 'menu',
2165
+ 'aria-controls': open ? `${id}-menu` : undefined,
2166
+ 'aria-expanded': open ? 'true' : 'false',
2167
+ 'aria-busy': busy ? 'true' : undefined,
2168
+ title: triggerAria,
2169
+ onClick: toggle,
2170
+ },
2171
+ h('svg', {
2172
+ viewBox: '0 0 16 16',
2173
+ width: 14,
2174
+ height: 14,
2175
+ fill: 'none',
2176
+ stroke: 'currentColor',
2177
+ strokeWidth: 1.3,
2178
+ 'aria-hidden': true,
2179
+ className: 'mb-picker-icon',
2180
+ },
2181
+ h('rect', { x: 2, y: 3, width: 12, height: 10, rx: 2 }),
2182
+ h('path', { d: 'M2 6.5h12M6 6.5V13' })),
2183
+ h('span', { className: 'mb-picker-name' }, triggerModel),
2184
+ effortLabel === undefined ? null : h('span', { className: 'mb-picker-trigger-effort' }, effortLabel),
2185
+ h('svg', {
2186
+ className: open ? 'mb-picker-chevron mb-picker-chevron-open' : 'mb-picker-chevron',
2187
+ viewBox: '0 0 16 16',
2188
+ width: 14,
2189
+ height: 14,
2190
+ fill: 'none',
2191
+ stroke: 'currentColor',
2192
+ strokeWidth: 1.5,
2193
+ 'aria-hidden': true,
2194
+ },
2195
+ h('path', { d: 'M4 6l4 4 4-4' }))),
2196
+ open ? ReactDOM.createPortal(menu, document.body) : null)
2197
+ }
2198
+
2199
+ function ModelPickerSeat(props) {
2200
+ const catalogSnap = useCatalog()
2201
+ // Take over only while we hold a usable catalog. On a fetch error or an
2202
+ // in-flight load, return null so the built-in picker keeps working
2203
+ // instead of leaving the user with no way to change models.
2204
+ if (catalogSnap.status !== 'ready') return null
2205
+ const display = readDisplay(catalogSnap.data)
2206
+ if (!display.takeoverPicker) return null
2207
+ return h(OrderedModelPicker, props)
2208
+ }
2209
+
2210
+ return {
2211
+ inject: ['slots', 'locale'],
2212
+ apply(ctx) {
2213
+ localeService = ctx.get('locale')
2214
+ publishModelDirectories(ctx.get('modelDirectories'))
2215
+ ctx.inject(['modelDirectories'], (scope) => {
2216
+ publishModelDirectories(scope.modelDirectories)
2217
+ let seat = null
2218
+ const takeoverEnabled = () => catalog.snap.status === 'ready' && readDisplay(catalog.snap.data).takeoverPicker !== false
2219
+ const syncSeat = () => {
2220
+ if (takeoverEnabled()) {
2221
+ if (seat === null) {
2222
+ seat = scope.slots.register({
2223
+ name: 'conversation.input.model',
2224
+ priority: -1,
2225
+ }, ModelPickerSeat)
2226
+ }
2227
+ return
2228
+ }
2229
+ if (seat !== null) {
2230
+ seat()
2231
+ seat = null
2232
+ }
2233
+ }
2234
+ scope.slots.inject('conversation.input.model', () => {
2235
+ const unsubscribe = subscribeCatalog(syncSeat)
2236
+ syncSeat()
2237
+ return () => {
2238
+ unsubscribe()
2239
+ if (seat !== null) {
2240
+ seat()
2241
+ seat = null
2242
+ }
2243
+ }
2244
+ })
2245
+ })
2246
+
2247
+ void ensureCatalog()
2248
+
2249
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
2250
+ name: 'settings.section',
2251
+ id: 'model-billing',
2252
+ order: 12,
2253
+ label: () => t('nav'),
2254
+ }, SettingsSection))
2255
+
2256
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
2257
+ name: 'conversation.composer.dock',
2258
+ id: 'model-billing-cost',
2259
+ order: 2,
2260
+ }, CostDock))
2261
+
2262
+ ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
2263
+ name: 'conversation.chat.turnTail',
2264
+ id: 'model-billing-turn',
2265
+ order: 30,
2266
+ }, TurnCost))
2267
+ },
2268
+ }
2269
+ },
2270
+ })