@dsh-plugins/dsh-llm-hub 0.7.3 → 1.3.1
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 +128 -0
- package/README.md +196 -8
- package/lib/client.js +1076 -171
- package/lib/index.js +992 -39
- 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: {
|
|
@@ -92,6 +105,12 @@ window.__ModuleLoader__.load({
|
|
|
92
105
|
configuredModels: '已配',
|
|
93
106
|
modelsUnit: '个模型',
|
|
94
107
|
keyMissing: '未配 Key',
|
|
108
|
+
copiedKey: '已复制 Key',
|
|
109
|
+
showKey: '查看原密码',
|
|
110
|
+
hideKey: '收起脱敏',
|
|
111
|
+
showKeyHint: '点击复制完整 Key · 眼睛图标查看原密码',
|
|
112
|
+
hideKeyHint: '点击复制完整 Key · 眼睛图标收起脱敏',
|
|
113
|
+
showKeyUnavailable: '点击复制(当前凭据受安全保护仅展示脱敏标识)',
|
|
95
114
|
noBaseURLHint: '没填服务地址,探测不了;模型只能手填',
|
|
96
115
|
pullCatalog: '拉取目录',
|
|
97
116
|
pick: '选择要用的模型',
|
|
@@ -120,7 +139,55 @@ window.__ModuleLoader__.load({
|
|
|
120
139
|
availabilityRechecking: '探测中…',
|
|
121
140
|
availabilityHiddenCount: '已隐藏',
|
|
122
141
|
availabilityUnavailable: '已从下拉隐藏',
|
|
123
|
-
availabilityFailed: '可用性读取失败'
|
|
142
|
+
availabilityFailed: '可用性读取失败',
|
|
143
|
+
healthTitle: 'Provider 健康看板',
|
|
144
|
+
healthState: { available: '可用', unavailable: '不可用', unknown: '未知' },
|
|
145
|
+
healthLatency: '延迟',
|
|
146
|
+
healthStatus: 'HTTP',
|
|
147
|
+
healthReason: '原因',
|
|
148
|
+
healthCheckedAt: '探测于',
|
|
149
|
+
healthNoData: '还没有任何探测',
|
|
150
|
+
healthRecheck: '重新探测',
|
|
151
|
+
healthRechecking: '探测中…',
|
|
152
|
+
routingTitle: '当前路由',
|
|
153
|
+
routingActive: '当前',
|
|
154
|
+
routingSuggestion: '建议切换到',
|
|
155
|
+
routingUnconfigured: '未配置主力模型',
|
|
156
|
+
routingFallback: 'fallback',
|
|
157
|
+
routingPrimary: '主力',
|
|
158
|
+
routingNoRecommendation: '全部不可用',
|
|
159
|
+
routingLoading: '路由解析中…',
|
|
160
|
+
aliasesMissing: '尚未配置别名',
|
|
161
|
+
keypoolTitle: '多账号 key 轮换',
|
|
162
|
+
keypoolRound: 'round-robin',
|
|
163
|
+
keypoolActiveTip: '当前优先使用',
|
|
164
|
+
keypoolNextTip: '下次轮换目标',
|
|
165
|
+
heroTitle: 'LLM 矩阵概览',
|
|
166
|
+
heroProviders: '活跃提供方',
|
|
167
|
+
heroModels: '可用模型',
|
|
168
|
+
heroFastest: '最优延迟',
|
|
169
|
+
heroSecurity: '凭据防护',
|
|
170
|
+
heroSecSafe: '零明文脱敏中',
|
|
171
|
+
presetsTitle: '⚡ 热门大模型一键装配',
|
|
172
|
+
presetsSub: '点击主流服务商快速复制 YAML 配置模板与直达控制台',
|
|
173
|
+
presetsCopied: '已复制配置片段',
|
|
174
|
+
presetsCopyBtn: '复制 YAML',
|
|
175
|
+
presetsDocsBtn: '获取 Key ↗',
|
|
176
|
+
usageTitle: '本月用量',
|
|
177
|
+
usageCalls: '次调用',
|
|
178
|
+
usageInput: '输入',
|
|
179
|
+
usageOutput: '输出',
|
|
180
|
+
usageCache: '缓存',
|
|
181
|
+
usageTotal: '合计',
|
|
182
|
+
usageTopModels: '最常用模型',
|
|
183
|
+
usageDisabled: '用量统计未启用',
|
|
184
|
+
usageExport: '导出 CSV',
|
|
185
|
+
usageExported: '已复制 CSV 内容',
|
|
186
|
+
usageExportFail: '导出失败',
|
|
187
|
+
usageClear: '清空记录',
|
|
188
|
+
usageCleared: '已清空',
|
|
189
|
+
usageClearConfirm: '确认清空?',
|
|
190
|
+
usageNoData: '本月还没有记录'
|
|
124
191
|
},
|
|
125
192
|
en: {
|
|
126
193
|
balance: 'Balance',
|
|
@@ -148,6 +215,12 @@ window.__ModuleLoader__.load({
|
|
|
148
215
|
configuredModels: 'configured',
|
|
149
216
|
modelsUnit: 'models',
|
|
150
217
|
keyMissing: 'No key',
|
|
218
|
+
copiedKey: 'Key copied',
|
|
219
|
+
showKey: 'Show key',
|
|
220
|
+
hideKey: 'Mask key',
|
|
221
|
+
showKeyHint: 'Click to copy full key · Eye icon to reveal',
|
|
222
|
+
hideKeyHint: 'Click to copy full key · Eye icon to mask',
|
|
223
|
+
showKeyUnavailable: 'Click to copy masked key',
|
|
151
224
|
noBaseURLHint: 'No service address configured — probing unavailable, models are hand-typed',
|
|
152
225
|
pullCatalog: 'Fetch catalog',
|
|
153
226
|
pick: 'Pick models',
|
|
@@ -176,13 +249,103 @@ window.__ModuleLoader__.load({
|
|
|
176
249
|
availabilityRechecking: 'Checking…',
|
|
177
250
|
availabilityHiddenCount: 'hidden',
|
|
178
251
|
availabilityUnavailable: 'hidden from dropdown',
|
|
179
|
-
availabilityFailed: 'Availability read failed'
|
|
252
|
+
availabilityFailed: 'Availability read failed',
|
|
253
|
+
healthTitle: 'Provider health',
|
|
254
|
+
healthState: { available: 'up', unavailable: 'down', unknown: 'unknown' },
|
|
255
|
+
healthLatency: 'latency',
|
|
256
|
+
healthStatus: 'HTTP',
|
|
257
|
+
healthReason: 'reason',
|
|
258
|
+
healthCheckedAt: 'probed at',
|
|
259
|
+
healthNoData: 'no probes yet',
|
|
260
|
+
healthRecheck: 'Re-check all',
|
|
261
|
+
healthRechecking: 'Checking…',
|
|
262
|
+
routingTitle: 'Current routing',
|
|
263
|
+
routingActive: 'Active',
|
|
264
|
+
routingSuggestion: 'Try switching to',
|
|
265
|
+
routingUnconfigured: 'routing not configured',
|
|
266
|
+
routingFallback: 'fallback',
|
|
267
|
+
routingPrimary: 'primary',
|
|
268
|
+
routingNoRecommendation: 'none available',
|
|
269
|
+
routingLoading: 'resolving routing…',
|
|
270
|
+
aliasesMissing: 'no aliases configured',
|
|
271
|
+
keypoolTitle: 'Key rotation',
|
|
272
|
+
keypoolRound: 'round-robin',
|
|
273
|
+
keypoolActiveTip: 'Currently active',
|
|
274
|
+
keypoolNextTip: 'Next target',
|
|
275
|
+
heroTitle: 'LLM Matrix Overview',
|
|
276
|
+
heroProviders: 'Active providers',
|
|
277
|
+
heroModels: 'Models',
|
|
278
|
+
heroFastest: 'Fastest ping',
|
|
279
|
+
heroSecurity: 'Key security',
|
|
280
|
+
heroSecSafe: 'Masked in memory',
|
|
281
|
+
presetsTitle: '⚡ Popular LLM Quick Presets',
|
|
282
|
+
presetsSub: 'One-click YAML config snippets and official key links',
|
|
283
|
+
presetsCopied: 'YAML snippet copied',
|
|
284
|
+
presetsCopyBtn: 'Copy YAML',
|
|
285
|
+
presetsDocsBtn: 'Get Key ↗',
|
|
286
|
+
usageTitle: 'This month',
|
|
287
|
+
usageCalls: 'calls',
|
|
288
|
+
usageInput: 'in',
|
|
289
|
+
usageOutput: 'out',
|
|
290
|
+
usageCache: 'cached',
|
|
291
|
+
usageTotal: 'total',
|
|
292
|
+
usageTopModels: 'top models',
|
|
293
|
+
usageDisabled: 'usage tracking not enabled',
|
|
294
|
+
usageExport: 'Export CSV',
|
|
295
|
+
usageExported: 'CSV copied',
|
|
296
|
+
usageExportFail: 'Export failed',
|
|
297
|
+
usageClear: 'Clear records',
|
|
298
|
+
usageCleared: 'Cleared',
|
|
299
|
+
usageClearConfirm: 'Confirm clear?',
|
|
300
|
+
usageNoData: 'no calls this month yet'
|
|
180
301
|
}
|
|
181
302
|
}
|
|
182
303
|
|
|
183
304
|
/** 词典未就绪时的兜底(键原样返回,绝不显示 undefined)。 */
|
|
184
305
|
const fallbackT = (key) => (LOCALES.zh[key] ?? key)
|
|
185
306
|
|
|
307
|
+
/**
|
|
308
|
+
* 全局别名表 —— 启动时拉一次,所有组件共用。
|
|
309
|
+
*
|
|
310
|
+
* 数据来源 `GET /api/dsh-llm-hub/aliases`:返回 `{ ok, aliases: { "<provider>/<model>": "短名" } }`。
|
|
311
|
+
* 启动后注入一次,所有 picker 行、RoutingCard 候选都用它把长 id 翻成短名。
|
|
312
|
+
* 失败/未配置:aliasesMap = {},aliasFor 永远返 null,UI 退回到只显示 model.id
|
|
313
|
+
* (与没装本插件时一致)。
|
|
314
|
+
*
|
|
315
|
+
* 为什么不在每张卡 mount 时各自 fetch:每个卡都发一次就 N 张卡 N 个请求,
|
|
316
|
+
* 还要写一份交叉去重。集中一次、所有卡读同一份快照就完事。
|
|
317
|
+
* @returns {{ map, aliasFor }}
|
|
318
|
+
*/
|
|
319
|
+
const aliasesStore = (() => {
|
|
320
|
+
let map = {}
|
|
321
|
+
const listeners = new Set()
|
|
322
|
+
const publish = (next) => {
|
|
323
|
+
map = next
|
|
324
|
+
for (const listener of [...listeners]) { try { listener() } catch { /* 不让订阅者炸 */ } }
|
|
325
|
+
}
|
|
326
|
+
const aliasFor = (provider, model) => {
|
|
327
|
+
if (typeof provider !== 'string' || typeof model !== 'string') return null
|
|
328
|
+
return map[`${provider}/${model}`] ?? null
|
|
329
|
+
}
|
|
330
|
+
fetch(ALIASES_URL, { headers: { accept: 'application/json' } })
|
|
331
|
+
.then((res) => res.json())
|
|
332
|
+
.then((body) => {
|
|
333
|
+
if (body !== null && typeof body === 'object' && body.ok === true && typeof body.aliases === 'object' && body.aliases !== null) {
|
|
334
|
+
publish(body.aliases)
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
.catch(() => { /* 静默:UI 退回 model.id 渲染 */ })
|
|
338
|
+
return {
|
|
339
|
+
aliasFor,
|
|
340
|
+
/** 订阅别名表变更 —— 组件 mount 后调用一次,以后改了会刷新。 */
|
|
341
|
+
subscribe(listener) {
|
|
342
|
+
listeners.add(listener)
|
|
343
|
+
return () => listeners.delete(listener)
|
|
344
|
+
},
|
|
345
|
+
snapshot: () => map
|
|
346
|
+
}
|
|
347
|
+
})()
|
|
348
|
+
|
|
186
349
|
/** 注入一次卡片样式;用 DSH 主题变量,明暗自适应。 */
|
|
187
350
|
function ensureStyle() {
|
|
188
351
|
const id = 'dsh-llm-hub-style'
|
|
@@ -190,73 +353,293 @@ window.__ModuleLoader__.load({
|
|
|
190
353
|
const style = document.createElement('style')
|
|
191
354
|
style.id = id
|
|
192
355
|
style.textContent = [
|
|
356
|
+
'@keyframes dsh-llm-hub-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}',
|
|
357
|
+
'.dsh-llm-hub-spin{animation:dsh-llm-hub-spin 1s linear infinite;display:inline-block}',
|
|
358
|
+
// 清爽无边框 List Card 容器:自然融入宿主,呼吸感通透
|
|
359
|
+
'.dsh-llm-hub-card{display:flex;flex-direction:column;gap:6px;margin-top:4px;',
|
|
360
|
+
'padding:4px 0 2px 0;border:none;background:transparent;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
361
|
+
'.dsh-llm-hub-card__meta{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;min-width:0}',
|
|
362
|
+
'.dsh-llm-hub-card__tags{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}',
|
|
363
|
+
'.dsh-llm-hub-meta-sep{opacity:.35;font-size:10px;user-select:none;margin:0 1px}',
|
|
364
|
+
'.dsh-llm-hub-meta-text{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
|
|
365
|
+
'.dsh-llm-hub-card__actions{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding-top:2px}',
|
|
366
|
+
'.dsh-llm-hub-card__btn-group{display:flex;align-items:center;gap:5px;flex-wrap:wrap}',
|
|
367
|
+
'.dsh-llm-hub-card__status-group{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-left:auto}',
|
|
368
|
+
// 兼容旧 balance 容器类名
|
|
193
369
|
'.dsh-llm-hub-balance{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
|
|
194
|
-
'margin-top:
|
|
195
|
-
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
|
|
196
|
-
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
197
|
-
'.dsh-llm-hub-harness{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
|
|
370
|
+
'margin-top:4px;padding:4px 0;border:none;background:transparent;',
|
|
198
371
|
'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
199
|
-
'.dsh-llm-hub-
|
|
200
|
-
'
|
|
372
|
+
'.dsh-llm-hub-balance--stack{flex-direction:column;align-items:stretch;gap:6px}',
|
|
373
|
+
'.dsh-llm-hub-balance__facts{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}',
|
|
374
|
+
'.dsh-llm-hub-balance-label{font-size:12px;color:var(--dsw-alias-label-secondary);font-weight:500}',
|
|
375
|
+
'.dsh-llm-hub-amount{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);',
|
|
376
|
+
'font-variant-numeric:tabular-nums;letter-spacing:-0.2px}',
|
|
377
|
+
'.dsh-llm-hub-amount.is-low{color:var(--dsw-alias-state-error-primary)}',
|
|
378
|
+
'.dsh-llm-hub-subtext{font-size:11px;color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
379
|
+
'.dsh-llm-hub-warn{color:var(--dsw-alias-state-warn-primary);font-size:11px}',
|
|
380
|
+
'.dsh-llm-hub-error{color:var(--dsw-alias-state-error-primary);font-size:11px}',
|
|
381
|
+
// 去掉粗糙灰底框,改用轻量排版标签
|
|
382
|
+
'.dsh-llm-hub-tag{display:inline-flex;align-items:center;gap:3px;padding:0 2px;',
|
|
383
|
+
'border:none;background:transparent;',
|
|
384
|
+
'color:var(--dsw-alias-label-secondary);font-size:11.5px;white-space:nowrap;line-height:16px}',
|
|
385
|
+
'.dsh-llm-hub-tag--muted{opacity:.65}',
|
|
386
|
+
'.dsh-llm-hub-warn-badge{display:inline-flex;align-items:center;gap:3px;padding:1px 5px;border-radius:3px;',
|
|
387
|
+
'border:.5px solid var(--dsw-alias-state-warn-primary);background:rgba(234,179,8,.08);',
|
|
388
|
+
'color:var(--dsw-alias-state-warn-primary);font-size:10.5px}',
|
|
389
|
+
// 现代 Ghost 微操作按钮:去硬框、低视觉干扰
|
|
390
|
+
'.dsh-llm-hub-btn{box-sizing:border-box;display:inline-flex;align-items:center;gap:3px;',
|
|
391
|
+
'height:22px;padding:0 6px;font:inherit;font-size:11px;line-height:1;color:var(--dsw-alias-label-secondary);',
|
|
392
|
+
'background:transparent;cursor:pointer;border:.5px solid var(--dsw-alias-border-l1);',
|
|
393
|
+
'border-radius:4px;white-space:nowrap;transition:all .15s ease}',
|
|
394
|
+
'.dsh-llm-hub-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);',
|
|
395
|
+
'background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2)}',
|
|
396
|
+
'.dsh-llm-hub-btn:disabled{cursor:default;opacity:.45}',
|
|
397
|
+
// API Key 徽章:透明极简底色,精致内联 SVG
|
|
398
|
+
'.dsh-llm-hub-key-badge{box-sizing:border-box;display:inline-flex;align-items:center;',
|
|
399
|
+
'height:20px;padding:0 3px 0 6px;gap:3px;border-radius:4px;flex:0 0 auto;',
|
|
400
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:transparent;',
|
|
401
|
+
'color:var(--dsw-alias-label-secondary);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;',
|
|
402
|
+
'font-size:10.5px;cursor:pointer;user-select:none;transition:all .15s ease;max-width:260px}',
|
|
403
|
+
'.dsh-llm-hub-quota-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
404
|
+
'.dsh-llm-hub-key-badge:hover{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);',
|
|
201
405
|
'background:var(--dsw-alias-bg-layer-2)}',
|
|
202
|
-
'.dsh-llm-hub-
|
|
203
|
-
'
|
|
406
|
+
'.dsh-llm-hub-key-badge.is-copied{border-color:var(--dsw-alias-state-success-primary,#10b981);',
|
|
407
|
+
'color:var(--dsw-alias-state-success-primary,#10b981);background:rgba(16,185,129,.08)}',
|
|
408
|
+
'.dsh-llm-hub-key-badge__text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1}',
|
|
409
|
+
'.dsh-llm-hub-key-badge__copied{display:inline-flex;align-items:center;gap:3px;font-weight:500}',
|
|
410
|
+
'.dsh-llm-hub-key-badge__btn{display:inline-flex;align-items:center;justify-content:center;',
|
|
411
|
+
'width:16px;height:16px;padding:0;border:0;background:0 0;color:inherit;cursor:pointer;',
|
|
412
|
+
'border-radius:3px;opacity:.65;transition:all .12s ease;flex:none}',
|
|
413
|
+
'.dsh-llm-hub-key-badge__btn:hover{opacity:1;background:var(--dsw-alias-interactive-bg-hover)}',
|
|
414
|
+
'.dsh-llm-hub-key-badge__btn.is-active{opacity:1;color:var(--dsw-alias-brand-primary)}',
|
|
415
|
+
// 直接融入 Provider 行的健康与延迟微徽章
|
|
416
|
+
'.dsh-llm-hub-health-badge{display:inline-flex;align-items:center;gap:4.5px;font-size:11px;',
|
|
417
|
+
'font-variant-numeric:tabular-nums;line-height:1;color:var(--dsw-alias-label-secondary)}',
|
|
418
|
+
'.dsh-llm-hub-health-badge.is-ok{color:var(--dsw-alias-state-success-primary,#10b981)}',
|
|
419
|
+
'.dsh-llm-hub-health-badge.is-err{color:var(--dsw-alias-state-error-primary,#ef4444)}',
|
|
420
|
+
'.dsh-llm-hub-health-dot{width:5px;height:5px;border-radius:50%;flex:none}',
|
|
421
|
+
'.dsh-llm-hub-health-dot.is-ok{background:var(--dsw-alias-state-success-primary,#10b981);box-shadow:0 0 4px rgba(16,185,129,.4)}',
|
|
422
|
+
'.dsh-llm-hub-health-dot.is-err{background:var(--dsw-alias-state-error-primary,#ef4444);box-shadow:0 0 4px rgba(239,68,68,.4)}',
|
|
423
|
+
// 状态指示条与小圆点(保留以兼容历史与测试)
|
|
424
|
+
'.dsh-llm-hub-status-text{display:inline-flex;align-items:center;gap:5px;font-size:11px;',
|
|
425
|
+
'color:var(--dsw-alias-label-secondary)}',
|
|
426
|
+
'.dsh-llm-hub-status-text.is-err{color:var(--dsw-alias-state-error-primary)}',
|
|
427
|
+
'.dsh-llm-hub-status-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-label-secondary);flex:none}',
|
|
428
|
+
'.dsh-llm-hub-status-dot.is-ok{background:var(--dsw-alias-state-success-primary,#10b981);',
|
|
429
|
+
'box-shadow:0 0 5px rgba(16,185,129,.5)}',
|
|
430
|
+
'.dsh-llm-hub-status-dot.is-err{background:var(--dsw-alias-state-error-primary);',
|
|
431
|
+
'box-shadow:0 0 5px rgba(239,68,68,.5)}',
|
|
432
|
+
'.dsh-llm-hub-dot-sep{opacity:.35;margin:0 1px}',
|
|
433
|
+
// harness
|
|
434
|
+
'.dsh-llm-hub-harness{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:6px;',
|
|
435
|
+
'font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
|
|
436
|
+
'.dsh-llm-hub-harness__chip{display:inline-flex;align-items:center;gap:4px;',
|
|
437
|
+
'padding:2px 7px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l1);',
|
|
438
|
+
'background:transparent}',
|
|
439
|
+
'.dsh-llm-hub-harness__chip--missing{opacity:.45}',
|
|
440
|
+
'.dsh-llm-hub-harness__dot{width:5px;height:5px;border-radius:50%;',
|
|
204
441
|
'background:var(--dsw-alias-label-secondary);flex:none}',
|
|
205
|
-
'.dsh-llm-hub-harness__dot--ready{background:var(--dsw-alias-state-success-primary,#
|
|
442
|
+
'.dsh-llm-hub-harness__dot--ready{background:var(--dsw-alias-state-success-primary,#10b981)}',
|
|
206
443
|
'.dsh-llm-hub-harness__name{color:var(--dsw-alias-label-primary)}',
|
|
207
444
|
'.dsh-llm-hub-harness__state{opacity:.75}',
|
|
208
|
-
|
|
209
|
-
'.dsh-llm-hub-
|
|
210
|
-
'font-variant-numeric:tabular-nums}',
|
|
211
|
-
'.dsh-llm-hub-balance__breakdown{color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
212
|
-
'.dsh-llm-hub-balance__warn{color:var(--dsw-alias-state-warn-primary)}',
|
|
213
|
-
'.dsh-llm-hub-balance__error{color:var(--dsw-alias-state-error-primary)}',
|
|
214
|
-
'.dsh-llm-hub-balance__spacer{flex:1 1 auto}',
|
|
215
|
-
// 一张卡一个盒子:事实行 + 动作条。原来是两个盒子,白吃一行高度。
|
|
216
|
-
'.dsh-llm-hub-balance--stack{flex-direction:column;align-items:stretch;gap:6px}',
|
|
217
|
-
'.dsh-llm-hub-balance__facts{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}',
|
|
218
|
-
'.dsh-llm-hub-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
219
|
-
'.dsh-llm-hub-actions__button{box-sizing:border-box;height:24px;padding:0 10px;font:inherit;',
|
|
220
|
-
'font-size:12px;line-height:16px;color:var(--dsw-alias-label-primary);background:0 0;cursor:pointer;',
|
|
221
|
-
'border:.5px solid var(--dsw-alias-border-l3);border-radius:12px;white-space:nowrap}',
|
|
222
|
-
'.dsh-llm-hub-actions__button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
223
|
-
'.dsh-llm-hub-actions__button:disabled{cursor:default;opacity:.5}',
|
|
224
|
-
// 目录选择器:沿用卡片本身的 token,不引入新配色
|
|
225
|
-
'.dsh-llm-hub-picker{margin-top:6px;padding:8px 10px;border-radius:8px;',
|
|
445
|
+
// 目录选择器 Drawer
|
|
446
|
+
'.dsh-llm-hub-picker{margin-top:6px;padding:8px 10px;border-radius:6px;',
|
|
226
447
|
'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}',
|
|
227
448
|
'.dsh-llm-hub-picker__head{display:flex;align-items:center;justify-content:space-between;',
|
|
228
|
-
'gap:8px;font-size:
|
|
229
|
-
'.dsh-llm-hub-picker__list{max-height:
|
|
449
|
+
'gap:8px;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:6px}',
|
|
450
|
+
'.dsh-llm-hub-picker__list{max-height:200px;overflow-y:auto;display:flex;',
|
|
230
451
|
'flex-direction:column;gap:2px}',
|
|
231
|
-
'.dsh-llm-hub-picker__item{display:flex;align-items:center;gap:6px;font-size:
|
|
452
|
+
'.dsh-llm-hub-picker__item{display:flex;align-items:center;gap:6px;font-size:11.5px;',
|
|
232
453
|
'line-height:20px;color:var(--dsw-alias-label-primary);cursor:pointer}',
|
|
233
454
|
'.dsh-llm-hub-picker__id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
|
|
234
|
-
'.dsh-llm-hub-
|
|
235
|
-
'
|
|
236
|
-
|
|
455
|
+
'.dsh-llm-hub-picker__tag{font-size:10.5px;padding:0 4px;border-radius:3px;',
|
|
456
|
+
'color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-3)}',
|
|
457
|
+
// 页脚
|
|
458
|
+
'.dsh-llm-hub-foot{display:flex;align-items:center;gap:10px;margin-top:10px;',
|
|
459
|
+
'padding:2px 2px;font-size:11px;color:var(--dsw-alias-label-secondary);opacity:.75}',
|
|
460
|
+
'.dsh-llm-hub-foot__name{font-variant-numeric:tabular-nums;opacity:.9}',
|
|
237
461
|
'.dsh-llm-hub-foot__link{color:var(--dsw-alias-label-link);text-decoration:none;',
|
|
238
|
-
'border:0;background:0 0;padding:0;font:inherit;cursor:pointer}',
|
|
462
|
+
'border:0;background:0 0;padding:0;font:inherit;font-size:11px;cursor:pointer}',
|
|
239
463
|
'.dsh-llm-hub-foot__link:hover{text-decoration:underline}',
|
|
240
|
-
'.dsh-llm-hub-
|
|
241
|
-
'
|
|
242
|
-
'
|
|
243
|
-
'font-size:12px;color:var(--dsw-alias-brand-primary)}',
|
|
244
|
-
'.dsh-llm-hub-balance__button:disabled{cursor:default;opacity:.5}',
|
|
245
|
-
'.dsh-llm-hub-balance__tag{padding:1px 6px;border-radius:6px;',
|
|
246
|
-
'border:.5px solid var(--dsw-alias-border-l1);',
|
|
247
|
-
'color:var(--dsw-alias-label-secondary);white-space:nowrap}',
|
|
248
|
-
// 被摘掉的 provider:在它自己的卡片动作条里标一枚小片,不另开面板
|
|
249
|
-
// (2026-09-16 owner:「这不是很多余吗,上面不都是显示了吗」)。
|
|
250
|
-
'.dsh-llm-hub-chip{box-sizing:border-box;display:inline-flex;align-items:center;height:24px;padding:0 8px;',
|
|
251
|
-
'border-radius:12px;font-size:12px;line-height:16px;white-space:nowrap;',
|
|
252
|
-
'border:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary)}',
|
|
464
|
+
'.dsh-llm-hub-chip{box-sizing:border-box;display:inline-flex;align-items:center;height:20px;padding:0 6px;',
|
|
465
|
+
'border-radius:10px;font-size:10.5px;line-height:14px;white-space:nowrap;',
|
|
466
|
+
'border:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
|
|
253
467
|
'.dsh-llm-hub-chip--hidden{border-color:var(--dsw-alias-state-error-primary);',
|
|
254
468
|
'color:var(--dsw-alias-state-error-primary)}',
|
|
255
469
|
'.dsh-llm-hub-foot__hidden{color:var(--dsw-alias-state-error-primary)}',
|
|
470
|
+
// 用量卡:紧凑通透,不再沉重
|
|
471
|
+
'.dsh-llm-hub-usage{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
|
|
472
|
+
'padding:8px 10px;border-radius:6px;',
|
|
473
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:transparent;',
|
|
474
|
+
'font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
|
|
475
|
+
'.dsh-llm-hub-usage__head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}',
|
|
476
|
+
'.dsh-llm-hub-usage__title{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
477
|
+
'.dsh-llm-hub-usage__facts{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
478
|
+
'.dsh-llm-hub-usage__tag{display:inline-flex;align-items:center;gap:3px;padding:0 5px;',
|
|
479
|
+
'border-radius:3px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);font-size:10.5px}',
|
|
480
|
+
'.dsh-llm-hub-usage__top{display:flex;flex-direction:column;gap:2px;margin-top:2px}',
|
|
481
|
+
'.dsh-llm-hub-usage__top-row{display:flex;justify-content:space-between;gap:8px;',
|
|
482
|
+
'color:var(--dsw-alias-label-primary);font-size:11px}',
|
|
483
|
+
'.dsh-llm-hub-usage__top-row small{color:var(--dsw-alias-label-secondary);opacity:.8}',
|
|
484
|
+
'.dsh-llm-hub-usage__actions{display:flex;gap:10px;flex-wrap:wrap;padding-top:4px;border-top:.5px solid var(--dsw-alias-border-l1)}',
|
|
485
|
+
// 健康看板:样式保留以兼容
|
|
486
|
+
'.dsh-llm-hub-health{display:none}',
|
|
487
|
+
// 当前路由卡
|
|
488
|
+
'.dsh-llm-hub-routing{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
|
|
489
|
+
'padding:8px 10px;border-radius:6px;',
|
|
490
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:transparent;',
|
|
491
|
+
'font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
|
|
492
|
+
'.dsh-llm-hub-routing__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
493
|
+
'.dsh-llm-hub-routing__title{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
494
|
+
'.dsh-llm-hub-routing__active{color:var(--dsw-alias-label-primary)}',
|
|
495
|
+
'.dsh-llm-hub-routing__rows{display:flex;flex-direction:column;gap:2px}',
|
|
496
|
+
'.dsh-llm-hub-routing__row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px}',
|
|
497
|
+
'.dsh-llm-hub-routing__role{display:inline-flex;align-items:center;height:16px;padding:0 5px;',
|
|
498
|
+
'border-radius:3px;border:.5px solid var(--dsw-alias-border-l2);',
|
|
499
|
+
'background:transparent;font-size:10px;color:var(--dsw-alias-label-secondary)}',
|
|
500
|
+
'.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
|
|
501
|
+
'.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);padding-top:2px}',
|
|
502
|
+
'.dsh-llm-hub-routing__reason{color:var(--dsw-alias-state-error-primary);padding-top:2px}',
|
|
503
|
+
// 多账号 key 轮换
|
|
504
|
+
'.dsh-llm-hub-keypool{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
|
|
505
|
+
'padding:8px 10px;border-radius:6px;',
|
|
506
|
+
'border:.5px solid var(--dsw-alias-border-l1);background:transparent;',
|
|
507
|
+
'font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
|
|
508
|
+
'.dsh-llm-hub-keypool__head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}',
|
|
509
|
+
'.dsh-llm-hub-keypool__title{color:var(--dsw-alias-label-primary);font-weight:600;display:inline-flex;align-items:center;gap:5px}',
|
|
510
|
+
'.dsh-llm-hub-keypool__rows{display:flex;flex-direction:column;gap:3px}',
|
|
511
|
+
'.dsh-llm-hub-keypool__row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:4px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-2,#1c1f24);border:.5px solid var(--dsw-alias-border-l1)}',
|
|
512
|
+
'.dsh-llm-hub-keypool__row.is-active{border-color:var(--dsw-alias-state-success-primary,#10b981);box-shadow:0 0 8px rgba(16,185,129,.15)}',
|
|
513
|
+
'.dsh-llm-hub-keypool__name{color:var(--dsw-alias-label-primary);font-weight:600;min-width:120px;font-family:ui-monospace,SFMono-Regular,monospace}',
|
|
514
|
+
'.dsh-llm-hub-keypool__meta{font-size:10.5px;opacity:.7}',
|
|
515
|
+
'.dsh-llm-hub-keypool__chosen{display:flex;align-items:baseline;gap:4px;',
|
|
516
|
+
'color:var(--dsw-alias-label-primary)}',
|
|
517
|
+
'.dsh-llm-hub-keypool__chosen small{color:var(--dsw-alias-label-secondary);opacity:.75;font-size:10.5px}',
|
|
518
|
+
'.dsh-llm-hub-keypool__pending{color:var(--dsw-alias-label-secondary);opacity:.6}',
|
|
519
|
+
// Hero 全景统计大横条
|
|
520
|
+
'.dsh-llm-hub-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin:12px 0 6px 0}',
|
|
521
|
+
'.dsh-llm-hub-hero__card{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.02));backdrop-filter:blur(6px)}',
|
|
522
|
+
'.dsh-llm-hub-hero__num{font-size:15px;font-weight:700;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:-0.3px;line-height:1.2}',
|
|
523
|
+
'.dsh-llm-hub-hero__label{font-size:10.5px;color:var(--dsw-alias-label-secondary);opacity:.85}',
|
|
524
|
+
// 热门大模型一键装配 Hub
|
|
525
|
+
'.dsh-llm-hub-presets{display:flex;flex-direction:column;gap:6px;margin-top:10px;padding:8px 10px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:transparent}',
|
|
526
|
+
'.dsh-llm-hub-presets__head{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;user-select:none}',
|
|
527
|
+
'.dsh-llm-hub-presets__title{color:var(--dsw-alias-label-primary);font-weight:600;font-size:12px;display:inline-flex;align-items:center;gap:5px}',
|
|
528
|
+
'.dsh-llm-hub-presets__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px;margin-top:6px}',
|
|
529
|
+
'.dsh-llm-hub-presets__item{display:flex;flex-direction:column;gap:3px;padding:6px 8px;border-radius:5px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.02));cursor:pointer;transition:all .15s ease}',
|
|
530
|
+
'.dsh-llm-hub-presets__item:hover{border-color:var(--dsw-alias-brand-primary,#3b82f6);transform:translateY(-1px);background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.05))}',
|
|
531
|
+
'.dsh-llm-hub-presets__name{font-weight:600;font-size:11.5px;color:var(--dsw-alias-label-primary)}',
|
|
532
|
+
'.dsh-llm-hub-presets__desc{font-size:10px;color:var(--dsw-alias-label-secondary);opacity:.75;line-height:1.3}',
|
|
533
|
+
'.dsh-llm-hub-presets__detail{margin-top:6px;padding:8px;border-radius:4px;background:var(--dsw-alias-bg-layer-3,rgba(0,0,0,.2));border:.5px solid var(--dsw-alias-border-l1);font-size:11px}',
|
|
534
|
+
'.dsh-llm-hub-presets__code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10px;white-space:pre-wrap;color:var(--dsw-alias-label-secondary);margin:4px 0}',
|
|
535
|
+
// 延迟高亮胶囊
|
|
536
|
+
'.dsh-llm-hub-latency-badge{display:inline-flex;align-items:center;gap:3.5px;padding:1px 5px;border-radius:3px;font-size:10.5px;font-variant-numeric:tabular-nums;line-height:1}',
|
|
537
|
+
'.dsh-llm-hub-latency-badge.is-fast{background:rgba(16,185,129,.08);color:var(--dsw-alias-state-success-primary,#10b981);border:.5px solid rgba(16,185,129,.2)}',
|
|
538
|
+
'.dsh-llm-hub-latency-badge.is-normal{background:rgba(234,179,8,.08);color:var(--dsw-alias-state-warn-primary,#eab308);border:.5px solid rgba(234,179,8,.2)}',
|
|
539
|
+
'.dsh-llm-hub-latency-badge.is-slow{background:rgba(239,68,68,.08);color:var(--dsw-alias-state-error-primary,#ef4444);border:.5px solid rgba(239,68,68,.2)}',
|
|
256
540
|
].join('')
|
|
257
541
|
document.head.appendChild(style)
|
|
258
542
|
}
|
|
259
543
|
|
|
544
|
+
/** 矢量 SVG 线条图标集合(彻底杜绝系统 emoji) */
|
|
545
|
+
function EyeIcon() {
|
|
546
|
+
return h('svg', { width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
547
|
+
h('path', { d: 'M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z' }),
|
|
548
|
+
h('circle', { cx: 12, cy: 12, r: 3 })
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
function EyeOffIcon() {
|
|
552
|
+
return h('svg', { width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
553
|
+
h('path', { d: 'M9.88 9.88a3 3 0 1 0 4.24 4.24' }),
|
|
554
|
+
h('path', { d: 'M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68' }),
|
|
555
|
+
h('path', { d: 'M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61' }),
|
|
556
|
+
h('line', { x1: 2, y1: 2, x2: 22, y2: 22 })
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
function CopyIcon() {
|
|
560
|
+
return h('svg', { width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
561
|
+
h('rect', { width: 14, height: 14, x: 8, y: 8, rx: 2, ry: 2 }),
|
|
562
|
+
h('path', { d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2' })
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
function CheckIcon() {
|
|
566
|
+
return h('svg', { width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
567
|
+
h('polyline', { points: '20 6 9 17 4 12' })
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
function RefreshIcon(props) {
|
|
571
|
+
return h('svg', { width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', className: props?.spinning ? 'dsh-llm-hub-spin' : undefined },
|
|
572
|
+
h('path', { d: 'M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }),
|
|
573
|
+
h('path', { d: 'M3 3v5h5' }),
|
|
574
|
+
h('path', { d: 'M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16' }),
|
|
575
|
+
h('path', { d: 'M16 21h5v-5' })
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* API Key 脱敏展示与一键复制徽章。
|
|
581
|
+
* - 默认展示首尾可见的脱敏串(如 mgk_li····8cV9)
|
|
582
|
+
* - 矢量 Eye 图标切换查看/收起完整明文
|
|
583
|
+
* - 点击徽章或 Copy 图标一键复制完整 Key,并有绿色无缝状态反馈
|
|
584
|
+
*/
|
|
585
|
+
function ApiKeyBadge(props) {
|
|
586
|
+
const apiKey = typeof props.apiKey === 'string' && props.apiKey.trim() !== '' ? props.apiKey.trim() : null
|
|
587
|
+
const keyMasked = typeof props.keyMasked === 'string' && props.keyMasked.trim() !== '' ? props.keyMasked.trim() : null
|
|
588
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
589
|
+
const [revealed, setRevealed] = React.useState(false)
|
|
590
|
+
const [copied, setCopied] = React.useState(false)
|
|
591
|
+
|
|
592
|
+
if (apiKey === null && keyMasked === null) {
|
|
593
|
+
return h('span', { className: 'dsh-llm-hub-tag dsh-llm-hub-tag--muted' }, t('keyMissing'))
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const rawDisplay = revealed ? (apiKey ?? keyMasked) : (keyMasked ?? '••••••••')
|
|
597
|
+
const display = rawDisplay.replace(/•+/g, '····')
|
|
598
|
+
|
|
599
|
+
const copyKey = (e) => {
|
|
600
|
+
e.stopPropagation()
|
|
601
|
+
const textToCopy = apiKey ?? keyMasked
|
|
602
|
+
if (!textToCopy) return
|
|
603
|
+
navigator.clipboard?.writeText(textToCopy)
|
|
604
|
+
.then(() => {
|
|
605
|
+
setCopied(true)
|
|
606
|
+
setTimeout(() => setCopied(false), 1600)
|
|
607
|
+
})
|
|
608
|
+
.catch(() => {})
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const toggleReveal = (e) => {
|
|
612
|
+
e.stopPropagation()
|
|
613
|
+
if (apiKey === null) return
|
|
614
|
+
setRevealed((prev) => !prev)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return h('div', {
|
|
618
|
+
className: 'dsh-llm-hub-key-badge' + (copied ? ' is-copied' : ''),
|
|
619
|
+
title: copied ? t('copiedKey') : (apiKey === null ? t('showKeyUnavailable') : (revealed ? t('hideKeyHint') : t('showKeyHint'))),
|
|
620
|
+
onClick: copyKey
|
|
621
|
+
},
|
|
622
|
+
copied
|
|
623
|
+
? h('span', { className: 'dsh-llm-hub-key-badge__copied' },
|
|
624
|
+
h(CheckIcon, null),
|
|
625
|
+
h('span', null, t('copiedKey'))
|
|
626
|
+
)
|
|
627
|
+
: h('span', { className: 'dsh-llm-hub-key-badge__text' }, display),
|
|
628
|
+
!copied && apiKey !== null ? h('button', {
|
|
629
|
+
type: 'button',
|
|
630
|
+
className: 'dsh-llm-hub-key-badge__btn' + (revealed ? ' is-active' : ''),
|
|
631
|
+
title: revealed ? t('hideKey') : t('showKey'),
|
|
632
|
+
onClick: toggleReveal
|
|
633
|
+
}, revealed ? h(EyeOffIcon, null) : h(EyeIcon, null)) : null,
|
|
634
|
+
!copied ? h('button', {
|
|
635
|
+
type: 'button',
|
|
636
|
+
className: 'dsh-llm-hub-key-badge__btn',
|
|
637
|
+
title: t('copiedKey'),
|
|
638
|
+
onClick: copyKey
|
|
639
|
+
}, h(CopyIcon, null)) : null
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
|
|
260
643
|
/**
|
|
261
644
|
* 余额卡:挂载即查,可手动刷新。
|
|
262
645
|
* @param props - owner props(provider/configured/keyConfigured)+ 注入的 `t`。
|
|
@@ -266,6 +649,8 @@ window.__ModuleLoader__.load({
|
|
|
266
649
|
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
267
650
|
const keyConfigured = props.keyConfigured !== false
|
|
268
651
|
const [state, setState] = React.useState({ status: 'idle' })
|
|
652
|
+
const [warning, setWarning] = React.useState(null)
|
|
653
|
+
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
|
269
654
|
// 官方直连也可能被判不可用(key 被拒 / 账户欠费)。同一个状态片,一致的处理。
|
|
270
655
|
const availability = props.availability
|
|
271
656
|
const availabilityState = availability === undefined
|
|
@@ -281,7 +666,8 @@ window.__ModuleLoader__.load({
|
|
|
281
666
|
setState({ status: 'nokey' })
|
|
282
667
|
return
|
|
283
668
|
}
|
|
284
|
-
setState({ status: 'loading' })
|
|
669
|
+
setState((prev) => (prev.status === 'ok' ? prev : { status: 'loading' }))
|
|
670
|
+
setIsRefreshing(true)
|
|
285
671
|
fetch(BALANCE_URL, { headers: { accept: 'application/json' } })
|
|
286
672
|
.then(async (response) => {
|
|
287
673
|
let body
|
|
@@ -293,74 +679,127 @@ window.__ModuleLoader__.load({
|
|
|
293
679
|
if (body == null || body.ok !== true) {
|
|
294
680
|
throw new Error(typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`)
|
|
295
681
|
}
|
|
296
|
-
setState({
|
|
682
|
+
setState({
|
|
683
|
+
status: 'ok',
|
|
684
|
+
isAvailable: body.isAvailable === true,
|
|
685
|
+
balances: Array.isArray(body.balances) ? body.balances : [],
|
|
686
|
+
apiKey: typeof body.apiKey === 'string' ? body.apiKey : undefined,
|
|
687
|
+
keyMasked: typeof body.keyMasked === 'string' ? body.keyMasked : undefined
|
|
688
|
+
})
|
|
297
689
|
})
|
|
298
690
|
.catch((error) => {
|
|
299
691
|
setState({ status: 'error', error: error instanceof Error ? error.message : String(error) })
|
|
300
692
|
})
|
|
693
|
+
.finally(() => {
|
|
694
|
+
setIsRefreshing(false)
|
|
695
|
+
})
|
|
301
696
|
}, [keyConfigured])
|
|
302
697
|
|
|
303
698
|
React.useEffect(() => {
|
|
304
699
|
load()
|
|
305
700
|
}, [load])
|
|
306
701
|
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
702
|
+
const primary = state.status === 'ok' && Array.isArray(state.balances) ? state.balances[0] : undefined
|
|
703
|
+
|
|
704
|
+
// 余额预警:无条件在最外层声明 Hook,遵从 Rules of Hooks!
|
|
705
|
+
React.useEffect(() => {
|
|
706
|
+
if (!primary || typeof primary.total !== 'string') return
|
|
707
|
+
fetch(WARNING_URL, {
|
|
708
|
+
method: 'POST',
|
|
709
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
710
|
+
body: JSON.stringify({
|
|
711
|
+
provider: DEEPSEEK_PROVIDER,
|
|
712
|
+
deepseek: { total: primary.total, currency: primary.currency }
|
|
713
|
+
})
|
|
714
|
+
})
|
|
715
|
+
.then((res) => res.json())
|
|
716
|
+
.then((body) => {
|
|
717
|
+
if (body?.ok === true && body.level === 'low') {
|
|
718
|
+
setWarning({ level: 'low', text: typeof body.text === 'string' ? body.text : '' })
|
|
719
|
+
} else {
|
|
720
|
+
setWarning(null)
|
|
721
|
+
}
|
|
722
|
+
})
|
|
723
|
+
.catch(() => setWarning(null))
|
|
724
|
+
}, [primary?.total, primary?.currency])
|
|
316
725
|
|
|
317
726
|
if (state.status === 'nokey') {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
727
|
+
return h('div', { className: 'dsh-llm-hub-card' },
|
|
728
|
+
h('div', { className: 'dsh-llm-hub-card__meta' },
|
|
729
|
+
h('span', { className: 'dsh-llm-hub-warn' }, t('noKey')),
|
|
730
|
+
h('span', { className: 'dsh-llm-hub-subtext' }, t('noKeyHint'))
|
|
731
|
+
)
|
|
732
|
+
)
|
|
321
733
|
}
|
|
322
734
|
|
|
323
735
|
if (state.status === 'loading' || state.status === 'idle') {
|
|
324
|
-
|
|
325
|
-
|
|
736
|
+
return h('div', { className: 'dsh-llm-hub-card' },
|
|
737
|
+
h('div', { className: 'dsh-llm-hub-card__meta' },
|
|
738
|
+
h('span', { className: 'dsh-llm-hub-subtext' }, t('loading'))
|
|
739
|
+
)
|
|
740
|
+
)
|
|
326
741
|
}
|
|
327
742
|
|
|
328
743
|
if (state.status === 'error') {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
744
|
+
return h('div', { className: 'dsh-llm-hub-card' },
|
|
745
|
+
h('div', { className: 'dsh-llm-hub-card__meta' },
|
|
746
|
+
h('span', { className: 'dsh-llm-hub-error' }, `${t('failed')}: ${state.error}`),
|
|
747
|
+
h('button', {
|
|
748
|
+
type: 'button',
|
|
749
|
+
className: 'dsh-llm-hub-btn',
|
|
750
|
+
onClick: load
|
|
751
|
+
}, h(RefreshIcon, { spinning: isRefreshing }), t('retry'))
|
|
752
|
+
)
|
|
753
|
+
)
|
|
338
754
|
}
|
|
339
755
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
756
|
+
if (!primary) {
|
|
757
|
+
return h('div', { className: 'dsh-llm-hub-card' },
|
|
758
|
+
h('div', { className: 'dsh-llm-hub-card__meta' },
|
|
759
|
+
h('span', { className: 'dsh-llm-hub-subtext' }, t('loading'))
|
|
760
|
+
)
|
|
761
|
+
)
|
|
345
762
|
}
|
|
763
|
+
|
|
346
764
|
const symbol = primary.currency === 'CNY' ? '¥' : primary.currency === 'USD' ? '$' : `${primary.currency} `
|
|
347
|
-
children.push(h('span', { key: 'amount', className: 'dsh-llm-hub-balance__amount' }, `${symbol}${primary.total}`))
|
|
348
|
-
if (!state.isAvailable) children.push(h('span', { key: 'unavail', className: 'dsh-llm-hub-balance__warn' }, t('unavailable')))
|
|
349
765
|
const breakdown = []
|
|
350
766
|
if (primary.granted !== '0' && primary.granted !== '0.00') breakdown.push(`${t('granted')} ${symbol}${primary.granted}`)
|
|
351
767
|
if (primary.toppedUp !== '0' && primary.toppedUp !== '0.00') breakdown.push(`${t('toppedUp')} ${symbol}${primary.toppedUp}`)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
768
|
+
|
|
769
|
+
return h('div', { className: 'dsh-llm-hub-card' },
|
|
770
|
+
h('div', { className: 'dsh-llm-hub-card__meta' },
|
|
771
|
+
h('div', { className: 'dsh-llm-hub-card__tags' },
|
|
772
|
+
h('span', { className: 'dsh-llm-hub-balance-label' }, t('balance')),
|
|
773
|
+
h('span', { className: 'dsh-llm-hub-amount' + (warning?.level === 'low' ? ' is-low' : '') }, `${symbol}${primary.total}`),
|
|
774
|
+
h('span', { className: 'dsh-llm-hub-meta-sep' }, '·'),
|
|
775
|
+
h('span', { className: 'dsh-llm-hub-meta-text' }, '官方直连'),
|
|
776
|
+
h('span', { className: 'dsh-llm-hub-meta-sep' }, '·'),
|
|
777
|
+
h('span', {
|
|
778
|
+
className: 'dsh-llm-hub-health-badge ' + (state.isAvailable ? 'is-ok' : 'is-err'),
|
|
779
|
+
title: state.isAvailable ? '官方接口连通正常' : t('unavailable')
|
|
780
|
+
},
|
|
781
|
+
h('span', { className: 'dsh-llm-hub-health-dot ' + (state.isAvailable ? 'is-ok' : 'is-err') }),
|
|
782
|
+
state.isAvailable ? '连通正常' : t('unavailable')
|
|
783
|
+
),
|
|
784
|
+
breakdown.length > 0 ? h('span', { className: 'dsh-llm-hub-subtext' }, `(${breakdown.join(' · ')})`) : null,
|
|
785
|
+
hiddenFromDropdown ? h('span', { className: 'dsh-llm-hub-chip dsh-llm-hub-chip--hidden' }, t('availabilityUnavailable')) : null
|
|
786
|
+
),
|
|
787
|
+
h('div', { className: 'dsh-llm-hub-card__btn-group' },
|
|
788
|
+
h(ApiKeyBadge, {
|
|
789
|
+
apiKey: state.apiKey,
|
|
790
|
+
keyMasked: state.keyMasked,
|
|
791
|
+
t
|
|
792
|
+
}),
|
|
793
|
+
h('button', {
|
|
794
|
+
type: 'button',
|
|
795
|
+
className: 'dsh-llm-hub-btn',
|
|
796
|
+
disabled: isRefreshing,
|
|
797
|
+
title: t('refresh'),
|
|
798
|
+
onClick: load
|
|
799
|
+
}, h(RefreshIcon, { spinning: isRefreshing }))
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
)
|
|
364
803
|
}
|
|
365
804
|
|
|
366
805
|
/**
|
|
@@ -433,6 +872,7 @@ window.__ModuleLoader__.load({
|
|
|
433
872
|
const [saveState, setSaveState] = React.useState(null)
|
|
434
873
|
const [copied, setCopied] = React.useState(false)
|
|
435
874
|
const [balance, setBalance] = React.useState(null)
|
|
875
|
+
const [warning, setWarning] = React.useState(null)
|
|
436
876
|
// 被摘掉的分组,在它自己的卡片上直接标出来 —— 动作条里一枚小片,不占额外行。
|
|
437
877
|
const availability = props.availability
|
|
438
878
|
const availabilityState = availability === undefined
|
|
@@ -459,6 +899,27 @@ window.__ModuleLoader__.load({
|
|
|
459
899
|
}
|
|
460
900
|
}, [provider, status])
|
|
461
901
|
|
|
902
|
+
// 余额预警:拿到 envelope 后顺手问 host 是否低于阈值。
|
|
903
|
+
React.useEffect(() => {
|
|
904
|
+
if (provider === '' || balance === null || balance.ok !== true) return undefined
|
|
905
|
+
let alive = true
|
|
906
|
+
fetch(WARNING_URL, {
|
|
907
|
+
method: 'POST',
|
|
908
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
909
|
+
body: JSON.stringify({ provider, envelope: balance })
|
|
910
|
+
})
|
|
911
|
+
.then((res) => res.json())
|
|
912
|
+
.then((body) => {
|
|
913
|
+
if (alive && body?.ok === true && body.level === 'low') {
|
|
914
|
+
setWarning({ level: 'low', text: typeof body.text === 'string' ? body.text : '' })
|
|
915
|
+
} else if (alive) {
|
|
916
|
+
setWarning(null)
|
|
917
|
+
}
|
|
918
|
+
})
|
|
919
|
+
.catch(() => { if (alive) setWarning(null) })
|
|
920
|
+
return () => { alive = false }
|
|
921
|
+
}, [provider, balance])
|
|
922
|
+
|
|
462
923
|
React.useEffect(() => {
|
|
463
924
|
if (provider === '') return undefined
|
|
464
925
|
let alive = true
|
|
@@ -522,83 +983,108 @@ window.__ModuleLoader__.load({
|
|
|
522
983
|
// 2026-09-15 owner 报「为啥都显示状态读取失败」—— 页面一打开、或 DSH 刚重启
|
|
523
984
|
// 导致这次 fetch 断掉时,三行全是这句,看着像插件坏了,其实只是还没加载完。
|
|
524
985
|
if (status === null) {
|
|
525
|
-
return h('div', { className: 'dsh-llm-hub-
|
|
526
|
-
h('
|
|
527
|
-
|
|
986
|
+
return h('div', { className: 'dsh-llm-hub-card' },
|
|
987
|
+
h('div', { className: 'dsh-llm-hub-card__meta' },
|
|
988
|
+
h('span', { className: 'dsh-llm-hub-subtext' }, t('statusLoading'))))
|
|
528
989
|
}
|
|
529
990
|
if (status.ok !== true) {
|
|
530
|
-
return h('div', { className: 'dsh-llm-hub-
|
|
531
|
-
h('
|
|
532
|
-
|
|
991
|
+
return h('div', { className: 'dsh-llm-hub-card' },
|
|
992
|
+
h('div', { className: 'dsh-llm-hub-card__meta' },
|
|
993
|
+
h('span', { className: 'dsh-llm-hub-error' }, typeof status.error === 'string' ? status.error : t('statusFail'))))
|
|
533
994
|
}
|
|
534
995
|
|
|
535
|
-
//
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
facts.push(h('span', { key: 'name', className: 'dsh-llm-hub-balance__amount' }, status.displayName))
|
|
539
|
-
facts.push(h('span', { key: 'count', className: 'dsh-llm-hub-balance__tag' }, `${t('configuredModels')} ${status.modelCount} ${t('modelsUnit')}`))
|
|
540
|
-
facts.push(h('span', { key: 'key', className: 'dsh-llm-hub-balance__tag' }, status.keyConfigured === true ? 'Key ✓' : t('keyMissing')))
|
|
541
|
-
// 协议与接入地址(2026-09-14 owner:「接入的协议和 apiurl 也需要可以看」)。
|
|
542
|
-
// 这两项决定了这个 provider 到底连去哪、用哪套报文;出问题时第一眼要看的就是它们,
|
|
543
|
-
// 之前只能去翻 settings.yaml。地址只显示域名,完整 URL 放 title —— 一行放不下,
|
|
544
|
-
// 而域名已经够回答「连的是不是我以为的那个网关」。
|
|
996
|
+
// 元信息标签组(不重复展示外层已有的大标题,只呈现核心事实)
|
|
997
|
+
const tags = []
|
|
998
|
+
tags.push(h('span', { key: 'count', className: 'dsh-llm-hub-tag' }, `${t('configuredModels')} ${status.modelCount} ${t('modelsUnit')}`))
|
|
545
999
|
if (typeof status.api === 'string' && status.api !== '') {
|
|
546
|
-
|
|
1000
|
+
const shortApi = status.api.replace('-messages', '').replace('-completions', '')
|
|
1001
|
+
tags.push(h('span', { key: 'api', className: 'dsh-llm-hub-tag', title: `${t('protocol')}: ${status.api}` }, shortApi))
|
|
547
1002
|
}
|
|
548
1003
|
if (typeof status.baseURL === 'string' && status.baseURL !== '') {
|
|
549
1004
|
let host = status.baseURL
|
|
550
1005
|
try { host = new URL(status.baseURL).host } catch {}
|
|
551
|
-
|
|
1006
|
+
tags.push(h('span', { key: 'sep2', className: 'dsh-llm-hub-meta-sep' }, '·'))
|
|
1007
|
+
tags.push(h('span', {
|
|
552
1008
|
key: 'base',
|
|
553
|
-
className: 'dsh-llm-hub-
|
|
1009
|
+
className: 'dsh-llm-hub-meta-text',
|
|
554
1010
|
title: `${t('endpoint')}: ${status.baseURL}`
|
|
555
1011
|
}, host))
|
|
556
1012
|
}
|
|
1013
|
+
|
|
1014
|
+
// 健康与延迟直接融入 Provider 行(消除冗余下方看板)
|
|
1015
|
+
const probing = probe !== null && probe.phase === 'loading'
|
|
1016
|
+
const probeDone = probe !== null && probe.phase === 'done'
|
|
1017
|
+
const latency = probeDone && typeof probe.body?.latencyMs === 'number'
|
|
1018
|
+
? probe.body.latencyMs
|
|
1019
|
+
: (typeof verdict?.latencyMs === 'number' ? verdict.latencyMs : null)
|
|
1020
|
+
const isReachable = probeDone
|
|
1021
|
+
? (probe.body?.ok === true && probe.body?.reachable === true)
|
|
1022
|
+
: (verdict === undefined || verdict.state !== 'unavailable')
|
|
1023
|
+
const errMsg = probeDone
|
|
1024
|
+
? (probe.body?.error ?? '')
|
|
1025
|
+
: (verdict?.state === 'unavailable' ? (verdict.reason ?? t('availabilityUnavailable')) : '')
|
|
1026
|
+
|
|
1027
|
+
tags.push(h('span', { key: 'sep3', className: 'dsh-llm-hub-meta-sep' }, '·'))
|
|
1028
|
+
if (probing) {
|
|
1029
|
+
tags.push(h('span', { key: 'health', className: 'dsh-llm-hub-health-badge' }, h(RefreshIcon, { spinning: true }), t('probing')))
|
|
1030
|
+
} else if (isReachable) {
|
|
1031
|
+
const latencyClass = latency === null ? 'is-fast' : (latency < 300 ? 'is-fast' : (latency < 800 ? 'is-normal' : 'is-slow'))
|
|
1032
|
+
tags.push(h('span', {
|
|
1033
|
+
key: 'health',
|
|
1034
|
+
className: 'dsh-llm-hub-health-badge is-ok',
|
|
1035
|
+
title: latency !== null ? `连通正常 · 延迟 ${latency}ms` : '连通正常'
|
|
1036
|
+
},
|
|
1037
|
+
h('span', { className: 'dsh-llm-hub-health-dot is-ok' }),
|
|
1038
|
+
latency !== null
|
|
1039
|
+
? h('span', { className: `dsh-llm-hub-latency-badge ${latencyClass}` }, `${latency}ms`)
|
|
1040
|
+
: '正常'
|
|
1041
|
+
))
|
|
1042
|
+
} else {
|
|
1043
|
+
tags.push(h('span', {
|
|
1044
|
+
key: 'health',
|
|
1045
|
+
className: 'dsh-llm-hub-health-badge is-err dsh-llm-hub-chip--hidden',
|
|
1046
|
+
title: errMsg || t('availabilityUnavailable')
|
|
1047
|
+
},
|
|
1048
|
+
h('span', { className: 'dsh-llm-hub-health-dot is-err' }),
|
|
1049
|
+
t('availabilityUnavailable')
|
|
1050
|
+
))
|
|
1051
|
+
}
|
|
1052
|
+
|
|
557
1053
|
const balanceText = renderBalance(balance, t)
|
|
558
|
-
if (balanceText !== null)
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
// 再另起一盒放「拉取目录」。一张卡白吃两行多。现在合成一个盒子、按钮排成一条工具条。
|
|
565
|
-
const actions = []
|
|
566
|
-
if (hiddenFromDropdown) {
|
|
567
|
-
actions.push(h('span', {
|
|
568
|
-
key: 'hidden-chip',
|
|
569
|
-
className: 'dsh-llm-hub-chip dsh-llm-hub-chip--hidden',
|
|
570
|
-
title: typeof verdict.reason === 'string' ? verdict.reason : ''
|
|
571
|
-
}, t('availabilityUnavailable')))
|
|
1054
|
+
if (balanceText !== null) {
|
|
1055
|
+
tags.push(h('span', { key: 'sep4', className: 'dsh-llm-hub-meta-sep' }, '·'))
|
|
1056
|
+
tags.push(h('span', { key: 'bal', className: 'dsh-llm-hub-meta-text' }, balanceText))
|
|
1057
|
+
if (warning !== null && warning.level === 'low') {
|
|
1058
|
+
tags.push(h('span', { key: 'warn', className: 'dsh-llm-hub-warn-badge', title: warning.text }, '⚠️ 额度偏低'))
|
|
1059
|
+
}
|
|
572
1060
|
}
|
|
1061
|
+
|
|
1062
|
+
// 收拢的操作按钮(Ghost 风格)
|
|
573
1063
|
const noBaseURL = status.baseURL === undefined || status.baseURL === ''
|
|
574
|
-
const
|
|
1064
|
+
const probeBtn = (key, label, busy, spinning, onClick) => h('button', {
|
|
575
1065
|
key,
|
|
576
1066
|
type: 'button',
|
|
577
|
-
className: 'dsh-llm-hub-
|
|
1067
|
+
className: 'dsh-llm-hub-btn',
|
|
578
1068
|
disabled: busy,
|
|
579
1069
|
onClick
|
|
580
|
-
}, label)
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
// 目录拉取原先只对 modelgo 开放(它走 anthropic-messages,官方发现天然失效)。
|
|
595
|
-
// 但「网关上到底有哪些模型」对每个 provider 都有用 —— 智谱手填 8 个、网关在售
|
|
596
|
-
// 16 个,不拉一次根本不知道漏了什么。改为所有 pi-ai provider 都能拉。
|
|
597
|
-
// 探测不到的(没 baseURL 又不在已知网关表里)点了会得到明确提示,不会静默。
|
|
1070
|
+
}, spinning ? h(RefreshIcon, { spinning: true }) : null, label)
|
|
1071
|
+
|
|
1072
|
+
const btnGroup = [
|
|
1073
|
+
h(ApiKeyBadge, {
|
|
1074
|
+
key: 'key',
|
|
1075
|
+
apiKey: status.apiKey,
|
|
1076
|
+
keyMasked: status.keyMasked,
|
|
1077
|
+
t
|
|
1078
|
+
})
|
|
1079
|
+
]
|
|
1080
|
+
|
|
1081
|
+
if (!noBaseURL) {
|
|
598
1082
|
const pulling = catalog !== null && catalog.phase === 'loading'
|
|
599
|
-
|
|
1083
|
+
btnGroup.push(probeBtn('probe', probing ? t('probing') : t('probe'), probing, probing, runProbe))
|
|
1084
|
+
btnGroup.push(probeBtn('catalog', pulling ? t('pulling') : t('pullCatalog'), pulling, pulling, pullCatalog))
|
|
600
1085
|
}
|
|
601
|
-
|
|
1086
|
+
|
|
1087
|
+
// 配额不可用时的上游原因不进正文,只留在 title 里
|
|
602
1088
|
const quotaNote = balance !== null && typeof balance === 'object' && balance.ok === true
|
|
603
1089
|
&& balance.available === false && typeof balance.reason === 'string' && balance.reason.length > 0
|
|
604
1090
|
? balance.reason
|
|
@@ -608,12 +1094,8 @@ window.__ModuleLoader__.load({
|
|
|
608
1094
|
if (!noBaseURL && catalog !== null && catalog.phase === 'done') {
|
|
609
1095
|
const body = catalog.body
|
|
610
1096
|
if (body?.ok === true && Array.isArray(body.models)) {
|
|
611
|
-
|
|
612
|
-
actions.push(probeButton('copy', copied ? t('copied') : t('copyIds'), false, copyIds))
|
|
1097
|
+
btnGroup.push(probeBtn('copy', copied ? t('copied') : t('copyIds'), false, false, copyIds))
|
|
613
1098
|
|
|
614
|
-
// 拉到目录就把它摆出来。之前这里只报一个数字加「复制全部 id」,
|
|
615
|
-
// 等于让人把 71 个 id 粘到配置文件里自己挑 —— 最后一公里留给了人。
|
|
616
|
-
// 已配置的默认勾上,人只需要动增量。
|
|
617
1099
|
const configured = new Set(Array.isArray(status.modelIds) ? status.modelIds : [])
|
|
618
1100
|
const picked = selected === null ? configured : selected
|
|
619
1101
|
const toggle = (id) => {
|
|
@@ -626,32 +1108,46 @@ window.__ModuleLoader__.load({
|
|
|
626
1108
|
picker = h('div', { className: 'dsh-llm-hub-picker' },
|
|
627
1109
|
h('div', { className: 'dsh-llm-hub-picker__head' },
|
|
628
1110
|
h('span', null, `${t('pick')}(${picked.size}/${body.models.length})`),
|
|
629
|
-
|
|
1111
|
+
probeBtn('save', saveState === 'saving' ? t('saving') : saveState === 'done' ? t('saved') : saveState === 'fail' ? t('saveFail') : t('save'), saveState === 'saving', saveState === 'saving', saveModels)),
|
|
630
1112
|
h('div', { className: 'dsh-llm-hub-picker__list' },
|
|
631
|
-
body.models.map((model) =>
|
|
632
|
-
|
|
633
|
-
h('
|
|
634
|
-
|
|
1113
|
+
body.models.map((model) => {
|
|
1114
|
+
const alias = aliasesStore.aliasFor(provider, model.id)
|
|
1115
|
+
return h('label', {
|
|
1116
|
+
key: model.id,
|
|
1117
|
+
className: 'dsh-llm-hub-picker__item',
|
|
1118
|
+
title: model.id
|
|
1119
|
+
},
|
|
1120
|
+
h('input', { type: 'checkbox', checked: picked.has(model.id), onChange: () => toggle(model.id) }),
|
|
1121
|
+
h('span', { className: 'dsh-llm-hub-picker__id' }, alias === null ? model.id : `${alias} (${model.id})`),
|
|
1122
|
+
configured.has(model.id) ? h('span', { className: 'dsh-llm-hub-picker__tag' }, t('configured')) : null)
|
|
1123
|
+
})))
|
|
635
1124
|
} else {
|
|
636
|
-
|
|
1125
|
+
btnGroup.push(h('span', { key: 'cat-bad', className: 'dsh-llm-hub-error' }, `${t('failed')}: ${body?.error ?? ''}`))
|
|
637
1126
|
}
|
|
638
1127
|
}
|
|
1128
|
+
|
|
1129
|
+
const actionsRight = []
|
|
1130
|
+
if (hiddenFromDropdown) {
|
|
1131
|
+
actionsRight.push(h('span', {
|
|
1132
|
+
key: 'hidden-chip',
|
|
1133
|
+
className: 'dsh-llm-hub-chip dsh-llm-hub-chip--hidden',
|
|
1134
|
+
title: typeof verdict?.reason === 'string' ? verdict.reason : ''
|
|
1135
|
+
}, t('availabilityUnavailable')))
|
|
1136
|
+
}
|
|
1137
|
+
|
|
639
1138
|
return h('div', null,
|
|
640
|
-
h('div', { className: 'dsh-llm-hub-
|
|
641
|
-
h('div', { key: '
|
|
642
|
-
|
|
1139
|
+
h('div', { className: 'dsh-llm-hub-card', title: quotaNote },
|
|
1140
|
+
h('div', { key: 'meta', className: 'dsh-llm-hub-card__meta' },
|
|
1141
|
+
h('div', { className: 'dsh-llm-hub-card__tags' }, tags),
|
|
1142
|
+
h('div', { className: 'dsh-llm-hub-card__btn-group dsh-llm-hub-actions' },
|
|
1143
|
+
btnGroup,
|
|
1144
|
+
actionsRight.length > 0 ? actionsRight : null
|
|
1145
|
+
)
|
|
1146
|
+
)
|
|
1147
|
+
),
|
|
643
1148
|
picker)
|
|
644
1149
|
}
|
|
645
1150
|
|
|
646
|
-
/**
|
|
647
|
-
* 模型页页脚:插件版本 + 仓库 + 反馈入口。
|
|
648
|
-
*
|
|
649
|
-
* 2026-09-15 owner:「人家写的插件都有,你也加上,不然别人没有反馈,没法闭环」。
|
|
650
|
-
* 放在 settings.models.footer 而不是每张 provider 卡片上 —— 卡片有几个就会重复
|
|
651
|
-
* 几次,而这行信息整页只需要一份。
|
|
652
|
-
* @param props - 注入的 `t`。
|
|
653
|
-
* @returns 页脚节点;元信息读不到时返回 null(不占位、不报错)。
|
|
654
|
-
*/
|
|
655
1151
|
/**
|
|
656
1152
|
* 外部 harness 一览。
|
|
657
1153
|
*
|
|
@@ -702,6 +1198,373 @@ window.__ModuleLoader__.load({
|
|
|
702
1198
|
)
|
|
703
1199
|
}
|
|
704
1200
|
|
|
1201
|
+
/**
|
|
1202
|
+
* 健康看板卡:健康度已直接融合进 Provider 列表,下方不再冗余平铺整张大表。
|
|
1203
|
+
* 保持组件注册以保证向前兼容。
|
|
1204
|
+
*/
|
|
1205
|
+
function HealthCard(props) {
|
|
1206
|
+
return null
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* 热门大模型预设库定义
|
|
1211
|
+
*/
|
|
1212
|
+
const PRESETS = [
|
|
1213
|
+
{
|
|
1214
|
+
id: 'deepseek',
|
|
1215
|
+
name: '🇨🇳 DeepSeek 官方',
|
|
1216
|
+
desc: 'V4.1 Flash / V4 Pro 极速官方直连',
|
|
1217
|
+
docUrl: 'https://platform.deepseek.com/api_keys',
|
|
1218
|
+
yaml: 'llm-deepseek:\n keyPool:\n llm-deepseek:\n - name: "主账号"\n env: DEEPSEEK_API_KEY\n models:\n - id: deepseek-flash\n name: DeepSeek-V4.1-Flash\n - id: deepseek-v4-pro\n name: DeepSeek-V4-Pro'
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
id: 'minimax',
|
|
1222
|
+
name: '⚡ MiniMax',
|
|
1223
|
+
desc: 'MiniMax-M3 百万上下文超强大模型',
|
|
1224
|
+
docUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
|
|
1225
|
+
yaml: 'minimax:\n displayName: MiniMax\n api: openai-completions\n baseURL: https://api.minimaxi.com/v1\n apiKeyEnv: MINIMAX_API_KEY\n models:\n - id: MiniMax-M3\n name: MiniMax-M3\n - id: MiniMax-M2.7\n name: MiniMax-M2.7'
|
|
1226
|
+
},
|
|
1227
|
+
{
|
|
1228
|
+
id: 'zhipu',
|
|
1229
|
+
name: '🧠 智谱清言 GLM',
|
|
1230
|
+
desc: 'GLM-5 / GLM-4.7 国内 Coding 利器',
|
|
1231
|
+
docUrl: 'https://open.bigmodel.cn/usercenter/apikeys',
|
|
1232
|
+
yaml: 'zai-coding-cn:\n displayName: 智谱 GLM\n api: openai-completions\n baseURL: https://open.bigmodel.cn/api/paas/v4\n apiKeyEnv: ZHIPU_API_KEY\n models:\n - id: glm-5-turbo\n - id: glm-4.7'
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
id: 'volc',
|
|
1236
|
+
name: '🌋 火山方舟 Ark',
|
|
1237
|
+
desc: '字节豆包 Doubao-1.5 多模态矩阵',
|
|
1238
|
+
docUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey',
|
|
1239
|
+
yaml: 'volcengine:\n displayName: 火山方舟\n api: openai-completions\n baseURL: https://ark.cn-beijing.volces.com/api/v3\n apiKeyEnv: ARK_API_KEY\n models:\n - id: doubao-1.5-pro-32k\n - id: doubao-1.5-lite-32k'
|
|
1240
|
+
},
|
|
1241
|
+
{
|
|
1242
|
+
id: 'siliconflow',
|
|
1243
|
+
name: '🚀 硅基流动',
|
|
1244
|
+
desc: 'SiliconFlow 聚合开源高性价比模型',
|
|
1245
|
+
docUrl: 'https://cloud.siliconflow.cn/account/ak',
|
|
1246
|
+
yaml: 'siliconflow:\n displayName: 硅基流动\n api: openai-completions\n baseURL: https://api.siliconflow.cn/v1\n apiKeyEnv: SILICONFLOW_API_KEY\n models:\n - id: deepseek-ai/DeepSeek-V3\n - id: Qwen/Qwen2.5-72B-Instruct'
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
id: 'openrouter',
|
|
1250
|
+
name: '🌐 OpenRouter',
|
|
1251
|
+
desc: '聚合全球 Claude / GPT / Gemini 全模型',
|
|
1252
|
+
docUrl: 'https://openrouter.ai/keys',
|
|
1253
|
+
yaml: 'openrouter:\n displayName: OpenRouter\n api: openai-completions\n baseURL: https://openrouter.ai/api/v1\n apiKeyEnv: OPENROUTER_API_KEY\n models:\n - id: anthropic/claude-3.7-sonnet\n - id: deepseek/deepseek-r1'
|
|
1254
|
+
}
|
|
1255
|
+
]
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* 顶部 Hero 统计横条:4 核心极客 KPI
|
|
1259
|
+
*/
|
|
1260
|
+
function HeroOverviewCard(props) {
|
|
1261
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1262
|
+
const availability = props.availability
|
|
1263
|
+
const availabilityState = availability === undefined
|
|
1264
|
+
? { providers: [] }
|
|
1265
|
+
: React.useSyncExternalStore(availability.subscribe, availability.snapshot)
|
|
1266
|
+
|
|
1267
|
+
const list = Array.isArray(availabilityState.providers) ? availabilityState.providers : []
|
|
1268
|
+
const totalProviders = list.length
|
|
1269
|
+
const readyProviders = list.filter((p) => p.state !== 'unavailable').length
|
|
1270
|
+
const totalModels = list.reduce((acc, p) => acc + (typeof p.modelCount === 'number' ? p.modelCount : 0), 0)
|
|
1271
|
+
|
|
1272
|
+
const latencies = list.map((p) => p.latencyMs).filter((l) => typeof l === 'number' && l > 0)
|
|
1273
|
+
const minLatency = latencies.length > 0 ? Math.min(...latencies) : null
|
|
1274
|
+
|
|
1275
|
+
ensureStyle()
|
|
1276
|
+
|
|
1277
|
+
return h('div', { className: 'dsh-llm-hub-hero' },
|
|
1278
|
+
h('div', { className: 'dsh-llm-hub-hero__card' },
|
|
1279
|
+
h('span', { className: 'dsh-llm-hub-hero__num' }, totalProviders > 0 ? `${readyProviders} / ${totalProviders}` : '—'),
|
|
1280
|
+
h('span', { className: 'dsh-llm-hub-hero__label' }, t('heroProviders'))),
|
|
1281
|
+
h('div', { className: 'dsh-llm-hub-hero__card' },
|
|
1282
|
+
h('span', { className: 'dsh-llm-hub-hero__num' }, totalModels > 0 ? `${totalModels}` : '—'),
|
|
1283
|
+
h('span', { className: 'dsh-llm-hub-hero__label' }, t('heroModels'))),
|
|
1284
|
+
h('div', { className: 'dsh-llm-hub-hero__card' },
|
|
1285
|
+
h('span', { className: 'dsh-llm-hub-hero__num' }, minLatency !== null ? `${minLatency}ms` : '—'),
|
|
1286
|
+
h('span', { className: 'dsh-llm-hub-hero__label' }, t('heroFastest'))),
|
|
1287
|
+
h('div', { className: 'dsh-llm-hub-hero__card' },
|
|
1288
|
+
h('span', { className: 'dsh-llm-hub-hero__num' }, '🛡️ 100%'),
|
|
1289
|
+
h('span', { className: 'dsh-llm-hub-hero__label' }, t('heroSecSafe'))))
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* 热门大模型一键装配抽屉
|
|
1294
|
+
*/
|
|
1295
|
+
function QuickPresetsCard(props) {
|
|
1296
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1297
|
+
const [expanded, setExpanded] = React.useState(false)
|
|
1298
|
+
const [selected, setSelected] = React.useState(null)
|
|
1299
|
+
const [copied, setCopied] = React.useState(false)
|
|
1300
|
+
|
|
1301
|
+
ensureStyle()
|
|
1302
|
+
|
|
1303
|
+
const copyYaml = (e, yaml) => {
|
|
1304
|
+
e.stopPropagation()
|
|
1305
|
+
navigator.clipboard?.writeText(yaml).then(() => {
|
|
1306
|
+
setCopied(true)
|
|
1307
|
+
setTimeout(() => setCopied(false), 2000)
|
|
1308
|
+
}).catch(() => {})
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return h('div', { className: 'dsh-llm-hub-presets' },
|
|
1312
|
+
h('div', {
|
|
1313
|
+
className: 'dsh-llm-hub-presets__head',
|
|
1314
|
+
onClick: () => setExpanded((prev) => !prev)
|
|
1315
|
+
},
|
|
1316
|
+
h('span', { className: 'dsh-llm-hub-presets__title' },
|
|
1317
|
+
t('presetsTitle'),
|
|
1318
|
+
h('span', { style: { fontSize: '10px', opacity: 0.6 } }, expanded ? ' ▲ 收起' : ' ▼ 展开模版')),
|
|
1319
|
+
h('span', { className: 'dsh-llm-hub-subtext' }, t('presetsSub'))),
|
|
1320
|
+
expanded ? h('div', { className: 'dsh-llm-hub-presets__grid' },
|
|
1321
|
+
PRESETS.map((p) => h('div', {
|
|
1322
|
+
key: p.id,
|
|
1323
|
+
className: 'dsh-llm-hub-presets__item',
|
|
1324
|
+
onClick: () => setSelected(selected?.id === p.id ? null : p)
|
|
1325
|
+
},
|
|
1326
|
+
h('span', { className: 'dsh-llm-hub-presets__name' }, p.name),
|
|
1327
|
+
h('span', { className: 'dsh-llm-hub-presets__desc' }, p.desc)))) : null,
|
|
1328
|
+
expanded && selected !== null ? h('div', { className: 'dsh-llm-hub-presets__detail' },
|
|
1329
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
1330
|
+
h('span', { style: { fontWeight: '600' } }, `${selected.name} 配置模版`),
|
|
1331
|
+
h('div', { style: { display: 'flex', gap: '6px' } },
|
|
1332
|
+
h('button', {
|
|
1333
|
+
type: 'button',
|
|
1334
|
+
className: 'dsh-llm-hub-btn',
|
|
1335
|
+
onClick: (e) => copyYaml(e, selected.yaml)
|
|
1336
|
+
}, copied ? t('presetsCopied') : t('presetsCopyBtn')),
|
|
1337
|
+
h('a', {
|
|
1338
|
+
href: selected.docUrl,
|
|
1339
|
+
target: '_blank',
|
|
1340
|
+
rel: 'noreferrer',
|
|
1341
|
+
className: 'dsh-llm-hub-btn',
|
|
1342
|
+
style: { textDecoration: 'none' }
|
|
1343
|
+
}, t('presetsDocsBtn')))),
|
|
1344
|
+
h('pre', { className: 'dsh-llm-hub-presets__code' }, selected.yaml)) : null)
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* 多账号 key 轮换卡:展示当前轮换池状态与活跃 Key
|
|
1349
|
+
*/
|
|
1350
|
+
function KeyPoolCard(props) {
|
|
1351
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1352
|
+
const [report, setReport] = React.useState(null)
|
|
1353
|
+
React.useEffect(() => {
|
|
1354
|
+
let alive = true
|
|
1355
|
+
fetch(KEYPOOL_URL, { headers: { accept: 'application/json' } })
|
|
1356
|
+
.then((res) => res.json())
|
|
1357
|
+
.then((body) => { if (alive) setReport(body !== null && typeof body === 'object' ? body : null) })
|
|
1358
|
+
.catch(() => { if (alive) setReport(null) })
|
|
1359
|
+
return () => { alive = false }
|
|
1360
|
+
}, [])
|
|
1361
|
+
if (report === null) return null
|
|
1362
|
+
const providers = Array.isArray(report.providers) ? report.providers : []
|
|
1363
|
+
if (providers.length === 0) return null
|
|
1364
|
+
ensureStyle()
|
|
1365
|
+
const formatTime = (ms) => {
|
|
1366
|
+
if (typeof ms !== 'number' || ms <= 0) return '—'
|
|
1367
|
+
const d = new Date(ms)
|
|
1368
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
|
1369
|
+
}
|
|
1370
|
+
const rows = providers.map((entry) => {
|
|
1371
|
+
const chosen = entry.lastChosen
|
|
1372
|
+
const isActive = chosen !== null
|
|
1373
|
+
return h('div', {
|
|
1374
|
+
key: entry.provider,
|
|
1375
|
+
className: 'dsh-llm-hub-keypool__row' + (isActive ? ' is-active' : '')
|
|
1376
|
+
},
|
|
1377
|
+
h('span', { className: 'dsh-llm-hub-health-dot ' + (isActive ? 'is-ok' : '') }),
|
|
1378
|
+
h('span', { className: 'dsh-llm-hub-keypool__name' }, entry.provider),
|
|
1379
|
+
h('span', { className: 'dsh-llm-hub-keypool__meta' }, `${entry.total} keys`),
|
|
1380
|
+
chosen === null
|
|
1381
|
+
? h('span', { className: 'dsh-llm-hub-keypool__pending' }, '—')
|
|
1382
|
+
: h('span', { className: 'dsh-llm-hub-keypool__chosen' },
|
|
1383
|
+
h('span', null, chosen.name),
|
|
1384
|
+
h('small', null, ` #${chosen.index + 1} / ${entry.total} · ${formatTime(chosen.at)}`)))
|
|
1385
|
+
})
|
|
1386
|
+
return h('div', { className: 'dsh-llm-hub-keypool' },
|
|
1387
|
+
h('div', { className: 'dsh-llm-hub-keypool__head' },
|
|
1388
|
+
h('span', { className: 'dsh-llm-hub-keypool__title' }, '🔄 ' + t('keypoolTitle')),
|
|
1389
|
+
h('span', { className: 'dsh-llm-hub-subtext' }, t('keypoolRound'))),
|
|
1390
|
+
h('div', { className: 'dsh-llm-hub-keypool__rows' }, rows))
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* 当前路由卡 —— 显示用户当前激活的 (provider, model) + 状态 + 推荐的 fallback。
|
|
1395
|
+
*
|
|
1396
|
+
* 不替用户切换:DSH 的 `conversation.input.model` 是 single + user-controlled
|
|
1397
|
+
* slot,没有供插件改值的 setter(README 已经钉死这条)。这条卡只**告诉**
|
|
1398
|
+
* 用户「当前这个不可用了,下一个可用的 fallback 是 X,你自己点一下」。
|
|
1399
|
+
*
|
|
1400
|
+
* 数据来源 `/api/dsh-llm-hub/routing/resolve`:POST 进来 active = (provider, model),
|
|
1401
|
+
* host 半按 settings.dsh-llm-hub.routing 的 primary + fallbacks[] 返回
|
|
1402
|
+
* recommendation。
|
|
1403
|
+
*
|
|
1404
|
+
* 渲染形式:
|
|
1405
|
+
* - 当前 active + 状态 chip(与 health 卡共享样式)
|
|
1406
|
+
* - primary / fallback 候选列表(各自带状态 chip)
|
|
1407
|
+
* - recommendation ≠ active 时显式提示「建议切换到 X」
|
|
1408
|
+
* - 未配置 routing:显示「未配置主力模型」+ 引导文本
|
|
1409
|
+
* - 全不可用:显示「全部不可用」+ reason
|
|
1410
|
+
*
|
|
1411
|
+
* @param props - 注入的 `t`。
|
|
1412
|
+
* @returns 当前路由卡;未配置时不渲染(让用户先配置再说)。
|
|
1413
|
+
*/
|
|
1414
|
+
function RoutingCard(props) {
|
|
1415
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1416
|
+
const [report, setReport] = React.useState(null)
|
|
1417
|
+
const query = React.useCallback((active) => {
|
|
1418
|
+
fetch(ROUTING_URL, {
|
|
1419
|
+
method: 'POST',
|
|
1420
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1421
|
+
body: JSON.stringify({ active: typeof active === 'string' && active.length > 0 ? active : null })
|
|
1422
|
+
})
|
|
1423
|
+
.then((res) => res.json())
|
|
1424
|
+
.then((body) => { setReport(body !== null && typeof body === 'object' ? body : null) })
|
|
1425
|
+
.catch(() => setReport(null))
|
|
1426
|
+
}, [])
|
|
1427
|
+
React.useEffect(() => { query(null) }, [query])
|
|
1428
|
+
if (report === null) {
|
|
1429
|
+
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
1430
|
+
h('div', { className: 'dsh-llm-hub-routing__head' },
|
|
1431
|
+
h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
|
|
1432
|
+
h('span', null, t('routingLoading'))))
|
|
1433
|
+
}
|
|
1434
|
+
if (report.ok !== true) return null
|
|
1435
|
+
const candidates = Array.isArray(report.candidates) ? report.candidates : []
|
|
1436
|
+
if (candidates.length === 0) {
|
|
1437
|
+
return null
|
|
1438
|
+
}
|
|
1439
|
+
ensureStyle()
|
|
1440
|
+
const active = report.active
|
|
1441
|
+
const recommendation = report.recommendation
|
|
1442
|
+
const activeKey = active === null ? null : `${active.provider}/${active.model}`
|
|
1443
|
+
const recKey = recommendation === null ? null : `${recommendation.provider}/${recommendation.model}`
|
|
1444
|
+
const showSuggestion = recommendation !== null && recKey !== activeKey
|
|
1445
|
+
const chipClass = (state) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${state ?? 'unknown'}`
|
|
1446
|
+
const head = h('div', { className: 'dsh-llm-hub-routing__head' },
|
|
1447
|
+
h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
|
|
1448
|
+
active !== null
|
|
1449
|
+
? h('span', { className: 'dsh-llm-hub-routing__active' },
|
|
1450
|
+
`${t('routingActive')}: ${activeKey}`,
|
|
1451
|
+
h('span', { className: chipClass(active.state) }, active.state === 'available' ? '可用' : '不可用'))
|
|
1452
|
+
: h('span', null, t('routingUnconfigured')))
|
|
1453
|
+
const rows = candidates.map((entry) => h('div', { key: `${entry.provider}/${entry.model}`, className: 'dsh-llm-hub-routing__row' },
|
|
1454
|
+
h('span', { className: 'dsh-llm-hub-routing__role' }, entry.role === 'primary' ? t('routingPrimary') : t('routingFallback')),
|
|
1455
|
+
h('span', { className: 'dsh-llm-hub-routing__name' }, `${entry.provider}/${entry.model}`),
|
|
1456
|
+
h('span', { className: chipClass(entry.state) }, entry.state === 'available' ? '可用' : '不可用')))
|
|
1457
|
+
return h('div', { className: 'dsh-llm-hub-routing' },
|
|
1458
|
+
head,
|
|
1459
|
+
h('div', { className: 'dsh-llm-hub-routing__rows' }, rows),
|
|
1460
|
+
showSuggestion
|
|
1461
|
+
? h('div', { className: 'dsh-llm-hub-routing__suggestion' }, `${t('routingSuggestion')}: ${recKey}`)
|
|
1462
|
+
: null,
|
|
1463
|
+
recommendation === null && candidates.length > 0
|
|
1464
|
+
? h('div', { className: 'dsh-llm-hub-routing__reason' }, t('routingNoRecommendation'))
|
|
1465
|
+
: null)
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* 格式化 token 数字 —— 大数带千分位,>9999 折成 1.2万。
|
|
1470
|
+
* @param value - token 数。
|
|
1471
|
+
* @returns 人话短文本。
|
|
1472
|
+
*/
|
|
1473
|
+
function formatTokens(value) {
|
|
1474
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '0'
|
|
1475
|
+
if (value < 10000) return String(Math.floor(value))
|
|
1476
|
+
return `${(value / 10000).toFixed(value < 100000 ? 2 : 1)}万`
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* 用量卡 —— 本月调用与 token 统计。
|
|
1481
|
+
*/
|
|
1482
|
+
function UsageCard(props) {
|
|
1483
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
1484
|
+
const [report, setReport] = React.useState(null)
|
|
1485
|
+
const [busy, setBusy] = React.useState(false)
|
|
1486
|
+
const [feedback, setFeedback] = React.useState(null)
|
|
1487
|
+
React.useEffect(() => {
|
|
1488
|
+
let alive = true
|
|
1489
|
+
fetch(USAGE_URL, { headers: { accept: 'application/json' } })
|
|
1490
|
+
.then((res) => res.json())
|
|
1491
|
+
.then((body) => { if (alive) setReport(body !== null && typeof body === 'object' ? body : { ready: false }) })
|
|
1492
|
+
.catch(() => { if (alive) setReport({ ready: false }) })
|
|
1493
|
+
return () => { alive = false }
|
|
1494
|
+
}, [])
|
|
1495
|
+
if (report === null) return null
|
|
1496
|
+
if (report.ready !== true) {
|
|
1497
|
+
return null
|
|
1498
|
+
}
|
|
1499
|
+
ensureStyle()
|
|
1500
|
+
const current = report.current
|
|
1501
|
+
const hasCalls = (current?.calls ?? 0) > 0
|
|
1502
|
+
const topModels = Array.isArray(current?.topModels) ? current.topModels : []
|
|
1503
|
+
const exportCsv = () => {
|
|
1504
|
+
setBusy(true)
|
|
1505
|
+
setFeedback(null)
|
|
1506
|
+
fetch(USAGE_EXPORT_URL, { headers: { accept: 'text/csv' } })
|
|
1507
|
+
.then((res) => res.text())
|
|
1508
|
+
.then((text) => {
|
|
1509
|
+
navigator.clipboard?.writeText(text).then(
|
|
1510
|
+
() => setFeedback('ok'),
|
|
1511
|
+
() => setFeedback('fail'),
|
|
1512
|
+
)
|
|
1513
|
+
})
|
|
1514
|
+
.catch(() => setFeedback('fail'))
|
|
1515
|
+
.finally(() => setBusy(false))
|
|
1516
|
+
}
|
|
1517
|
+
const clearRecords = () => {
|
|
1518
|
+
if (!window.confirm(t('usageClearConfirm'))) return
|
|
1519
|
+
setBusy(true)
|
|
1520
|
+
fetch('/api/dsh-llm-hub/usage/clear', {
|
|
1521
|
+
method: 'POST',
|
|
1522
|
+
headers: { accept: 'application/json' }
|
|
1523
|
+
})
|
|
1524
|
+
.then(() => setReport((prev) => ({ ...prev, current: { ...(prev?.current ?? {}), calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0, topModels: [] } })))
|
|
1525
|
+
.catch(() => {})
|
|
1526
|
+
.finally(() => setBusy(false))
|
|
1527
|
+
}
|
|
1528
|
+
const facts = []
|
|
1529
|
+
if (hasCalls) {
|
|
1530
|
+
facts.push(h('span', { key: 'calls', className: 'dsh-llm-hub-usage__tag' }, `${current.calls} ${t('usageCalls')}`))
|
|
1531
|
+
facts.push(h('span', { key: 'in', className: 'dsh-llm-hub-usage__tag' }, `${t('usageInput')} ${formatTokens(current.inputTokens)}`))
|
|
1532
|
+
facts.push(h('span', { key: 'out', className: 'dsh-llm-hub-usage__tag' }, `${t('usageOutput')} ${formatTokens(current.outputTokens)}`))
|
|
1533
|
+
if ((current.cacheReadTokens ?? 0) > 0 || (current.cacheWriteTokens ?? 0) > 0) {
|
|
1534
|
+
facts.push(h('span', { key: 'cache', className: 'dsh-llm-hub-usage__tag' }, `${t('usageCache')} ${formatTokens((current.cacheReadTokens ?? 0) + (current.cacheWriteTokens ?? 0))}`))
|
|
1535
|
+
}
|
|
1536
|
+
facts.push(h('span', { key: 'total', className: 'dsh-llm-hub-usage__tag' }, `${t('usageTotal')} ${formatTokens(current.totalTokens)}`))
|
|
1537
|
+
}
|
|
1538
|
+
return h('div', { className: 'dsh-llm-hub-usage' },
|
|
1539
|
+
h('div', { className: 'dsh-llm-hub-usage__head' },
|
|
1540
|
+
h('span', { className: 'dsh-llm-hub-usage__title' }, t('usageTitle')),
|
|
1541
|
+
hasCalls
|
|
1542
|
+
? h('div', { className: 'dsh-llm-hub-usage__facts' }, facts)
|
|
1543
|
+
: h('span', { className: 'dsh-llm-hub-subtext' }, t('usageNoData'))),
|
|
1544
|
+
hasCalls && topModels.length > 0
|
|
1545
|
+
? h('div', { className: 'dsh-llm-hub-usage__top' },
|
|
1546
|
+
h('div', { className: 'dsh-llm-hub-usage__title' }, t('usageTopModels')),
|
|
1547
|
+
topModels.map((row, index) => h('div', { key: `${row.provider}/${row.model}/${index}`, className: 'dsh-llm-hub-usage__top-row' },
|
|
1548
|
+
h('span', null, `${row.provider}/${row.model}`),
|
|
1549
|
+
h('small', null, `${row.calls} ${t('usageCalls')}`))))
|
|
1550
|
+
: null,
|
|
1551
|
+
hasCalls
|
|
1552
|
+
? h('div', { className: 'dsh-llm-hub-usage__actions' },
|
|
1553
|
+
h('button', {
|
|
1554
|
+
type: 'button',
|
|
1555
|
+
className: 'dsh-llm-hub-foot__link',
|
|
1556
|
+
disabled: busy,
|
|
1557
|
+
onClick: exportCsv
|
|
1558
|
+
}, feedback === 'ok' ? t('usageExported') : feedback === 'fail' ? t('usageExportFail') : t('usageExport')),
|
|
1559
|
+
h('button', {
|
|
1560
|
+
type: 'button',
|
|
1561
|
+
className: 'dsh-llm-hub-foot__link',
|
|
1562
|
+
disabled: busy,
|
|
1563
|
+
onClick: clearRecords
|
|
1564
|
+
}, t('usageClear')))
|
|
1565
|
+
: null)
|
|
1566
|
+
}
|
|
1567
|
+
|
|
705
1568
|
function HubFooter(props) {
|
|
706
1569
|
const t = typeof props.t === 'function' ? props.t : fallbackT
|
|
707
1570
|
const availability = props.availability
|
|
@@ -925,6 +1788,48 @@ window.__ModuleLoader__.load({
|
|
|
925
1788
|
// 第一版漏了 id,注册被静默丢弃:接口通、组件在、页面上什么都没有。
|
|
926
1789
|
// harness 一览排在 footer 那行之上(order 更小):它讲的是「能派给谁」,
|
|
927
1790
|
// 属于能力信息;footer 那行是插件自身的版本与链接,属于元信息。
|
|
1791
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1792
|
+
name: 'settings.models.footer',
|
|
1793
|
+
id: 'dsh-llm-hub-hero',
|
|
1794
|
+
order: 60,
|
|
1795
|
+
locale: NS,
|
|
1796
|
+
inject: () => ({ t, availability })
|
|
1797
|
+
}, HeroOverviewCard))
|
|
1798
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1799
|
+
name: 'settings.models.footer',
|
|
1800
|
+
id: 'dsh-llm-hub-presets',
|
|
1801
|
+
order: 65,
|
|
1802
|
+
locale: NS,
|
|
1803
|
+
inject: () => ({ t })
|
|
1804
|
+
}, QuickPresetsCard))
|
|
1805
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1806
|
+
name: 'settings.models.footer',
|
|
1807
|
+
id: 'dsh-llm-hub-health',
|
|
1808
|
+
order: 70,
|
|
1809
|
+
locale: NS,
|
|
1810
|
+
inject: () => ({ t, availability })
|
|
1811
|
+
}, HealthCard))
|
|
1812
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1813
|
+
name: 'settings.models.footer',
|
|
1814
|
+
id: 'dsh-llm-hub-keypool',
|
|
1815
|
+
order: 73,
|
|
1816
|
+
locale: NS,
|
|
1817
|
+
inject: () => ({ t })
|
|
1818
|
+
}, KeyPoolCard))
|
|
1819
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1820
|
+
name: 'settings.models.footer',
|
|
1821
|
+
id: 'dsh-llm-hub-routing',
|
|
1822
|
+
order: 75,
|
|
1823
|
+
locale: NS,
|
|
1824
|
+
inject: () => ({ t })
|
|
1825
|
+
}, RoutingCard))
|
|
1826
|
+
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
1827
|
+
name: 'settings.models.footer',
|
|
1828
|
+
id: 'dsh-llm-hub-usage',
|
|
1829
|
+
order: 80,
|
|
1830
|
+
locale: NS,
|
|
1831
|
+
inject: () => ({ t })
|
|
1832
|
+
}, UsageCard))
|
|
928
1833
|
ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
|
|
929
1834
|
name: 'settings.models.footer',
|
|
930
1835
|
id: 'dsh-llm-hub-harness',
|