dsh-model-billing 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +172 -2
- package/client.js +1498 -0
- package/cordis.patch.yml +4 -0
- package/host.js +898 -0
- package/icon.svg +13 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
package/client.js
ADDED
|
@@ -0,0 +1,1498 @@
|
|
|
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
|
+
turnCost: '费用',
|
|
82
|
+
turnCostTitle: '根据本轮 token 用量、缓存命中与价格估算',
|
|
83
|
+
chooseModel: '选择模型',
|
|
84
|
+
loadingModels: '加载模型…',
|
|
85
|
+
searchModel: '搜索模型',
|
|
86
|
+
noMatch: '没有匹配的模型',
|
|
87
|
+
pickerEmpty: '暂无可用模型',
|
|
88
|
+
reasoningEffort: '推理强度',
|
|
89
|
+
providerDefault: '提供方默认',
|
|
90
|
+
switching: '切换中…',
|
|
91
|
+
pickerRetry: '重新加载',
|
|
92
|
+
pickerAria: '选择模型与推理强度',
|
|
93
|
+
pickerUnavailable: '模型目录尚未就绪,请刷新页面',
|
|
94
|
+
},
|
|
95
|
+
en: {
|
|
96
|
+
nav: 'Models & Cost',
|
|
97
|
+
title: 'Models & Cost',
|
|
98
|
+
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.',
|
|
99
|
+
loading: 'Loading the model catalog…',
|
|
100
|
+
loadFailed: 'Could not load the model catalog',
|
|
101
|
+
retry: 'Retry',
|
|
102
|
+
modelsCount: '{count} models',
|
|
103
|
+
save: 'Save',
|
|
104
|
+
saving: 'Saving…',
|
|
105
|
+
saved: 'Saved',
|
|
106
|
+
unsaved: 'Unsaved changes',
|
|
107
|
+
undo: 'Discard changes',
|
|
108
|
+
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.',
|
|
109
|
+
noModels: 'No configured models are available.',
|
|
110
|
+
up: 'Move up',
|
|
111
|
+
down: 'Move down',
|
|
112
|
+
cacheHitPrice: 'Input · cache hit',
|
|
113
|
+
cacheMissPrice: 'Input · cache miss',
|
|
114
|
+
outputPrice: 'Output',
|
|
115
|
+
perMillion: 'CNY / million tokens',
|
|
116
|
+
deepseekTag: 'DeepSeek',
|
|
117
|
+
deepseekNote: 'Official peak/off-peak prices are read-only; DeepSeek is now billed automatically at the event time.',
|
|
118
|
+
officialPrice: 'Official prices (read-only)',
|
|
119
|
+
peak: 'Peak',
|
|
120
|
+
offPeak: 'Off-peak',
|
|
121
|
+
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.',
|
|
122
|
+
pricePlaceholder: 'Not set',
|
|
123
|
+
priceUnset: 'Not priced',
|
|
124
|
+
priceHitShort: 'hit',
|
|
125
|
+
priceMissShort: 'miss',
|
|
126
|
+
priceOutShort: 'out',
|
|
127
|
+
priceUnit: 'CNY/M',
|
|
128
|
+
invalidPrice: 'Prices must be non-negative numbers, or blank when unset.',
|
|
129
|
+
saveFailed: 'Save failed: {message}',
|
|
130
|
+
savedAt: 'Saved at {time}',
|
|
131
|
+
formula: 'Estimate: cached input × hit price + (uncached input + cache write) × miss price + output × output price.',
|
|
132
|
+
formulaNote: 'Prices are per million tokens. Cache writes use the cache-miss price; models without a full price set produce no amount.',
|
|
133
|
+
singlePrice: 'Prices',
|
|
134
|
+
settingsOpen: 'Edit in Settings',
|
|
135
|
+
costEstimate: 'Cost estimate',
|
|
136
|
+
costDetails: 'Cost details',
|
|
137
|
+
close: 'Close',
|
|
138
|
+
thisTurn: 'Turn',
|
|
139
|
+
sessionTotal: 'Session',
|
|
140
|
+
cacheHit: 'Cache hit rate',
|
|
141
|
+
tokens: 'Input / output',
|
|
142
|
+
turn: 'Turn',
|
|
143
|
+
content: 'Content',
|
|
144
|
+
time: 'Time',
|
|
145
|
+
model: 'Model',
|
|
146
|
+
cost: 'Cost',
|
|
147
|
+
byModel: 'By model',
|
|
148
|
+
excludedShort: 'Excluded',
|
|
149
|
+
partial: 'partly unpriced',
|
|
150
|
+
partialShort: 'partly unpriced',
|
|
151
|
+
partialNote: 'Some non-DeepSeek calls do not have a complete price set; the total includes only priced calls.',
|
|
152
|
+
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.',
|
|
153
|
+
turnCost: 'Cost',
|
|
154
|
+
turnCostTitle: 'Estimated from this turn’s tokens, cache hits and prices',
|
|
155
|
+
chooseModel: 'Select model',
|
|
156
|
+
loadingModels: 'Loading models…',
|
|
157
|
+
searchModel: 'Search models',
|
|
158
|
+
noMatch: 'No matching model',
|
|
159
|
+
pickerEmpty: 'No model available',
|
|
160
|
+
reasoningEffort: 'Reasoning effort',
|
|
161
|
+
providerDefault: 'Provider default',
|
|
162
|
+
switching: 'Switching…',
|
|
163
|
+
pickerRetry: 'Reload',
|
|
164
|
+
pickerAria: 'Select model and reasoning effort',
|
|
165
|
+
pickerUnavailable: 'The model directory is not ready; reload the page',
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const t = (key, values) => {
|
|
170
|
+
const active = localeService?.getSnapshot?.().active ?? 'zh'
|
|
171
|
+
const dict = active.toLowerCase().startsWith('en') ? STRINGS.en : STRINGS.zh
|
|
172
|
+
const template = dict[key] ?? STRINGS.zh[key] ?? key
|
|
173
|
+
return values === undefined
|
|
174
|
+
? template
|
|
175
|
+
: template.replace(/\{(\w+)\}/g, (match, name) => values[name] === undefined ? match : String(values[name]))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let localeService
|
|
179
|
+
let modelDirectories
|
|
180
|
+
let modelDirectoryRevision = 0
|
|
181
|
+
const modelDirectoryListeners = new Set()
|
|
182
|
+
|
|
183
|
+
function publishModelDirectories(next) {
|
|
184
|
+
if (next === undefined || next === modelDirectories) return
|
|
185
|
+
modelDirectories = next
|
|
186
|
+
modelDirectoryRevision += 1
|
|
187
|
+
for (const listener of [...modelDirectoryListeners]) listener()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function subscribeModelDirectories(listener) {
|
|
191
|
+
modelDirectoryListeners.add(listener)
|
|
192
|
+
return () => modelDirectoryListeners.delete(listener)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function useModelDirectoriesRevision() {
|
|
196
|
+
return useSyncExternalStore(
|
|
197
|
+
subscribeModelDirectories,
|
|
198
|
+
() => modelDirectoryRevision,
|
|
199
|
+
() => 0,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function subscribeLocale(listener) {
|
|
204
|
+
return localeService?.subscribe?.(listener) ?? (() => {})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function localeRevision() {
|
|
208
|
+
return localeService?.getSnapshot?.().revision ?? 0
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function useT() {
|
|
212
|
+
useSyncExternalStore(subscribeLocale, localeRevision, () => 0)
|
|
213
|
+
return t
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------
|
|
217
|
+
// Shared catalog state
|
|
218
|
+
// ---------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
const catalog = {
|
|
221
|
+
snap: { status: 'idle', data: null, error: null },
|
|
222
|
+
listeners: new Set(),
|
|
223
|
+
inflight: null,
|
|
224
|
+
generation: 0,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function requestJson(url, options) {
|
|
228
|
+
const response = await fetch(url, {
|
|
229
|
+
credentials: 'same-origin',
|
|
230
|
+
cache: 'no-store',
|
|
231
|
+
...options,
|
|
232
|
+
headers: {
|
|
233
|
+
accept: 'application/json',
|
|
234
|
+
...(options?.headers ?? {}),
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
let body
|
|
238
|
+
try {
|
|
239
|
+
body = await response.json()
|
|
240
|
+
} catch {
|
|
241
|
+
throw new Error(`HTTP ${response.status}`)
|
|
242
|
+
}
|
|
243
|
+
if (!response.ok || body?.ok === false) {
|
|
244
|
+
throw new Error(body?.message ?? `HTTP ${response.status}`)
|
|
245
|
+
}
|
|
246
|
+
return body
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function emitCatalog() {
|
|
250
|
+
for (const listener of [...catalog.listeners]) listener()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function setCatalog(draft) {
|
|
254
|
+
catalog.snap = { ...catalog.snap, ...draft }
|
|
255
|
+
emitCatalog()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function ensureCatalog(force = false) {
|
|
259
|
+
if (!force && catalog.snap.status === 'ready') return catalog.snap.data
|
|
260
|
+
if (catalog.inflight !== null && !force) return catalog.inflight
|
|
261
|
+
catalog.generation += 1
|
|
262
|
+
const generation = catalog.generation
|
|
263
|
+
setCatalog({ status: 'loading', error: null })
|
|
264
|
+
const operation = requestJson('/model-billing/catalog')
|
|
265
|
+
.then((data) => {
|
|
266
|
+
if (generation !== catalog.generation) return data
|
|
267
|
+
setCatalog({ status: 'ready', data, error: null })
|
|
268
|
+
return data
|
|
269
|
+
})
|
|
270
|
+
.catch((error) => {
|
|
271
|
+
if (generation === catalog.generation) {
|
|
272
|
+
setCatalog({ status: 'error', error: error instanceof Error ? error.message : String(error) })
|
|
273
|
+
}
|
|
274
|
+
return null
|
|
275
|
+
})
|
|
276
|
+
.finally(() => {
|
|
277
|
+
if (catalog.inflight === operation) catalog.inflight = null
|
|
278
|
+
})
|
|
279
|
+
catalog.inflight = operation
|
|
280
|
+
return operation
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function subscribeCatalog(listener) {
|
|
284
|
+
catalog.listeners.add(listener)
|
|
285
|
+
if (catalog.snap.status === 'idle') void ensureCatalog()
|
|
286
|
+
return () => catalog.listeners.delete(listener)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function catalogSnapshot() {
|
|
290
|
+
return catalog.snap
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function useCatalog() {
|
|
294
|
+
return useSyncExternalStore(subscribeCatalog, catalogSnapshot, catalogSnapshot)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------
|
|
298
|
+
// Shared session usage state
|
|
299
|
+
// ---------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
const usageSlots = new Map()
|
|
302
|
+
|
|
303
|
+
function usageSlot(sessionId) {
|
|
304
|
+
let slot = usageSlots.get(sessionId)
|
|
305
|
+
if (slot === undefined) {
|
|
306
|
+
slot = {
|
|
307
|
+
snap: { status: 'idle', data: null, error: null },
|
|
308
|
+
listeners: new Set(),
|
|
309
|
+
inflight: null,
|
|
310
|
+
generation: 0,
|
|
311
|
+
revision: null,
|
|
312
|
+
}
|
|
313
|
+
usageSlots.set(sessionId, slot)
|
|
314
|
+
}
|
|
315
|
+
return slot
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function emitUsage(slot) {
|
|
319
|
+
for (const listener of [...slot.listeners]) listener()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function ensureUsage(sessionId, revision, force = false) {
|
|
323
|
+
const slot = usageSlot(sessionId)
|
|
324
|
+
if (!force && slot.revision === revision && slot.snap.status !== 'error') return
|
|
325
|
+
if (slot.inflight !== null && slot.inflight === revision && !force) return
|
|
326
|
+
slot.revision = revision
|
|
327
|
+
slot.generation += 1
|
|
328
|
+
const generation = slot.generation
|
|
329
|
+
slot.snap = { ...slot.snap, status: 'loading', error: null }
|
|
330
|
+
emitUsage(slot)
|
|
331
|
+
const operation = requestJson(`/model-billing/usage?sessionId=${encodeURIComponent(sessionId)}`)
|
|
332
|
+
.then((data) => {
|
|
333
|
+
if (generation !== slot.generation) return
|
|
334
|
+
slot.snap = { status: 'ready', data, error: null }
|
|
335
|
+
emitUsage(slot)
|
|
336
|
+
})
|
|
337
|
+
.catch((error) => {
|
|
338
|
+
if (generation !== slot.generation) return
|
|
339
|
+
slot.snap = {
|
|
340
|
+
status: 'error',
|
|
341
|
+
data: slot.snap.data,
|
|
342
|
+
error: error instanceof Error ? error.message : String(error),
|
|
343
|
+
}
|
|
344
|
+
emitUsage(slot)
|
|
345
|
+
})
|
|
346
|
+
.finally(() => {
|
|
347
|
+
if (slot.inflight === operation) slot.inflight = null
|
|
348
|
+
})
|
|
349
|
+
slot.inflight = operation
|
|
350
|
+
return operation
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function useSessionUsage(sessionId, revision) {
|
|
354
|
+
const slot = usageSlot(sessionId)
|
|
355
|
+
const snap = useSyncExternalStore(
|
|
356
|
+
(listener) => {
|
|
357
|
+
slot.listeners.add(listener)
|
|
358
|
+
return () => slot.listeners.delete(listener)
|
|
359
|
+
},
|
|
360
|
+
() => slot.snap,
|
|
361
|
+
() => slot.snap,
|
|
362
|
+
)
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
if (sessionId !== undefined && revision !== null) ensureUsage(sessionId, revision)
|
|
365
|
+
}, [sessionId, revision])
|
|
366
|
+
return snap
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function tokenUsageRevision(tokenUsage) {
|
|
370
|
+
if (tokenUsage === undefined || tokenUsage === null) return 'none'
|
|
371
|
+
return [
|
|
372
|
+
tokenUsage.uncachedInputTokens ?? '',
|
|
373
|
+
tokenUsage.outputTokens ?? '',
|
|
374
|
+
tokenUsage.cacheReadTokens ?? '',
|
|
375
|
+
tokenUsage.cacheWriteTokens ?? '',
|
|
376
|
+
].join(':')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------
|
|
380
|
+
// Formatting
|
|
381
|
+
// ---------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
function formatMoney(value) {
|
|
384
|
+
if (value === null || value === undefined || !Number.isFinite(value)) return '—'
|
|
385
|
+
if (value === 0) return '¥0'
|
|
386
|
+
const fixed = value.toFixed(2)
|
|
387
|
+
return fixed === '0.00' ? '<¥0.01' : `¥${fixed}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function formatTokens(value) {
|
|
391
|
+
if (!Number.isFinite(value)) return '—'
|
|
392
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
|
|
393
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
|
|
394
|
+
return String(Math.round(value))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function formatPercent(value) {
|
|
398
|
+
return value === null || value === undefined ? '—' : `${(value * 100).toFixed(1)}%`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function formatPrice(value) {
|
|
402
|
+
if (value === null || value === undefined) return '—'
|
|
403
|
+
return String(Number(value.toFixed(6)))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function PriceLines({ pricing, translate }) {
|
|
407
|
+
if (pricing?.builtin === true) {
|
|
408
|
+
const line = (label, prices) =>
|
|
409
|
+
`${label} ${translate('priceHitShort')} ${formatPrice(prices?.cacheHit)} · ${translate('priceMissShort')} ${formatPrice(prices?.cacheMiss)} · ${translate('priceOutShort')} ${formatPrice(prices?.output)} ${translate('priceUnit')}`
|
|
410
|
+
return h('div', { className: 'mb-picker-prices' },
|
|
411
|
+
h('div', { className: 'mb-picker-price-line' }, line(translate('peak'), pricing.peak)),
|
|
412
|
+
h('div', { className: 'mb-picker-price-line' }, line(translate('offPeak'), pricing.offPeak)))
|
|
413
|
+
}
|
|
414
|
+
const hasAny = pricing !== null && pricing !== undefined
|
|
415
|
+
&& [pricing.cacheHit, pricing.cacheMiss, pricing.output].some((value) => typeof value === 'number')
|
|
416
|
+
if (!hasAny) {
|
|
417
|
+
return h('div', { className: 'mb-picker-prices' }, translate('priceUnset'))
|
|
418
|
+
}
|
|
419
|
+
return h('div', { className: 'mb-picker-prices' },
|
|
420
|
+
h('div', { className: 'mb-picker-price-line' },
|
|
421
|
+
`${translate('priceHitShort')} ${formatPrice(pricing.cacheHit)} · ${translate('priceMissShort')} ${formatPrice(pricing.cacheMiss)} · ${translate('priceOutShort')} ${formatPrice(pricing.output)} ${translate('priceUnit')}`))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function timeText(timestamp) {
|
|
425
|
+
if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
|
|
426
|
+
try {
|
|
427
|
+
return new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
428
|
+
} catch {
|
|
429
|
+
return ''
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------
|
|
434
|
+
// Styles
|
|
435
|
+
// ---------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
const CSS = `
|
|
438
|
+
.mb-page { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; padding: 4px 0 28px; }
|
|
439
|
+
.mb-page * { box-sizing: border-box; }
|
|
440
|
+
.mb-title { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
|
|
441
|
+
.mb-intro { margin: 0 0 14px; color: var(--dsw-alias-label-secondary); max-width: 760px; }
|
|
442
|
+
.mb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 14px 0; }
|
|
443
|
+
.mb-muted { color: var(--dsw-alias-label-secondary); }
|
|
444
|
+
.mb-spacer { flex: 1; }
|
|
445
|
+
.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; }
|
|
446
|
+
.mb-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
|
|
447
|
+
.mb-btn:disabled { opacity: .45; cursor: default; }
|
|
448
|
+
.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; }
|
|
449
|
+
.mb-btn-primary:hover:not(:disabled) { background: var(--dsw-alias-brand-primary); opacity: .9; }
|
|
450
|
+
.mb-btn-icon { width: 28px; height: 28px; padding: 0; font-size: 13px; }
|
|
451
|
+
.mb-status { color: var(--dsw-alias-label-secondary); min-height: 18px; }
|
|
452
|
+
.mb-status[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
|
|
453
|
+
.mb-status[data-tone="success"] { color: var(--dsw-alias-state-success-primary); }
|
|
454
|
+
.mb-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; max-width: 980px; }
|
|
455
|
+
.mb-card { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 10px; padding: 12px 14px; }
|
|
456
|
+
.mb-card-head { display: flex; align-items: flex-start; gap: 12px; }
|
|
457
|
+
.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; }
|
|
458
|
+
.mb-name { font-weight: 600; font-size: 14px; }
|
|
459
|
+
.mb-sub { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; }
|
|
460
|
+
.mb-head-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
461
|
+
.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; }
|
|
462
|
+
.mb-tag-excluded { color: var(--dsw-alias-state-warn-primary); }
|
|
463
|
+
.mb-prices { display: grid; gap: 10px; margin-top: 12px; }
|
|
464
|
+
.mb-price-group { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px 12px 12px; }
|
|
465
|
+
.mb-price-group-title { display: flex; gap: 8px; align-items: baseline; margin-bottom: 8px; font-weight: 600; }
|
|
466
|
+
.mb-price-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
|
467
|
+
.mb-field { display: grid; gap: 4px; min-width: 0; }
|
|
468
|
+
.mb-field-label { color: var(--dsw-alias-label-secondary); font-size: 11px; }
|
|
469
|
+
.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; }
|
|
470
|
+
.mb-input:focus { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -1px; }
|
|
471
|
+
.mb-note { margin: 8px 0 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
|
|
472
|
+
.mb-warn { margin: 6px 0 0; color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
|
|
473
|
+
.mb-empty { border: 1px dashed var(--dsw-alias-border-l2); border-radius: 10px; padding: 28px; text-align: center; color: var(--dsw-alias-label-secondary); }
|
|
474
|
+
.mb-footer-note { max-width: 980px; margin-top: 14px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
|
|
475
|
+
.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)); }
|
|
476
|
+
.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; }
|
|
477
|
+
.mb-dock-trigger:hover, .mb-dock-trigger[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
|
|
478
|
+
.mb-dock-amount { color: var(--dsw-alias-label-secondary); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
479
|
+
.mb-dock-sep { opacity: .55; }
|
|
480
|
+
.mb-dock-icon { flex: none; width: 14px; height: 14px; }
|
|
481
|
+
.mb-dock-caret { font-size: 10px; opacity: .7; }
|
|
482
|
+
.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); }
|
|
483
|
+
.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); }
|
|
484
|
+
.mb-panel-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
485
|
+
.mb-panel-title { font-weight: 600; }
|
|
486
|
+
.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; }
|
|
487
|
+
.mb-panel-close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
488
|
+
.mb-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
|
|
489
|
+
.mb-summary-item { background: var(--dsw-alias-bg-layer-2); border-radius: 7px; padding: 7px 9px; min-width: 0; }
|
|
490
|
+
.mb-summary-key { color: var(--dsw-alias-label-secondary); font-size: 11px; }
|
|
491
|
+
.mb-summary-value { font-variant-numeric: tabular-nums; font-weight: 600; overflow-wrap: anywhere; }
|
|
492
|
+
.mb-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
493
|
+
.mb-table th, .mb-table td { text-align: left; padding: 4px 5px; border-bottom: 1px solid var(--dsw-alias-border-l1); vertical-align: top; }
|
|
494
|
+
.mb-table th { color: var(--dsw-alias-label-secondary); font-weight: 500; font-size: 11px; }
|
|
495
|
+
.mb-table td.num, .mb-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
496
|
+
.mb-table tr:last-child td { border-bottom: 0; }
|
|
497
|
+
.mb-turn-table { table-layout: fixed; }
|
|
498
|
+
.mb-turn-table th, .mb-turn-table td, .mb-turn-table th.num, .mb-turn-table td.num { text-align: left; }
|
|
499
|
+
.mb-turn-table th:nth-child(1), .mb-turn-table td:nth-child(1) { width: 5%; }
|
|
500
|
+
.mb-turn-table th:nth-child(2), .mb-turn-table td:nth-child(2) { width: 26.5%; }
|
|
501
|
+
.mb-turn-table th:nth-child(3), .mb-turn-table td:nth-child(3) { width: 12.5%; }
|
|
502
|
+
.mb-turn-table th:nth-child(4), .mb-turn-table td:nth-child(4) { width: 20%; }
|
|
503
|
+
.mb-turn-table th:nth-child(5), .mb-turn-table td:nth-child(5) { width: 10%; }
|
|
504
|
+
.mb-turn-table th:nth-child(6), .mb-turn-table td:nth-child(6) { width: 16%; }
|
|
505
|
+
.mb-turn-table th:nth-child(7), .mb-turn-table td:nth-child(7) { width: 10%; }
|
|
506
|
+
.mb-turn-preview, .mb-turn-model { color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
507
|
+
.mb-turn-model { color: var(--dsw-alias-label-primary); }
|
|
508
|
+
.mb-panel-note { margin: 9px 0 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
|
|
509
|
+
.mb-turn { color: var(--dsw-alias-label-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
510
|
+
.mb-turn strong { color: var(--dsw-alias-label-primary); font-weight: 600; }
|
|
511
|
+
.mb-picker { position: relative; display: flex; min-width: 0; }
|
|
512
|
+
.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; }
|
|
513
|
+
.mb-picker-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
|
514
|
+
.mb-picker-trigger:focus-visible { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
|
|
515
|
+
.mb-picker-trigger:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary)); cursor: default; }
|
|
516
|
+
.mb-picker-icon { display: var(--dsh-composer-model-icon-display, none); flex: none; }
|
|
517
|
+
.mb-picker-name { color: inherit; text-overflow: ellipsis; white-space: nowrap; min-width: 0; overflow: hidden; display: var(--dsh-composer-model-text-display, block); }
|
|
518
|
+
.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); }
|
|
519
|
+
.mb-picker-chevron { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); flex: none; transition: transform .12s; }
|
|
520
|
+
.mb-picker-chevron-open { transform: rotate(180deg); }
|
|
521
|
+
.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; }
|
|
522
|
+
.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; }
|
|
523
|
+
.mb-picker-search::placeholder { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); }
|
|
524
|
+
.mb-picker-search:focus { border-color: var(--dsw-alias-state-business-primary); outline: none; }
|
|
525
|
+
.mb-picker-list { min-height: 0; overflow-y: auto; padding: 0 2px; display: flex; flex-direction: column; }
|
|
526
|
+
.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; }
|
|
527
|
+
.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; }
|
|
528
|
+
.mb-picker-row + .mb-picker-row { margin-top: 2px; }
|
|
529
|
+
.mb-picker-row:hover:not(:disabled), .mb-picker-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
|
|
530
|
+
.mb-picker-row:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary)); cursor: default; }
|
|
531
|
+
.mb-picker-option-copy { flex-direction: column; flex: 1; min-width: 0; gap: 4px; display: flex; }
|
|
532
|
+
.mb-picker-model { color: inherit; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 400; line-height: 20px; overflow: hidden; }
|
|
533
|
+
.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; }
|
|
534
|
+
.mb-picker-price-line { white-space: normal; overflow-wrap: anywhere; }
|
|
535
|
+
.mb-picker-check { color: var(--dsw-alias-label-primary); flex: 0 0 14px; place-items: center; display: grid; font-size: 12px; }
|
|
536
|
+
.mb-picker-check svg { width: 14px; height: 14px; }
|
|
537
|
+
.mb-picker-empty { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); padding: 8px; font-size: 12px; line-height: 18px; }
|
|
538
|
+
.mb-picker-divider { border-top: 1px solid var(--dsw-alias-border-l1); margin: 3px 0; }
|
|
539
|
+
.mb-picker-efforts { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 2px 4px 4px; }
|
|
540
|
+
.mb-picker-efforts-label { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); font-size: 11px; margin-right: 2px; }
|
|
541
|
+
.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; }
|
|
542
|
+
.mb-picker-chip:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
543
|
+
.mb-picker-chip[aria-pressed="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
544
|
+
.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; }
|
|
545
|
+
.mb-picker-retry { align-self: flex-start; margin: 0 4px 2px; }
|
|
546
|
+
.mb-picker-status { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); padding: 4px 7px; font-size: 11px; line-height: 16px; }
|
|
547
|
+
.mb-fatal { border: 1px solid var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); border-radius: 8px; padding: 12px; }
|
|
548
|
+
@media (max-width: 720px) {
|
|
549
|
+
.mb-price-grid { grid-template-columns: 1fr; }
|
|
550
|
+
.mb-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
551
|
+
.mb-head-actions { width: 100%; margin-left: 38px; justify-content: flex-start; }
|
|
552
|
+
}
|
|
553
|
+
`
|
|
554
|
+
|
|
555
|
+
function Style() {
|
|
556
|
+
return h('style', null, CSS)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------------
|
|
560
|
+
// Settings section
|
|
561
|
+
// ---------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
|
|
564
|
+
const PRICE_LABEL_KEYS = {
|
|
565
|
+
cacheHit: 'cacheHitPrice',
|
|
566
|
+
cacheMiss: 'cacheMissPrice',
|
|
567
|
+
output: 'outputPrice',
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function toDraftPricing(pricing) {
|
|
571
|
+
if (pricing?.builtin === true) return { ...pricing, peak: { ...pricing.peak }, offPeak: { ...pricing.offPeak } }
|
|
572
|
+
const source = pricing ?? {}
|
|
573
|
+
return Object.fromEntries(PRICE_FIELDS.map((field) => [
|
|
574
|
+
field,
|
|
575
|
+
source[field] === null || source[field] === undefined ? '' : String(source[field]),
|
|
576
|
+
]))
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function toDraft(models) {
|
|
580
|
+
return models.map((model) => ({
|
|
581
|
+
provider: model.provider,
|
|
582
|
+
providerName: model.providerName,
|
|
583
|
+
model: model.model,
|
|
584
|
+
name: model.name,
|
|
585
|
+
isDeepSeek: model.isDeepSeek,
|
|
586
|
+
pricing: toDraftPricing(model.pricing),
|
|
587
|
+
}))
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function parsePriceInput(value) {
|
|
591
|
+
if (typeof value === 'string' && value.trim() === '') return null
|
|
592
|
+
const number = Number(value)
|
|
593
|
+
if (!Number.isFinite(number) || number < 0 || number > 1e12) return undefined
|
|
594
|
+
return Math.round(number * 1e8) / 1e8
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function SettingsSection() {
|
|
598
|
+
const translate = useT()
|
|
599
|
+
const snap = useCatalog()
|
|
600
|
+
const [draft, setDraft] = useState(null)
|
|
601
|
+
const [dirty, setDirty] = useState(false)
|
|
602
|
+
const [saving, setSaving] = useState(false)
|
|
603
|
+
const [status, setStatus] = useState(null)
|
|
604
|
+
|
|
605
|
+
useEffect(() => {
|
|
606
|
+
if (snap.data === null) return
|
|
607
|
+
setDraft(toDraft(snap.data.models))
|
|
608
|
+
setDirty(false)
|
|
609
|
+
}, [snap.data])
|
|
610
|
+
|
|
611
|
+
const models = draft ?? []
|
|
612
|
+
const update = (index, change) => {
|
|
613
|
+
setDraft((current) => current === null ? current : current.map((entry, at) => at === index ? change(entry) : entry))
|
|
614
|
+
setDirty(true)
|
|
615
|
+
setStatus(null)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const updatePrice = (index, field, value) => {
|
|
619
|
+
update(index, (entry) => ({
|
|
620
|
+
...entry,
|
|
621
|
+
pricing: { ...entry.pricing, [field]: value },
|
|
622
|
+
}))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const move = (index, delta) => {
|
|
626
|
+
const target = index + delta
|
|
627
|
+
if (target < 0 || target >= models.length) return
|
|
628
|
+
setDraft((current) => {
|
|
629
|
+
if (current === null) return current
|
|
630
|
+
const next = [...current]
|
|
631
|
+
const [item] = next.splice(index, 1)
|
|
632
|
+
next.splice(target, 0, item)
|
|
633
|
+
return next
|
|
634
|
+
})
|
|
635
|
+
setDirty(true)
|
|
636
|
+
setStatus(null)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const reset = () => {
|
|
640
|
+
if (snap.data !== null) setDraft(toDraft(snap.data.models))
|
|
641
|
+
setDirty(false)
|
|
642
|
+
setStatus(null)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const save = async () => {
|
|
646
|
+
if (draft === null) return
|
|
647
|
+
const payload = []
|
|
648
|
+
for (const entry of draft) {
|
|
649
|
+
if (entry.pricing.builtin === true) {
|
|
650
|
+
payload.push({ provider: entry.provider, model: entry.model, pricing: {} })
|
|
651
|
+
continue
|
|
652
|
+
}
|
|
653
|
+
const pricing = {}
|
|
654
|
+
for (const field of PRICE_FIELDS) {
|
|
655
|
+
const parsed = parsePriceInput(entry.pricing[field])
|
|
656
|
+
if (parsed === undefined) {
|
|
657
|
+
setStatus({ tone: 'error', text: translate('invalidPrice') })
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
pricing[field] = parsed
|
|
661
|
+
}
|
|
662
|
+
payload.push({ provider: entry.provider, model: entry.model, pricing })
|
|
663
|
+
}
|
|
664
|
+
setSaving(true)
|
|
665
|
+
setStatus(null)
|
|
666
|
+
try {
|
|
667
|
+
await requestJson('/model-billing/save', {
|
|
668
|
+
method: 'POST',
|
|
669
|
+
headers: { 'content-type': 'application/json' },
|
|
670
|
+
body: JSON.stringify({ models: payload }),
|
|
671
|
+
})
|
|
672
|
+
setDirty(false)
|
|
673
|
+
setStatus({ tone: 'success', text: translate('saved') })
|
|
674
|
+
await ensureCatalog(true)
|
|
675
|
+
} catch (error) {
|
|
676
|
+
setStatus({ tone: 'error', text: translate('saveFailed', { message: error instanceof Error ? error.message : String(error) }) })
|
|
677
|
+
} finally {
|
|
678
|
+
setSaving(false)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (snap.data === null) {
|
|
683
|
+
return h(Fragment, null,
|
|
684
|
+
h(Style),
|
|
685
|
+
h('section', { className: 'mb-page' },
|
|
686
|
+
h('h2', { className: 'mb-title' }, translate('title')),
|
|
687
|
+
snap.status === 'error'
|
|
688
|
+
? h('div', { className: 'mb-fatal' },
|
|
689
|
+
h('div', null, translate('loadFailed')),
|
|
690
|
+
h('p', { className: 'mb-note' }, snap.error),
|
|
691
|
+
h('button', { type: 'button', className: 'mb-btn', onClick: () => void ensureCatalog(true) }, translate('retry')))
|
|
692
|
+
: h('p', { className: 'mb-muted' }, translate('loading'))))
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (draft === null) {
|
|
696
|
+
return h(Fragment, null,
|
|
697
|
+
h(Style),
|
|
698
|
+
h('section', { className: 'mb-page' },
|
|
699
|
+
h('h2', { className: 'mb-title' }, translate('title')),
|
|
700
|
+
h('p', { className: 'mb-muted' }, translate('loading'))))
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (models.length === 0) {
|
|
704
|
+
return h(Fragment, null,
|
|
705
|
+
h(Style),
|
|
706
|
+
h('section', { className: 'mb-page' },
|
|
707
|
+
h('h2', { className: 'mb-title' }, translate('title')),
|
|
708
|
+
h('p', { className: 'mb-intro' }, translate('intro')),
|
|
709
|
+
h('div', { className: 'mb-empty' }, translate('noModels'))))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return h(Fragment, null,
|
|
713
|
+
h(Style),
|
|
714
|
+
h('section', { className: 'mb-page' },
|
|
715
|
+
h('h2', { className: 'mb-title' }, translate('title')),
|
|
716
|
+
h('p', { className: 'mb-intro' }, translate('intro')),
|
|
717
|
+
h('div', { className: 'mb-toolbar' },
|
|
718
|
+
h('span', { className: 'mb-muted' }, translate('modelsCount', { count: models.length })),
|
|
719
|
+
h('span', { className: 'mb-spacer' }),
|
|
720
|
+
dirty ? h('span', { className: 'mb-muted' }, translate('unsaved')) : null,
|
|
721
|
+
h('button', { type: 'button', className: 'mb-btn', disabled: !dirty || saving, onClick: reset }, translate('undo')),
|
|
722
|
+
h('button', { type: 'button', className: 'mb-btn mb-btn-primary', disabled: !dirty || saving, onClick: () => void save() }, saving ? translate('saving') : translate('save'))),
|
|
723
|
+
status === null ? null : h('div', { className: 'mb-status', 'data-tone': status.tone, role: 'status' }, status.text),
|
|
724
|
+
h('p', { className: 'mb-note' }, translate('orderHint')),
|
|
725
|
+
h('ul', { className: 'mb-list' },
|
|
726
|
+
models.map((entry, index) => h(SettingsModelCard, {
|
|
727
|
+
key: `${entry.provider}\u0000${entry.model}`,
|
|
728
|
+
entry,
|
|
729
|
+
index,
|
|
730
|
+
count: models.length,
|
|
731
|
+
translate,
|
|
732
|
+
onMove: move,
|
|
733
|
+
onUpdatePrice: updatePrice,
|
|
734
|
+
}))),
|
|
735
|
+
h('p', { className: 'mb-footer-note' }, translate('formula')),
|
|
736
|
+
h('p', { className: 'mb-footer-note' }, translate('formulaNote'))))
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function SettingsModelCard({ entry, index, count, translate, onMove, onUpdatePrice }) {
|
|
740
|
+
const id = useId()
|
|
741
|
+
const pricing = entry.pricing
|
|
742
|
+
const builtin = pricing.builtin === true
|
|
743
|
+
const fields = builtin ? null : PRICE_FIELDS.map((field) => {
|
|
744
|
+
const inputId = `${id}-${field}`
|
|
745
|
+
return h('label', { className: 'mb-field', htmlFor: inputId, key: field },
|
|
746
|
+
h('span', { className: 'mb-field-label' }, translate(PRICE_LABEL_KEYS[field])),
|
|
747
|
+
h('input', {
|
|
748
|
+
id: inputId,
|
|
749
|
+
className: 'mb-input',
|
|
750
|
+
type: 'text',
|
|
751
|
+
inputMode: 'decimal',
|
|
752
|
+
autoComplete: 'off',
|
|
753
|
+
placeholder: translate('pricePlaceholder'),
|
|
754
|
+
value: pricing[field],
|
|
755
|
+
onChange: (event) => onUpdatePrice(index, field, event.target.value),
|
|
756
|
+
}))
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
return h('li', { className: 'mb-card' },
|
|
760
|
+
h('div', { className: 'mb-card-head' },
|
|
761
|
+
h('div', { className: 'mb-index' }, index + 1),
|
|
762
|
+
h('div', { style: { minWidth: 0 } },
|
|
763
|
+
h('div', { className: 'mb-name' },
|
|
764
|
+
entry.name,
|
|
765
|
+
entry.isDeepSeek ? h('span', { className: 'mb-tag' }, translate('deepseekTag')) : null),
|
|
766
|
+
h('div', { className: 'mb-sub' }, `${entry.providerName} · ${entry.provider} / ${entry.model}`)),
|
|
767
|
+
h('div', { className: 'mb-head-actions' },
|
|
768
|
+
h('button', {
|
|
769
|
+
type: 'button',
|
|
770
|
+
className: 'mb-btn mb-btn-icon',
|
|
771
|
+
disabled: index === 0,
|
|
772
|
+
title: translate('up'),
|
|
773
|
+
'aria-label': translate('up'),
|
|
774
|
+
onClick: () => onMove(index, -1),
|
|
775
|
+
}, '↑'),
|
|
776
|
+
h('button', {
|
|
777
|
+
type: 'button',
|
|
778
|
+
className: 'mb-btn mb-btn-icon',
|
|
779
|
+
disabled: index === count - 1,
|
|
780
|
+
title: translate('down'),
|
|
781
|
+
'aria-label': translate('down'),
|
|
782
|
+
onClick: () => onMove(index, 1),
|
|
783
|
+
}, '↓'))),
|
|
784
|
+
entry.isDeepSeek ? h('p', { className: 'mb-note' }, translate('deepseekNote')) : null,
|
|
785
|
+
h('div', { className: 'mb-prices' },
|
|
786
|
+
h('div', { className: 'mb-price-group' },
|
|
787
|
+
h('div', { className: 'mb-price-group-title' },
|
|
788
|
+
h('span', null, builtin ? translate('officialPrice') : translate('singlePrice')),
|
|
789
|
+
h('span', { className: 'mb-muted', style: { fontWeight: 400, fontSize: '11px' } }, translate('perMillion'))),
|
|
790
|
+
builtin
|
|
791
|
+
? h(Fragment, null,
|
|
792
|
+
h(PriceLines, { pricing, translate }),
|
|
793
|
+
h('p', { className: 'mb-note' }, translate('deepseekSchedule')))
|
|
794
|
+
: h('div', { className: 'mb-price-grid' }, fields))))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ---------------------------------------------------------------------
|
|
798
|
+
// Composer dock cost summary
|
|
799
|
+
// ---------------------------------------------------------------------
|
|
800
|
+
|
|
801
|
+
function CostDock({ sessionId, useProjection }) {
|
|
802
|
+
const translate = useT()
|
|
803
|
+
const tokenUsage = useProjection('tokenUsage')
|
|
804
|
+
const revision = tokenUsageRevision(tokenUsage)
|
|
805
|
+
const usage = useSessionUsage(sessionId, revision)
|
|
806
|
+
const catalogSnap = useCatalog()
|
|
807
|
+
const [open, setOpen] = useState(false)
|
|
808
|
+
const [panelPos, setPanelPos] = useState(null)
|
|
809
|
+
const triggerRef = useRef(null)
|
|
810
|
+
const panelRef = useRef(null)
|
|
811
|
+
|
|
812
|
+
useLayoutEffect(() => {
|
|
813
|
+
if (!open) {
|
|
814
|
+
setPanelPos(null)
|
|
815
|
+
return undefined
|
|
816
|
+
}
|
|
817
|
+
const place = () => {
|
|
818
|
+
const rect = triggerRef.current?.getBoundingClientRect()
|
|
819
|
+
if (rect === undefined) return
|
|
820
|
+
const width = Math.min(660, window.innerWidth - 24)
|
|
821
|
+
const naturalLeft = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))
|
|
822
|
+
// The dock sits near the right edge; shift the whole detail panel left so
|
|
823
|
+
// the bottom-right whale widget cannot cover it.
|
|
824
|
+
const left = Math.max(12, naturalLeft - 120)
|
|
825
|
+
const spaceAbove = rect.top - 12
|
|
826
|
+
const spaceBelow = window.innerHeight - rect.bottom - 12
|
|
827
|
+
const above = spaceAbove > spaceBelow
|
|
828
|
+
const maxHeight = Math.min(430, Math.max(180, above ? spaceAbove : spaceBelow))
|
|
829
|
+
setPanelPos(above
|
|
830
|
+
? { left, width, bottom: window.innerHeight - rect.top + 8, maxHeight }
|
|
831
|
+
: { left, width, top: rect.bottom + 8, maxHeight })
|
|
832
|
+
}
|
|
833
|
+
place()
|
|
834
|
+
window.addEventListener('resize', place)
|
|
835
|
+
window.addEventListener('scroll', place, true)
|
|
836
|
+
return () => {
|
|
837
|
+
window.removeEventListener('resize', place)
|
|
838
|
+
window.removeEventListener('scroll', place, true)
|
|
839
|
+
}
|
|
840
|
+
}, [open])
|
|
841
|
+
|
|
842
|
+
useEffect(() => {
|
|
843
|
+
if (!open) return undefined
|
|
844
|
+
const closeOutside = (event) => {
|
|
845
|
+
if (triggerRef.current?.contains(event.target) === true) return
|
|
846
|
+
if (panelRef.current?.contains(event.target) === true) return
|
|
847
|
+
setOpen(false)
|
|
848
|
+
}
|
|
849
|
+
const closeOnEscape = (event) => {
|
|
850
|
+
if (event.key !== 'Escape') return
|
|
851
|
+
event.preventDefault()
|
|
852
|
+
setOpen(false)
|
|
853
|
+
triggerRef.current?.focus()
|
|
854
|
+
}
|
|
855
|
+
document.addEventListener('mousedown', closeOutside)
|
|
856
|
+
document.addEventListener('keydown', closeOnEscape)
|
|
857
|
+
return () => {
|
|
858
|
+
document.removeEventListener('mousedown', closeOutside)
|
|
859
|
+
document.removeEventListener('keydown', closeOnEscape)
|
|
860
|
+
}
|
|
861
|
+
}, [open])
|
|
862
|
+
|
|
863
|
+
const data = usage.data
|
|
864
|
+
if (data === null || data === undefined || data.totals?.hasBillable !== true) {
|
|
865
|
+
return null
|
|
866
|
+
}
|
|
867
|
+
const totals = data.totals
|
|
868
|
+
const last = [...(data.turns ?? [])].reverse().find((entry) => entry.hasBillable) ?? null
|
|
869
|
+
const totalText = totals.costKnown
|
|
870
|
+
? `≈ ${formatMoney(totals.cost)}`
|
|
871
|
+
: totals.pricedAttempts > 0 ? `≈ ${formatMoney(totals.cost)}*` : translate('pricePlaceholder')
|
|
872
|
+
const lastText = last === null || last === undefined || !last.hasBillable
|
|
873
|
+
? null
|
|
874
|
+
: last.costKnown ? `≈ ${formatMoney(last.cost)}` : last.pricedAttempts > 0 ? `≈ ${formatMoney(last.cost)}*` : translate('pricePlaceholder')
|
|
875
|
+
const orderIndex = new Map((catalogSnap.data?.models ?? []).map((model) => [`${model.provider}\u0000${model.model}`, model.order]))
|
|
876
|
+
const byModel = [...(data.byModel ?? [])].sort((left, right) => {
|
|
877
|
+
const leftKey = left.provider === null ? '' : `${left.provider}\u0000${left.model}`
|
|
878
|
+
const rightKey = right.provider === null ? '' : `${right.provider}\u0000${right.model}`
|
|
879
|
+
return (orderIndex.get(leftKey) ?? Number.MAX_SAFE_INTEGER) - (orderIndex.get(rightKey) ?? Number.MAX_SAFE_INTEGER)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
const modelNameOf = (model) => {
|
|
883
|
+
const catalogRow = catalogSnap.data?.models?.find((entry) => entry.provider === model?.provider && entry.model === model?.model)
|
|
884
|
+
return catalogRow?.name ?? model?.name ?? model?.model ?? '—'
|
|
885
|
+
}
|
|
886
|
+
const turnModelNames = (turn) => {
|
|
887
|
+
const names = []
|
|
888
|
+
for (const model of turn.models ?? []) {
|
|
889
|
+
const name = modelNameOf(model)
|
|
890
|
+
if (!names.includes(name)) names.push(name)
|
|
891
|
+
}
|
|
892
|
+
return names
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const header = h('thead', null,
|
|
896
|
+
h('tr', null,
|
|
897
|
+
h('th', null, translate('turn')),
|
|
898
|
+
h('th', null, translate('content')),
|
|
899
|
+
h('th', null, translate('time')),
|
|
900
|
+
h('th', null, translate('model')),
|
|
901
|
+
h('th', { className: 'num' }, translate('cost')),
|
|
902
|
+
h('th', { className: 'num' }, translate('tokens')),
|
|
903
|
+
h('th', { className: 'num' }, translate('cacheHit'))))
|
|
904
|
+
|
|
905
|
+
const turnRows = [...(data.turns ?? [])].reverse().slice(0, 20).map((turn) => {
|
|
906
|
+
const costText = turn.costKnown
|
|
907
|
+
? `≈ ${formatMoney(turn.cost)}`
|
|
908
|
+
: turn.pricedAttempts > 0 ? `≈ ${formatMoney(turn.cost)}*` : translate('pricePlaceholder')
|
|
909
|
+
const modelNames = turnModelNames(turn)
|
|
910
|
+
const modelText = modelNames.length === 0
|
|
911
|
+
? '—'
|
|
912
|
+
: modelNames.length === 1 ? modelNames[0] : `${modelNames[0]} +${modelNames.length - 1}`
|
|
913
|
+
return h('tr', { key: turn.turn },
|
|
914
|
+
h('td', null, `#${turn.turn}`),
|
|
915
|
+
h('td', { className: 'mb-turn-preview', title: turn.preview }, turn.preview || '—'),
|
|
916
|
+
h('td', null, `${timeText(turn.startTime)}–${timeText(turn.endTime)}`),
|
|
917
|
+
h('td', { className: 'mb-turn-model', title: modelNames.join(' / ') }, modelText),
|
|
918
|
+
h('td', { className: 'num' }, costText),
|
|
919
|
+
h('td', { className: 'num' }, `${formatTokens(turn.promptTokens)} / ${formatTokens(turn.outputTokens)}`),
|
|
920
|
+
h('td', { className: 'num' }, formatPercent(turn.cacheHit)))
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
const turnTable = h('div', { style: { overflowX: 'auto' } },
|
|
924
|
+
h('table', { className: 'mb-table mb-turn-table' }, header, h('tbody', null, turnRows)))
|
|
925
|
+
|
|
926
|
+
const modelRows = byModel.map((model) => {
|
|
927
|
+
const catalogRow = catalogSnap.data?.models?.find((entry) => entry.provider === model.provider && entry.model === model.model)
|
|
928
|
+
const modelName = catalogRow === undefined ? model.name : `${catalogRow.providerName} / ${catalogRow.name}`
|
|
929
|
+
return h('tr', { key: `${model.provider}\u0000${model.model}\u0000${model.name}` },
|
|
930
|
+
h('td', null,
|
|
931
|
+
modelName,
|
|
932
|
+
model.isDeepSeek ? h('span', { className: 'mb-tag' }, translate('deepseekTag')) : null),
|
|
933
|
+
h('td', { className: 'num' },
|
|
934
|
+
model.pricedAttempts > 0 ? `≈ ${formatMoney(model.cost)}` : translate('pricePlaceholder')),
|
|
935
|
+
h('td', { className: 'num', style: { color: 'var(--dsw-alias-label-secondary)' } },
|
|
936
|
+
`${formatTokens(model.promptTokens)} / ${formatTokens(model.outputTokens)}`))
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
const modelTable = byModel.length === 0
|
|
940
|
+
? null
|
|
941
|
+
: h('div', { style: { marginTop: '10px' } },
|
|
942
|
+
h('div', { className: 'mb-panel-title', style: { fontSize: '12px', marginBottom: '4px' } }, translate('byModel')),
|
|
943
|
+
h('div', { style: { overflowX: 'auto' } },
|
|
944
|
+
h('table', { className: 'mb-table' }, h('tbody', null, modelRows))))
|
|
945
|
+
|
|
946
|
+
const panelStyle = panelPos === null
|
|
947
|
+
? { left: 0, top: 0, width: 660, visibility: 'hidden' }
|
|
948
|
+
: {
|
|
949
|
+
left: panelPos.left,
|
|
950
|
+
width: panelPos.width,
|
|
951
|
+
maxHeight: panelPos.maxHeight,
|
|
952
|
+
...panelPos.top === undefined ? { bottom: panelPos.bottom } : { top: panelPos.top },
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const panel = h('div', {
|
|
956
|
+
ref: panelRef,
|
|
957
|
+
className: 'mb-panel mb-panel-floating',
|
|
958
|
+
style: panelStyle,
|
|
959
|
+
role: 'dialog',
|
|
960
|
+
'aria-label': translate('costDetails'),
|
|
961
|
+
tabIndex: -1,
|
|
962
|
+
},
|
|
963
|
+
h('div', { className: 'mb-panel-head' },
|
|
964
|
+
h('span', { className: 'mb-panel-title' }, translate('costDetails')),
|
|
965
|
+
h('span', { className: 'mb-muted' }, translate('sessionTotal')),
|
|
966
|
+
usage.status === 'error'
|
|
967
|
+
? h('button', {
|
|
968
|
+
type: 'button',
|
|
969
|
+
className: 'mb-btn',
|
|
970
|
+
onClick: () => void ensureUsage(sessionId, revision, true),
|
|
971
|
+
}, translate('retry'))
|
|
972
|
+
: null,
|
|
973
|
+
h('button', {
|
|
974
|
+
type: 'button',
|
|
975
|
+
className: 'mb-panel-close',
|
|
976
|
+
'aria-label': translate('close'),
|
|
977
|
+
title: translate('close'),
|
|
978
|
+
onClick: () => {
|
|
979
|
+
setOpen(false)
|
|
980
|
+
triggerRef.current?.focus()
|
|
981
|
+
},
|
|
982
|
+
}, '×')),
|
|
983
|
+
h('div', { className: 'mb-summary' },
|
|
984
|
+
summaryItem(translate('sessionTotal'), totalText),
|
|
985
|
+
summaryItem(translate('thisTurn'), lastText ?? '—'),
|
|
986
|
+
summaryItem(translate('cacheHit'), formatPercent(totals.cacheHit)),
|
|
987
|
+
summaryItem(translate('tokens'), `${formatTokens(totals.promptTokens)} / ${formatTokens(totals.outputTokens)}`)),
|
|
988
|
+
turnTable,
|
|
989
|
+
modelTable,
|
|
990
|
+
h('p', { className: 'mb-panel-note' }, translate('estimateNote')),
|
|
991
|
+
totals.partial ? h('p', { className: 'mb-warn' }, translate('partialNote')) : null)
|
|
992
|
+
|
|
993
|
+
return h('div', { className: 'mb-dock' },
|
|
994
|
+
h(Style),
|
|
995
|
+
h('button', {
|
|
996
|
+
ref: triggerRef,
|
|
997
|
+
type: 'button',
|
|
998
|
+
className: 'mb-dock-trigger',
|
|
999
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
1000
|
+
'aria-haspopup': 'dialog',
|
|
1001
|
+
onClick: () => setOpen((value) => !value),
|
|
1002
|
+
title: translate('costEstimate'),
|
|
1003
|
+
},
|
|
1004
|
+
h('svg', {
|
|
1005
|
+
className: 'mb-dock-icon',
|
|
1006
|
+
viewBox: '0 0 16 16',
|
|
1007
|
+
fill: 'none',
|
|
1008
|
+
stroke: 'currentColor',
|
|
1009
|
+
strokeWidth: 1.2,
|
|
1010
|
+
'aria-hidden': true,
|
|
1011
|
+
},
|
|
1012
|
+
h('circle', { cx: 8, cy: 8, r: 5.5 }),
|
|
1013
|
+
h('path', { d: 'M8 5v6M6.2 6.8h3.6M6.2 9.2h3.6' })),
|
|
1014
|
+
h('span', null, translate('costEstimate')),
|
|
1015
|
+
h('span', { className: 'mb-dock-amount' }, totalText),
|
|
1016
|
+
lastText === null ? null : h(Fragment, null,
|
|
1017
|
+
h('span', { className: 'mb-dock-sep' }, '·'),
|
|
1018
|
+
h('span', null, `${translate('thisTurn')} ${lastText}`)),
|
|
1019
|
+
totals.partial ? h('span', { className: 'mb-warn', style: { margin: 0 } }, translate('partial')) : null),
|
|
1020
|
+
open ? ReactDOM.createPortal(panel, document.body) : null)
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function summaryItem(label, value) {
|
|
1024
|
+
return h('div', { className: 'mb-summary-item', key: label },
|
|
1025
|
+
h('div', { className: 'mb-summary-key' }, label),
|
|
1026
|
+
h('div', { className: 'mb-summary-value' }, value))
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ---------------------------------------------------------------------
|
|
1030
|
+
// Per-turn cost chip
|
|
1031
|
+
// ---------------------------------------------------------------------
|
|
1032
|
+
|
|
1033
|
+
function TurnCost({ turn, sessionId, useProjection }) {
|
|
1034
|
+
const translate = useT()
|
|
1035
|
+
const tokenUsage = useProjection('tokenUsage')
|
|
1036
|
+
const revision = tokenUsageRevision(tokenUsage)
|
|
1037
|
+
const usage = useSessionUsage(sessionId, revision)
|
|
1038
|
+
const row = usage.data?.turns?.find((entry) => entry.turn === turn)
|
|
1039
|
+
if (row === undefined || row.hasBillable !== true) return null
|
|
1040
|
+
const costText = row.costKnown
|
|
1041
|
+
? `≈ ${formatMoney(row.cost)}`
|
|
1042
|
+
: row.pricedAttempts > 0 ? `≈ ${formatMoney(row.cost)}*` : translate('pricePlaceholder')
|
|
1043
|
+
return h('span', {
|
|
1044
|
+
className: 'mb-turn',
|
|
1045
|
+
title: translate('turnCostTitle'),
|
|
1046
|
+
},
|
|
1047
|
+
translate('turnCost'), ' ', h('strong', null, costText),
|
|
1048
|
+
row.cacheHit === null ? null : ` · ${translate('cacheHit')} ${formatPercent(row.cacheHit)}`,
|
|
1049
|
+
row.partial ? ` · ${translate('partialShort')}` : '')
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ---------------------------------------------------------------------
|
|
1053
|
+
// Custom-ordered model picker in the composer seat
|
|
1054
|
+
// ---------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
const MODEL_KEY_SEPARATOR = '\u0000'
|
|
1057
|
+
|
|
1058
|
+
function OrderedModelPicker({ locked, sessionId, useProjection }) {
|
|
1059
|
+
const translate = useT()
|
|
1060
|
+
const catalogSnap = useCatalog()
|
|
1061
|
+
const selectionProjection = useProjection('modelSelection')
|
|
1062
|
+
const directoryRevision = useModelDirectoriesRevision()
|
|
1063
|
+
const [directoryTick, setDirectoryTick] = useState(0)
|
|
1064
|
+
const directory = useMemo(() => {
|
|
1065
|
+
if (modelDirectories === undefined || sessionId === undefined) return null
|
|
1066
|
+
try {
|
|
1067
|
+
return modelDirectories.directoryFor(sessionId)
|
|
1068
|
+
} catch {
|
|
1069
|
+
return null
|
|
1070
|
+
}
|
|
1071
|
+
}, [sessionId, directoryRevision, directoryTick])
|
|
1072
|
+
|
|
1073
|
+
const [state, setState] = useState(null)
|
|
1074
|
+
const [open, setOpen] = useState(false)
|
|
1075
|
+
const [query, setQuery] = useState('')
|
|
1076
|
+
const [selecting, setSelecting] = useState(false)
|
|
1077
|
+
const [failure, setFailure] = useState(null)
|
|
1078
|
+
const [menuPos, setMenuPos] = useState(null)
|
|
1079
|
+
const rootRef = useRef(null)
|
|
1080
|
+
const triggerRef = useRef(null)
|
|
1081
|
+
const menuRef = useRef(null)
|
|
1082
|
+
const searchRef = useRef(null)
|
|
1083
|
+
const id = useId()
|
|
1084
|
+
|
|
1085
|
+
useEffect(() => {
|
|
1086
|
+
if (directory === null) {
|
|
1087
|
+
setState(null)
|
|
1088
|
+
return undefined
|
|
1089
|
+
}
|
|
1090
|
+
const sync = () => setState(directory.store.getSnapshot())
|
|
1091
|
+
sync()
|
|
1092
|
+
const unsubscribe = directory.store.subscribe(sync)
|
|
1093
|
+
directory.load().catch(() => {})
|
|
1094
|
+
return unsubscribe
|
|
1095
|
+
}, [directory])
|
|
1096
|
+
|
|
1097
|
+
useEffect(() => {
|
|
1098
|
+
if (directory !== null || directoryTick >= 20) return undefined
|
|
1099
|
+
const timer = setTimeout(() => setDirectoryTick((value) => value + 1), 300)
|
|
1100
|
+
return () => clearTimeout(timer)
|
|
1101
|
+
}, [directory, directoryTick])
|
|
1102
|
+
|
|
1103
|
+
useEffect(() => {
|
|
1104
|
+
if (!open) return undefined
|
|
1105
|
+
const closeOutside = (event) => {
|
|
1106
|
+
if (rootRef.current?.contains(event.target) === true) return
|
|
1107
|
+
if (menuRef.current?.contains(event.target) === true) return
|
|
1108
|
+
setOpen(false)
|
|
1109
|
+
}
|
|
1110
|
+
document.addEventListener('mousedown', closeOutside)
|
|
1111
|
+
return () => document.removeEventListener('mousedown', closeOutside)
|
|
1112
|
+
}, [open])
|
|
1113
|
+
|
|
1114
|
+
useEffect(() => {
|
|
1115
|
+
if (!open) {
|
|
1116
|
+
setQuery('')
|
|
1117
|
+
return undefined
|
|
1118
|
+
}
|
|
1119
|
+
const closeOnEscape = (event) => {
|
|
1120
|
+
if (event.key !== 'Escape') return
|
|
1121
|
+
event.preventDefault()
|
|
1122
|
+
setOpen(false)
|
|
1123
|
+
queueMicrotask(() => triggerRef.current?.focus())
|
|
1124
|
+
}
|
|
1125
|
+
document.addEventListener('keydown', closeOnEscape)
|
|
1126
|
+
return () => document.removeEventListener('keydown', closeOnEscape)
|
|
1127
|
+
}, [open])
|
|
1128
|
+
|
|
1129
|
+
useEffect(() => {
|
|
1130
|
+
if (locked && open) setOpen(false)
|
|
1131
|
+
}, [locked, open])
|
|
1132
|
+
|
|
1133
|
+
const rank = useMemo(() => {
|
|
1134
|
+
const map = new Map()
|
|
1135
|
+
for (const model of catalogSnap.data?.models ?? []) {
|
|
1136
|
+
map.set(`${model.provider}${MODEL_KEY_SEPARATOR}${model.model}`, model.order)
|
|
1137
|
+
}
|
|
1138
|
+
return map
|
|
1139
|
+
}, [catalogSnap.data])
|
|
1140
|
+
|
|
1141
|
+
const priceByKey = useMemo(() => {
|
|
1142
|
+
const map = new Map()
|
|
1143
|
+
for (const model of catalogSnap.data?.models ?? []) {
|
|
1144
|
+
map.set(`${model.provider}${MODEL_KEY_SEPARATOR}${model.model}`, model.pricing)
|
|
1145
|
+
}
|
|
1146
|
+
return map
|
|
1147
|
+
}, [catalogSnap.data])
|
|
1148
|
+
|
|
1149
|
+
const groups = state?.groups
|
|
1150
|
+
const catalogModels = catalogSnap.data?.models
|
|
1151
|
+
const entries = useMemo(() => {
|
|
1152
|
+
if (Array.isArray(groups) && groups.length > 0) {
|
|
1153
|
+
const list = []
|
|
1154
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
1155
|
+
for (const [modelIndex, model] of group.models.entries()) {
|
|
1156
|
+
list.push({ group, model, groupIndex, modelIndex })
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
list.sort((left, right) => {
|
|
1160
|
+
const leftRank = rank.get(`${left.group.id}${MODEL_KEY_SEPARATOR}${left.model.id}`) ?? Number.MAX_SAFE_INTEGER
|
|
1161
|
+
const rightRank = rank.get(`${right.group.id}${MODEL_KEY_SEPARATOR}${right.model.id}`) ?? Number.MAX_SAFE_INTEGER
|
|
1162
|
+
return leftRank - rightRank || left.groupIndex - right.groupIndex || left.modelIndex - right.modelIndex
|
|
1163
|
+
})
|
|
1164
|
+
return list
|
|
1165
|
+
}
|
|
1166
|
+
return (catalogModels ?? []).map((model, index) => ({
|
|
1167
|
+
group: { id: model.provider, name: model.providerName },
|
|
1168
|
+
model: {
|
|
1169
|
+
id: model.model,
|
|
1170
|
+
name: model.name,
|
|
1171
|
+
...(model.reasoning === undefined ? {} : { reasoning: model.reasoning }),
|
|
1172
|
+
},
|
|
1173
|
+
groupIndex: model.order ?? index,
|
|
1174
|
+
modelIndex: index,
|
|
1175
|
+
}))
|
|
1176
|
+
}, [groups, catalogModels, rank])
|
|
1177
|
+
|
|
1178
|
+
const projected = selectionProjection?.next ?? selectionProjection?.lastUsed ?? null
|
|
1179
|
+
const current = state?.current ?? projected
|
|
1180
|
+
const currentEntry = current === null
|
|
1181
|
+
? undefined
|
|
1182
|
+
: entries.find((entry) => entry.group.id === current.provider && entry.model.id === current.model)
|
|
1183
|
+
const currentCatalog = current === null
|
|
1184
|
+
? undefined
|
|
1185
|
+
: (catalogModels ?? []).find((model) => model.provider === current.provider && model.model === current.model)
|
|
1186
|
+
const currentReasoning = currentEntry?.model.reasoning
|
|
1187
|
+
const effectiveEffort = current?.reasoningEffort ?? currentReasoning?.defaultEffort
|
|
1188
|
+
const effortLabel = current === null
|
|
1189
|
+
? undefined
|
|
1190
|
+
: currentReasoning === undefined
|
|
1191
|
+
? state?.retainedEffort
|
|
1192
|
+
: effectiveEffort === undefined
|
|
1193
|
+
? translate('providerDefault')
|
|
1194
|
+
: currentReasoning.efforts.find((effort) => effort.id === effectiveEffort)?.name ?? effectiveEffort
|
|
1195
|
+
const effortChoices = currentReasoning === undefined
|
|
1196
|
+
? []
|
|
1197
|
+
: [
|
|
1198
|
+
...(currentReasoning.defaultEffort === undefined
|
|
1199
|
+
? [{ key: 'provider-default', effort: undefined, label: translate('providerDefault') }]
|
|
1200
|
+
: []),
|
|
1201
|
+
...currentReasoning.efforts.map((effort) => ({
|
|
1202
|
+
key: `effort:${effort.id}`,
|
|
1203
|
+
effort: effort.id,
|
|
1204
|
+
label: effort.name,
|
|
1205
|
+
})),
|
|
1206
|
+
]
|
|
1207
|
+
|
|
1208
|
+
const showSearch = entries.length > 4
|
|
1209
|
+
const term = query.trim().toLowerCase()
|
|
1210
|
+
const filtered = useMemo(() => {
|
|
1211
|
+
if (term === '') return entries
|
|
1212
|
+
return entries.filter((entry) => `${entry.model.name} ${entry.group.name}`.toLowerCase().includes(term))
|
|
1213
|
+
}, [entries, term])
|
|
1214
|
+
|
|
1215
|
+
useLayoutEffect(() => {
|
|
1216
|
+
if (!open) {
|
|
1217
|
+
setMenuPos(null)
|
|
1218
|
+
return undefined
|
|
1219
|
+
}
|
|
1220
|
+
const place = () => {
|
|
1221
|
+
const rect = triggerRef.current?.getBoundingClientRect()
|
|
1222
|
+
if (rect === undefined) return
|
|
1223
|
+
const width = Math.min(360, window.innerWidth - 24)
|
|
1224
|
+
const left = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))
|
|
1225
|
+
const spaceAbove = rect.top - 12
|
|
1226
|
+
const spaceBelow = window.innerHeight - rect.bottom - 12
|
|
1227
|
+
const openBelow = spaceBelow > spaceAbove
|
|
1228
|
+
const maxHeight = Math.min(480, Math.max(160, openBelow ? spaceBelow : spaceAbove))
|
|
1229
|
+
setMenuPos(openBelow
|
|
1230
|
+
? { left, top: rect.bottom + 8, width, maxHeight }
|
|
1231
|
+
: { left, bottom: window.innerHeight - rect.top + 8, width, maxHeight })
|
|
1232
|
+
}
|
|
1233
|
+
place()
|
|
1234
|
+
window.addEventListener('scroll', place, true)
|
|
1235
|
+
window.addEventListener('resize', place)
|
|
1236
|
+
return () => {
|
|
1237
|
+
window.removeEventListener('scroll', place, true)
|
|
1238
|
+
window.removeEventListener('resize', place)
|
|
1239
|
+
}
|
|
1240
|
+
}, [open, filtered.length, state?.status])
|
|
1241
|
+
|
|
1242
|
+
useEffect(() => {
|
|
1243
|
+
if (!open) return undefined
|
|
1244
|
+
const timer = setTimeout(() => {
|
|
1245
|
+
if (showSearch) {
|
|
1246
|
+
searchRef.current?.focus()
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
1249
|
+
const first = menuRef.current?.querySelector('.mb-picker-row:not(:disabled)')
|
|
1250
|
+
if (first instanceof HTMLElement) first.focus()
|
|
1251
|
+
}, 0)
|
|
1252
|
+
return () => clearTimeout(timer)
|
|
1253
|
+
}, [open, showSearch])
|
|
1254
|
+
|
|
1255
|
+
const loading = catalogSnap.data === null && (state === null || state.status === 'loading')
|
|
1256
|
+
const busy = selecting || state?.status === 'selecting'
|
|
1257
|
+
const currentName = currentEntry?.model.name ?? currentCatalog?.name
|
|
1258
|
+
const triggerModel = currentName ?? (loading ? translate('loadingModels') : translate('chooseModel'))
|
|
1259
|
+
const triggerAria = effortLabel === undefined ? triggerModel : `${triggerModel} · ${effortLabel}`
|
|
1260
|
+
|
|
1261
|
+
const close = (restoreFocus) => {
|
|
1262
|
+
setOpen(false)
|
|
1263
|
+
setQuery('')
|
|
1264
|
+
setFailure(null)
|
|
1265
|
+
if (restoreFocus) queueMicrotask(() => triggerRef.current?.focus())
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const submit = async (selection) => {
|
|
1269
|
+
if (busy) return
|
|
1270
|
+
setSelecting(true)
|
|
1271
|
+
setFailure(null)
|
|
1272
|
+
try {
|
|
1273
|
+
const result = directory !== null
|
|
1274
|
+
? await directory.select(selection)
|
|
1275
|
+
: await requestJson('/model-billing/select', {
|
|
1276
|
+
method: 'POST',
|
|
1277
|
+
headers: { 'content-type': 'application/json' },
|
|
1278
|
+
body: JSON.stringify({ sessionId, ...selection }),
|
|
1279
|
+
})
|
|
1280
|
+
if (result?.ok === false) {
|
|
1281
|
+
setFailure(result.error?.message ?? translate('pickerEmpty'))
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
close(true)
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
setFailure(error instanceof Error ? error.message : String(error))
|
|
1287
|
+
} finally {
|
|
1288
|
+
setSelecting(false)
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const chooseModel = (entry) => {
|
|
1293
|
+
if (current?.provider === entry.group.id && current?.model === entry.model.id) {
|
|
1294
|
+
close(true)
|
|
1295
|
+
return
|
|
1296
|
+
}
|
|
1297
|
+
void submit({ provider: entry.group.id, model: entry.model.id })
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const chooseEffort = (effort) => {
|
|
1301
|
+
if (current === null) return
|
|
1302
|
+
if (effectiveEffort === effort) {
|
|
1303
|
+
close(true)
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
void submit({
|
|
1307
|
+
provider: current.provider,
|
|
1308
|
+
model: current.model,
|
|
1309
|
+
...effort === undefined ? {} : { reasoningEffort: effort },
|
|
1310
|
+
})
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const toggle = () => {
|
|
1314
|
+
if (open) {
|
|
1315
|
+
close(true)
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
setFailure(null)
|
|
1319
|
+
setOpen(true)
|
|
1320
|
+
if (directory !== null) directory.load().catch(() => {})
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const menuStyle = menuPos === null
|
|
1324
|
+
? { left: 0, top: 0, width: 360, visibility: 'hidden' }
|
|
1325
|
+
: {
|
|
1326
|
+
left: menuPos.left,
|
|
1327
|
+
width: menuPos.width,
|
|
1328
|
+
maxHeight: menuPos.maxHeight,
|
|
1329
|
+
...menuPos.top === undefined ? { bottom: menuPos.bottom } : { top: menuPos.top },
|
|
1330
|
+
}
|
|
1331
|
+
const errorText = failure ?? (state?.status === 'error' ? state.error : null)
|
|
1332
|
+
|
|
1333
|
+
const menu = h('div', {
|
|
1334
|
+
ref: menuRef,
|
|
1335
|
+
id: `${id}-menu`,
|
|
1336
|
+
className: 'mb-picker-menu',
|
|
1337
|
+
style: menuStyle,
|
|
1338
|
+
role: 'dialog',
|
|
1339
|
+
'aria-label': translate('pickerAria'),
|
|
1340
|
+
},
|
|
1341
|
+
showSearch ? h('input', {
|
|
1342
|
+
ref: searchRef,
|
|
1343
|
+
className: 'mb-picker-search',
|
|
1344
|
+
type: 'search',
|
|
1345
|
+
value: query,
|
|
1346
|
+
placeholder: translate('searchModel'),
|
|
1347
|
+
'aria-label': translate('searchModel'),
|
|
1348
|
+
onChange: (event) => setQuery(event.target.value),
|
|
1349
|
+
onKeyDown: (event) => {
|
|
1350
|
+
if (event.key !== 'Enter' || filtered.length === 0 || busy) return
|
|
1351
|
+
event.preventDefault()
|
|
1352
|
+
chooseModel(filtered[0])
|
|
1353
|
+
},
|
|
1354
|
+
}) : null,
|
|
1355
|
+
h('div', { className: 'mb-picker-list', role: 'menu' },
|
|
1356
|
+
filtered.length === 0
|
|
1357
|
+
? h('div', { className: 'mb-picker-empty' }, term === '' ? translate('pickerEmpty') : translate('noMatch'))
|
|
1358
|
+
: filtered.map((entry, index) => {
|
|
1359
|
+
const selected = current?.provider === entry.group.id && current?.model === entry.model.id
|
|
1360
|
+
const previous = filtered[index - 1]
|
|
1361
|
+
const showGroup = previous === undefined || previous.group.id !== entry.group.id
|
|
1362
|
+
const price = priceByKey.get(`${entry.group.id}${MODEL_KEY_SEPARATOR}${entry.model.id}`)
|
|
1363
|
+
return h(Fragment, { key: `${entry.group.id}${MODEL_KEY_SEPARATOR}${entry.model.id}` },
|
|
1364
|
+
showGroup ? h('div', { className: 'mb-picker-group' }, entry.group.name) : null,
|
|
1365
|
+
h('button', {
|
|
1366
|
+
type: 'button',
|
|
1367
|
+
role: 'menuitemradio',
|
|
1368
|
+
className: 'mb-picker-row',
|
|
1369
|
+
'aria-checked': selected ? 'true' : 'false',
|
|
1370
|
+
title: entry.model.name,
|
|
1371
|
+
disabled: busy,
|
|
1372
|
+
onClick: () => chooseModel(entry),
|
|
1373
|
+
},
|
|
1374
|
+
h('span', { className: 'mb-picker-option-copy' },
|
|
1375
|
+
h('span', { className: 'mb-picker-model' }, entry.model.name),
|
|
1376
|
+
h(PriceLines, { pricing: price, translate })),
|
|
1377
|
+
h('span', { className: 'mb-picker-check' },
|
|
1378
|
+
selected
|
|
1379
|
+
? h('svg', {
|
|
1380
|
+
viewBox: '0 0 16 16',
|
|
1381
|
+
width: 14,
|
|
1382
|
+
height: 14,
|
|
1383
|
+
fill: 'none',
|
|
1384
|
+
stroke: 'currentColor',
|
|
1385
|
+
strokeWidth: 1.7,
|
|
1386
|
+
'aria-hidden': true,
|
|
1387
|
+
},
|
|
1388
|
+
h('path', { d: 'M3.5 8.5l3 3 6-7' }))
|
|
1389
|
+
: null)))
|
|
1390
|
+
})),
|
|
1391
|
+
effortChoices.length === 0 ? null : h(Fragment, null,
|
|
1392
|
+
h('div', { className: 'mb-picker-divider' }),
|
|
1393
|
+
h('div', { className: 'mb-picker-efforts' },
|
|
1394
|
+
h('span', { className: 'mb-picker-efforts-label' }, translate('reasoningEffort')),
|
|
1395
|
+
effortChoices.map((choice) => h('button', {
|
|
1396
|
+
type: 'button',
|
|
1397
|
+
key: choice.key,
|
|
1398
|
+
className: 'mb-picker-chip',
|
|
1399
|
+
'aria-pressed': effectiveEffort === choice.effort ? 'true' : 'false',
|
|
1400
|
+
disabled: busy,
|
|
1401
|
+
onClick: () => chooseEffort(choice.effort),
|
|
1402
|
+
}, choice.label)))),
|
|
1403
|
+
busy ? h('div', { className: 'mb-picker-status' }, translate('switching')) : null,
|
|
1404
|
+
errorText === null ? null : h('div', { className: 'mb-picker-error' }, errorText),
|
|
1405
|
+
state?.status === 'error'
|
|
1406
|
+
? h('button', {
|
|
1407
|
+
type: 'button',
|
|
1408
|
+
className: 'mb-btn mb-picker-retry',
|
|
1409
|
+
onClick: () => {
|
|
1410
|
+
setFailure(null)
|
|
1411
|
+
directory.load().catch(() => {})
|
|
1412
|
+
},
|
|
1413
|
+
}, translate('pickerRetry'))
|
|
1414
|
+
: null)
|
|
1415
|
+
|
|
1416
|
+
return h('div', {
|
|
1417
|
+
className: 'mb-picker',
|
|
1418
|
+
ref: rootRef,
|
|
1419
|
+
},
|
|
1420
|
+
h(Style),
|
|
1421
|
+
h('button', {
|
|
1422
|
+
ref: triggerRef,
|
|
1423
|
+
type: 'button',
|
|
1424
|
+
className: 'mb-picker-trigger',
|
|
1425
|
+
disabled: locked,
|
|
1426
|
+
'aria-label': triggerAria,
|
|
1427
|
+
'aria-haspopup': 'menu',
|
|
1428
|
+
'aria-controls': open ? `${id}-menu` : undefined,
|
|
1429
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
1430
|
+
'aria-busy': busy ? 'true' : undefined,
|
|
1431
|
+
title: triggerAria,
|
|
1432
|
+
onClick: toggle,
|
|
1433
|
+
},
|
|
1434
|
+
h('svg', {
|
|
1435
|
+
viewBox: '0 0 16 16',
|
|
1436
|
+
width: 14,
|
|
1437
|
+
height: 14,
|
|
1438
|
+
fill: 'none',
|
|
1439
|
+
stroke: 'currentColor',
|
|
1440
|
+
strokeWidth: 1.3,
|
|
1441
|
+
'aria-hidden': true,
|
|
1442
|
+
className: 'mb-picker-icon',
|
|
1443
|
+
},
|
|
1444
|
+
h('rect', { x: 2, y: 3, width: 12, height: 10, rx: 2 }),
|
|
1445
|
+
h('path', { d: 'M2 6.5h12M6 6.5V13' })),
|
|
1446
|
+
h('span', { className: 'mb-picker-name' }, triggerModel),
|
|
1447
|
+
effortLabel === undefined ? null : h('span', { className: 'mb-picker-trigger-effort' }, effortLabel),
|
|
1448
|
+
h('svg', {
|
|
1449
|
+
className: open ? 'mb-picker-chevron mb-picker-chevron-open' : 'mb-picker-chevron',
|
|
1450
|
+
viewBox: '0 0 16 16',
|
|
1451
|
+
width: 14,
|
|
1452
|
+
height: 14,
|
|
1453
|
+
fill: 'none',
|
|
1454
|
+
stroke: 'currentColor',
|
|
1455
|
+
strokeWidth: 1.5,
|
|
1456
|
+
'aria-hidden': true,
|
|
1457
|
+
},
|
|
1458
|
+
h('path', { d: 'M4 6l4 4 4-4' }))),
|
|
1459
|
+
open ? ReactDOM.createPortal(menu, document.body) : null)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return {
|
|
1463
|
+
inject: ['slots', 'locale'],
|
|
1464
|
+
apply(ctx) {
|
|
1465
|
+
localeService = ctx.get('locale')
|
|
1466
|
+
publishModelDirectories(ctx.get('modelDirectories'))
|
|
1467
|
+
ctx.inject(['modelDirectories'], (scope) => {
|
|
1468
|
+
publishModelDirectories(scope.modelDirectories)
|
|
1469
|
+
scope.slots.inject('conversation.input.model', () => scope.slots.register({
|
|
1470
|
+
name: 'conversation.input.model',
|
|
1471
|
+
priority: -1,
|
|
1472
|
+
}, OrderedModelPicker))
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
void ensureCatalog()
|
|
1476
|
+
|
|
1477
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1478
|
+
name: 'settings.section',
|
|
1479
|
+
id: 'model-billing',
|
|
1480
|
+
order: 12,
|
|
1481
|
+
label: () => t('nav'),
|
|
1482
|
+
}, SettingsSection))
|
|
1483
|
+
|
|
1484
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
1485
|
+
name: 'conversation.composer.dock',
|
|
1486
|
+
id: 'model-billing-cost',
|
|
1487
|
+
order: 2,
|
|
1488
|
+
}, CostDock))
|
|
1489
|
+
|
|
1490
|
+
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
|
1491
|
+
name: 'conversation.chat.turnTail',
|
|
1492
|
+
id: 'model-billing-turn',
|
|
1493
|
+
order: 30,
|
|
1494
|
+
}, TurnCost))
|
|
1495
|
+
},
|
|
1496
|
+
}
|
|
1497
|
+
},
|
|
1498
|
+
})
|