@dsh-plugins/dsh-llm-hub 1.3.3 → 1.5.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
@@ -73,10 +73,13 @@ window.__ModuleLoader__.load({
73
73
  const HEALTH_URL = '/api/dsh-llm-hub/health'
74
74
  /** 智能路由建议:当前激活 + 推荐 fallback。 */
75
75
  const ROUTING_URL = '/api/dsh-llm-hub/routing/resolve'
76
+ /** 路由组状态:模式、激活组、每组候选与可用性、最近实际路由。 */
77
+ const ROUTING_STATE_URL = '/api/dsh-llm-hub/routing/state'
78
+ /** 路由组切换:POST { mode?, activeGroup? },持久化进 settings。 */
79
+ const ROUTING_SELECT_URL = '/api/dsh-llm-hub/routing/select'
76
80
  /** 模型别名:把长 id 显示成短名(人话)。 */
77
81
  const ALIASES_URL = '/api/dsh-llm-hub/aliases'
78
- /** 多账号 key 轮换:debug 路由,看每个 provider 当前轮到哪个 key。 */
79
- const KEYPOOL_URL = '/api/dsh-llm-hub/keypool/status'
82
+ const BILLING_MODE_URL = '/api/dsh-llm-hub/billing-mode'
80
83
 
81
84
  const LOCALES = {
82
85
  zh: {
@@ -85,6 +88,10 @@ window.__ModuleLoader__.load({
85
88
  refresh: '刷新余额',
86
89
  retry: '重试',
87
90
  noKey: '未配置 API Key',
91
+ billingUsage: '按量付费',
92
+ billingSub: '套餐',
93
+ billingSwitchToSub: '切换为套餐模式',
94
+ billingSwitchToUsage: '切换为按量付费模式',
88
95
  noKeyHint: '在下方填入 DeepSeek API Key 后即可查询余额。',
89
96
  granted: '赠送',
90
97
  toppedUp: '充值',
@@ -157,11 +164,15 @@ window.__ModuleLoader__.load({
157
164
  routingPrimary: '主力',
158
165
  routingNoRecommendation: '全部不可用',
159
166
  routingLoading: '路由解析中…',
167
+ routeChipSmart: '智能',
168
+ routeChipManual: '手动',
169
+ routeChipModeSmart: '智能路由',
170
+ routeChipModeManual: '手动模型',
171
+ routeChipWouldRoute: '将路由到',
172
+ routeChipLastRoute: '最近实际',
173
+ routeChipReasonNone: '候选全部不可用',
174
+ routeChipFailOpen: '路由失败时按原配置发出',
160
175
  aliasesMissing: '尚未配置别名',
161
- keypoolTitle: '多账号 key 轮换',
162
- keypoolRound: 'round-robin',
163
- keypoolActiveTip: '当前优先使用',
164
- keypoolNextTip: '下次轮换目标',
165
176
  heroTitle: 'LLM 矩阵概览',
166
177
  heroProviders: '活跃提供方',
167
178
  heroModels: '可用模型',
@@ -191,6 +202,7 @@ window.__ModuleLoader__.load({
191
202
  suiteTitle: 'Webkubor DSH 扩展家族',
192
203
  suiteDesc: '一键构建完整智能体开发环境',
193
204
  suiteActive: '已激活',
205
+ suiteUnknown: '判定不可用',
194
206
  suiteCopyInstall: '复制安装',
195
207
  suiteCopied: '已复制命令'
196
208
  },
@@ -201,6 +213,10 @@ window.__ModuleLoader__.load({
201
213
  retry: 'Retry',
202
214
  noKey: 'No API key',
203
215
  noKeyHint: 'Add a DeepSeek API key below to see the balance.',
216
+ billingUsage: 'Pay-As-You-Go',
217
+ billingSub: 'Plan',
218
+ billingSwitchToSub: 'Switch to Plan mode',
219
+ billingSwitchToUsage: 'Switch to Pay-As-You-Go mode',
204
220
  granted: 'Granted',
205
221
  toppedUp: 'Topped up',
206
222
  unavailable: 'Account unavailable',
@@ -272,11 +288,15 @@ window.__ModuleLoader__.load({
272
288
  routingPrimary: 'primary',
273
289
  routingNoRecommendation: 'none available',
274
290
  routingLoading: 'resolving routing…',
291
+ routeChipSmart: 'Smart',
292
+ routeChipManual: 'Manual',
293
+ routeChipModeSmart: 'Smart routing',
294
+ routeChipModeManual: 'Manual model',
295
+ routeChipWouldRoute: 'Routes to',
296
+ routeChipLastRoute: 'Last used',
297
+ routeChipReasonNone: 'All candidates unavailable',
298
+ routeChipFailOpen: 'Falls back to your selection when routing fails',
275
299
  aliasesMissing: 'no aliases configured',
276
- keypoolTitle: 'Key rotation',
277
- keypoolRound: 'round-robin',
278
- keypoolActiveTip: 'Currently active',
279
- keypoolNextTip: 'Next target',
280
300
  heroTitle: 'LLM Matrix Overview',
281
301
  heroProviders: 'Active providers',
282
302
  heroModels: 'Models',
@@ -306,6 +326,7 @@ window.__ModuleLoader__.load({
306
326
  suiteTitle: 'Webkubor DSH Plugin Suite',
307
327
  suiteDesc: 'One-click full agent development environment',
308
328
  suiteActive: 'Active',
329
+ suiteUnknown: 'Undetectable',
309
330
  suiteCopyInstall: 'Copy install',
310
331
  suiteCopied: 'Command copied'
311
332
  }
@@ -388,6 +409,14 @@ window.__ModuleLoader__.load({
388
409
  '.dsh-llm-hub-subtext{font-size:11px;color:var(--dsw-alias-label-secondary);opacity:.8}',
389
410
  '.dsh-llm-hub-warn{color:var(--dsw-alias-state-warn-primary);font-size:11px}',
390
411
  '.dsh-llm-hub-error{color:var(--dsw-alias-state-error-primary);font-size:11px}',
412
+ '.dsh-llm-hub-billing-capsule{display:inline-flex;align-items:center;background:var(--dsw-alias-fill-quaternary, rgba(125,125,125,0.08));border-radius:12px;padding:1.5px;gap:2px;user-select:none;margin-right:2px}',
413
+ '.dsh-llm-hub-billing-pill{font-size:10px;line-height:14px;padding:1px 6px;border-radius:10px;cursor:pointer;color:var(--dsw-alias-label-tertiary);transition:all .15s ease}',
414
+ '.dsh-llm-hub-billing-pill:hover{color:var(--dsw-alias-label-primary)}',
415
+ '.dsh-llm-hub-billing-pill.is-active{font-weight:600;background:var(--dsw-alias-fill-primary, #fff);box-shadow:0 1px 2px rgba(0,0,0,0.08)}',
416
+ '.dsh-llm-hub-billing-pill.is-active.is-usage{color:#2563eb}',
417
+ '.dsh-llm-hub-billing-pill.is-active.is-sub{color:#059669}',
418
+ '.dsh-llm-hub-key-chip{display:inline-flex;align-items:center;gap:3px;font-size:10.5px;padding:1px 5px;border-radius:4px;background:var(--dsw-alias-fill-quaternary, rgba(125,125,125,0.06));border:1px solid var(--dsw-alias-line-secondary, rgba(125,125,125,0.15));margin-right:4px}',
419
+ '.dsh-llm-hub-key-chip.is-down{opacity:.6;border-style:dashed}',
391
420
  // 去掉粗糙灰底框,改用轻量排版标签
392
421
  '.dsh-llm-hub-tag{display:inline-flex;align-items:center;gap:3px;padding:0 2px;',
393
422
  'border:none;background:transparent;',
@@ -510,22 +539,34 @@ window.__ModuleLoader__.load({
510
539
  '.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
511
540
  '.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);padding-top:2px}',
512
541
  '.dsh-llm-hub-routing__reason{color:var(--dsw-alias-state-error-primary);padding-top:2px}',
513
- // 多账号 key 轮换
514
- '.dsh-llm-hub-keypool{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
515
- 'padding:8px 10px;border-radius:6px;',
516
- 'border:.5px solid var(--dsw-alias-border-l1);background:transparent;',
517
- 'font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
518
- '.dsh-llm-hub-keypool__head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}',
519
- '.dsh-llm-hub-keypool__title{color:var(--dsw-alias-label-primary);font-weight:600;display:inline-flex;align-items:center;gap:5px}',
520
- '.dsh-llm-hub-keypool__rows{display:flex;flex-direction:column;gap:3px}',
521
- '.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)}',
522
- '.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)}',
523
- '.dsh-llm-hub-keypool__name{color:var(--dsw-alias-label-primary);font-weight:600;min-width:120px;font-family:ui-monospace,SFMono-Regular,monospace}',
524
- '.dsh-llm-hub-keypool__meta{font-size:10.5px;opacity:.7}',
525
- '.dsh-llm-hub-keypool__chosen{display:flex;align-items:baseline;gap:4px;',
526
- 'color:var(--dsw-alias-label-primary)}',
527
- '.dsh-llm-hub-keypool__chosen small{color:var(--dsw-alias-label-secondary);opacity:.75;font-size:10.5px}',
528
- '.dsh-llm-hub-keypool__pending{color:var(--dsw-alias-label-secondary);opacity:.6}',
542
+ // composer 快切 chip(conversation.input.left)
543
+ '.dsh-llm-hub-route{position:relative;display:inline-flex}',
544
+ '.dsh-llm-hub-route__btn{box-sizing:border-box;display:inline-flex;align-items:center;gap:4px;height:22px;',
545
+ 'padding:0 8px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:transparent;cursor:pointer;',
546
+ 'font:inherit;font-size:11px;color:var(--dsw-alias-label-secondary);transition:all .15s ease;white-space:nowrap}',
547
+ '.dsh-llm-hub-route__btn:hover{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);',
548
+ 'background:var(--dsw-alias-bg-layer-2)}',
549
+ '.dsh-llm-hub-route__btn.is-smart{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary)}',
550
+ '.dsh-llm-hub-route__btn.is-busy{opacity:.55;cursor:progress}',
551
+ '.dsh-llm-hub-route__mark{font-size:11px;line-height:1}',
552
+ '.dsh-llm-hub-route__menu{position:absolute;bottom:calc(100% + 6px);left:0;z-index:60;min-width:240px;',
553
+ 'padding:8px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l2);',
554
+ 'background:var(--dsw-alias-bg-layer-1,rgba(15,15,15,.96));backdrop-filter:blur(14px);',
555
+ 'box-shadow:0 8px 24px rgba(0,0,0,.28);display:flex;flex-direction:column;gap:8px;font-size:11px}',
556
+ '.dsh-llm-hub-route__modes{display:flex;gap:4px}',
557
+ '.dsh-llm-hub-route__mode{flex:1;padding:4px 6px;border-radius:5px;border:.5px solid var(--dsw-alias-border-l1);',
558
+ 'background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:10.5px;transition:all .15s ease}',
559
+ '.dsh-llm-hub-route__mode.is-on{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);',
560
+ 'background:var(--dsw-alias-bg-layer-2)}',
561
+ '.dsh-llm-hub-route__group{display:flex;flex-direction:column;gap:3px;padding:6px 7px;border-radius:6px;',
562
+ 'border:.5px solid var(--dsw-alias-border-l1);background:transparent;cursor:pointer;text-align:left;',
563
+ 'font:inherit;color:var(--dsw-alias-label-primary);transition:all .15s ease}',
564
+ '.dsh-llm-hub-route__group:hover{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2)}',
565
+ '.dsh-llm-hub-route__group.is-active{border-color:var(--dsw-alias-state-warn-primary)}',
566
+ '.dsh-llm-hub-route__group-top{display:flex;align-items:center;justify-content:space-between;gap:6px}',
567
+ '.dsh-llm-hub-route__group-name{font-weight:600;font-size:11px}',
568
+ '.dsh-llm-hub-route__state{font-size:10px;color:var(--dsw-alias-label-secondary);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
569
+ '.dsh-llm-hub-route__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
529
570
  // Hero 全景统计大横条
530
571
  '.dsh-llm-hub-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin:12px 0 6px 0}',
531
572
  '.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)}',
@@ -574,6 +615,7 @@ window.__ModuleLoader__.load({
574
615
  '.dsh-suite-card-bottom{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-top:auto;font-size:10.5px}',
575
616
  '.dsh-suite-badge-active{display:inline-flex;align-items:center;gap:4px;color:var(--dsw-alias-state-success-primary,#10b981);font-weight:600;font-size:10.5px}',
576
617
  '.dsh-suite-badge-active-dot{width:5px;height:5px;border-radius:50%;background:#10b981;box-shadow:0 0 6px rgba(16,185,129,0.6)}',
618
+ '.dsh-suite-badge-unknown{display:inline-flex;align-items:center;gap:4px;color:var(--dsw-alias-label-tertiary);font-weight:550;font-size:10.5px}',
577
619
  '.dsh-suite-btn-action{cursor:pointer;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:550;background:var(--dsw-alias-interactive-bg-subtle,rgba(255,255,255,0.06));border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:3px}',
578
620
  '.dsh-suite-btn-action:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,0.12));border-color:var(--dsw-alias-border-l1)}',
579
621
  '.dsh-suite-link{color:var(--dsw-alias-label-tertiary);text-decoration:none;font-size:10px;display:inline-flex;align-items:center}',
@@ -722,13 +764,15 @@ window.__ModuleLoader__.load({
722
764
  if (body == null || body.ok !== true) {
723
765
  throw new Error(typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`)
724
766
  }
725
- setState({
767
+ setState((prev) => ({
726
768
  status: 'ok',
727
769
  isAvailable: body.isAvailable === true,
728
770
  balances: Array.isArray(body.balances) ? body.balances : [],
729
771
  apiKey: typeof body.apiKey === 'string' ? body.apiKey : undefined,
730
- keyMasked: typeof body.keyMasked === 'string' ? body.keyMasked : undefined
731
- })
772
+ keyMasked: typeof body.keyMasked === 'string' ? body.keyMasked : undefined,
773
+ keys: Array.isArray(body.keys) ? body.keys : undefined,
774
+ billingMode: typeof body.billingMode === 'string' ? body.billingMode : (prev.billingMode || 'usage')
775
+ }))
732
776
  })
733
777
  .catch((error) => {
734
778
  setState({ status: 'error', error: error instanceof Error ? error.message : String(error) })
@@ -809,9 +853,31 @@ window.__ModuleLoader__.load({
809
853
  if (primary.granted !== '0' && primary.granted !== '0.00') breakdown.push(`${t('granted')} ${symbol}${primary.granted}`)
810
854
  if (primary.toppedUp !== '0' && primary.toppedUp !== '0.00') breakdown.push(`${t('toppedUp')} ${symbol}${primary.toppedUp}`)
811
855
 
856
+ const mode = state.billingMode || 'usage'
857
+ const switchMode = (newMode) => {
858
+ setState((prev) => ({ ...prev, billingMode: newMode }))
859
+ fetch(BILLING_MODE_URL, {
860
+ method: 'POST',
861
+ headers: { 'content-type': 'application/json' },
862
+ body: JSON.stringify({ provider: 'deepseek', mode: newMode })
863
+ }).catch(() => {})
864
+ }
865
+
812
866
  return h('div', { className: 'dsh-llm-hub-card' },
813
867
  h('div', { className: 'dsh-llm-hub-card__meta' },
814
868
  h('div', { className: 'dsh-llm-hub-card__tags' },
869
+ h('span', { className: 'dsh-llm-hub-billing-capsule' },
870
+ h('span', {
871
+ className: 'dsh-llm-hub-billing-pill ' + (mode === 'usage' ? 'is-active is-usage' : ''),
872
+ title: mode === 'usage' ? t('billingSwitchToSub') : t('billingSwitchToUsage'),
873
+ onClick: () => switchMode('usage')
874
+ }, t('billingUsage')),
875
+ h('span', {
876
+ className: 'dsh-llm-hub-billing-pill ' + (mode === 'subscription' ? 'is-active is-sub' : ''),
877
+ title: mode === 'subscription' ? t('billingSwitchToUsage') : t('billingSwitchToSub'),
878
+ onClick: () => switchMode('subscription')
879
+ }, t('billingSub'))
880
+ ),
815
881
  h('span', { className: 'dsh-llm-hub-balance-label' }, t('balance')),
816
882
  h('span', { className: 'dsh-llm-hub-amount' + (warning?.level === 'low' ? ' is-low' : '') }, `${symbol}${primary.total}`),
817
883
  h('span', { className: 'dsh-llm-hub-meta-sep' }, '·'),
@@ -841,10 +907,64 @@ window.__ModuleLoader__.load({
841
907
  onClick: load
842
908
  }, h(RefreshIcon, { spinning: isRefreshing }))
843
909
  )
844
- )
910
+ ),
911
+ state.keys && state.keys.length > 1
912
+ ? h('div', { className: 'dsh-llm-hub-card__actions', style: { paddingTop: '3px' } },
913
+ h('div', { className: 'dsh-llm-hub-card__tags' },
914
+ state.keys.map((k, idx) => h('span', {
915
+ key: k.ref,
916
+ className: 'dsh-llm-hub-key-chip' + (k.isAvailable ? '' : ' is-down'),
917
+ title: `${k.ref}: ${k.isAvailable ? '正常' : (k.error || '不可用')}`
918
+ }, `Key ${idx + 1} (${k.keyMasked}): ${k.isAvailable ? (k.currency === 'USD' ? '$' : '¥') + k.total : '⚠️ 余额不足'}`))
919
+ )
920
+ )
921
+ : null
845
922
  )
846
923
  }
847
924
 
925
+ function detectBillingMode(profile, status, balance) {
926
+ if (profile?.billingMode === 'usage' || profile?.billingMode === 'subscription') {
927
+ return profile.billingMode
928
+ }
929
+ if (status?.billingMode === 'usage' || status?.billingMode === 'subscription') {
930
+ return status.billingMode
931
+ }
932
+ if (balance && typeof balance === 'object') {
933
+ if (balance.kind === 'plan' || balance.kind === 'quota') return 'subscription'
934
+ if (Array.isArray(balance.items)) {
935
+ for (const item of balance.items) {
936
+ if (item?.resetsAt) return 'subscription'
937
+ if (typeof item?.weeklyPercent === 'number') return 'subscription'
938
+ if (item?.label && /(5h|hour|hourly|week|month|day|分|时|天|周|月)/i.test(item.label)) return 'subscription'
939
+ if (typeof item?.percent === 'number' && balance.meaning) return 'subscription'
940
+ }
941
+ }
942
+ }
943
+ const providerName = String(profile?.provider || profile?.id || status?.provider || '').toLowerCase()
944
+ const baseURL = String(status?.baseURL || profile?.baseURL || '').toLowerCase()
945
+
946
+ // 特殊套餐/订阅服务商智能识别(如 OpenCode Go、各类 Coding Plan 等)
947
+ if (providerName.includes('opencode') || baseURL.includes('opencode.ai') || providerName.includes('coding-plan')) {
948
+ if (balance?.kind !== 'cash' && balance?.kind !== 'deepseek') {
949
+ return 'subscription'
950
+ }
951
+ }
952
+
953
+ // 中转站(无定时限额的聚合/反代如 modelgo, relay, proxy, oneapi 等)基本都是按量付费
954
+ if (providerName.includes('modelgo') || providerName.includes('relay') || providerName.includes('proxy') || providerName.includes('oneapi') || providerName.includes('newapi') || providerName.includes('gateway')) {
955
+ return 'usage'
956
+ }
957
+
958
+ // 厂商原厂域名或现金余额默认按量付费
959
+ if (baseURL.includes('deepseek.com') || baseURL.includes('bigmodel.cn') || baseURL.includes('openai.com') || baseURL.includes('anthropic.com') || baseURL.includes('volces.com') || baseURL.includes('moonshot.cn') || baseURL.includes('minimax.chat') || baseURL.includes('baichuan-ai.com')) {
960
+ return 'usage'
961
+ }
962
+ if (balance?.kind === 'cash' || balance?.kind === 'deepseek') {
963
+ return 'usage'
964
+ }
965
+ return 'usage'
966
+ }
967
+
848
968
  /**
849
969
  * 把 host 半的余额信封折成一行短文本;未加载 / 不支持时返回 null。
850
970
  * reason 原样透传,因为 host 半保证它一定是人话("未填写服务地址,查不了余额"、
@@ -946,6 +1066,7 @@ window.__ModuleLoader__.load({
946
1066
  const [copied, setCopied] = React.useState(false)
947
1067
  const [balance, setBalance] = React.useState(null)
948
1068
  const [warning, setWarning] = React.useState(null)
1069
+ const [billingModeOverride, setBillingModeOverride] = React.useState(null)
949
1070
  // 被摘掉的分组,在它自己的卡片上直接标出来 —— 动作条里一枚小片,不占额外行。
950
1071
  const availability = props.availability
951
1072
  const availabilityState = availability === undefined
@@ -1068,8 +1189,30 @@ window.__ModuleLoader__.load({
1068
1189
  h('span', { className: 'dsh-llm-hub-error' }, typeof status.error === 'string' ? status.error : t('statusFail'))))
1069
1190
  }
1070
1191
 
1192
+ const currentBillingMode = billingModeOverride || status.billingMode || detectBillingMode(props.provider, status, balance)
1193
+ const switchBillingMode = (newMode) => {
1194
+ setBillingModeOverride(newMode)
1195
+ fetch(BILLING_MODE_URL, {
1196
+ method: 'POST',
1197
+ headers: { 'content-type': 'application/json' },
1198
+ body: JSON.stringify({ provider, mode: newMode })
1199
+ }).catch(() => {})
1200
+ }
1201
+
1071
1202
  // 元信息标签组(不重复展示外层已有的大标题,只呈现核心事实)
1072
1203
  const tags = []
1204
+ tags.push(h('span', { key: 'billing-capsule', className: 'dsh-llm-hub-billing-capsule' },
1205
+ h('span', {
1206
+ className: 'dsh-llm-hub-billing-pill ' + (currentBillingMode === 'usage' ? 'is-active is-usage' : ''),
1207
+ title: currentBillingMode === 'usage' ? t('billingSwitchToSub') : t('billingSwitchToUsage'),
1208
+ onClick: () => switchBillingMode('usage')
1209
+ }, t('billingUsage')),
1210
+ h('span', {
1211
+ className: 'dsh-llm-hub-billing-pill ' + (currentBillingMode === 'subscription' ? 'is-active is-sub' : ''),
1212
+ title: currentBillingMode === 'subscription' ? t('billingSwitchToUsage') : t('billingSwitchToSub'),
1213
+ onClick: () => switchBillingMode('subscription')
1214
+ }, t('billingSub'))
1215
+ ))
1073
1216
  tags.push(h('span', { key: 'count', className: 'dsh-llm-hub-tag' }, `${t('configuredModels')} ${status.modelCount} ${t('modelsUnit')}`))
1074
1217
  if (typeof status.api === 'string' && status.api !== '') {
1075
1218
  const shortApi = status.api.replace('-messages', '').replace('-completions', '')
@@ -1420,49 +1563,103 @@ window.__ModuleLoader__.load({
1420
1563
  }
1421
1564
 
1422
1565
  /**
1423
- * 多账号 key 轮换卡:展示当前轮换池状态与活跃 Key
1566
+ * composer 路由快切 chip(1.5.0 智能路由组)。
1567
+ *
1568
+ * 挂在 `conversation.input.left`(composer 工具行左端,追加型 list slot):
1569
+ * · 未配置任何路由组时整个 chip 不渲染 —— 存在即有意义;
1570
+ * · 关起时一行小字:⚡智能 · 公司(smart 模式)/ 🧭 · 公司(manual);
1571
+ * · 点开是浮层:模式二选一(智能路由 = 接管,实际路由按额度自动切;
1572
+ * 手动 = 尊重你在模型下拉里选的),下面是组列表(每组显示「将路由到
1573
+ * 哪个」与可用状态),点组即切、立即持久化。
1574
+ *
1575
+ * 数据流:GET /routing/state(挂载 + 每 30s 轮询 + 操作后重拉),
1576
+ * 写操作 POST /routing/select。chip 显示「最近实际路由」—— smart 模式
1577
+ * 下模型下拉显示什么已经不重要,这里才是事实。
1424
1578
  */
1425
- function KeyPoolCard(props) {
1579
+ function SmartRouteChip(props) {
1426
1580
  const t = typeof props.t === 'function' ? props.t : fallbackT
1427
- const [report, setReport] = React.useState(null)
1428
- React.useEffect(() => {
1429
- let alive = true
1430
- fetch(KEYPOOL_URL, { headers: { accept: 'application/json' } })
1581
+ const [state, setState] = React.useState(null)
1582
+ const [open, setOpen] = React.useState(false)
1583
+ const [busy, setBusy] = React.useState(false)
1584
+ const query = React.useCallback(() => {
1585
+ fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
1431
1586
  .then((res) => res.json())
1432
- .then((body) => { if (alive) setReport(body !== null && typeof body === 'object' ? body : null) })
1433
- .catch(() => { if (alive) setReport(null) })
1434
- return () => { alive = false }
1587
+ .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) setState(body) })
1588
+ .catch(() => {})
1435
1589
  }, [])
1436
- if (report === null) return null
1437
- const providers = Array.isArray(report.providers) ? report.providers : []
1438
- if (providers.length === 0) return null
1590
+ React.useEffect(() => {
1591
+ query()
1592
+ const interval = setInterval(query, 30000)
1593
+ return () => clearInterval(interval)
1594
+ }, [query])
1595
+ // 一个组都没有:chip 无事可做,不渲染(配置在 settings.yaml 里加)。
1596
+ if (state === null || !Array.isArray(state.groups) || state.groups.length === 0) return null
1439
1597
  ensureStyle()
1440
- const formatTime = (ms) => {
1441
- if (typeof ms !== 'number' || ms <= 0) return '—'
1442
- const d = new Date(ms)
1443
- return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
1598
+ const activeGroup = state.groups.find((group) => group.id === state.activeGroup) ?? state.groups[0]
1599
+ const isSmart = state.mode === 'smart'
1600
+ const chipClass = [
1601
+ 'dsh-llm-hub-route__btn',
1602
+ isSmart ? 'is-smart' : '',
1603
+ busy ? 'is-busy' : ''
1604
+ ].filter(Boolean).join(' ')
1605
+ const label = (activeGroup && (activeGroup.label ?? activeGroup.id)) ?? ''
1606
+ const chipTitle = isSmart
1607
+ ? `${t('routeChipModeSmart')} · ${label} —— ${state.lastDecision?.to ? `${t('routeChipLastRoute')}: ${state.lastDecision.to.provider}/${state.lastDecision.to.model}` : t('routeChipFailOpen')}`
1608
+ : `${t('routeChipModeManual')} · ${label}`
1609
+ const select = async (patch) => {
1610
+ setBusy(true)
1611
+ fetch(ROUTING_SELECT_URL, {
1612
+ method: 'POST',
1613
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1614
+ body: JSON.stringify(patch)
1615
+ })
1616
+ .then((res) => res.json())
1617
+ .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) setState(body) })
1618
+ .catch(() => {})
1619
+ .finally(() => { setBusy(false); query() })
1620
+ }
1621
+ const stateText = (group) => {
1622
+ if (group.wouldRoute) {
1623
+ const next = `${group.wouldRoute.provider}/${group.wouldRoute.model}`
1624
+ return group.reason !== null && group.reason !== undefined ? group.reason : `${t('routeChipWouldRoute')}: ${next}`
1625
+ }
1626
+ return group.reason ?? t('routeChipReasonNone')
1444
1627
  }
1445
- const rows = providers.map((entry) => {
1446
- const chosen = entry.lastChosen
1447
- const isActive = chosen !== null
1448
- return h('div', {
1449
- key: entry.provider,
1450
- className: 'dsh-llm-hub-keypool__row' + (isActive ? ' is-active' : '')
1628
+ const menu = open ? h('div', { className: 'dsh-llm-hub-route__menu' },
1629
+ h('div', { className: 'dsh-llm-hub-route__modes' },
1630
+ h('button', {
1631
+ type: 'button',
1632
+ className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
1633
+ onClick: () => { if (!isSmart) select({ mode: 'smart' }) }
1634
+ }, `⚡ ${t('routeChipModeSmart')}`),
1635
+ h('button', {
1636
+ type: 'button',
1637
+ className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
1638
+ onClick: () => { if (isSmart) select({ mode: 'manual' }) }
1639
+ }, t('routeChipModeManual'))),
1640
+ ...state.groups.map((group) => h('button', {
1641
+ key: group.id,
1642
+ type: 'button',
1643
+ className: `dsh-llm-hub-route__group${group.id === state.activeGroup ? ' is-active' : ''}`,
1644
+ onClick: () => {
1645
+ const patch = { activeGroup: group.id }
1646
+ if (!isSmart) patch.mode = 'smart'
1647
+ select(patch)
1648
+ }
1451
1649
  },
1452
- h('span', { className: 'dsh-llm-hub-health-dot ' + (isActive ? 'is-ok' : '') }),
1453
- h('span', { className: 'dsh-llm-hub-keypool__name' }, entry.provider),
1454
- h('span', { className: 'dsh-llm-hub-keypool__meta' }, `${entry.total} keys`),
1455
- chosen === null
1456
- ? h('span', { className: 'dsh-llm-hub-keypool__pending' }, '—')
1457
- : h('span', { className: 'dsh-llm-hub-keypool__chosen' },
1458
- h('span', null, chosen.name),
1459
- h('small', null, ` #${chosen.index + 1} / ${entry.total} · ${formatTime(chosen.at)}`)))
1460
- })
1461
- return h('div', { className: 'dsh-llm-hub-keypool' },
1462
- h('div', { className: 'dsh-llm-hub-keypool__head' },
1463
- h('span', { className: 'dsh-llm-hub-keypool__title' }, '🔄 ' + t('keypoolTitle')),
1464
- h('span', { className: 'dsh-llm-hub-subtext' }, t('keypoolRound'))),
1465
- h('div', { className: 'dsh-llm-hub-keypool__rows' }, rows))
1650
+ h('span', { className: 'dsh-llm-hub-route__group-top' },
1651
+ h('span', { className: 'dsh-llm-hub-route__group-name' }, group.label ?? group.id),
1652
+ h('span', { className: 'dsh-llm-hub-route__state' }, stateText(group)))))) : null
1653
+ return h('div', { className: 'dsh-llm-hub-route' },
1654
+ h('button', {
1655
+ type: 'button',
1656
+ className: chipClass,
1657
+ title: chipTitle,
1658
+ onClick: () => { setOpen(!open); query() }
1659
+ },
1660
+ h('span', { className: 'dsh-llm-hub-route__mark' }, isSmart ? '⚡' : '🧭'),
1661
+ h('span', null, `${isSmart ? `${t('routeChipSmart')} · ` : ''}${label}`)),
1662
+ menu)
1466
1663
  }
1467
1664
 
1468
1665
  /**
@@ -1668,11 +1865,13 @@ window.__ModuleLoader__.load({
1668
1865
  onClick: () => {
1669
1866
  // 别写死我自己的 profile 名,也别只给一条 npm i —— 装进 node_modules
1670
1867
  // 不等于接进 boot graph,少了 bundles 那行插件根本不会加载。
1868
+ // 第 2 步对 `dsh plugin add` 是自动的(装完会 reconcile bundles),
1869
+ // 只有手写 package.json 或从别处搬时才需要手动补,所以标成「如果没自动加」。
1671
1870
  const text = [
1672
1871
  `${meta.name} —— DSH 模型页增强:网关可达性探测、余额/配额、模型目录勾选写回配置。`,
1673
1872
  '装到你的 DSH(profile 名按自己的改,默认 web):',
1674
1873
  ` 1) dsh plugin --profile web add ${meta.name}`,
1675
- ` 2) 把 "${meta.name}" 加进 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles`,
1874
+ ` 2) 确认 "${meta.name}" 在 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles 里(dsh plugin add 会自动加)`,
1676
1875
  ' 3) 重启 DSH',
1677
1876
  typeof meta.homepage === 'string' ? `仓库:${meta.homepage}` : ''
1678
1877
  ].filter((line) => line !== '').join('\n')
@@ -1707,6 +1906,38 @@ window.__ModuleLoader__.load({
1707
1906
  links)
1708
1907
  }
1709
1908
 
1909
+ /**
1910
+ * 已激活的客户端插件 id 集合 —— 读宿主注入的 `window.__DSH_BOOT__.entries`。
1911
+ *
1912
+ * 为什么是这个源:`__DSH_BOOT__` 由 `dsh-client-modules` 在渲染 index 时注入,
1913
+ * 就是浏览器真正要拉取并执行的那份组合图。它在,插件就一定活着;它不在,
1914
+ * 无论 npm 装没装都不会加载。比「读 profile 的 package.json」更硬:后者只说明
1915
+ * 装过,**接没接进 boot graph 是另一回事**(少了 `dsh.profile.bundles` 那行,
1916
+ * 依赖装了也不加载),而那正是这套 Dock 想告诉人的事。
1917
+ *
1918
+ * 读不到就返回 null(不是空集合):分不清「一个都没装」和「读不到」时,
1919
+ * 宁可不做判定 —— 全部显示成未安装,是把探测失败谎报成事实。
1920
+ * @returns 已激活的 id 集合,或 null。
1921
+ */
1922
+ function readActivatedPluginIds() {
1923
+ try {
1924
+ const boot = typeof window === 'undefined' ? undefined : window.__DSH_BOOT__
1925
+ if (boot === null || typeof boot !== 'object' || !Array.isArray(boot.entries)) return null
1926
+ return new Set(boot.entries.filter((entry) => entry && typeof entry.id === 'string').map((entry) => entry.id))
1927
+ } catch {
1928
+ return null
1929
+ }
1930
+ }
1931
+
1932
+ /** 一个家族条目是否已激活:按 id 与 aliases 逐个比对(scope 迁移过的包名都在 aliases 里)。 */
1933
+ function isSuiteItemActive(item, activated) {
1934
+ if (activated === null) return null
1935
+ for (const candidate of [item.id, ...(item.aliases ?? [])]) {
1936
+ if (activated.has(candidate)) return true
1937
+ }
1938
+ return false
1939
+ }
1940
+
1710
1941
  /**
1711
1942
  * Webkubor DSH 扩展家族互导 Dock
1712
1943
  */
@@ -1733,8 +1964,7 @@ window.__ModuleLoader__.load({
1733
1964
  icon: '🎛️',
1734
1965
  desc: '多模型网关、余额配额、多Key轮换与极客大屏',
1735
1966
  repo: 'https://github.com/webkubor/dsh-llm-hub',
1736
- pkg: '@dsh-plugins/dsh-llm-hub',
1737
- isCurrent: true
1967
+ pkg: '@dsh-plugins/dsh-llm-hub'
1738
1968
  },
1739
1969
  {
1740
1970
  id: '@dsh-plugins/dsh-user-mirror',
@@ -1756,8 +1986,14 @@ window.__ModuleLoader__.load({
1756
1986
  }
1757
1987
  ]
1758
1988
 
1989
+ // 判定在 render 里现读:`__DSH_BOOT__` 是宿主渲染 index 时注入的全局,之后
1990
+ // 不会再变(组合图变了要整页重载),所以不需要状态也不需要订阅。
1991
+ const activated = readActivatedPluginIds()
1992
+
1759
1993
  const handleCopy = (pkg) => {
1760
- const cmd = `dsh plugin install ${pkg}`
1994
+ // 命令必须带 `--profile`:`dsh plugin` 是 pnpm 的透传层,profile 是
1995
+ // **必填**选项,漏了直接报错退出 —— 复制出去的命令得是能跑的。
1996
+ const cmd = `dsh plugin --profile web add ${pkg}`
1761
1997
  if (navigator.clipboard) {
1762
1998
  navigator.clipboard.writeText(cmd)
1763
1999
  }
@@ -1775,7 +2011,7 @@ window.__ModuleLoader__.load({
1775
2011
  ),
1776
2012
  h('div', { className: 'dsh-suite-cards' },
1777
2013
  ...suite.map((item) => {
1778
- const isInstalled = item.isCurrent
2014
+ const active = isSuiteItemActive(item, activated)
1779
2015
  const isCopied = copiedPkg === item.pkg
1780
2016
 
1781
2017
  return h('div', { key: item.id, className: 'dsh-suite-card' },
@@ -1790,17 +2026,21 @@ window.__ModuleLoader__.load({
1790
2026
  ),
1791
2027
  h('div', { className: 'dsh-suite-card-desc' }, item.desc),
1792
2028
  h('div', { className: 'dsh-suite-card-bottom' },
1793
- isInstalled ? (
2029
+ active === true ? (
1794
2030
  h('span', { className: 'dsh-suite-badge-active' },
1795
2031
  h('span', { className: 'dsh-suite-badge-active-dot' }),
1796
2032
  t('suiteActive')
1797
2033
  )
2034
+ ) : active === null ? (
2035
+ // 读不到 boot graph(老宿主 / 非 web 载体):说「判定不了」,
2036
+ // 不说「没装」—— 后者是把探测失败当结论。
2037
+ h('span', { className: 'dsh-suite-badge-unknown' }, t('suiteUnknown'))
1798
2038
  ) : (
1799
2039
  h('button', {
1800
2040
  type: 'button',
1801
2041
  className: 'dsh-suite-btn-action',
1802
2042
  onClick: () => handleCopy(item.pkg),
1803
- title: `安装命令: dsh plugin install ${item.pkg}`
2043
+ title: `安装命令: dsh plugin --profile web add ${item.pkg}`
1804
2044
  }, isCopied ? `✓ ${t('suiteCopied')}` : `⚡ ${t('suiteCopyInstall')}`)
1805
2045
  ),
1806
2046
  h('a', {
@@ -1993,13 +2233,6 @@ window.__ModuleLoader__.load({
1993
2233
  locale: NS,
1994
2234
  inject: () => ({ t, availability })
1995
2235
  }, HealthCard))
1996
- ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
1997
- name: 'settings.models.footer',
1998
- id: 'dsh-llm-hub-keypool',
1999
- order: 73,
2000
- locale: NS,
2001
- inject: () => ({ t })
2002
- }, KeyPoolCard))
2003
2236
  ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2004
2237
  name: 'settings.models.footer',
2005
2238
  id: 'dsh-llm-hub-routing',
@@ -2035,6 +2268,15 @@ window.__ModuleLoader__.load({
2035
2268
  locale: NS,
2036
2269
  inject: () => ({ t })
2037
2270
  }, WebkuborSuiteDock))
2271
+ // composer 工具行左端的路由快切 chip —— 「切公司 / 切个人 + 智能开关」
2272
+ // 的高频操作留在聊天页,不用进设置页。list slot 必须带 id(同 footer 的教训)。
2273
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
2274
+ name: 'conversation.input.left',
2275
+ id: 'dsh-llm-hub-route-chip',
2276
+ order: 20,
2277
+ locale: NS,
2278
+ inject: () => ({ t })
2279
+ }, SmartRouteChip))
2038
2280
  }
2039
2281
 
2040
2282
  exports.apply = apply