@dsh-plugins/dsh-llm-hub 0.7.3 → 1.3.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 +115 -0
- package/README.md +185 -4
- package/lib/client.js +627 -7
- package/lib/index.js +1000 -38
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -52,6 +52,8 @@ window.__ModuleLoader__.load({
|
|
|
52
52
|
const DEEPSEEK_PROVIDER = 'deepseek-official'
|
|
53
53
|
/** host 半注册的余额路由。 */
|
|
54
54
|
const BALANCE_URL = '/api/dsh-llm-hub/balance'
|
|
55
|
+
/** host 半的余额预警路由。 */
|
|
56
|
+
const WARNING_URL = '/api/dsh-llm-hub/warning/check'
|
|
55
57
|
/** 本插件为 pi-ai 路由注册进 provider-card 的 key —— 等于 pi-ai 的 settingsNs。 */
|
|
56
58
|
const PIAI_CARD_KEY = 'llm-pi-ai'
|
|
57
59
|
/** host 半的 pi-ai 旁路路由前缀。 */
|
|
@@ -64,6 +66,17 @@ window.__ModuleLoader__.load({
|
|
|
64
66
|
const HARNESS_URL = '/api/dsh-llm-hub/harness'
|
|
65
67
|
const AVAILABILITY_URL = '/api/dsh-llm-hub/availability'
|
|
66
68
|
const AVAILABILITY_RECHECK_URL = '/api/dsh-llm-hub/availability/recheck'
|
|
69
|
+
/** 用量统计:月度聚合 + 导出 CSV + 清空。 */
|
|
70
|
+
const USAGE_URL = '/api/dsh-llm-hub/usage/summary'
|
|
71
|
+
const USAGE_EXPORT_URL = '/api/dsh-llm-hub/usage/export'
|
|
72
|
+
/** 健康看板:所有 provider 的探测结果(延迟 / HTTP 状态 / 原因)。 */
|
|
73
|
+
const HEALTH_URL = '/api/dsh-llm-hub/health'
|
|
74
|
+
/** 智能路由建议:当前激活 + 推荐 fallback。 */
|
|
75
|
+
const ROUTING_URL = '/api/dsh-llm-hub/routing/resolve'
|
|
76
|
+
/** 模型别名:把长 id 显示成短名(人话)。 */
|
|
77
|
+
const ALIASES_URL = '/api/dsh-llm-hub/aliases'
|
|
78
|
+
/** 多账号 key 轮换:debug 路由,看每个 provider 当前轮到哪个 key。 */
|
|
79
|
+
const KEYPOOL_URL = '/api/dsh-llm-hub/keypool/status'
|
|
67
80
|
|
|
68
81
|
const LOCALES = {
|
|
69
82
|
zh: {
|
|
@@ -120,7 +133,42 @@ window.__ModuleLoader__.load({
|
|
|
120
133
|
availabilityRechecking: '探测中…',
|
|
121
134
|
availabilityHiddenCount: '已隐藏',
|
|
122
135
|
availabilityUnavailable: '已从下拉隐藏',
|
|
123
|
-
availabilityFailed: '可用性读取失败'
|
|
136
|
+
availabilityFailed: '可用性读取失败',
|
|
137
|
+
healthTitle: 'Provider 健康看板',
|
|
138
|
+
healthState: { available: '可用', unavailable: '不可用', unknown: '未知' },
|
|
139
|
+
healthLatency: '延迟',
|
|
140
|
+
healthStatus: 'HTTP',
|
|
141
|
+
healthReason: '原因',
|
|
142
|
+
healthCheckedAt: '探测于',
|
|
143
|
+
healthNoData: '还没有任何探测',
|
|
144
|
+
healthRecheck: '重新探测',
|
|
145
|
+
healthRechecking: '探测中…',
|
|
146
|
+
routingTitle: '当前路由',
|
|
147
|
+
routingActive: '当前',
|
|
148
|
+
routingSuggestion: '建议切换到',
|
|
149
|
+
routingUnconfigured: '未配置主力模型',
|
|
150
|
+
routingFallback: 'fallback',
|
|
151
|
+
routingPrimary: '主力',
|
|
152
|
+
routingNoRecommendation: '全部不可用',
|
|
153
|
+
routingLoading: '路由解析中…',
|
|
154
|
+
aliasesMissing: '尚未配置别名',
|
|
155
|
+
keypoolTitle: '多账号 key 轮换',
|
|
156
|
+
keypoolRound: 'round-robin',
|
|
157
|
+
usageTitle: '本月用量',
|
|
158
|
+
usageCalls: '次调用',
|
|
159
|
+
usageInput: '输入',
|
|
160
|
+
usageOutput: '输出',
|
|
161
|
+
usageCache: '缓存',
|
|
162
|
+
usageTotal: '合计',
|
|
163
|
+
usageTopModels: '最常用模型',
|
|
164
|
+
usageDisabled: '用量统计未启用',
|
|
165
|
+
usageExport: '导出 CSV',
|
|
166
|
+
usageExported: '已复制 CSV 内容',
|
|
167
|
+
usageExportFail: '导出失败',
|
|
168
|
+
usageClear: '清空记录',
|
|
169
|
+
usageCleared: '已清空',
|
|
170
|
+
usageClearConfirm: '确认清空?',
|
|
171
|
+
usageNoData: '本月还没有记录'
|
|
124
172
|
},
|
|
125
173
|
en: {
|
|
126
174
|
balance: 'Balance',
|
|
@@ -176,13 +224,90 @@ window.__ModuleLoader__.load({
|
|
|
176
224
|
availabilityRechecking: 'Checking…',
|
|
177
225
|
availabilityHiddenCount: 'hidden',
|
|
178
226
|
availabilityUnavailable: 'hidden from dropdown',
|
|
179
|
-
availabilityFailed: 'Availability read failed'
|
|
227
|
+
availabilityFailed: 'Availability read failed',
|
|
228
|
+
healthTitle: 'Provider health',
|
|
229
|
+
healthState: { available: 'up', unavailable: 'down', unknown: 'unknown' },
|
|
230
|
+
healthLatency: 'latency',
|
|
231
|
+
healthStatus: 'HTTP',
|
|
232
|
+
healthReason: 'reason',
|
|
233
|
+
healthCheckedAt: 'probed at',
|
|
234
|
+
healthNoData: 'no probes yet',
|
|
235
|
+
healthRecheck: 'Re-check all',
|
|
236
|
+
healthRechecking: 'Checking…',
|
|
237
|
+
routingTitle: 'Current routing',
|
|
238
|
+
routingActive: 'Active',
|
|
239
|
+
routingSuggestion: 'Try switching to',
|
|
240
|
+
routingUnconfigured: 'routing not configured',
|
|
241
|
+
routingFallback: 'fallback',
|
|
242
|
+
routingPrimary: 'primary',
|
|
243
|
+
routingNoRecommendation: 'none available',
|
|
244
|
+
routingLoading: 'resolving routing…',
|
|
245
|
+
aliasesMissing: 'no aliases configured',
|
|
246
|
+
keypoolTitle: 'Key rotation',
|
|
247
|
+
keypoolRound: 'round-robin',
|
|
248
|
+
usageTitle: 'This month',
|
|
249
|
+
usageCalls: 'calls',
|
|
250
|
+
usageInput: 'in',
|
|
251
|
+
usageOutput: 'out',
|
|
252
|
+
usageCache: 'cached',
|
|
253
|
+
usageTotal: 'total',
|
|
254
|
+
usageTopModels: 'top models',
|
|
255
|
+
usageDisabled: 'usage tracking not enabled',
|
|
256
|
+
usageExport: 'Export CSV',
|
|
257
|
+
usageExported: 'CSV copied',
|
|
258
|
+
usageExportFail: 'Export failed',
|
|
259
|
+
usageClear: 'Clear records',
|
|
260
|
+
usageCleared: 'Cleared',
|
|
261
|
+
usageClearConfirm: 'Confirm clear?',
|
|
262
|
+
usageNoData: 'no calls this month yet'
|
|
180
263
|
}
|
|
181
264
|
}
|
|
182
265
|
|
|
183
266
|
/** 词典未就绪时的兜底(键原样返回,绝不显示 undefined)。 */
|
|
184
267
|
const fallbackT = (key) => (LOCALES.zh[key] ?? key)
|
|
185
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 全局别名表 —— 启动时拉一次,所有组件共用。
|
|
271
|
+
*
|
|
272
|
+
* 数据来源 `GET /api/dsh-llm-hub/aliases`:返回 `{ ok, aliases: { "<provider>/<model>": "短名" } }`。
|
|
273
|
+
* 启动后注入一次,所有 picker 行、RoutingCard 候选都用它把长 id 翻成短名。
|
|
274
|
+
* 失败/未配置:aliasesMap = {},aliasFor 永远返 null,UI 退回到只显示 model.id
|
|
275
|
+
* (与没装本插件时一致)。
|
|
276
|
+
*
|
|
277
|
+
* 为什么不在每张卡 mount 时各自 fetch:每个卡都发一次就 N 张卡 N 个请求,
|
|
278
|
+
* 还要写一份交叉去重。集中一次、所有卡读同一份快照就完事。
|
|
279
|
+
* @returns {{ map, aliasFor }}
|
|
280
|
+
*/
|
|
281
|
+
const aliasesStore = (() => {
|
|
282
|
+
let map = {}
|
|
283
|
+
const listeners = new Set()
|
|
284
|
+
const publish = (next) => {
|
|
285
|
+
map = next
|
|
286
|
+
for (const listener of [...listeners]) { try { listener() } catch { /* 不让订阅者炸 */ } }
|
|
287
|
+
}
|
|
288
|
+
const aliasFor = (provider, model) => {
|
|
289
|
+
if (typeof provider !== 'string' || typeof model !== 'string') return null
|
|
290
|
+
return map[`${provider}/${model}`] ?? null
|
|
291
|
+
}
|
|
292
|
+
fetch(ALIASES_URL, { headers: { accept: 'application/json' } })
|
|
293
|
+
.then((res) => res.json())
|
|
294
|
+
.then((body) => {
|
|
295
|
+
if (body !== null && typeof body === 'object' && body.ok === true && typeof body.aliases === 'object' && body.aliases !== null) {
|
|
296
|
+
publish(body.aliases)
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
.catch(() => { /* 静默:UI 退回 model.id 渲染 */ })
|
|
300
|
+
return {
|
|
301
|
+
aliasFor,
|
|
302
|
+
/** 订阅别名表变更 —— 组件 mount 后调用一次,以后改了会刷新。 */
|
|
303
|
+
subscribe(listener) {
|
|
304
|
+
listeners.add(listener)
|
|
305
|
+
return () => listeners.delete(listener)
|
|
306
|
+
},
|
|
307
|
+
snapshot: () => map
|
|
308
|
+
}
|
|
309
|
+
})()
|
|
310
|
+
|
|
186
311
|
/** 注入一次卡片样式;用 DSH 主题变量,明暗自适应。 */
|
|
187
312
|
function ensureStyle() {
|
|
188
313
|
const id = 'dsh-llm-hub-style'
|
|
@@ -208,6 +333,7 @@ window.__ModuleLoader__.load({
|
|
|
208
333
|
'.dsh-llm-hub-balance__label{color:var(--dsw-alias-label-secondary)}',
|
|
209
334
|
'.dsh-llm-hub-balance__amount{color:var(--dsw-alias-label-primary);font-weight:600;',
|
|
210
335
|
'font-variant-numeric:tabular-nums}',
|
|
336
|
+
'.dsh-llm-hub-balance__amount--low{color:var(--dsw-alias-state-error-primary)}',
|
|
211
337
|
'.dsh-llm-hub-balance__breakdown{color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
212
338
|
'.dsh-llm-hub-balance__warn{color:var(--dsw-alias-state-warn-primary)}',
|
|
213
339
|
'.dsh-llm-hub-balance__error{color:var(--dsw-alias-state-error-primary)}',
|
|
@@ -253,6 +379,82 @@ window.__ModuleLoader__.load({
|
|
|
253
379
|
'.dsh-llm-hub-chip--hidden{border-color:var(--dsw-alias-state-error-primary);',
|
|
254
380
|
'color:var(--dsw-alias-state-error-primary)}',
|
|
255
381
|
'.dsh-llm-hub-foot__hidden{color:var(--dsw-alias-state-error-primary)}',
|
|
382
|
+
// 用量卡:放在 footer 之前(order 80 vs harness 90 vs footer 100)
|
|
383
|
+
'.dsh-llm-hub-usage{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
|
|
384
|
+
'padding:8px 10px;border-radius:8px;',
|
|
385
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
|
|
386
|
+
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
387
|
+
'.dsh-llm-hub-usage__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
388
|
+
'.dsh-llm-hub-usage__title{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
389
|
+
'.dsh-llm-hub-usage__facts{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
390
|
+
'.dsh-llm-hub-usage__tag{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;',
|
|
391
|
+
'border-radius:999px;border:.5px solid var(--dsw-alias-border-l1);',
|
|
392
|
+
'background:var(--dsw-alias-bg-layer-3)}',
|
|
393
|
+
'.dsh-llm-hub-usage__top{display:flex;flex-direction:column;gap:2px;margin-top:2px}',
|
|
394
|
+
'.dsh-llm-hub-usage__top-row{display:flex;justify-content:space-between;gap:8px;',
|
|
395
|
+
'color:var(--dsw-alias-label-primary)}',
|
|
396
|
+
'.dsh-llm-hub-usage__top-row small{color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
397
|
+
'.dsh-llm-hub-usage__actions{display:flex;gap:6px;flex-wrap:wrap}',
|
|
398
|
+
// 健康看板:放在用量卡之上(order 70 vs usage 80)
|
|
399
|
+
'.dsh-llm-hub-health{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
|
|
400
|
+
'padding:8px 10px;border-radius:8px;',
|
|
401
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
|
|
402
|
+
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
403
|
+
'.dsh-llm-hub-health__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
404
|
+
'.dsh-llm-hub-health__title{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
405
|
+
'.dsh-llm-hub-health__rows{display:flex;flex-direction:column;gap:2px}',
|
|
406
|
+
'.dsh-llm-hub-health__row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;',
|
|
407
|
+
'padding:2px 4px;border-radius:6px}',
|
|
408
|
+
'.dsh-llm-hub-health__name{color:var(--dsw-alias-label-primary);font-weight:600;',
|
|
409
|
+
'min-width:80px}',
|
|
410
|
+
'.dsh-llm-hub-health__chip{display:inline-flex;align-items:center;height:20px;padding:0 8px;',
|
|
411
|
+
'border-radius:999px;border:.5px solid var(--dsw-alias-border-l3);',
|
|
412
|
+
'font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
|
|
413
|
+
'.dsh-llm-hub-health__chip--available{border-color:var(--dsw-alias-state-success-primary,#3fb950);',
|
|
414
|
+
'color:var(--dsw-alias-state-success-primary,#3fb950)}',
|
|
415
|
+
'.dsh-llm-hub-health__chip--unavailable{border-color:var(--dsw-alias-state-error-primary);',
|
|
416
|
+
'color:var(--dsw-alias-state-error-primary)}',
|
|
417
|
+
'.dsh-llm-hub-health__chip--unknown{border-color:var(--dsw-alias-border-l3);',
|
|
418
|
+
'color:var(--dsw-alias-label-secondary)}',
|
|
419
|
+
'.dsh-llm-hub-health__metric{font-size:11px;opacity:.85}',
|
|
420
|
+
'.dsh-llm-hub-health__metric--time{margin-left:auto}',
|
|
421
|
+
'.dsh-llm-hub-health__reason{color:var(--dsw-alias-state-warn-primary);',
|
|
422
|
+
'max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
423
|
+
// 当前路由卡:放在 health 70 与 usage 80 之间(order 75)
|
|
424
|
+
'.dsh-llm-hub-routing{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
|
|
425
|
+
'padding:8px 10px;border-radius:8px;',
|
|
426
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
|
|
427
|
+
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
428
|
+
'.dsh-llm-hub-routing__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
429
|
+
'.dsh-llm-hub-routing__title{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
430
|
+
'.dsh-llm-hub-routing__active{color:var(--dsw-alias-label-primary)}',
|
|
431
|
+
'.dsh-llm-hub-routing__rows{display:flex;flex-direction:column;gap:2px}',
|
|
432
|
+
'.dsh-llm-hub-routing__row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;',
|
|
433
|
+
'padding:2px 4px;border-radius:6px}',
|
|
434
|
+
'.dsh-llm-hub-routing__role{display:inline-flex;align-items:center;height:20px;padding:0 8px;',
|
|
435
|
+
'border-radius:999px;border:.5px solid var(--dsw-alias-border-l3);',
|
|
436
|
+
'background:var(--dsw-alias-bg-layer-3);font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
437
|
+
'.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
438
|
+
'.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);',
|
|
439
|
+
'padding-top:4px}',
|
|
440
|
+
'.dsh-llm-hub-routing__reason{color:var(--dsw-alias-state-error-primary);',
|
|
441
|
+
'padding-top:4px}',
|
|
442
|
+
// 多账号 key 轮换:放在 routing 75 之前(order 73)
|
|
443
|
+
'.dsh-llm-hub-keypool{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
|
|
444
|
+
'padding:8px 10px;border-radius:8px;',
|
|
445
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
|
|
446
|
+
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
447
|
+
'.dsh-llm-hub-keypool__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
448
|
+
'.dsh-llm-hub-keypool__title{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
449
|
+
'.dsh-llm-hub-keypool__rows{display:flex;flex-direction:column;gap:2px}',
|
|
450
|
+
'.dsh-llm-hub-keypool__row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
|
|
451
|
+
'padding:2px 4px;border-radius:6px}',
|
|
452
|
+
'.dsh-llm-hub-keypool__name{color:var(--dsw-alias-label-primary);font-weight:600;min-width:120px}',
|
|
453
|
+
'.dsh-llm-hub-keypool__meta{font-size:11px;opacity:.7}',
|
|
454
|
+
'.dsh-llm-hub-keypool__chosen{display:flex;align-items:baseline;gap:4px;',
|
|
455
|
+
'color:var(--dsw-alias-label-primary)}',
|
|
456
|
+
'.dsh-llm-hub-keypool__chosen small{color:var(--dsw-alias-label-secondary);opacity:.75;font-size:11px}',
|
|
457
|
+
'.dsh-llm-hub-keypool__pending{color:var(--dsw-alias-label-secondary);opacity:.6}',
|
|
256
458
|
].join('')
|
|
257
459
|
document.head.appendChild(style)
|
|
258
460
|
}
|
|
@@ -266,6 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
266
468
|
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
267
469
|
const keyConfigured = props.keyConfigured !== false
|
|
268
470
|
const [state, setState] = React.useState({ status: 'idle' })
|
|
471
|
+
const [warning, setWarning] = React.useState(null)
|
|
269
472
|
// 官方直连也可能被判不可用(key 被拒 / 账户欠费)。同一个状态片,一致的处理。
|
|
270
473
|
const availability = props.availability
|
|
271
474
|
const availabilityState = availability === undefined
|
|
@@ -343,8 +546,39 @@ window.__ModuleLoader__.load({
|
|
|
343
546
|
children.push(h('span', { key: 'empty', className: 'dsh-llm-hub-balance__breakdown' }, t('loading')))
|
|
344
547
|
return h('div', { className: 'dsh-llm-hub-balance' }, children)
|
|
345
548
|
}
|
|
549
|
+
// 余额预警:拿到数字后顺手问 host 是否低于阈值。状态独立:
|
|
550
|
+
// warning=null 表示还没回(不显示标签)/ level='low' 才上色 + 提示。
|
|
551
|
+
React.useEffect(() => {
|
|
552
|
+
fetch(WARNING_URL, {
|
|
553
|
+
method: 'POST',
|
|
554
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
555
|
+
body: JSON.stringify({
|
|
556
|
+
provider: DEEPSEEK_PROVIDER,
|
|
557
|
+
deepseek: { total: primary.total, currency: primary.currency }
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
.then((res) => res.json())
|
|
561
|
+
.then((body) => {
|
|
562
|
+
if (body?.ok === true && body.level === 'low') {
|
|
563
|
+
setWarning({ level: 'low', text: typeof body.text === 'string' ? body.text : '' })
|
|
564
|
+
} else {
|
|
565
|
+
setWarning(null)
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
.catch(() => setWarning(null))
|
|
569
|
+
}, [primary.total, primary.currency])
|
|
346
570
|
const symbol = primary.currency === 'CNY' ? '¥' : primary.currency === 'USD' ? '$' : `${primary.currency} `
|
|
347
|
-
|
|
571
|
+
const amountClass = warning !== null && warning.level === 'low'
|
|
572
|
+
? 'dsh-llm-hub-balance__amount dsh-llm-hub-balance__amount--low'
|
|
573
|
+
: 'dsh-llm-hub-balance__amount'
|
|
574
|
+
children.push(h('span', { key: 'amount', className: amountClass }, `${symbol}${primary.total}`))
|
|
575
|
+
if (warning !== null && warning.level === 'low') {
|
|
576
|
+
children.push(h('span', {
|
|
577
|
+
key: 'warning',
|
|
578
|
+
className: 'dsh-llm-hub-balance__warn',
|
|
579
|
+
title: warning.text
|
|
580
|
+
}, '⚠️'))
|
|
581
|
+
}
|
|
348
582
|
if (!state.isAvailable) children.push(h('span', { key: 'unavail', className: 'dsh-llm-hub-balance__warn' }, t('unavailable')))
|
|
349
583
|
const breakdown = []
|
|
350
584
|
if (primary.granted !== '0' && primary.granted !== '0.00') breakdown.push(`${t('granted')} ${symbol}${primary.granted}`)
|
|
@@ -433,6 +667,7 @@ window.__ModuleLoader__.load({
|
|
|
433
667
|
const [saveState, setSaveState] = React.useState(null)
|
|
434
668
|
const [copied, setCopied] = React.useState(false)
|
|
435
669
|
const [balance, setBalance] = React.useState(null)
|
|
670
|
+
const [warning, setWarning] = React.useState(null)
|
|
436
671
|
// 被摘掉的分组,在它自己的卡片上直接标出来 —— 动作条里一枚小片,不占额外行。
|
|
437
672
|
const availability = props.availability
|
|
438
673
|
const availabilityState = availability === undefined
|
|
@@ -459,6 +694,27 @@ window.__ModuleLoader__.load({
|
|
|
459
694
|
}
|
|
460
695
|
}, [provider, status])
|
|
461
696
|
|
|
697
|
+
// 余额预警:拿到 envelope 后顺手问 host 是否低于阈值。
|
|
698
|
+
React.useEffect(() => {
|
|
699
|
+
if (provider === '' || balance === null || balance.ok !== true) return undefined
|
|
700
|
+
let alive = true
|
|
701
|
+
fetch(WARNING_URL, {
|
|
702
|
+
method: 'POST',
|
|
703
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
704
|
+
body: JSON.stringify({ provider, envelope: balance })
|
|
705
|
+
})
|
|
706
|
+
.then((res) => res.json())
|
|
707
|
+
.then((body) => {
|
|
708
|
+
if (alive && body?.ok === true && body.level === 'low') {
|
|
709
|
+
setWarning({ level: 'low', text: typeof body.text === 'string' ? body.text : '' })
|
|
710
|
+
} else if (alive) {
|
|
711
|
+
setWarning(null)
|
|
712
|
+
}
|
|
713
|
+
})
|
|
714
|
+
.catch(() => { if (alive) setWarning(null) })
|
|
715
|
+
return () => { alive = false }
|
|
716
|
+
}, [provider, balance])
|
|
717
|
+
|
|
462
718
|
React.useEffect(() => {
|
|
463
719
|
if (provider === '') return undefined
|
|
464
720
|
let alive = true
|
|
@@ -556,6 +812,13 @@ window.__ModuleLoader__.load({
|
|
|
556
812
|
}
|
|
557
813
|
const balanceText = renderBalance(balance, t)
|
|
558
814
|
if (balanceText !== null) facts.push(h('span', { key: 'bal', className: 'dsh-llm-hub-balance__tag' }, balanceText))
|
|
815
|
+
if (warning !== null && warning.level === 'low') {
|
|
816
|
+
facts.push(h('span', {
|
|
817
|
+
key: 'warning',
|
|
818
|
+
className: 'dsh-llm-hub-balance__warn',
|
|
819
|
+
title: warning.text
|
|
820
|
+
}, '⚠️'))
|
|
821
|
+
}
|
|
559
822
|
|
|
560
823
|
// 动作行:**所有按钮挤在同一行**。
|
|
561
824
|
//
|
|
@@ -628,10 +891,18 @@ window.__ModuleLoader__.load({
|
|
|
628
891
|
h('span', null, `${t('pick')}(${picked.size}/${body.models.length})`),
|
|
629
892
|
probeButton('save', saveState === 'saving' ? t('saving') : saveState === 'done' ? t('saved') : saveState === 'fail' ? t('saveFail') : t('save'), saveState === 'saving', saveModels)),
|
|
630
893
|
h('div', { className: 'dsh-llm-hub-picker__list' },
|
|
631
|
-
body.models.map((model) =>
|
|
632
|
-
|
|
633
|
-
h('
|
|
634
|
-
|
|
894
|
+
body.models.map((model) => {
|
|
895
|
+
const alias = aliasesStore.aliasFor(provider, model.id)
|
|
896
|
+
return h('label', {
|
|
897
|
+
key: model.id,
|
|
898
|
+
className: 'dsh-llm-hub-picker__item',
|
|
899
|
+
// 完整 id 永远在 title 里 —— 复制 model id 粘配置用。
|
|
900
|
+
title: model.id
|
|
901
|
+
},
|
|
902
|
+
h('input', { type: 'checkbox', checked: picked.has(model.id), onChange: () => toggle(model.id) }),
|
|
903
|
+
h('span', { className: 'dsh-llm-hub-picker__id' }, alias === null ? model.id : `${alias} (${model.id})`),
|
|
904
|
+
configured.has(model.id) ? h('span', { className: 'dsh-llm-hub-picker__tag' }, t('configured')) : null)
|
|
905
|
+
})))
|
|
635
906
|
} else {
|
|
636
907
|
actions.push(h('span', { key: 'cat-bad', className: 'dsh-llm-hub-balance__error' }, `${t('failed')}: ${body?.error ?? ''}`))
|
|
637
908
|
}
|
|
@@ -702,6 +973,327 @@ window.__ModuleLoader__.load({
|
|
|
702
973
|
)
|
|
703
974
|
}
|
|
704
975
|
|
|
976
|
+
/**
|
|
977
|
+
* 健康看板卡:把所有 provider 的探测结果铺成一张表,每行:
|
|
978
|
+
* 名称 | 状态(可用/不可用/未知) | 延迟 | HTTP | 原因 | 探测时间
|
|
979
|
+
*
|
|
980
|
+
* 不复用 availability payload:那个为下拉隐藏服务,shape 不一样;新加
|
|
981
|
+
* `/api/dsh-llm-hub/health` 路由返回带 latencyMs / status 的全字段。
|
|
982
|
+
* 状态 chip 与隐藏下拉那枚 chip 共用样式变量。
|
|
983
|
+
*
|
|
984
|
+
* 「重新探测」按钮 = availability 路由的 recheck(共用同一条底层 refresh
|
|
985
|
+
* 流程,避免双探),触发后乐观更新 checkedAt 让用户看见反馈。
|
|
986
|
+
*
|
|
987
|
+
* @param props - 注入的 `t` 和 `availability`(recheck 用)。
|
|
988
|
+
* @returns 健康看板;探测数据为空时返回 null(启动竞态)。
|
|
989
|
+
*/
|
|
990
|
+
function HealthCard(props) {
|
|
991
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
992
|
+
const availability = props.availability
|
|
993
|
+
const [report, setReport] = React.useState(null)
|
|
994
|
+
const [busy, setBusy] = React.useState(false)
|
|
995
|
+
const load = React.useCallback(() => {
|
|
996
|
+
fetch(HEALTH_URL, { headers: { accept: 'application/json' } })
|
|
997
|
+
.then((res) => res.json())
|
|
998
|
+
.then((body) => { setReport(body !== null && typeof body === 'object' ? body : { ok: false }) })
|
|
999
|
+
.catch(() => setReport({ ok: false }))
|
|
1000
|
+
}, [])
|
|
1001
|
+
React.useEffect(() => { load() }, [load])
|
|
1002
|
+
// availability store 推 probing=true 时也刷一次 —— 探测完成后数据更全
|
|
1003
|
+
const availabilityState = availability === undefined
|
|
1004
|
+
? { probing: false }
|
|
1005
|
+
: React.useSyncExternalStore(availability.subscribe, availability.snapshot)
|
|
1006
|
+
React.useEffect(() => {
|
|
1007
|
+
if (availabilityState.probing === false) load()
|
|
1008
|
+
}, [availabilityState.probing, load])
|
|
1009
|
+
if (report === null) return null
|
|
1010
|
+
if (report.ok !== true) return null
|
|
1011
|
+
const providers = Array.isArray(report.providers) ? report.providers : []
|
|
1012
|
+
if (providers.length === 0) {
|
|
1013
|
+
ensureStyle()
|
|
1014
|
+
return h('div', { className: 'dsh-llm-hub-health' },
|
|
1015
|
+
h('div', { className: 'dsh-llm-hub-health__head' },
|
|
1016
|
+
h('span', { className: 'dsh-llm-hub-health__title' }, t('healthTitle')),
|
|
1017
|
+
h('span', null, t('healthNoData'))))
|
|
1018
|
+
}
|
|
1019
|
+
ensureStyle()
|
|
1020
|
+
const recheck = () => {
|
|
1021
|
+
if (availability === undefined) return
|
|
1022
|
+
setBusy(true)
|
|
1023
|
+
availability.recheck().finally(() => { setBusy(false); load() })
|
|
1024
|
+
}
|
|
1025
|
+
const formatLatency = (value) => typeof value === 'number' ? `${value}ms` : '—'
|
|
1026
|
+
const formatCheckedAt = (ms) => {
|
|
1027
|
+
if (typeof ms !== 'number' || ms <= 0) return '—'
|
|
1028
|
+
const d = new Date(ms)
|
|
1029
|
+
const hh = String(d.getHours()).padStart(2, '0')
|
|
1030
|
+
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
1031
|
+
const ss = String(d.getSeconds()).padStart(2, '0')
|
|
1032
|
+
return `${hh}:${mm}:${ss}`
|
|
1033
|
+
}
|
|
1034
|
+
const stateLabel = (state) => {
|
|
1035
|
+
const map = typeof t('healthState') === 'object' ? t('healthState') : { available: 'up', unavailable: 'down', unknown: 'unknown' }
|
|
1036
|
+
return map[state] ?? state
|
|
1037
|
+
}
|
|
1038
|
+
const stateClass = (state) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${state ?? 'unknown'}`
|
|
1039
|
+
const rows = providers.map((entry) => h('div', { key: entry.provider, className: 'dsh-llm-hub-health__row' },
|
|
1040
|
+
h('span', { className: 'dsh-llm-hub-health__name' }, entry.displayName ?? entry.provider),
|
|
1041
|
+
h('span', { className: stateClass(entry.state), title: typeof entry.code === 'string' ? entry.code : '' }, stateLabel(entry.state)),
|
|
1042
|
+
h('span', { className: 'dsh-llm-hub-health__metric' }, `${t('healthLatency')} ${formatLatency(entry.latencyMs)}`),
|
|
1043
|
+
h('span', { className: 'dsh-llm-hub-health__metric' }, `${t('healthStatus')} ${typeof entry.status === 'number' ? entry.status : '—'}`),
|
|
1044
|
+
typeof entry.reason === 'string' && entry.reason.length > 0
|
|
1045
|
+
? h('span', { className: 'dsh-llm-hub-health__reason', title: entry.reason }, entry.reason)
|
|
1046
|
+
: null,
|
|
1047
|
+
h('span', { className: 'dsh-llm-hub-health__metric dsh-llm-hub-health__metric--time' }, `${t('healthCheckedAt')} ${formatCheckedAt(entry.checkedAt)}`)))
|
|
1048
|
+
return h('div', { className: 'dsh-llm-hub-health' },
|
|
1049
|
+
h('div', { className: 'dsh-llm-hub-health__head' },
|
|
1050
|
+
h('span', { className: 'dsh-llm-hub-health__title' }, t('healthTitle')),
|
|
1051
|
+
availability === undefined ? null : h('button', {
|
|
1052
|
+
key: 'recheck',
|
|
1053
|
+
type: 'button',
|
|
1054
|
+
className: 'dsh-llm-hub-foot__link',
|
|
1055
|
+
disabled: busy,
|
|
1056
|
+
onClick: recheck
|
|
1057
|
+
}, busy ? t('healthRechecking') : t('healthRecheck'))),
|
|
1058
|
+
h('div', { className: 'dsh-llm-hub-health__rows' }, rows))
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* 多账号 key 轮换卡 —— 显示每个 provider 当前轮到了哪个 key。
|
|
1063
|
+
*
|
|
1064
|
+
* 设计为调试 / 可见性卡:用户想知道「我现在用的是哪一把 key」「下一把是谁」
|
|
1065
|
+
* 「上次失败用的是哪个 env」。**不**显示 apiKey 实际值,只显示 name + env 名。
|
|
1066
|
+
*
|
|
1067
|
+
* 暂不做「下一把」按钮 —— host 半 round-robin 已经在每次解析时推进,用户
|
|
1068
|
+
* 想强制换的话只要刷一次页面(或重探 health)就轮到下一个。
|
|
1069
|
+
*
|
|
1070
|
+
* @param props - 注入的 `t`。
|
|
1071
|
+
* @returns 轮换卡;没配 keyPool 时返 null。
|
|
1072
|
+
*/
|
|
1073
|
+
function KeyPoolCard(props) {
|
|
1074
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1075
|
+
const [report, setReport] = React.useState(null)
|
|
1076
|
+
React.useEffect(() => {
|
|
1077
|
+
let alive = true
|
|
1078
|
+
fetch(KEYPOOL_URL, { headers: { accept: 'application/json' } })
|
|
1079
|
+
.then((res) => res.json())
|
|
1080
|
+
.then((body) => { if (alive) setReport(body !== null && typeof body === 'object' ? body : null) })
|
|
1081
|
+
.catch(() => { if (alive) setReport(null) })
|
|
1082
|
+
return () => { alive = false }
|
|
1083
|
+
}, [])
|
|
1084
|
+
if (report === null) return null
|
|
1085
|
+
const providers = Array.isArray(report.providers) ? report.providers : []
|
|
1086
|
+
if (providers.length === 0) return null
|
|
1087
|
+
ensureStyle()
|
|
1088
|
+
const formatTime = (ms) => {
|
|
1089
|
+
if (typeof ms !== 'number' || ms <= 0) return '—'
|
|
1090
|
+
const d = new Date(ms)
|
|
1091
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
|
1092
|
+
}
|
|
1093
|
+
const rows = providers.map((entry) => {
|
|
1094
|
+
const chosen = entry.lastChosen
|
|
1095
|
+
return h('div', { key: entry.provider, className: 'dsh-llm-hub-keypool__row' },
|
|
1096
|
+
h('span', { className: 'dsh-llm-hub-keypool__name' }, entry.provider),
|
|
1097
|
+
h('span', { className: 'dsh-llm-hub-keypool__meta' }, `${entry.total} keys`),
|
|
1098
|
+
chosen === null
|
|
1099
|
+
? h('span', { className: 'dsh-llm-hub-keypool__pending' }, '—')
|
|
1100
|
+
: h('span', { className: 'dsh-llm-hub-keypool__chosen' },
|
|
1101
|
+
h('span', null, chosen.name),
|
|
1102
|
+
h('small', null, ` #${chosen.index + 1} / ${entry.total} · ${formatTime(chosen.at)}`)))
|
|
1103
|
+
})
|
|
1104
|
+
return h('div', { className: 'dsh-llm-hub-keypool' },
|
|
1105
|
+
h('div', { className: 'dsh-llm-hub-keypool__head' },
|
|
1106
|
+
h('span', { className: 'dsh-llm-hub-keypool__title' }, t('keypoolTitle')),
|
|
1107
|
+
h('span', null, t('keypoolRound'))),
|
|
1108
|
+
h('div', { className: 'dsh-llm-hub-keypool__rows' }, rows))
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* 当前路由卡 —— 显示用户当前激活的 (provider, model) + 状态 + 推荐的 fallback。
|
|
1113
|
+
*
|
|
1114
|
+
* 不替用户切换:DSH 的 `conversation.input.model` 是 single + user-controlled
|
|
1115
|
+
* slot,没有供插件改值的 setter(README 已经钉死这条)。这条卡只**告诉**
|
|
1116
|
+
* 用户「当前这个不可用了,下一个可用的 fallback 是 X,你自己点一下」。
|
|
1117
|
+
*
|
|
1118
|
+
* 数据来源 `/api/dsh-llm-hub/routing/resolve`:POST 进来 active = (provider, model),
|
|
1119
|
+
* host 半按 settings.dsh-llm-hub.routing 的 primary + fallbacks[] 返回
|
|
1120
|
+
* recommendation。
|
|
1121
|
+
*
|
|
1122
|
+
* 渲染形式:
|
|
1123
|
+
* - 当前 active + 状态 chip(与 health 卡共享样式)
|
|
1124
|
+
* - primary / fallback 候选列表(各自带状态 chip)
|
|
1125
|
+
* - recommendation ≠ active 时显式提示「建议切换到 X」
|
|
1126
|
+
* - 未配置 routing:显示「未配置主力模型」+ 引导文本
|
|
1127
|
+
* - 全不可用:显示「全部不可用」+ reason
|
|
1128
|
+
*
|
|
1129
|
+
* @param props - 注入的 `t`。
|
|
1130
|
+
* @returns 当前路由卡;未配置时不渲染(让用户先配置再说)。
|
|
1131
|
+
*/
|
|
1132
|
+
function RoutingCard(props) {
|
|
1133
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1134
|
+
const [report, setReport] = React.useState(null)
|
|
1135
|
+
const query = React.useCallback((active) => {
|
|
1136
|
+
fetch(ROUTING_URL, {
|
|
1137
|
+
method: 'POST',
|
|
1138
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1139
|
+
body: JSON.stringify({ active: typeof active === 'string' && active.length > 0 ? active : null })
|
|
1140
|
+
})
|
|
1141
|
+
.then((res) => res.json())
|
|
1142
|
+
.then((body) => { setReport(body !== null && typeof body === 'object' ? body : null) })
|
|
1143
|
+
.catch(() => setReport(null))
|
|
1144
|
+
}, [])
|
|
1145
|
+
React.useEffect(() => { query(null) }, [query])
|
|
1146
|
+
if (report === null) {
|
|
1147
|
+
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
1148
|
+
h('div', { className: 'dsh-llm-hub-routing__head' },
|
|
1149
|
+
h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
|
|
1150
|
+
h('span', null, t('routingLoading'))))
|
|
1151
|
+
}
|
|
1152
|
+
if (report.ok !== true) return null
|
|
1153
|
+
const candidates = Array.isArray(report.candidates) ? report.candidates : []
|
|
1154
|
+
if (candidates.length === 0) {
|
|
1155
|
+
ensureStyle()
|
|
1156
|
+
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
1157
|
+
h('div', { className: 'dsh-llm-hub-routing__head' },
|
|
1158
|
+
h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
|
|
1159
|
+
h('span', null, t('routingUnconfigured'))))
|
|
1160
|
+
}
|
|
1161
|
+
ensureStyle()
|
|
1162
|
+
const active = report.active
|
|
1163
|
+
const recommendation = report.recommendation
|
|
1164
|
+
const activeKey = active === null ? null : `${active.provider}/${active.model}`
|
|
1165
|
+
const recKey = recommendation === null ? null : `${recommendation.provider}/${recommendation.model}`
|
|
1166
|
+
const showSuggestion = recommendation !== null && recKey !== activeKey
|
|
1167
|
+
const chipClass = (state) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${state ?? 'unknown'}`
|
|
1168
|
+
const head = h('div', { className: 'dsh-llm-hub-routing__head' },
|
|
1169
|
+
h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
|
|
1170
|
+
active !== null
|
|
1171
|
+
? h('span', { className: 'dsh-llm-hub-routing__active' },
|
|
1172
|
+
`${t('routingActive')}: ${active.provider}/${active.model}`,
|
|
1173
|
+
h('span', { className: chipClass(active.state), style: { marginLeft: '6px' } }, active.state))
|
|
1174
|
+
: null)
|
|
1175
|
+
const rows = candidates.map((entry, index) => h('div', { key: `${entry.provider}/${entry.model}/${index}`, className: 'dsh-llm-hub-routing__row' },
|
|
1176
|
+
h('span', { className: 'dsh-llm-hub-routing__role' }, index === 0 ? t('routingPrimary') : `${t('routingFallback')} ${index}`),
|
|
1177
|
+
h('span', { className: 'dsh-llm-hub-routing__name' }, `${entry.provider}/${entry.model}`),
|
|
1178
|
+
h('span', { className: chipClass(entry.state) }, entry.state)))
|
|
1179
|
+
const footer = showSuggestion
|
|
1180
|
+
? h('div', { className: 'dsh-llm-hub-routing__suggestion' },
|
|
1181
|
+
h('span', null, `${t('routingSuggestion')} `),
|
|
1182
|
+
h('strong', null, `${recommendation.provider}/${recommendation.model}`))
|
|
1183
|
+
: (typeof report.reason === 'string' && report.reason.length > 0
|
|
1184
|
+
? h('div', { className: 'dsh-llm-hub-routing__reason' }, report.reason)
|
|
1185
|
+
: null)
|
|
1186
|
+
return h('div', { className: 'dsh-llm-hub-routing' }, head, h('div', { className: 'dsh-llm-hub-routing__rows' }, rows), footer)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* 格式化 token 数字 —— 大数带千分位,>9999 折成 1.2万。
|
|
1191
|
+
* @param value - token 数。
|
|
1192
|
+
* @returns 人话短文本。
|
|
1193
|
+
*/
|
|
1194
|
+
function formatTokens(value) {
|
|
1195
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '0'
|
|
1196
|
+
if (value < 10000) return String(Math.floor(value))
|
|
1197
|
+
return `${(value / 10000).toFixed(value < 100000 ? 2 : 1)}万`
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* 本月用量卡。
|
|
1202
|
+
*
|
|
1203
|
+
* 显示 input / output / cache tokens + 调用次数 + 前 5 个最常用模型。
|
|
1204
|
+
* 不算钱 —— open source DSH 没收 chat 文本定价接口,每个 provider 写硬编码价目表
|
|
1205
|
+
* 既过时又快塌。把 token 数留给用户对照官方价目表,导出按钮拿整张 CSV。
|
|
1206
|
+
*
|
|
1207
|
+
* @param props - 注入的 `t`。
|
|
1208
|
+
* @returns 月度用量卡;统计未启用时返回 null(harness 一览照常显示)。
|
|
1209
|
+
*/
|
|
1210
|
+
function UsageCard(props) {
|
|
1211
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1212
|
+
const [report, setReport] = React.useState(null)
|
|
1213
|
+
const [busy, setBusy] = React.useState(false)
|
|
1214
|
+
const [feedback, setFeedback] = React.useState(null)
|
|
1215
|
+
React.useEffect(() => {
|
|
1216
|
+
let alive = true
|
|
1217
|
+
fetch(USAGE_URL, { headers: { accept: 'application/json' } })
|
|
1218
|
+
.then((res) => res.json())
|
|
1219
|
+
.then((body) => { if (alive) setReport(body !== null && typeof body === 'object' ? body : { ready: false }) })
|
|
1220
|
+
.catch(() => { if (alive) setReport({ ready: false }) })
|
|
1221
|
+
return () => { alive = false }
|
|
1222
|
+
}, [])
|
|
1223
|
+
if (report === null) return null
|
|
1224
|
+
if (report.ready !== true) {
|
|
1225
|
+
// 用量统计未启用:照实说,不占位。
|
|
1226
|
+
ensureStyle()
|
|
1227
|
+
return h('div', { className: 'dsh-llm-hub-usage' },
|
|
1228
|
+
h('div', { className: 'dsh-llm-hub-usage__head' },
|
|
1229
|
+
h('span', { className: 'dsh-llm-hub-usage__title' }, t('usageTitle')),
|
|
1230
|
+
h('span', null, t('usageDisabled'))))
|
|
1231
|
+
}
|
|
1232
|
+
ensureStyle()
|
|
1233
|
+
const current = report.current
|
|
1234
|
+
const topModels = Array.isArray(current?.topModels) ? current.topModels : []
|
|
1235
|
+
const exportCsv = () => {
|
|
1236
|
+
setBusy(true)
|
|
1237
|
+
setFeedback(null)
|
|
1238
|
+
fetch(USAGE_EXPORT_URL, { headers: { accept: 'text/csv' } })
|
|
1239
|
+
.then((res) => res.text())
|
|
1240
|
+
.then((text) => {
|
|
1241
|
+
navigator.clipboard?.writeText(text).then(
|
|
1242
|
+
() => setFeedback('ok'),
|
|
1243
|
+
() => setFeedback('fail'),
|
|
1244
|
+
)
|
|
1245
|
+
})
|
|
1246
|
+
.catch(() => setFeedback('fail'))
|
|
1247
|
+
.finally(() => setBusy(false))
|
|
1248
|
+
}
|
|
1249
|
+
const clearRecords = () => {
|
|
1250
|
+
if (!window.confirm(t('usageClearConfirm'))) return
|
|
1251
|
+
setBusy(true)
|
|
1252
|
+
fetch('/api/dsh-llm-hub/usage/clear', {
|
|
1253
|
+
method: 'POST',
|
|
1254
|
+
headers: { accept: 'application/json' }
|
|
1255
|
+
})
|
|
1256
|
+
.then(() => setReport((prev) => ({ ...prev, current: { ...(prev?.current ?? {}), calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0, topModels: [] } })))
|
|
1257
|
+
.catch(() => {})
|
|
1258
|
+
.finally(() => setBusy(false))
|
|
1259
|
+
}
|
|
1260
|
+
const facts = []
|
|
1261
|
+
if (current) {
|
|
1262
|
+
facts.push(h('span', { key: 'calls', className: 'dsh-llm-hub-usage__tag' }, `${current.calls ?? 0} ${t('usageCalls')}`))
|
|
1263
|
+
facts.push(h('span', { key: 'in', className: 'dsh-llm-hub-usage__tag' }, `${t('usageInput')} ${formatTokens(current.inputTokens)}`))
|
|
1264
|
+
facts.push(h('span', { key: 'out', className: 'dsh-llm-hub-usage__tag' }, `${t('usageOutput')} ${formatTokens(current.outputTokens)}`))
|
|
1265
|
+
if ((current.cacheReadTokens ?? 0) > 0 || (current.cacheWriteTokens ?? 0) > 0) {
|
|
1266
|
+
facts.push(h('span', { key: 'cache', className: 'dsh-llm-hub-usage__tag' }, `${t('usageCache')} ${formatTokens((current.cacheReadTokens ?? 0) + (current.cacheWriteTokens ?? 0))}`))
|
|
1267
|
+
}
|
|
1268
|
+
facts.push(h('span', { key: 'total', className: 'dsh-llm-hub-usage__tag' }, `${t('usageTotal')} ${formatTokens(current.totalTokens)}`))
|
|
1269
|
+
}
|
|
1270
|
+
const topSection = topModels.length > 0
|
|
1271
|
+
? h('div', { className: 'dsh-llm-hub-usage__top' },
|
|
1272
|
+
h('div', { className: 'dsh-llm-hub-usage__title' }, t('usageTopModels')),
|
|
1273
|
+
topModels.map((row, index) => h('div', { key: `${row.provider}/${row.model}/${index}`, className: 'dsh-llm-hub-usage__top-row' },
|
|
1274
|
+
h('span', null, `${row.provider}/${row.model}`),
|
|
1275
|
+
h('small', null, `${row.calls} ${t('usageCalls')}`))))
|
|
1276
|
+
: h('div', null, t('usageNoData'))
|
|
1277
|
+
return h('div', { className: 'dsh-llm-hub-usage' },
|
|
1278
|
+
h('div', { className: 'dsh-llm-hub-usage__head' },
|
|
1279
|
+
h('span', { className: 'dsh-llm-hub-usage__title' }, t('usageTitle')),
|
|
1280
|
+
h('div', { className: 'dsh-llm-hub-usage__facts' }, facts)),
|
|
1281
|
+
topSection,
|
|
1282
|
+
h('div', { className: 'dsh-llm-hub-usage__actions' },
|
|
1283
|
+
h('button', {
|
|
1284
|
+
type: 'button',
|
|
1285
|
+
className: 'dsh-llm-hub-foot__link',
|
|
1286
|
+
disabled: busy,
|
|
1287
|
+
onClick: exportCsv
|
|
1288
|
+
}, feedback === 'ok' ? t('usageExported') : feedback === 'fail' ? t('usageExportFail') : t('usageExport')),
|
|
1289
|
+
h('button', {
|
|
1290
|
+
type: 'button',
|
|
1291
|
+
className: 'dsh-llm-hub-foot__link',
|
|
1292
|
+
disabled: busy || (current?.calls ?? 0) === 0,
|
|
1293
|
+
onClick: clearRecords
|
|
1294
|
+
}, t('usageClear'))))
|
|
1295
|
+
}
|
|
1296
|
+
|
|
705
1297
|
function HubFooter(props) {
|
|
706
1298
|
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
707
1299
|
const availability = props.availability
|
|
@@ -925,6 +1517,34 @@ window.__ModuleLoader__.load({
|
|
|
925
1517
|
// 第一版漏了 id,注册被静默丢弃:接口通、组件在、页面上什么都没有。
|
|
926
1518
|
// harness 一览排在 footer 那行之上(order 更小):它讲的是「能派给谁」,
|
|
927
1519
|
// 属于能力信息;footer 那行是插件自身的版本与链接,属于元信息。
|
|
1520
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1521
|
+
name: 'settings.models.footer',
|
|
1522
|
+
id: 'dsh-llm-hub-health',
|
|
1523
|
+
order: 70,
|
|
1524
|
+
locale: NS,
|
|
1525
|
+
inject: () => ({ t, availability })
|
|
1526
|
+
}, HealthCard))
|
|
1527
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1528
|
+
name: 'settings.models.footer',
|
|
1529
|
+
id: 'dsh-llm-hub-keypool',
|
|
1530
|
+
order: 73,
|
|
1531
|
+
locale: NS,
|
|
1532
|
+
inject: () => ({ t })
|
|
1533
|
+
}, KeyPoolCard))
|
|
1534
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1535
|
+
name: 'settings.models.footer',
|
|
1536
|
+
id: 'dsh-llm-hub-routing',
|
|
1537
|
+
order: 75,
|
|
1538
|
+
locale: NS,
|
|
1539
|
+
inject: () => ({ t })
|
|
1540
|
+
}, RoutingCard))
|
|
1541
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1542
|
+
name: 'settings.models.footer',
|
|
1543
|
+
id: 'dsh-llm-hub-usage',
|
|
1544
|
+
order: 80,
|
|
1545
|
+
locale: NS,
|
|
1546
|
+
inject: () => ({ t })
|
|
1547
|
+
}, UsageCard))
|
|
928
1548
|
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
929
1549
|
name: 'settings.models.footer',
|
|
930
1550
|
id: 'dsh-llm-hub-harness',
|