@dsh-plugins/dsh-llm-hub 0.7.2 → 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 +138 -0
- package/README.md +185 -4
- package/lib/client.js +713 -7
- package/lib/index.js +1000 -38
- package/package.json +3 -3
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 旁路路由前缀。 */
|
|
@@ -60,8 +62,21 @@ window.__ModuleLoader__.load({
|
|
|
60
62
|
const META_URL = '/api/dsh-llm-hub/meta'
|
|
61
63
|
|
|
62
64
|
/** 模型可用性:读缓存 / 强制全量重探(host 半同名路由)。 */
|
|
65
|
+
/** harness 探测快照;host 半在 apply 时探一次,这里只读。 */
|
|
66
|
+
const HARNESS_URL = '/api/dsh-llm-hub/harness'
|
|
63
67
|
const AVAILABILITY_URL = '/api/dsh-llm-hub/availability'
|
|
64
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'
|
|
65
80
|
|
|
66
81
|
const LOCALES = {
|
|
67
82
|
zh: {
|
|
@@ -75,6 +90,13 @@ window.__ModuleLoader__.load({
|
|
|
75
90
|
toppedUp: '充值',
|
|
76
91
|
unavailable: '账户不可用',
|
|
77
92
|
failed: '查询失败',
|
|
93
|
+
harnessTitle: '外部 Agent',
|
|
94
|
+
harnessReady: '可委派',
|
|
95
|
+
harnessOccupied: '已由其它插件提供',
|
|
96
|
+
harnessMissing: '未安装',
|
|
97
|
+
harnessHintReady: '会话里可用 subagent_%s 把独立任务交给它',
|
|
98
|
+
harnessHintOccupied: '本机装了,但这个提供方已被另一个插件注册',
|
|
99
|
+
harnessHintMissing: '装上 %s 并重启 DSH,它就会出现',
|
|
78
100
|
probe: '探测网关',
|
|
79
101
|
probing: '探测中…',
|
|
80
102
|
reachable: '可达',
|
|
@@ -111,7 +133,42 @@ window.__ModuleLoader__.load({
|
|
|
111
133
|
availabilityRechecking: '探测中…',
|
|
112
134
|
availabilityHiddenCount: '已隐藏',
|
|
113
135
|
availabilityUnavailable: '已从下拉隐藏',
|
|
114
|
-
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: '本月还没有记录'
|
|
115
172
|
},
|
|
116
173
|
en: {
|
|
117
174
|
balance: 'Balance',
|
|
@@ -124,6 +181,13 @@ window.__ModuleLoader__.load({
|
|
|
124
181
|
toppedUp: 'Topped up',
|
|
125
182
|
unavailable: 'Account unavailable',
|
|
126
183
|
failed: 'Balance check failed',
|
|
184
|
+
harnessTitle: 'External agents',
|
|
185
|
+
harnessReady: 'ready',
|
|
186
|
+
harnessOccupied: 'provided by another plugin',
|
|
187
|
+
harnessMissing: 'not installed',
|
|
188
|
+
harnessHintReady: 'Delegate a standalone task with subagent_%s',
|
|
189
|
+
harnessHintOccupied: 'Installed here, but another plugin already registered this provider',
|
|
190
|
+
harnessHintMissing: 'Install %s and restart DSH to make it appear',
|
|
127
191
|
probe: 'Probe gateway',
|
|
128
192
|
probing: 'Probing…',
|
|
129
193
|
reachable: 'Reachable',
|
|
@@ -160,13 +224,90 @@ window.__ModuleLoader__.load({
|
|
|
160
224
|
availabilityRechecking: 'Checking…',
|
|
161
225
|
availabilityHiddenCount: 'hidden',
|
|
162
226
|
availabilityUnavailable: 'hidden from dropdown',
|
|
163
|
-
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'
|
|
164
263
|
}
|
|
165
264
|
}
|
|
166
265
|
|
|
167
266
|
/** 词典未就绪时的兜底(键原样返回,绝不显示 undefined)。 */
|
|
168
267
|
const fallbackT = (key) => (LOCALES.zh[key] ?? key)
|
|
169
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
|
+
|
|
170
311
|
/** 注入一次卡片样式;用 DSH 主题变量,明暗自适应。 */
|
|
171
312
|
function ensureStyle() {
|
|
172
313
|
const id = 'dsh-llm-hub-style'
|
|
@@ -178,9 +319,21 @@ window.__ModuleLoader__.load({
|
|
|
178
319
|
'margin-top:8px;padding:8px 10px;border-radius:8px;',
|
|
179
320
|
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
|
|
180
321
|
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
322
|
+
'.dsh-llm-hub-harness{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
|
|
323
|
+
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
324
|
+
'.dsh-llm-hub-harness__chip{display:inline-flex;align-items:center;gap:5px;',
|
|
325
|
+
'padding:2px 8px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l1);',
|
|
326
|
+
'background:var(--dsw-alias-bg-layer-2)}',
|
|
327
|
+
'.dsh-llm-hub-harness__chip--missing{opacity:.55}',
|
|
328
|
+
'.dsh-llm-hub-harness__dot{width:6px;height:6px;border-radius:50%;',
|
|
329
|
+
'background:var(--dsw-alias-label-secondary);flex:none}',
|
|
330
|
+
'.dsh-llm-hub-harness__dot--ready{background:var(--dsw-alias-state-success-primary,#3fb950)}',
|
|
331
|
+
'.dsh-llm-hub-harness__name{color:var(--dsw-alias-label-primary)}',
|
|
332
|
+
'.dsh-llm-hub-harness__state{opacity:.75}',
|
|
181
333
|
'.dsh-llm-hub-balance__label{color:var(--dsw-alias-label-secondary)}',
|
|
182
334
|
'.dsh-llm-hub-balance__amount{color:var(--dsw-alias-label-primary);font-weight:600;',
|
|
183
335
|
'font-variant-numeric:tabular-nums}',
|
|
336
|
+
'.dsh-llm-hub-balance__amount--low{color:var(--dsw-alias-state-error-primary)}',
|
|
184
337
|
'.dsh-llm-hub-balance__breakdown{color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
185
338
|
'.dsh-llm-hub-balance__warn{color:var(--dsw-alias-state-warn-primary)}',
|
|
186
339
|
'.dsh-llm-hub-balance__error{color:var(--dsw-alias-state-error-primary)}',
|
|
@@ -226,6 +379,82 @@ window.__ModuleLoader__.load({
|
|
|
226
379
|
'.dsh-llm-hub-chip--hidden{border-color:var(--dsw-alias-state-error-primary);',
|
|
227
380
|
'color:var(--dsw-alias-state-error-primary)}',
|
|
228
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}',
|
|
229
458
|
].join('')
|
|
230
459
|
document.head.appendChild(style)
|
|
231
460
|
}
|
|
@@ -239,6 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
239
468
|
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
240
469
|
const keyConfigured = props.keyConfigured !== false
|
|
241
470
|
const [state, setState] = React.useState({ status: 'idle' })
|
|
471
|
+
const [warning, setWarning] = React.useState(null)
|
|
242
472
|
// 官方直连也可能被判不可用(key 被拒 / 账户欠费)。同一个状态片,一致的处理。
|
|
243
473
|
const availability = props.availability
|
|
244
474
|
const availabilityState = availability === undefined
|
|
@@ -316,8 +546,39 @@ window.__ModuleLoader__.load({
|
|
|
316
546
|
children.push(h('span', { key: 'empty', className: 'dsh-llm-hub-balance__breakdown' }, t('loading')))
|
|
317
547
|
return h('div', { className: 'dsh-llm-hub-balance' }, children)
|
|
318
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])
|
|
319
570
|
const symbol = primary.currency === 'CNY' ? '¥' : primary.currency === 'USD' ? '$' : `${primary.currency} `
|
|
320
|
-
|
|
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
|
+
}
|
|
321
582
|
if (!state.isAvailable) children.push(h('span', { key: 'unavail', className: 'dsh-llm-hub-balance__warn' }, t('unavailable')))
|
|
322
583
|
const breakdown = []
|
|
323
584
|
if (primary.granted !== '0' && primary.granted !== '0.00') breakdown.push(`${t('granted')} ${symbol}${primary.granted}`)
|
|
@@ -406,6 +667,7 @@ window.__ModuleLoader__.load({
|
|
|
406
667
|
const [saveState, setSaveState] = React.useState(null)
|
|
407
668
|
const [copied, setCopied] = React.useState(false)
|
|
408
669
|
const [balance, setBalance] = React.useState(null)
|
|
670
|
+
const [warning, setWarning] = React.useState(null)
|
|
409
671
|
// 被摘掉的分组,在它自己的卡片上直接标出来 —— 动作条里一枚小片,不占额外行。
|
|
410
672
|
const availability = props.availability
|
|
411
673
|
const availabilityState = availability === undefined
|
|
@@ -432,6 +694,27 @@ window.__ModuleLoader__.load({
|
|
|
432
694
|
}
|
|
433
695
|
}, [provider, status])
|
|
434
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
|
+
|
|
435
718
|
React.useEffect(() => {
|
|
436
719
|
if (provider === '') return undefined
|
|
437
720
|
let alive = true
|
|
@@ -529,6 +812,13 @@ window.__ModuleLoader__.load({
|
|
|
529
812
|
}
|
|
530
813
|
const balanceText = renderBalance(balance, t)
|
|
531
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
|
+
}
|
|
532
822
|
|
|
533
823
|
// 动作行:**所有按钮挤在同一行**。
|
|
534
824
|
//
|
|
@@ -601,10 +891,18 @@ window.__ModuleLoader__.load({
|
|
|
601
891
|
h('span', null, `${t('pick')}(${picked.size}/${body.models.length})`),
|
|
602
892
|
probeButton('save', saveState === 'saving' ? t('saving') : saveState === 'done' ? t('saved') : saveState === 'fail' ? t('saveFail') : t('save'), saveState === 'saving', saveModels)),
|
|
603
893
|
h('div', { className: 'dsh-llm-hub-picker__list' },
|
|
604
|
-
body.models.map((model) =>
|
|
605
|
-
|
|
606
|
-
h('
|
|
607
|
-
|
|
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
|
+
})))
|
|
608
906
|
} else {
|
|
609
907
|
actions.push(h('span', { key: 'cat-bad', className: 'dsh-llm-hub-balance__error' }, `${t('failed')}: ${body?.error ?? ''}`))
|
|
610
908
|
}
|
|
@@ -625,6 +923,377 @@ window.__ModuleLoader__.load({
|
|
|
625
923
|
* @param props - 注入的 `t`。
|
|
626
924
|
* @returns 页脚节点;元信息读不到时返回 null(不占位、不报错)。
|
|
627
925
|
*/
|
|
926
|
+
/**
|
|
927
|
+
* 外部 harness 一览。
|
|
928
|
+
*
|
|
929
|
+
* 只读 host 半 apply 时探好的快照(`/api/dsh-llm-hub/harness`),不自己探 ——
|
|
930
|
+
* 探测要 spawn 解析可执行文件,那是 host 的活,前端重复做一遍只会两处不一致。
|
|
931
|
+
*
|
|
932
|
+
* 未安装的**也显示**(置灰):这一行的用处一半是「现在能派给谁」,另一半是
|
|
933
|
+
* 「装上哪个就能多派一个」。全过滤掉就只剩前一半,用户永远不知道还能有什么。
|
|
934
|
+
* @param props - `t` 文案函数。
|
|
935
|
+
* @returns 一行 chips;探测未完成或接口不可用时返回 null。
|
|
936
|
+
*/
|
|
937
|
+
function HarnessRow(props) {
|
|
938
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
939
|
+
const [report, setReport] = React.useState(null)
|
|
940
|
+
React.useEffect(() => {
|
|
941
|
+
let alive = true
|
|
942
|
+
fetch(HARNESS_URL, { headers: { accept: 'application/json' } })
|
|
943
|
+
.then((res) => res.json())
|
|
944
|
+
.then((body) => { if (alive) setReport(body) })
|
|
945
|
+
.catch(() => { if (alive) setReport({ ok: false }) })
|
|
946
|
+
return () => { alive = false }
|
|
947
|
+
}, [])
|
|
948
|
+
// probed 为 false 是启动竞态(host 还没探完),不是「一个都没装」——
|
|
949
|
+
// 这时候渲染「未安装 ×3」会说谎,所以什么都不显示。
|
|
950
|
+
if (report === null || report.ok !== true || report.probed !== true) return null
|
|
951
|
+
const list = Array.isArray(report.harnesses) ? report.harnesses : []
|
|
952
|
+
if (list.length === 0) return null
|
|
953
|
+
ensureStyle()
|
|
954
|
+
return h('div', { className: 'dsh-llm-hub-harness' },
|
|
955
|
+
h('span', { className: 'dsh-llm-hub-harness__state' }, t('harnessTitle')),
|
|
956
|
+
list.map((item) => {
|
|
957
|
+
const state = item.registered === true
|
|
958
|
+
? { text: t('harnessReady'), hint: t('harnessHintReady').replace('%s', String(item.id).replace(/-/g, '_')), ready: true }
|
|
959
|
+
: (item.installed === true
|
|
960
|
+
? { text: t('harnessOccupied'), hint: t('harnessHintOccupied'), ready: false }
|
|
961
|
+
: { text: t('harnessMissing'), hint: t('harnessHintMissing').replace('%s', item.bin), ready: false })
|
|
962
|
+
return h('span', {
|
|
963
|
+
key: item.id,
|
|
964
|
+
className: 'dsh-llm-hub-harness__chip' + (item.installed === true ? '' : ' dsh-llm-hub-harness__chip--missing'),
|
|
965
|
+
// 路径放进 tooltip:装了却不生效时,第一件要确认的就是「它找到的是哪个副本」。
|
|
966
|
+
title: state.hint + (typeof item.executable === 'string' ? '\n' + item.executable : '')
|
|
967
|
+
},
|
|
968
|
+
h('span', { className: 'dsh-llm-hub-harness__dot' + (state.ready ? ' dsh-llm-hub-harness__dot--ready' : '') }),
|
|
969
|
+
h('span', { className: 'dsh-llm-hub-harness__name' }, item.displayName),
|
|
970
|
+
h('span', { className: 'dsh-llm-hub-harness__state' }, state.text)
|
|
971
|
+
)
|
|
972
|
+
})
|
|
973
|
+
)
|
|
974
|
+
}
|
|
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
|
+
|
|
628
1297
|
function HubFooter(props) {
|
|
629
1298
|
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
630
1299
|
const availability = props.availability
|
|
@@ -846,6 +1515,43 @@ window.__ModuleLoader__.load({
|
|
|
846
1515
|
// 的 children 声明),list 类 slot 注册**必须带 id** —— 参照宿主自己的
|
|
847
1516
|
// settings.onboarding 注册(id:'welcome-notice', order:-100)。
|
|
848
1517
|
// 第一版漏了 id,注册被静默丢弃:接口通、组件在、页面上什么都没有。
|
|
1518
|
+
// harness 一览排在 footer 那行之上(order 更小):它讲的是「能派给谁」,
|
|
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))
|
|
1548
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1549
|
+
name: 'settings.models.footer',
|
|
1550
|
+
id: 'dsh-llm-hub-harness',
|
|
1551
|
+
order: 90,
|
|
1552
|
+
locale: NS,
|
|
1553
|
+
inject: () => ({ t })
|
|
1554
|
+
}, HarnessRow))
|
|
849
1555
|
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
850
1556
|
name: 'settings.models.footer',
|
|
851
1557
|
id: 'dsh-llm-hub-footer',
|