@dsh-plugins/dsh-llm-hub 1.8.0 → 1.9.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/lib/client.js CHANGED
@@ -417,7 +417,7 @@ window.__ModuleLoader__.load({
417
417
  * 全局别名表 —— 启动时拉一次,所有组件共用。
418
418
  *
419
419
  * 数据来源 `GET /api/dsh-llm-hub/aliases`:返回 `{ ok, aliases: { "<provider>/<model>": "短名" } }`。
420
- * 启动后注入一次,所有 picker 行、RoutingCard 候选都用它把长 id 翻成短名。
420
+ * 启动后注入一次,所有 picker 行都用它把长 id 翻成短名。
421
421
  * 失败/未配置:aliasesMap = {},aliasFor 永远返 null,UI 退回到只显示 model.id
422
422
  * (与没装本插件时一致)。
423
423
  *
@@ -602,41 +602,27 @@ window.__ModuleLoader__.load({
602
602
  // 健康看板:样式保留以兼容
603
603
  '.dsh-llm-hub-health{display:none}',
604
604
  // 当前路由卡
605
- '.dsh-llm-hub-routing{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
606
605
  'padding:8px 10px;border-radius:6px;',
607
606
  'border:.5px solid var(--dsw-alias-border-l1);background:transparent;',
608
607
  'font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
609
- '.dsh-llm-hub-routing__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
610
- '.dsh-llm-hub-routing__title{color:var(--dsw-alias-label-primary);font-weight:600}',
611
- '.dsh-llm-hub-routing__active{color:var(--dsw-alias-label-primary)}',
612
- '.dsh-llm-hub-routing__rows{display:flex;flex-direction:column;gap:2px}',
613
- '.dsh-llm-hub-routing__row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px}',
614
- '.dsh-llm-hub-routing__role{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:18px;padding:0 6px;white-space:nowrap;',
615
608
  'border-radius:3px;border:.5px solid var(--dsw-alias-border-l2);',
616
609
  'background:transparent;font-size:10.5px;color:var(--dsw-alias-label-secondary);transition:all .15s ease}',
617
610
  // 主力:品牌色徽标,与兜底行的中性色拉开差距
618
- '.dsh-llm-hub-routing__role.is-primary{',
619
611
  'color:var(--dsw-alias-brand-primary,#3b82f6);',
620
612
  'border-color:var(--dsw-alias-brand-primary,#3b82f6);',
621
613
  'background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.06));font-weight:600}',
622
- '.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
623
614
  // capability 标识:思考 / 多模态。两枚 chip 同形,左思考、右图像;
624
615
  // 边色走 state 提示色,跟健康状态 chip 区分开 —— 一个说「能干什么」,
625
616
  // 一个说「能不能用」。
626
- '.dsh-llm-hub-routing__reasoning,.dsh-llm-hub-routing__multimodal{',
627
617
  'display:inline-flex;align-items:center;justify-content:center;height:18px;min-width:20px;padding:0 3px;',
628
618
  'border-radius:3px;font-size:10px;line-height:14px;font-weight:500;',
629
619
  'background:transparent;cursor:help}',
630
620
  // 思考:走 warn 主题色(消耗更多 token、需要警觉)
631
- '.dsh-llm-hub-routing__reasoning{',
632
621
  'border:.5px solid var(--dsw-alias-state-warn-primary);',
633
622
  'color:var(--dsw-alias-state-warn-primary)}',
634
623
  // 多模态:走 success 主题色(输入更丰富 = 好)
635
- '.dsh-llm-hub-routing__multimodal{',
636
624
  'border:.5px solid var(--dsw-alias-state-success-primary);',
637
625
  'color:var(--dsw-alias-state-success-primary)}',
638
- '.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);padding-top:2px}',
639
- '.dsh-llm-hub-routing__reason{font-size:10.5px;line-height:1.5;color:var(--dsw-alias-label-tertiary);padding-top:2px}',
640
626
  // composer 快切 chip(conversation.input.left)
641
627
  '.dsh-llm-hub-route{position:relative;display:inline-flex}',
642
628
  '.dsh-llm-hub-route__btn{box-sizing:border-box;display:inline-flex;align-items:center;gap:4px;height:22px;',
@@ -669,103 +655,44 @@ window.__ModuleLoader__.load({
669
655
  '.dsh-llm-hub-route__order{font-size:10px;color:var(--dsw-alias-label-secondary);',
670
656
  'font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;line-height:1.4}',
671
657
  // 智能路由编辑器(设置页路由卡)
672
- '.dsh-llm-hub-routing__modes{display:flex;gap:4px;margin-left:auto}',
673
658
  // 规则说明:去掉盒子背景,只留淡灰单行注脚,视觉权重要低于第 2 行「将路由到…」
674
- '.dsh-llm-hub-routing__rule{display:flex;gap:6px;align-items:center;flex-wrap:wrap;font-size:10.5px;line-height:1.5;',
675
659
  'color:var(--dsw-alias-label-tertiary);padding:0}',
676
- '.dsh-llm-hub-routing__rule-tag{flex:none;font-weight:500;color:var(--dsw-alias-label-secondary)}',
677
- '.dsh-llm-hub-routing__now{font-size:11px;color:var(--dsw-alias-label-primary);font-weight:600}',
678
- '.dsh-llm-hub-routing__groups{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
679
- '.dsh-llm-hub-routing__group{border:.5px solid var(--dsw-alias-border-l1);border-radius:6px;padding:6px 8px;display:flex;flex-direction:column;gap:5px;transition:border-color .15s ease,box-shadow .15s ease}',
680
660
  // 当前组:边框降一档 + 顶部 brand 细线高亮,替代原本的 warn 四边描边
681
- '.dsh-llm-hub-routing__group.is-active{border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));box-shadow:inset 0 1px 0 var(--dsw-alias-brand-primary,#3b82f6)}',
682
- '.dsh-llm-hub-routing__group-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
683
- '.dsh-llm-hub-routing__group-toggle{display:inline-flex;align-items:center;justify-content:center;height:22px;padding:0 8px;margin-left:4px;',
684
661
  'border:.5px solid var(--dsw-alias-border-l1);border-radius:4px;background:transparent;cursor:pointer;',
685
662
  'font:inherit;font-size:11px;line-height:1;color:var(--dsw-alias-label-secondary);white-space:nowrap;transition:all .15s ease}',
686
- '.dsh-llm-hub-routing__group-toggle:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));background:var(--dsw-alias-bg-layer-2)}',
687
- '.dsh-llm-hub-routing__group-order{display:flex;align-items:center;gap:2px;flex:none}',
688
- '.dsh-llm-hub-routing__group-move{display:inline-flex;align-items:center;justify-content:center;width:18px;height:22px;',
689
663
  'border:0;background:transparent;padding:0;cursor:pointer;font:inherit;font-size:12px;line-height:1;',
690
664
  'color:var(--dsw-alias-label-tertiary);border-radius:3px;transition:all .15s ease}',
691
- '.dsh-llm-hub-routing__group-move:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}',
692
- '.dsh-llm-hub-routing__group-move:disabled{opacity:.25;cursor:default}',
693
- '.dsh-llm-hub-routing__group-empty{font-style:normal;color:var(--dsw-alias-label-tertiary)}',
694
665
  // 折叠行(1.6.2):组名 + 「将路由到 X」+ 主力 + 兜底条数,一行读完。
695
666
  // 组名给固定宽度,与下面展开态的 label 输入框同宽 —— 收起/展开时这一列不跳。
696
- '.dsh-llm-hub-routing__group-name{flex:0 0 112px;min-width:0;overflow:hidden;text-overflow:ellipsis;',
697
667
  'white-space:nowrap;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-primary)}',
698
- '.dsh-llm-hub-routing__group-label{flex:0 0 112px;width:112px;box-sizing:border-box;height:22px;padding:0 8px;border-radius:5px;',
699
668
  'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);',
700
669
  'font:inherit;font-size:11px;outline:none;transition:border-color .15s ease}',
701
- '.dsh-llm-hub-routing__group-label:focus{border-color:var(--dsw-alias-brand-primary,#3b82f6)}',
702
670
  // 允许换行,不允许截断(1.6.2 浏览器实测后改的)。设置内容区只有 564px,
703
671
  // 扣掉序号/组名/两个按钮,摘要只剩 ~210px;而真实数据的 provider/model
704
672
  // 要 200-215px —— 差的这几十像素,省不掉,只能换行。之前用 nowrap+ellipsis
705
673
  // 把「→ modelgo-gateway/claude-sonnet-5」砍成「→ modelgo-gatewa…」,
706
674
  // 砍掉的正好是判断路由结果最需要的那段:看不出是 sonnet-5 还是别的。
707
675
  // 组头因此高一行,是「读得全」该付的代价。
708
- '.dsh-llm-hub-routing__group-state{flex:1;min-width:0;font-size:10.5px;line-height:1.5;',
709
676
  'color:var(--dsw-alias-label-secondary);display:flex;flex-wrap:wrap;align-items:center;gap:2px 8px}',
710
677
  // 断行点放在 provider 与 model 之间的斜杠上:读起来是两条完整信息,
711
678
  // 而不是把一个词从中间劈开。break-all 是兜底 —— provider id 里也有长词。
712
- '.dsh-llm-hub-routing__group-would{font-weight:600;color:var(--dsw-alias-label-primary);',
713
679
  'min-width:0;word-break:break-word;overflow-wrap:anywhere}',
714
680
  // 可用/总:紧跟在「将路由到」后面,且**不参与压缩** —— 压它等于把
715
681
  // 「还剩几个能兜」这个答案藏掉,而它只要 30px。
716
- '.dsh-llm-hub-routing__group-count{flex:none;display:inline-flex;align-items:center;gap:3px;',
717
682
  'color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
718
- '.dsh-llm-hub-routing__group-count .dsh-llm-hub-routing__dot{margin:0}',
719
- '.dsh-llm-hub-routing__candidates{display:flex;flex-direction:column;gap:3px;padding-top:2px}',
720
683
  // 1.7.0 路由编辑器:序号徽标、编辑按钮、原地下拉、备注、放大的操作按钮、自动保存状态
721
- '.dsh-llm-hub-routing__group-index{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:22px;border-radius:4px;',
722
684
  'padding:0 6px;font-size:10.5px;font-weight:600;background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.08));color:var(--dsw-alias-label-secondary)}',
723
685
  // 全组共用一张网格:行与 pick 都是 display:contents,列宽由整组一起决定 ——
724
686
  // 各行自成网格时,右侧标签列按本行内容伸缩,模型下拉的右边缘每行都不一样(浏览器实测 908 vs 845px)。
725
- '.dsh-llm-hub-routing-page{display:flex;flex-direction:column;gap:6px}',
726
- '.dsh-llm-hub-routing-page__head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
727
- '.dsh-llm-hub-routing__group-delete{margin-left:4px;white-space:nowrap}',
728
- '.dsh-llm-hub-routing-page__title{margin:0;font-size:18px;font-weight:600;color:var(--dsw-alias-label-primary)}',
729
- '.dsh-llm-hub-routing-page__desc{margin:0 0 8px;font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary)}',
730
- '.dsh-llm-hub-routing__candidates{display:grid;grid-template-columns:40px minmax(0,1fr) minmax(0,1.3fr);gap:4px 6px;align-items:center}',
731
- '.dsh-llm-hub-routing__candidates>:not(.dsh-llm-hub-routing__row){grid-column:1/-1}',
732
- '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__row,.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__pick{display:contents}',
733
- '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__role{grid-column:1;justify-self:start;margin-top:6px}',
734
- '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__pick .dsh-llm-hub-routing__select{width:100%;min-width:0;max-width:none;margin-top:6px}',
735
- '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__name{grid-column:2/4;min-width:0;margin-top:6px}',
736
- '.dsh-llm-hub-routing__line2{grid-column:2/-1;display:flex;align-items:center;gap:6px;min-width:0}',
737
687
  // 标签区给固定宽度并靠右:有无「思考/图像」标签的行,备注框都一样宽,右边缘对齐。
738
- '.dsh-llm-hub-routing__meta{display:inline-flex;align-items:center;justify-content:flex-end;gap:4px;white-space:nowrap;flex:none;width:78px}',
739
- '.dsh-llm-hub-routing__dot{display:inline-block;flex:none;width:7px;height:7px;border-radius:50%;margin:0 2px;cursor:help;background:var(--dsw-alias-label-tertiary)}',
740
- '.dsh-llm-hub-routing__dot--available{background:var(--dsw-alias-state-success-primary)}',
741
- '.dsh-llm-hub-routing__dot--unavailable{background:var(--dsw-alias-state-error-primary)}',
742
688
  // 半可用单独一色:全绿和全红之间还有「还剩几个」这个状态,
743
689
  // 混成全绿会让人以为不用管,混成全红会误报故障。
744
- '.dsh-llm-hub-routing__dot--degraded{background:var(--dsw-alias-state-warn-primary)}',
745
- '.dsh-llm-hub-routing__candidates .dsh-llm-hub-routing__row .dsh-llm-hub-routing__note{flex:1 1 auto;width:auto;max-width:none;min-width:60px}',
746
690
  // 同理固定宽度:主力行没有「设为主力」,按钮区只有 77px(其余 141px),备注右边缘会错开 64px。
747
- '.dsh-llm-hub-routing__ops{display:inline-flex;justify-content:flex-end;gap:4px;white-space:nowrap;flex:none;min-width:141px}',
748
- '.dsh-llm-hub-routing__note{box-sizing:border-box;height:22px;padding:0 8px;border-radius:4px;',
749
691
  'border:.5px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);',
750
692
  'font:inherit;font-size:10.5px;flex:1 1 120px;min-width:90px;max-width:260px;outline:none;transition:border-color .15s ease}',
751
- '.dsh-llm-hub-routing__note:focus{border-color:var(--dsw-alias-brand-primary,#3b82f6)}',
752
- '.dsh-llm-hub-routing__note::placeholder{color:var(--dsw-alias-label-tertiary)}',
753
693
  // 候选区是纵向 flex:note 的 flex-basis 120px 在这里会变成「高 120px」,组备注必须脱离 flex 伸缩。
754
- '.dsh-llm-hub-routing__note--group{display:block;flex:none;width:100%;max-width:none;margin:2px 0 6px}',
755
- '.dsh-llm-hub-routing__op{border:.5px solid var(--dsw-alias-border-l1);background:transparent;border-radius:4px;min-width:22px;height:22px;padding:0 6px;cursor:pointer;font:inherit;font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
756
- '.dsh-llm-hub-routing__op:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))}',
757
- '.dsh-llm-hub-routing__op:disabled{opacity:.3;cursor:default}',
758
- '.dsh-llm-hub-routing__op--danger:hover:not(:disabled){color:var(--dsw-alias-state-error-primary)}',
759
- '.dsh-llm-hub-routing__status{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}',
760
- '.dsh-llm-hub-routing__status.is-fail{color:var(--dsw-alias-state-error-primary)}',
761
- '.dsh-llm-hub-routing__add{display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:10.5px;color:var(--dsw-alias-label-secondary);padding-top:4px}',
762
- '.dsh-llm-hub-routing__select{box-sizing:border-box;height:22px;padding:0 6px;border-radius:4px;border:.5px solid var(--dsw-alias-border-l1);',
763
694
  'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;max-width:170px;outline:none;',
764
695
  'transition:border-color .15s ease;cursor:pointer}',
765
- '.dsh-llm-hub-routing__select:focus{border-color:var(--dsw-alias-brand-primary,#3b82f6)}',
766
- '.dsh-llm-hub-routing__actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-l1);margin-top:8px}',
767
- '.dsh-llm-hub-routing__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
768
- '.dsh-llm-hub-routing__mm{display:inline-flex;align-items:center;margin-left:4px;font-size:10px;cursor:help;',
769
696
  'color:var(--dsw-alias-state-success-primary,#10b981)}',
770
697
  // Hero 全景统计大横条
771
698
  '.dsh-llm-hub-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin:12px 0 6px 0}',
@@ -1103,14 +1030,20 @@ window.__ModuleLoader__.load({
1103
1030
  }, h(RefreshIcon, { spinning: isRefreshing }))
1104
1031
  )
1105
1032
  ),
1106
- state.keys && state.keys.length > 1
1033
+ state.keys && state.keys.length > 0
1107
1034
  ? h('div', { className: 'dsh-llm-hub-card__actions', style: { paddingTop: '3px' } },
1108
1035
  h('div', { className: 'dsh-llm-hub-card__tags' },
1109
- state.keys.map((k, idx) => h('span', {
1110
- key: k.ref,
1111
- className: 'dsh-llm-hub-key-chip' + (k.isAvailable ? '' : ' is-down'),
1112
- title: `${k.ref}: ${k.isAvailable ? '正常' : (k.error || '不可用')}`
1113
- }, `Key ${idx + 1} (${k.keyMasked}): ${k.isAvailable ? (k.currency === 'USD' ? '$' : '¥') + k.total : '⚠️ 余额不足'}`))
1036
+ state.keys.length > 1
1037
+ ? state.keys.map((k, idx) => h('span', {
1038
+ key: k.ref,
1039
+ className: 'dsh-llm-hub-key-chip' + (k.isAvailable ? '' : ' is-down'),
1040
+ title: `${k.ref}: ${k.isAvailable ? '正常' : (k.error || '不可用')}`
1041
+ }, `Key ${idx + 1} (${k.keyMasked}): ${k.isAvailable ? (k.currency === 'USD' ? '$' : '¥') + k.total : '⚠️ 余额不足'}`))
1042
+ : h('span', {
1043
+ className: 'dsh-llm-hub-key-chip',
1044
+ style: { opacity: '0.8', fontSize: '11px' },
1045
+ title: '已配置单 Key。如需多账号自由轮换,可在 apiKeyEnv 中配置多个环境变量名'
1046
+ }, `🔑 单 Key: ${state.keys[0].ref || 'DEEPSEEK_API_KEY'} (${state.keys[0].keyMasked}) · 支持多 Key 自由轮换`)
1114
1047
  )
1115
1048
  )
1116
1049
  : null
@@ -1680,7 +1613,20 @@ window.__ModuleLoader__.load({
1680
1613
  const list = Array.isArray(availabilityState.providers) ? availabilityState.providers : []
1681
1614
  const totalProviders = list.length
1682
1615
  const readyProviders = list.filter((p) => p.state !== 'unavailable').length
1683
- const totalModels = list.reduce((acc, p) => acc + (typeof p.modelCount === 'number' ? p.modelCount : 0), 0)
1616
+ // 统计可用模型数:只统计健康可用(ready)的 provider 下的模型,并对同名模型去重
1617
+ const readyList = list.filter((p) => p.state !== 'unavailable')
1618
+ const distinctModels = new Set()
1619
+ for (const p of readyList) {
1620
+ if (Array.isArray(p.modelIds)) {
1621
+ for (const id of p.modelIds) {
1622
+ if (typeof id === 'string' && id.trim() !== '') distinctModels.add(id.trim())
1623
+ }
1624
+ }
1625
+ }
1626
+ // 若返回的数据带 modelIds 则以去重后的数量为准;若仅有 modelCount 降级累加
1627
+ const totalModels = distinctModels.size > 0
1628
+ ? distinctModels.size
1629
+ : readyList.reduce((acc, p) => acc + (typeof p.modelCount === 'number' ? p.modelCount : 0), 0)
1684
1630
 
1685
1631
  const latencies = list.map((p) => p.latencyMs).filter((l) => typeof l === 'number' && l > 0)
1686
1632
  const minLatency = latencies.length > 0 ? Math.min(...latencies) : null
@@ -1765,28 +1711,60 @@ window.__ModuleLoader__.load({
1765
1711
  * 写操作 POST /routing/select。chip 显示「最近实际路由」—— smart 模式
1766
1712
  * 下模型下拉显示什么已经不重要,这里才是事实。
1767
1713
  */
1768
- function SmartRouteChip(props) {
1714
+ /**
1715
+ * ProviderUsageChip —— composer 左端的信息位:当前供应商 · 当前模型 · 可用数 · 配额 · 本期用量。
1716
+ *
1717
+ * ## 为什么不是原来的「公司 / 个人」切换 chip(2026-10-09)
1718
+ *
1719
+ * owner 决定**去掉公司 / 个人分组与智能开关这一整块路由能力**,只保留主模型下拉链路。
1720
+ * 但那个位置不能留空 —— 它原来是高频入口,空出来等于聊天页少了一块状态。
1721
+ * 所以改成**只读的信息位**:显示当前实际在用的供应商与模型,以及它们的可用数、
1722
+ * 配额与用量;点开看详情。
1723
+ *
1724
+ * ## 三条数据通路都是现成的,不新造接口
1725
+ *
1726
+ * ROUTING_STATE_URL → 当前 provider/model(保留调用:路由能力去掉了,但
1727
+ * 「现在实际在用哪个」这条信息它仍是唯一来源)
1728
+ * AVAILABILITY_URL → 可用数(截图里的「9/9 CLI」那类)
1729
+ * BALANCE_URL → 配额/余额(截图里的「54%」那类)
1730
+ * USAGE_URL → 用量汇总(详情浮层里的 tok / 请求数)
1731
+ *
1732
+ * ## 交互
1733
+ *
1734
+ * 点 chip 展开详情浮层,点外部或按 Esc 收起。**不做任何切换** —— 换模型走上面的
1735
+ * 主模型下拉,那是唯一的模型切换入口。留一个「能点但什么都不改」的入口是误导。
1736
+ */
1737
+ function ProviderUsageChip(props) {
1769
1738
  const t = typeof props.t === 'function' ? props.t : fallbackT
1770
- const [state, setState] = React.useState(null)
1739
+ const [routing, setRouting] = React.useState(null)
1740
+ const [availability, setAvailability] = React.useState(null)
1741
+ const [balance, setBalance] = React.useState(null)
1742
+ const [usage, setUsage] = React.useState(null)
1771
1743
  const [open, setOpen] = React.useState(false)
1772
- const [busy, setBusy] = React.useState(false)
1773
- // 浮层根节点:判断「这次点击算不算点在浮层外面」要用它。
1774
1744
  const rootRef = React.useRef(null)
1745
+
1746
+ // 一个 getJSON 收口:四条通路的失败模式完全一样(网关没起 / 未授权),
1747
+ // 分开写四遍 try/catch 只是把同一个空状态抄四遍。
1748
+ const getJSON = React.useCallback((url) => fetch(url, { headers: { accept: 'application/json' } })
1749
+ .then((res) => (res.ok ? res.json() : null))
1750
+ .then((body) => (body !== null && typeof body === 'object' && body.ok === true ? body : null))
1751
+ .catch(() => null), [])
1752
+
1775
1753
  const query = React.useCallback(() => {
1776
- fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
1777
- .then((res) => res.json())
1778
- .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) setState(body) })
1779
- .catch(() => {})
1780
- }, [])
1754
+ getJSON(ROUTING_STATE_URL).then(setRouting)
1755
+ getJSON(AVAILABILITY_URL).then(setAvailability)
1756
+ getJSON(BALANCE_URL).then(setBalance)
1757
+ getJSON(USAGE_URL).then(setUsage)
1758
+ }, [getJSON])
1759
+
1781
1760
  React.useEffect(() => {
1782
1761
  query()
1783
1762
  const interval = setInterval(query, 30000)
1784
1763
  return () => clearInterval(interval)
1785
1764
  }, [query])
1786
- // 点浮层外面 / 按 Esc 收起来。少了这条,用户在浮层里做完选择后
1787
- // 手会先伸向输入框 —— 浮层却还盖在上面,不点第二次 chip 就挡着视线。
1788
- // 只在 open 时挂监听:关着的时候不留全局 handler。
1789
- // 必须写在下面那个 `state === null` 早返回之前 —— hooks 不能条件调用。
1765
+
1766
+ // 点浮层外面 / 按 Esc 收起 —— 与原 chip 同款手感。
1767
+ // 只在 open 时挂监听;必须写在下面的早返回之前,hooks 不能条件调用。
1790
1768
  React.useEffect(() => {
1791
1769
  if (!open) return undefined
1792
1770
  const onPointerDown = (event) => {
@@ -1802,89 +1780,114 @@ window.__ModuleLoader__.load({
1802
1780
  document.removeEventListener('keydown', onKeyDown)
1803
1781
  }
1804
1782
  }, [open])
1805
- // 一个组都没有:chip 无事可做,不渲染(配置在 settings.yaml 里加)。
1806
- if (state === null || !Array.isArray(state.groups) || state.groups.length === 0) return null
1783
+
1784
+ // 四条通路都没回来 → 没东西可显示,别占位置。
1785
+ if (routing === null && availability === null && balance === null && usage === null) return null
1807
1786
  ensureStyle()
1808
- const activeGroup = state.groups.find((group) => group.id === state.activeGroup) ?? state.groups[0]
1809
- const isSmart = state.mode === 'smart'
1810
- const chipClass = [
1811
- 'dsh-llm-hub-route__btn',
1812
- isSmart ? 'is-smart' : '',
1813
- busy ? 'is-busy' : ''
1814
- ].filter(Boolean).join(' ')
1815
- const label = (activeGroup && (activeGroup.label ?? activeGroup.id)) ?? ''
1816
- const chipTitle = isSmart
1817
- ? `${t('routeChipModeSmart')} · ${label} —— ${state.lastDecision?.to ? `${t('routeChipLastRoute')}: ${state.lastDecision.to.provider}/${state.lastDecision.to.model}` : t('routeChipFailOpen')}`
1818
- : `${t('routeChipModeManual')} · ${label}`
1819
- const select = async (patch) => {
1820
- setBusy(true)
1821
- fetch(ROUTING_SELECT_URL, {
1822
- method: 'POST',
1823
- headers: { 'content-type': 'application/json', accept: 'application/json' },
1824
- body: JSON.stringify(patch)
1825
- })
1826
- .then((res) => res.json())
1827
- // 选完即收:浮层是「选一个值」的一次性动作,不是常驻面板。
1828
- // 留着不关,用户会以为没点上,只能再点一次 chip 才收得回去。
1829
- // 写失败时**不关** —— 状态没变,收起来等于把失败藏了。
1830
- .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) { setState(body); setOpen(false) } })
1831
- .catch(() => {})
1832
- .finally(() => { setBusy(false); query() })
1833
- }
1834
- const stateText = (group) => {
1835
- if (group.wouldRoute) {
1836
- const next = `${group.wouldRoute.provider}/${group.wouldRoute.model}`
1837
- return group.reason !== null && group.reason !== undefined ? group.reason : `${t('routeChipWouldRoute')}: ${next}`
1838
- }
1839
- return group.reason ?? t('routeChipReasonNone')
1840
- }
1841
- const menu = open ? h('div', { className: 'dsh-llm-hub-route__menu' },
1842
- h('div', { className: 'dsh-llm-hub-route__modes' },
1843
- h('button', {
1844
- type: 'button',
1845
- className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
1846
- // 点当前已生效的那个模式:没有请求要发,但**照样收起浮层**。
1847
- // 否则「点了完全没反应」,用户会当成界面卡住反复点。
1848
- onClick: () => { if (isSmart) setOpen(false); else select({ mode: 'smart' }) }
1849
- }, `⚡ ${t('routeChipModeSmart')}`),
1850
- h('button', {
1851
- type: 'button',
1852
- className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
1853
- onClick: () => { if (!isSmart) setOpen(false); else select({ mode: 'manual' }) }
1854
- }, t('routeChipModeManual'))),
1855
- ...state.groups.map((group) => h('button', {
1856
- key: group.id,
1857
- type: 'button',
1858
- className: `dsh-llm-hub-route__group${group.id === state.activeGroup ? ' is-active' : ''}`,
1859
- onClick: () => {
1860
- const patch = { activeGroup: group.id }
1861
- if (!isSmart) patch.mode = 'smart'
1862
- select(patch)
1863
- }
1864
- },
1865
- h('span', { className: 'dsh-llm-hub-route__group-top' },
1866
- h('span', { className: 'dsh-llm-hub-route__group-name' }, group.label ?? group.id),
1867
- h('span', { className: 'dsh-llm-hub-route__state' }, stateText(group))),
1868
- // 顺序即优先级:把候选按组内顺序摊出来(① = 主力),让人一眼看到
1869
- // 「智能」到底怎么个智能法,而不是只有一个 wouldRoute 结果。
1870
- h('span', { className: 'dsh-llm-hub-route__order' },
1871
- (group.candidates ?? []).map((candidate, index) => `${index === 0 ? '①' : index === 1 ? '②' : index === 2 ? '③' : `(${index + 1})`}${candidate.provider}/${candidate.model}${candidate.state === 'unavailable' ? '✗' : ''}`).join(' → ')),
1872
- group.reason !== null && group.reason !== undefined && group.wouldRoute === null
1873
- ? h('span', { className: 'dsh-llm-hub-route__hint' }, group.reason)
1874
- : null)),
1875
- h('div', { className: 'dsh-llm-hub-route__hint' }, t('routeRuleText'))) : null
1876
- return h('div', { className: 'dsh-llm-hub-route', ref: rootRef },
1787
+
1788
+ // 当前 provider/model:routing.active 优先(它是「现在真的在用哪个」);
1789
+ // 没有就退回 usage 里最近一条 —— 至少显示最近用过的,不至于空着。
1790
+ const active = (routing && routing.active) || (usage && usage.latest) || null
1791
+ const provider = (active && (active.provider ?? active.providerId)) || ''
1792
+ const model = (active && (active.model ?? active.modelId)) || ''
1793
+ if (provider === '' && model === '') return null
1794
+
1795
+ const availText = availabilitySummary(availability)
1796
+ const quotaText = quotaSummary(balance)
1797
+ const usageText = usageSummary(usage)
1798
+
1799
+ const parts = []
1800
+ if (provider !== '') parts.push(provider)
1801
+ if (model !== '') parts.push(model)
1802
+ if (availText !== '') parts.push(availText)
1803
+ if (quotaText !== '') parts.push(quotaText)
1804
+ // 用量也进 chip 本体,不只放在详情浮层里 —— 你截图上那行本来就带着它。
1805
+ // (漏了这一行是第一次跑测试时被抓出来的:usageText 算了却没进 parts。)
1806
+ if (usageText !== '') parts.push(usageText)
1807
+ const label = parts.join(' · ')
1808
+
1809
+ return h('div', { ref: rootRef, style: { position: 'relative' } },
1877
1810
  h('button', {
1878
1811
  type: 'button',
1879
- className: chipClass,
1880
- title: chipTitle,
1881
- onClick: () => { setOpen(!open); query() }
1812
+ className: 'dsh-llm-hub-route__btn',
1813
+ onClick: () => setOpen((v) => !v),
1814
+ title: `${label} —— 点击查看详情`,
1815
+ style: { cursor: 'pointer' },
1882
1816
  },
1883
- h('span', { className: 'dsh-llm-hub-route__mark' }, isSmart ? '⚡' : '🧭'),
1884
- h('span', null, `${isSmart ? `${t('routeChipSmart')} · ` : ''}${label}`)),
1885
- menu)
1817
+ h('span', { className: 'dsh-llm-hub-route__mark' }, '◈'),
1818
+ h('span', { style: { marginLeft: '5px' } }, label)),
1819
+ open && h('div', { className: 'dsh-llm-hub-route__menu' },
1820
+ h('div', { style: { minWidth: '260px', padding: '10px 12px' } },
1821
+ detailRows([
1822
+ ['供应商', provider],
1823
+ ['模型', model],
1824
+ availText !== '' ? ['可用', availText] : null,
1825
+ quotaText !== '' ? ['配额', quotaText] : null,
1826
+ usageText !== '' ? ['用量', usageText] : null,
1827
+ routing && routing.lastDecision && routing.lastDecision.to
1828
+ ? ['最近路由', `${routing.lastDecision.to.provider}/${routing.lastDecision.to.model}`]
1829
+ : null,
1830
+ ]),
1831
+ h('div', {
1832
+ style: { marginTop: '8px', paddingTop: '8px', borderTop: '.5px solid var(--dsw-alias-border-l2)', fontSize: '11px', opacity: '.7' },
1833
+ }, t('routeChipNoSwitch') || '模型切换请用上方主模型下拉'))))
1886
1834
  }
1887
1835
 
1836
+ /** 详情浮层的行:过滤掉 null,键值两列。 */
1837
+ function detailRows(pairs) {
1838
+ const rows = pairs.filter((p) => p !== null && p !== undefined && p[1])
1839
+ if (rows.length === 0) return null
1840
+ return h('div', { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 14px', fontSize: '12px' } },
1841
+ rows.map((p, idx) => h(React.Fragment, { key: idx },
1842
+ h('span', { style: { opacity: '.65' } }, p[0]),
1843
+ h('span', { style: { fontFamily: 'var(--dsw-mono, monospace)' } }, String(p[1])))))
1844
+ }
1845
+
1846
+ /** 可用数 → 「9/9」这类。拿不到就返回空串(不编数字)。 */
1847
+ function availabilitySummary(body) {
1848
+ if (!body) return ''
1849
+ const list = Array.isArray(body.providers) ? body.providers : Array.isArray(body) ? body : null
1850
+ if (list === null) {
1851
+ const ok = body.available
1852
+ const total = body.total
1853
+ if (typeof ok === 'number' && typeof total === 'number') return `${ok}/${total}`
1854
+ return ''
1855
+ }
1856
+ const ok = list.filter((p) => p && (p.state === 'available' || p.available === true)).length
1857
+ return `${ok}/${list.length}`
1858
+ }
1859
+
1860
+ /** 配额/余额 → 「54%」这类百分比。算不出就不显示。 */
1861
+ function quotaSummary(body) {
1862
+ if (!body) return ''
1863
+ const used = Number(body.used)
1864
+ const limit = Number(body.limit ?? body.quota ?? body.total)
1865
+ if (Number.isFinite(used) && Number.isFinite(limit) && limit > 0) {
1866
+ return `${Math.round((used / limit) * 100)}%`
1867
+ }
1868
+ if (typeof body.balance === 'number') return String(body.balance)
1869
+ if (typeof body.remaining === 'string') return body.remaining
1870
+ return ''
1871
+ }
1872
+
1873
+ /** 用量 → 「948M tok」这类紧凑写法。没有就空串。 */
1874
+ function usageSummary(body) {
1875
+ if (!body) return ''
1876
+ const total = Number(body.totalTokens ?? body.total_tokens)
1877
+ if (Number.isFinite(total)) return `${compactTokens(total)} tok`
1878
+ const reqs = Number(body.requests ?? body.requestCount)
1879
+ if (Number.isFinite(reqs)) return `${reqs} 次`
1880
+ return ''
1881
+ }
1882
+
1883
+ /** token 计数压缩:948M / 12.3K / 456。 */
1884
+ function compactTokens(n) {
1885
+ if (!Number.isFinite(n)) return '0'
1886
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`
1887
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
1888
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}K`
1889
+ return String(n)
1890
+ }
1888
1891
  /**
1889
1892
  * 当前路由卡 —— 显示用户当前激活的 (provider, model) + 状态 + 推荐的 fallback。
1890
1893
  *
@@ -1906,523 +1909,6 @@ window.__ModuleLoader__.load({
1906
1909
  * @param props - 注入的 `t`。
1907
1910
  * @returns 当前路由卡;未配置时不渲染(让用户先配置再说)。
1908
1911
  */
1909
- function RoutingCard(props) {
1910
- const t = typeof props.t === 'function' ? props.t : fallbackT
1911
- const [state, setState] = React.useState(null)
1912
- const [catalog, setCatalog] = React.useState([])
1913
- // draft 是编辑器的工作副本:[{ id, label, candidates: [{provider, model}] }],
1914
- // candidates[0] = 主力(primary),其余是 fallback —— 与 settings 形态一一对应。
1915
- const [draft, setDraft] = React.useState(null)
1916
- const [activeGroup, setActiveGroup] = React.useState('')
1917
- // null = 还没手动开合过,此时默认展开当前组 —— 一进来就能看到、能改。
1918
- const [expanded, setExpanded] = React.useState(null)
1919
- const [picker, setPicker] = React.useState({ provider: '', model: '' })
1920
- const [busy, setBusy] = React.useState(false)
1921
- const [notice, setNotice] = React.useState(null)
1922
- // 保存过/刚拉回的 draft 指纹:用它判断「脏」,避免后台刷新把用户正在编辑的内容冲掉。
1923
- const [savedKey, setSavedKey] = React.useState('')
1924
- // label 归一成「空 = null」:草稿里是 '',服务端回的是 null,不归一会永远显示「未保存」。
1925
- // 空组(刚点「新建组」、还没加候选)不参与比较:服务端会丢弃空组,
1926
- // 若把它算成「脏」,自动保存会一直重试、而且保存回读后这个组就消失了。
1927
- const draftKeyOf = (report) => JSON.stringify((report?.groups ?? [])
1928
- .filter((group) => (group.candidates ?? []).length > 0)
1929
- .map((group) => [
1930
- group.id,
1931
- group.label ? group.label : null,
1932
- group.note ? group.note : null,
1933
- (group.candidates ?? []).map((candidate) => [`${candidate.provider}/${candidate.model}`, candidate.note ? candidate.note : null])
1934
- ]))
1935
- const draftFrom = (report) => (report.groups ?? []).map((group) => ({
1936
- id: group.id,
1937
- label: group.label ?? '',
1938
- note: group.note ?? '',
1939
- candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model, note: candidate.note ?? '' }))
1940
- }))
1941
- // force=true 只用在一处:保存成功后的回读 —— 那时服务端就是权威。
1942
- // 其余时候(挂载、切模式、轮询)保留用户正在编辑的草稿。
1943
- const adopt = React.useCallback((report, force) => {
1944
- setState(report)
1945
- setActiveGroup(report.activeGroup)
1946
- setSavedKey(draftKeyOf(report))
1947
- setDraft((prev) => (force === true || prev === null ? draftFrom(report) : prev))
1948
- }, [])
1949
- const loadState = React.useCallback(() => {
1950
- fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
1951
- .then((res) => res.json())
1952
- .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) adopt(body) })
1953
- .catch(() => {})
1954
- }, [adopt])
1955
- React.useEffect(() => {
1956
- loadState()
1957
- fetch(ROUTING_CATALOG_URL, { headers: { accept: 'application/json' } })
1958
- .then((res) => res.json())
1959
- .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true && Array.isArray(body.providers)) setCatalog(body.providers) })
1960
- .catch(() => {})
1961
- }, [loadState])
1962
- // 自动保存的防抖定时器。必须写在下面那个 `state === null` 提前 return 之前 ——
1963
- // hooks 不能出现在条件分支后面;save 定义在后面,所以经 ref 调用。
1964
- const saveRef = React.useRef(null)
1965
- const draftKey = draft === null ? '' : draftKeyOf({ groups: draft })
1966
- React.useEffect(() => {
1967
- if (draft === null || draftKey === savedKey || notice === 'fail' || notice === 'saving') return undefined
1968
- const timer = setTimeout(() => { if (typeof saveRef.current === 'function') saveRef.current() }, 800)
1969
- return () => clearTimeout(timer)
1970
- }, [draftKey, savedKey, notice])
1971
- if (state === null) {
1972
- return h('div', { className: 'dsh-llm-hub-routing dsh-llm-hub-routing-page' },
1973
- h('div', { className: 'dsh-llm-hub-routing-page__head' },
1974
- h('h2', { className: 'dsh-llm-hub-routing-page__title' }, t('routingNav')),
1975
- h('span', null, t('routingLoading'))))
1976
- }
1977
- ensureStyle()
1978
- const isSmart = state.mode === 'smart'
1979
- const groups = draft ?? []
1980
- const isDirty = draft !== null && draftKeyOf({ groups }) !== savedKey
1981
- const post = (url, payload) => fetch(url, {
1982
- method: 'POST',
1983
- headers: { 'content-type': 'application/json', accept: 'application/json' },
1984
- body: JSON.stringify(payload)
1985
- }).then((res) => res.json())
1986
- const setMode = (mode) => {
1987
- setNotice(null)
1988
- post(ROUTING_SELECT_URL, { mode })
1989
- .then((body) => { if (body?.ok === true) adopt(body, true) })
1990
- .catch(() => setNotice('fail'))
1991
- }
1992
- const markActive = (id) => {
1993
- setActiveGroup(id)
1994
- setNotice(null)
1995
- post(ROUTING_SELECT_URL, { activeGroup: id })
1996
- .then((body) => { if (body?.ok === true) adopt(body) })
1997
- .catch(() => setNotice('fail'))
1998
- }
1999
- // 自动保存(1.7.0):原来要去卡片最底下点「保存」,改完就走的人会丢改动。
2000
- // 草稿变「脏」后 800ms 防抖落盘;回读**不强制**覆盖草稿 —— 用户可能还在打字。
2001
- // 只提交有候选的组(空组服务端本来就丢弃),草稿里的空组留在页面上。
2002
- const save = () => {
2003
- const payload = groups.filter((group) => group.candidates.length > 0)
2004
- if (payload.length === 0) return
2005
- setBusy(true)
2006
- setNotice('saving')
2007
- post(ROUTING_CONFIG_URL, { groups: payload, activeGroup })
2008
- .then((body) => {
2009
- if (body?.ok === true) { adopt(body); setNotice('saved') }
2010
- else setNotice('fail')
2011
- })
2012
- .catch(() => setNotice('fail'))
2013
- .finally(() => setBusy(false))
2014
- }
2015
- saveRef.current = save
2016
- // ── 草稿编辑(全部是纯函数式更新,不碰服务端;保存才落盘)──
2017
- const editGroup = (id, mutate) => setDraft(groups.map((group) => (group.id === id ? mutate(group) : group)))
2018
- const moveCandidate = (id, index, delta) => editGroup(id, (group) => {
2019
- const next = [...group.candidates]
2020
- const target = index + delta
2021
- if (target < 0 || target >= next.length) return group
2022
- const [item] = next.splice(index, 1)
2023
- next.splice(target, 0, item)
2024
- return { ...group, candidates: next }
2025
- })
2026
- /**
2027
- * 组之间上下移。
2028
- *
2029
- * 为什么需要:候选(组内的 provider/model)早就能用 ↑↓ 排,但组本身
2030
- * (公司 / 个人 …)此前没有任何排序入口 —— 顺序只能靠手改 settings.yaml。
2031
- * 而组的先后是有语义的:activeGroup 之外的组在智能路由下也会按顺序兜底,
2032
- * 「先用哪个池子」必须在页面上能配。
2033
- *
2034
- * @param {number} index - 当前组下标。
2035
- * @param {number} delta - -1 上移 / +1 下移。
2036
- */
2037
- const moveGroup = (index, delta) => {
2038
- const target = index + delta
2039
- if (target < 0 || target >= groups.length) return
2040
- const next = [...groups]
2041
- const [item] = next.splice(index, 1)
2042
- next.splice(target, 0, item)
2043
- setDraft(next)
2044
- }
2045
- const updateCandidate = (id, index, patch) => editGroup(id, (group) => ({
2046
- ...group,
2047
- candidates: group.candidates.map((candidate, i) => (i === index ? { ...candidate, ...patch } : candidate))
2048
- }))
2049
- // 换提供方时模型跟着换成该家第一个模型 —— 留着旧 model 名几乎一定是错的组合。
2050
- const changeProvider = (id, index, provider) => {
2051
- const entry = catalog.find((item) => item.id === provider)
2052
- updateCandidate(id, index, { provider, model: entry?.models?.[0]?.id ?? '' })
2053
- }
2054
- const makePrimary = (id, index) => editGroup(id, (group) => {
2055
- const next = [...group.candidates]
2056
- const [item] = next.splice(index, 1)
2057
- return { ...group, candidates: [item, ...next] }
2058
- })
2059
- const removeCandidate = (id, index) => editGroup(id, (group) => ({ ...group, candidates: group.candidates.filter((_, i) => i !== index) }))
2060
- const addCandidate = (id) => {
2061
- const provider = picker.provider
2062
- const model = picker.model
2063
- if (provider === '' || model === '') return
2064
- editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model, note: '' }] }))
2065
- setPicker({ provider: '', model: '' })
2066
- }
2067
- const addGroup = () => {
2068
- let index = groups.length + 1
2069
- let id = `group-${index}`
2070
- while (groups.some((group) => group.id === id)) { index += 1; id = `group-${index}` }
2071
- setDraft([...groups, { id, label: '', note: '', candidates: [] }])
2072
- setExpanded(id)
2073
- }
2074
- const removeGroup = (id) => {
2075
- setDraft(groups.filter((group) => group.id !== id))
2076
- if (expanded === id) setExpanded(null)
2077
- }
2078
- const catalogEntryFor = (provider) => catalog.find((item) => item.id === provider)
2079
- const modelOptionsFor = (provider, current) => {
2080
- const ids = (catalogEntryFor(provider)?.models ?? []).map((model) => model.id)
2081
- return current && !ids.includes(current) ? [current, ...ids] : ids
2082
- }
2083
- const stateChipClass = (value) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${value ?? 'unknown'}`
2084
- /**
2085
- * 一组里探测为「可用」的候选数。探不到(没记录)时返回 0 —— 折叠行要报
2086
- * 「0/N」而不是假装健康:未探明和不可用在用户眼里同样是「先别指望它」。
2087
- * @param {object | undefined} live - /routing/state 里该组的快照。
2088
- * @returns {number} 可用候选数。
2089
- */
2090
- const availableOf = (live) => (live?.candidates ?? []).filter((item) => item.state === 'available').length
2091
- /**
2092
- * 折叠行那个健康点的颜色:全可用 / 还剩几个 / 一个都不剩。
2093
- * 不用单一绿点,绿点只说「还行」,说不出「还剩几个能兜」。
2094
- * @param {object | undefined} live - 该组快照。
2095
- * @returns {'available' | 'degraded' | 'unavailable'}
2096
- */
2097
- const groupDotState = (live) => {
2098
- const total = live?.candidates?.length ?? 0
2099
- const available = availableOf(live)
2100
- if (total === 0 || available === 0) return 'unavailable'
2101
- return available < total ? 'degraded' : 'available'
2102
- }
2103
- /**
2104
- * 一个候选是否多模态(输入含 text 之外的模态,典型是 image)。
2105
- *
2106
- * 数据来自目录接口的 `input` 字段(settings 里本来就有);目录读不到时
2107
- * 返回 undefined —— UI 不标而不是瞎猜。
2108
- */
2109
- const isMultimodal = (provider, modelId) => {
2110
- const entry = catalog.find((item) => item.id === provider)
2111
- const model = Array.isArray(entry?.models) ? entry.models.find((item) => item.id === modelId) : undefined
2112
- const input = Array.isArray(model?.input) ? model.input : []
2113
- return input.length > 0 && input.some((kind) => kind !== 'text')
2114
- }
2115
- /**
2116
- * 一个候选的推理档位列表(来自 settings 的 reasoningEfforts 字段);
2117
- * 没声明推理能力时返回 undefined —— UI 不标。
2118
- * 返回的是档位对象的浅拷贝(UI 用),外面别 mutate。
2119
- */
2120
- const reasoningLevels = (provider, modelId) => {
2121
- const entry = catalog.find((item) => item.id === provider)
2122
- const model = Array.isArray(entry?.models) ? entry.models.find((item) => item.id === modelId) : undefined
2123
- const levels = Array.isArray(model?.reasoning?.levels) ? model.reasoning.levels : []
2124
- return levels.length > 0 ? levels : undefined
2125
- }
2126
- /**
2127
- * 推理档位的人话短文本:超 3 个档位时压成 `a/b/c+2`,避免长列表把行挤爆。
2128
- */
2129
- const reasoningShort = (levels) => {
2130
- if (levels === undefined) return ''
2131
- const names = levels.map((level) => level.name)
2132
- if (names.length <= 3) return names.join('/')
2133
- return `${names.slice(0, 3).join('/')}+${names.length - 3}`
2134
- }
2135
- // 「现在会路由到哪」:smart 模式看 lastDecision(真实请求),否则按组顺序的推演。
2136
- const resolvedText = () => {
2137
- if (isSmart) {
2138
- const decision = state.lastDecision
2139
- if (decision?.to) return `${t('routeChipLastRoute')}: ${decision.to.provider}/${decision.to.model}`
2140
- return decision?.reason ?? t('routeChipFailOpen')
2141
- }
2142
- const group = state.groups.find((item) => item.id === activeGroup) ?? state.groups[0]
2143
- if (group?.wouldRoute) return `${t('routeChipWouldRoute')}: ${group.wouldRoute.provider}/${group.wouldRoute.model}`
2144
- return group?.reason ?? t('routeChipReasonNone')
2145
- }
2146
- // 页头(1.7.0):「路由」标题与模式开关同一行。原来页头一个「路由」、卡内又一个「当前路由」,重复。
2147
- const header = h('div', { className: 'dsh-llm-hub-routing-page__head' },
2148
- h('h2', { className: 'dsh-llm-hub-routing-page__title' }, t('routingNav')),
2149
- h('div', { className: 'dsh-llm-hub-routing__modes' },
2150
- h('button', {
2151
- type: 'button',
2152
- className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
2153
- onClick: () => { if (!isSmart) setMode('smart') }
2154
- }, `⚡ ${t('routeChipModeSmart')}`),
2155
- h('button', {
2156
- type: 'button',
2157
- className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
2158
- onClick: () => { if (isSmart) setMode('manual') }
2159
- }, t('routeChipModeManual'))))
2160
- const pageDesc = h('p', { className: 'dsh-llm-hub-routing-page__desc' }, t('routingPageDesc'))
2161
- const rule = h('div', { className: 'dsh-llm-hub-routing__rule' },
2162
- h('span', { className: 'dsh-llm-hub-routing__rule-tag' }, t('routeRuleTitle')),
2163
- h('span', null, t('routeRuleText')))
2164
- // 只在智能路由真的接管时提示。手动模式下那行「请求还是用你在模型下拉里
2165
- // 选的」讲的是当前高亮的按钮本身,没有新信息,却常驻占一整行;
2166
- // 而接管后「下拉只作参考」是按钮推不出来的,必须留着。
2167
- const noticeLine = isSmart
2168
- ? h('div', { className: 'dsh-llm-hub-routing__reason' }, t('routeSmartNotice'))
2169
- : null
2170
- const nowLine = h('div', { className: 'dsh-llm-hub-routing__now' }, resolvedText())
2171
- const groupRows = groups.map((group, groupIndex) => {
2172
- const live = state.groups.find((item) => item.id === group.id)
2173
- // 始终折叠,展开才编辑(1.6.2)。原先是「当前组默认展开」,于是这页一进来
2174
- // 就是一屏逐条候选的表单 —— 而进来看的是「这几组各自会路由到哪」,
2175
- // 那是折叠行就能答完的问题。编辑是低频动作,折叠是默认态。
2176
- const isOpen = expanded === group.id
2177
- const candidates = group.candidates
2178
- return h('div', { key: group.id, className: `dsh-llm-hub-routing__group${activeGroup === group.id ? ' is-active' : ''}` },
2179
- h('div', { className: 'dsh-llm-hub-routing__group-top' },
2180
- // 顺序即优先级,所以序号 + 上下移都得露在折叠态 —— 折叠着也能改顺序。
2181
- h('div', { className: 'dsh-llm-hub-routing__group-order' },
2182
- h('span', { className: 'dsh-llm-hub-routing__group-index' }, `${groupIndex + 1}`),
2183
- h('button', {
2184
- type: 'button',
2185
- className: 'dsh-llm-hub-routing__group-move',
2186
- title: t('routeUp'),
2187
- disabled: groupIndex === 0,
2188
- onClick: () => moveGroup(groupIndex, -1)
2189
- }, '↑'),
2190
- h('button', {
2191
- type: 'button',
2192
- className: 'dsh-llm-hub-routing__group-move',
2193
- title: t('routeDown'),
2194
- disabled: groupIndex === groups.length - 1,
2195
- onClick: () => moveGroup(groupIndex, 1)
2196
- }, '↓')),
2197
- // 组名折叠时是**纯文本**,展开才给输入框。折叠行还摆着一个输入框,
2198
- // 会让「这一行正在编辑」和「这一行只是显示」看起来一模一样 ——
2199
- // 而折叠行的职责恰恰是「看,不是改」。
2200
- isOpen
2201
- ? h('input', {
2202
- className: 'dsh-llm-hub-routing__group-label',
2203
- value: group.label ?? '',
2204
- placeholder: t('routeGroupLabelPlaceholder'),
2205
- onChange: (event) => editGroup(group.id, (current) => ({ ...current, label: event.target.value }))
2206
- })
2207
- : h('span', { className: 'dsh-llm-hub-routing__group-name' }, group.label || group.id),
2208
- // 折叠态就三件事:会路由到哪 · 组里几个候选、几个可用 · 组名。
2209
- // 为什么是这三件:设置内容区只有 564px,组头扣掉序号/组名/两个按钮后,
2210
- // 摘要只剩 ~235px;而「将路由到 modelgo-gateway/claude-sonnet-5」按真实数据
2211
- // 就要 231px。再塞第四件(主力 provider/model)必然把最该被读到的那段
2212
- // 压成「将路由到 m…」(浏览器实测)。所以砍内容,不是调 CSS:
2213
- // · 主力去掉 —— 它答「第一个是谁」,这行该答「实际走哪个」,展开态第一行就有;
2214
- // · 「+N 条兜底」去掉 —— 旁边「4/5」已把总数和可用数都说了,是同义重复;
2215
- // · 「将路由到」四字换成箭头 —— 省 48px,全称进 title 悬停可见。
2216
- !isOpen
2217
- ? h('span', { className: 'dsh-llm-hub-routing__group-state' },
2218
- candidates.length === 0
2219
- ? h('em', { className: 'dsh-llm-hub-routing__group-empty' }, t('routeNoCandidate'))
2220
- : [
2221
- live?.wouldRoute
2222
- ? h('b', {
2223
- key: 'w',
2224
- className: 'dsh-llm-hub-routing__group-would',
2225
- title: `${t('routeChipWouldRoute')} ${live.wouldRoute.provider}/${live.wouldRoute.model}`
2226
- }, `\u2192 ${live.wouldRoute.provider}/${live.wouldRoute.model}`)
2227
- : h('em', {
2228
- key: 'w',
2229
- className: 'dsh-llm-hub-routing__group-would',
2230
- title: live?.reason ?? t('routeChipReasonNone')
2231
- }, live?.reason ?? t('routeChipReasonNone')),
2232
- // 「可用/总」比一个孤零零的绿点有信息:点只说「还行」,数字说清还剩几个能兜。
2233
- h('span', { key: 'c', className: 'dsh-llm-hub-routing__group-count' },
2234
- h('span', { className: `dsh-llm-hub-routing__dot dsh-llm-hub-routing__dot--${groupDotState(live)}` }),
2235
- `${availableOf(live)}/${live?.candidates?.length ?? candidates.length}`),
2236
- ])
2237
- : null,
2238
- activeGroup === group.id
2239
- ? h('span', { className: 'dsh-llm-hub-routing__role' }, t('routeActiveTag'))
2240
- : h('button', {
2241
- type: 'button',
2242
- className: 'dsh-llm-hub-foot__link',
2243
- onClick: () => markActive(group.id)
2244
- }, t('routeSetActive')),
2245
- // 「删除该组」原来孤零零挂在组底正中;挪到组头、只在展开编辑时出现,挨着「收起」。
2246
- isOpen
2247
- ? h('button', {
2248
- type: 'button',
2249
- className: 'dsh-llm-hub-routing__op dsh-llm-hub-routing__op--danger dsh-llm-hub-routing__group-delete',
2250
- onClick: () => removeGroup(group.id)
2251
- }, t('routeDeleteGroup'))
2252
- : null,
2253
- // 原来开合藏在序号「▸ 1」上,看不出能点;现在是一个写明「编辑 / 收起」的按钮。
2254
- // 关起用 null 而不是 '':空串只是「曾经用过的哨兵值」,读代码的人分不清它
2255
- // 跟「还没开合过」是不是一回事(1.6.2 之前确实当成两码事)。
2256
- h('button', {
2257
- type: 'button',
2258
- className: 'dsh-llm-hub-btn dsh-llm-hub-routing__group-toggle',
2259
- onClick: () => setExpanded(isOpen ? null : group.id)
2260
- }, isOpen ? `▾ ${t('routeCollapse')}` : `✎ ${t('routeEdit')}`)),
2261
- isOpen
2262
- ? h('div', { className: 'dsh-llm-hub-routing__candidates' },
2263
- h('input', {
2264
- className: 'dsh-llm-hub-routing__note dsh-llm-hub-routing__note--group',
2265
- value: group.note ?? '',
2266
- placeholder: t('routeGroupNotePlaceholder'),
2267
- onChange: (event) => editGroup(group.id, (current) => ({ ...current, note: event.target.value }))
2268
- }),
2269
- ...candidates.map((candidate, index) => {
2270
- const liveCandidate = live?.candidates?.find((item) => item.provider === candidate.provider && item.model === candidate.model)
2271
- const multimodal = isMultimodal(candidate.provider, candidate.model)
2272
- const reasoning = reasoningLevels(candidate.provider, candidate.model)
2273
- const reasoningTip = reasoning === undefined
2274
- ? undefined
2275
- : `${t('routeReasoningTip')}\n${reasoning.map((level) => ` · ${level.name} → ${level.id}`).join('\n')}`
2276
- return h('div', { key: `${candidate.provider}/${candidate.model}/${index}`, className: `dsh-llm-hub-routing__row${index === 0 ? ' is-primary' : ''}` },
2277
- h('span', { className: `dsh-llm-hub-routing__role${index === 0 ? ' is-primary' : ''}` }, index === 0 ? t('routePrimaryTag') : `${t('routeFallbackTag')}${index}`),
2278
- // 原地改(1.7.0):提供方 / 模型直接是下拉框,不用删了重加再连点 ↑。
2279
- // 目录里没有这家(没配置 / 目录没拉到)时退回纯文本,不让一行坏数据变成下拉框里的空值。
2280
- catalogEntryFor(candidate.provider) !== undefined
2281
- ? h('span', { className: 'dsh-llm-hub-routing__pick' },
2282
- h('select', {
2283
- className: 'dsh-llm-hub-routing__select',
2284
- value: candidate.provider,
2285
- title: t('routeAddProvider'),
2286
- onChange: (event) => changeProvider(group.id, index, event.target.value)
2287
- }, ...catalog.map((entry) => h('option', { key: entry.id, value: entry.id }, `${entry.displayName ?? entry.id}`))),
2288
- h('select', {
2289
- className: 'dsh-llm-hub-routing__select',
2290
- value: candidate.model,
2291
- title: t('routeAddModel'),
2292
- onChange: (event) => updateCandidate(group.id, index, { model: event.target.value })
2293
- }, ...modelOptionsFor(candidate.provider, candidate.model).map((id) => h('option', { key: id, value: id }, id))))
2294
- : h('span', { className: 'dsh-llm-hub-routing__name' }, `${candidate.provider}/${candidate.model}`),
2295
- // 行排成固定两行网格(1.7.0):第一行 角色 · 提供方 · 模型 · 能力/状态,
2296
- // 第二行 备注(占满) · 操作按钮(右对齐)。原来全部 flex-wrap,每行按内容长短
2297
- // 在不同位置折行,五行排不成列(浏览器实测截图)。
2298
- // 第二行:备注(撑满)· 能力/状态标签 · 操作按钮。标签原来在第一行最右,固定吃掉约 180px,
2299
- // 两个下拉被挤到截断「Antigravity 本地反…」「MiniMax-M3.1-Flash-Prev…」。
2300
- h('span', { className: 'dsh-llm-hub-routing__line2' },
2301
- h('input', {
2302
- className: 'dsh-llm-hub-routing__note',
2303
- value: candidate.note ?? '',
2304
- placeholder: t('routeNotePlaceholder'),
2305
- title: candidate.note ?? '',
2306
- onChange: (event) => updateCandidate(group.id, index, { note: event.target.value })
2307
- }),
2308
- h('span', { className: 'dsh-llm-hub-routing__meta' },
2309
- // 窄容器只放图标,文字进 title(1.7.0):「思考 high/max / 图像 / 可用」三枚文字标签
2310
- // 吃掉约 176px,备注框被挤到只剩 150px、备注被截断(浏览器实测)。
2311
- reasoning !== undefined
2312
- ? h('span', {
2313
- key: 'reasoning',
2314
- className: 'dsh-llm-hub-routing__reasoning',
2315
- title: `${t('routeReasoningShort')} ${reasoningShort(reasoning)}\n${reasoningTip}`
2316
- }, '💭')
2317
- : null,
2318
- multimodal === true
2319
- ? h('span', {
2320
- key: 'multimodal',
2321
- className: 'dsh-llm-hub-routing__multimodal',
2322
- title: `${t('routeMultimodalShort')} · ${t('routeMultimodalTip')}`
2323
- }, '👁')
2324
- : null,
2325
- h('span', {
2326
- className: `dsh-llm-hub-routing__dot dsh-llm-hub-routing__dot--${liveCandidate?.state ?? 'unknown'}`,
2327
- title: liveCandidate?.state === 'available' ? '可用' : (liveCandidate?.state === 'unknown' ? '未探明' : '不可用')
2328
- })),
2329
- h('span', { className: 'dsh-llm-hub-routing__ops' },
2330
- index > 0
2331
- ? h('button', { type: 'button', className: 'dsh-llm-hub-routing__op', onClick: () => makePrimary(group.id, index) }, t('routeMakePrimary'))
2332
- : null,
2333
- h('button', { type: 'button', className: 'dsh-llm-hub-routing__op', title: t('routeUp'), disabled: index === 0, onClick: () => moveCandidate(group.id, index, -1) }, '↑'),
2334
- h('button', { type: 'button', className: 'dsh-llm-hub-routing__op', title: t('routeDown'), disabled: index === candidates.length - 1, onClick: () => moveCandidate(group.id, index, 1) }, '↓'),
2335
- h('button', { type: 'button', className: 'dsh-llm-hub-routing__op dsh-llm-hub-routing__op--danger', title: t('routeRemove'), onClick: () => removeCandidate(group.id, index) }, '✕'))))
2336
- }),
2337
- h('div', { className: 'dsh-llm-hub-routing__add' },
2338
- h('span', null, t('routeAddProvider')),
2339
- catalog.length > 0
2340
- ? h('select', {
2341
- className: 'dsh-llm-hub-routing__select',
2342
- value: picker.provider,
2343
- onChange: (event) => {
2344
- const provider = event.target.value
2345
- const entry = catalog.find((item) => item.id === provider)
2346
- setPicker({ provider, model: entry?.models?.[0]?.id ?? '' })
2347
- }
2348
- },
2349
- h('option', { value: '' }, '—'),
2350
- ...catalog.map((entry) => h('option', { key: entry.id, value: entry.id }, `${entry.displayName ?? entry.id}`)))
2351
- // 目录读不到(host 还没重启 / settings 里没有 provider)时退回手输 ——
2352
- // 不让「拉不到目录」变成「编辑不了」。
2353
- : h('input', {
2354
- className: 'dsh-llm-hub-routing__select',
2355
- placeholder: 'provider',
2356
- value: picker.provider,
2357
- onChange: (event) => setPicker({ provider: event.target.value, model: picker.model })
2358
- }),
2359
- h('span', null, t('routeAddModel')),
2360
- catalog.find((item) => item.id === picker.provider) !== undefined
2361
- ? h('select', {
2362
- className: 'dsh-llm-hub-routing__select',
2363
- value: picker.model,
2364
- onChange: (event) => setPicker({ provider: picker.provider, model: event.target.value })
2365
- },
2366
- h('option', { value: '' }, '—'),
2367
- ...((catalog.find((item) => item.id === picker.provider)?.models) ?? []).map((model) => {
2368
- const multimodal = Array.isArray(model?.input) && model.input.some((kind) => kind !== 'text')
2369
- const reasoningLevels = Array.isArray(model?.reasoning?.levels) ? model.reasoning.levels : []
2370
- const flags = []
2371
- if (reasoningLevels.length > 0) flags.push(`💭 ${reasoningShort(reasoningLevels)}`)
2372
- if (multimodal === true) flags.push('👁')
2373
- return h('option', { key: model.id, value: model.id }, flags.length === 0 ? model.id : `${model.id} ${flags.join(' ')}`)
2374
- }))
2375
- : h('input', {
2376
- className: 'dsh-llm-hub-routing__select',
2377
- placeholder: 'model',
2378
- value: picker.model,
2379
- onChange: (event) => setPicker({ provider: picker.provider, model: event.target.value })
2380
- }),
2381
- h('button', {
2382
- type: 'button',
2383
- className: 'dsh-llm-hub-btn',
2384
- onClick: () => addCandidate(group.id)
2385
- }, t('routeAdd'))),
2386
- catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null)
2387
- : null)
2388
- })
2389
- // 空闲态不再写死「已保存」—— 那三个字跟「刚刚存成功」长得一模一样,
2390
- // 读起来像一条一直挂着的成功提示,而它其实什么都没说。空闲时干脆不占位,
2391
- // 真存过 / 存到一半 / 存失败才给反馈。
2392
- const noticing = notice === 'fail' || notice === 'saving' || notice === 'saved'
2393
- const footer = h('div', { className: 'dsh-llm-hub-routing__actions' },
2394
- h('button', { type: 'button', className: 'dsh-llm-hub-btn', onClick: addGroup }, t('routeNewGroup')),
2395
- // 注意这里不能写成 h(cond ? <span/> : null) —— 多包的那层 h 没有 tag,
2396
- // flatten 不递归进去,状态文案在测试里就永远取不到(真实浏览器里没事,
2397
- // 因为 DOM 照样渲染 —— 正是「测试挂了但界面看着正常」的那类坑)。
2398
- noticing || busy || isDirty
2399
- ? h('span', { className: `dsh-llm-hub-routing__status${notice === 'fail' ? ' is-fail' : ''}` },
2400
- notice === 'fail' ? t('routeSaveFailed')
2401
- : (busy || notice === 'saving' || isDirty) ? t('routeSaving')
2402
- : t('routeAutoSaved'))
2403
- : null,
2404
- notice === 'fail'
2405
- ? h('button', { type: 'button', className: 'dsh-llm-hub-btn', disabled: busy, onClick: () => { setNotice(null); save() } }, t('routeRetry'))
2406
- : null,
2407
- h('span', { className: 'dsh-llm-hub-routing__hint' }, t('routeEditHint')))
2408
- return h('div', { className: 'dsh-llm-hub-routing dsh-llm-hub-routing-page' },
2409
- header,
2410
- pageDesc,
2411
- rule,
2412
- noticeLine,
2413
- nowLine,
2414
- h('div', { className: 'dsh-llm-hub-routing__groups' }, groupRows),
2415
- footer)
2416
- }
2417
-
2418
- /**
2419
- * 「路由」设置页:页头(标题 + 一句怎么用)+ 路由编辑器。
2420
- * 直接调用 RoutingCard(props) 而不是 h(RoutingCard):少一层组件,hooks 顺序不变。
2421
- */
2422
- function RoutingSection(props) {
2423
- return RoutingCard(props)
2424
- }
2425
-
2426
1912
  /**
2427
1913
  * 格式化 token 数字 —— 大数带千分位,>9999 折成 1.2万。
2428
1914
  * @param value - token 数。
@@ -2555,10 +2041,11 @@ window.__ModuleLoader__.load({
2555
2041
  // 只有手写 package.json 或从别处搬时才需要手动补,所以标成「如果没自动加」。
2556
2042
  const text = [
2557
2043
  `${meta.name} —— DSH 模型页增强:网关可达性探测、余额/配额、模型目录勾选写回配置。`,
2558
- '装到你的 DSH(profile 名按自己的改,默认 web):',
2559
- ` 1) dsh plugin --profile web add ${meta.name}`,
2560
- ` 2) 确认 "${meta.name}" 在 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles 里(dsh plugin add 会自动加)`,
2561
- ' 3) 重启 DSH',
2044
+ '装到你的 DSH:',
2045
+ ' · 桌面端 app:在应用内的「插件管理」里搜 ' + meta.name + ' 直接装',
2046
+ ' (app 独占托管 desktop profile,CLI 的 dsh plugin --profile desktop 会被拒)',
2047
+ ` · 自建 CLI profile:dsh plugin --profile <你的profile名> add ${meta.name}`,
2048
+ ` 然后确认 "${meta.name}" 在该 profile 的 package.json 的 dsh.profile.bundles 里(plugin add 会自动加),重启 DSH`,
2562
2049
  typeof meta.homepage === 'string' ? `仓库:${meta.homepage}` : ''
2563
2050
  ].filter((line) => line !== '').join('\n')
2564
2051
  navigator.clipboard?.writeText(text).then(() => setShared(true)).catch(() => setShared(false))
@@ -2679,7 +2166,17 @@ window.__ModuleLoader__.load({
2679
2166
  const handleCopy = (pkg) => {
2680
2167
  // 命令必须带 `--profile`:`dsh plugin` 是 pnpm 的透传层,profile 是
2681
2168
  // **必填**选项,漏了直接报错退出 —— 复制出去的命令得是能跑的。
2682
- const cmd = `dsh plugin --profile web add ${pkg}`
2169
+ //
2170
+ // ⚠️ 2026-10-12 改:原来写死 `--profile web`。web profile 已于 2026-10-02
2171
+ // 退役,当前宿主是桌面端 app(profile `desktop`,app 独占托管,CLI 会被
2172
+ // 拒)。写死任何具体 profile 名都等于给一条必然过期的指令,所以两条路
2173
+ // 都给:app 里走插件管理,CLI profile 名留占位符由对方自己填。
2174
+ const cmd = [
2175
+ `# 桌面端 app:在应用内的「插件管理」里搜 ${pkg} 直接装`,
2176
+ `# (app 独占托管 desktop profile,CLI 的 dsh plugin --profile desktop 会被拒)`,
2177
+ `# 自建 CLI profile:`,
2178
+ `dsh plugin --profile <你的profile名> add ${pkg}`
2179
+ ].join('\n')
2683
2180
  if (navigator.clipboard) {
2684
2181
  navigator.clipboard.writeText(cmd)
2685
2182
  }
@@ -2726,7 +2223,7 @@ window.__ModuleLoader__.load({
2726
2223
  type: 'button',
2727
2224
  className: 'dsh-suite-btn-action',
2728
2225
  onClick: () => handleCopy(item.pkg),
2729
- title: `安装命令: dsh plugin --profile web add ${item.pkg}`
2226
+ title: `安装:app 插件管理搜 ${item.pkg},或 dsh plugin --profile <你的profile名> add ${item.pkg}`
2730
2227
  }, isCopied ? `✓ ${t('suiteCopied')}` : `⚡ ${t('suiteCopyInstall')}`)
2731
2228
  ),
2732
2229
  h('a', {
@@ -2912,17 +2409,6 @@ window.__ModuleLoader__.load({
2912
2409
  locale: NS,
2913
2410
  inject: () => ({ t, availability })
2914
2411
  }, HealthCard))
2915
- // 路由配置是独立的一页(1.7.0):原来挂在「模型」页脚第 75 位,要往下翻好几屏才找得到,
2916
- // 用户的原话是「我自己怎么选主力、在哪里改」。settings.section 的 order:
2917
- // 通用 0 / 模型 10 / 插件 15 —— 取 12,正好排在「模型」下面。
2918
- ctx.slots.inject('settings.section', () => ctx.slots.register({
2919
- name: 'settings.section',
2920
- id: 'dsh-llm-hub-routing',
2921
- order: 12,
2922
- label: () => t('routingNav'),
2923
- locale: NS,
2924
- inject: () => ({ t })
2925
- }, RoutingSection))
2926
2412
  ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2927
2413
  name: 'settings.models.footer',
2928
2414
  id: 'dsh-llm-hub-usage',
@@ -2951,15 +2437,17 @@ window.__ModuleLoader__.load({
2951
2437
  locale: NS,
2952
2438
  inject: () => ({ t })
2953
2439
  }, WebkuborSuiteDock))
2954
- // composer 工具行左端的路由快切 chip —— 「切公司 / 切个人 + 智能开关」
2955
- // 的高频操作留在聊天页,不用进设置页。list slot 必须带 id(同 footer 的教训)。
2440
+ // composer 工具行左端的信息位 —— 当前供应商 / 模型 / 可用数 / 配额。
2441
+ // 2026-10-09 起这个位置不再是「切公司 / 个人 + 智能开关」的路由入口:
2442
+ // owner 决定去掉那整块路由能力,位置改放只读状态(点开看详情)。
2443
+ // list slot 必须带 id(同 footer 的教训)。
2956
2444
  ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
2957
2445
  name: 'conversation.input.left',
2958
- id: 'dsh-llm-hub-route-chip',
2446
+ id: 'dsh-llm-hub-provider-usage',
2959
2447
  order: 20,
2960
2448
  locale: NS,
2961
2449
  inject: () => ({ t })
2962
- }, SmartRouteChip))
2450
+ }, ProviderUsageChip))
2963
2451
  }
2964
2452
 
2965
2453
  exports.apply = apply