@dsh-plugins/dsh-llm-hub 0.7.3 → 1.3.0

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