@dsh-plugins/dsh-llm-hub 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.7.0] - 2026-09-29
6
+
7
+ ### 🧭 路由设置卡:始终折叠,展开才编辑
8
+
9
+ - **默认全折叠**,当前组也不例外(反了 1.7.0 之前的「当前组默认展开」)。进这页要回答的是「这几组各自会路由到哪」,折叠行就答完了;逐条候选是低频编辑动作,默认展开等于一进来就糊一屏表单
10
+ - 折叠行折叠时组名是**纯文本**,展开才给输入框 —— 折叠行还摆着输入框,会让「在编辑」和「在显示」长得一样
11
+ - 折叠行只留三件事:**会路由到哪** · **可用/总** · 组名。为此砍掉两处:
12
+ - 「主力 provider/model」—— 答的是「第一个是谁」,展开态第一行就有
13
+ - 「+N 条兜底」—— 与「4/5」同义重复
14
+ - 新增组健康点三色:全绿(都可用)/ 琥珀(还剩几个)/ 红(一个不剩)。半可用混成全绿会让人以为不用管,混成全红会误报故障
15
+ - 全不可用时,不可用原因顶替「会路由到」那个位置 —— 两者是同一个槽位,不是两件事
16
+
17
+ **踩坑(都靠浏览器实测发现,单测一条都拦不住)**
18
+
19
+ - 设置内容区只有 564px,组头扣掉序号/组名/两个按钮后摘要只剩 ~210px,真实数据的 provider/model 要 200-215px。差的这几十像素省不掉:先砍内容,仍不够才改成**换行**而不是截断 —— 砍掉的正好是判断路由最需要的模型名
20
+ - `.dot--degraded` 一开始写在基础 `.dot` 规则**之前**:同特异度下后者生效,灰底把琥珀色盖掉,`4/5` 显示成灰点。已移到基础规则之后
21
+ - DSH 的 client bundle 缓存很硬:`Page.reload --ignoreCache` 不足以刷新样式表,必须 `Network.clearBrowserCache`。中途因此把「源码已改、页面没变」误判成 CSS 没写对
22
+
23
+ ## [1.6.2] - 2026-09-29
24
+
25
+ ### 🐛 修复:路由快切 chip 的浮层点完不消失
26
+
27
+ - 症状:在浮层里点「智能路由 / 手动模型」或点某个组,按钮高亮变了、状态也切了,浮层却纹丝不动地盖在输入框上,只能**再点一次 chip** 才收得回去(2026-09-29 浏览器实测)
28
+ - 根因:`open` 只有 chip 按钮那一个 toggle 在写 —— 菜单内所有 `onClick` 都只调 `select()`,从不 `setOpen(false)`,也没有「点外部 / Esc」的兜底
29
+ - 修复:
30
+ - 点组 / 切模式**写成功后立刻收起**浮层(写失败**不收** —— 状态没变,收起来等于把失败藏了)
31
+ - 点当前**已生效**的那个模式(本来就没请求可发)也收起,否则「点了完全没反应」会被当成界面卡住
32
+ - 补上「点浮层外面」与「Esc」两条关闭路径;只在 `open` 时挂 document 监听,卸载时卸干净
33
+ - 测试:新增 7 条回归。React 桩补上 setter / ref / effect 记录 —— 「点完浮层有没有收起」看渲染树验不出来,发生在事件回调里,只能观察状态被写成了什么
34
+
5
35
  ## [1.6.1] - 2026-09-28
6
36
 
7
37
  ### 🧹 去掉模型页底部的「热门大模型一键装配」,改为添加时按厂商匹配
package/lib/client.js CHANGED
@@ -168,6 +168,10 @@ window.__ModuleLoader__.load({
168
168
  routingPrimary: '主力',
169
169
  routingNoRecommendation: '全部不可用',
170
170
  routingLoading: '路由解析中…',
171
+ routingNav: '路由',
172
+ // 只讲「这页怎么改」。原先这里还复述了一遍「按顺序取第一个可用的」,
173
+ // 而下面的规则块和模式提示已经各讲了一次 —— 同一件事在三屏之内说了三遍。
174
+ routingPageDesc: '每个组是一串按优先级排的模型:第一个是主力,后面依次兜底。点「设为主力」或直接在下拉框里换模型来调整顺序。',
171
175
  routeChipSmart: '智能',
172
176
  routeChipManual: '手动',
173
177
  routeChipModeSmart: '智能路由',
@@ -180,10 +184,18 @@ window.__ModuleLoader__.load({
180
184
  routeRuleText: '按组内顺序取第一个可用;探测失败或额度耗尽的自动跳过;全不可用则保持原样(fail-open)。顺序即优先级 —— 固定成本(包月/订阅)放前面,按量付费放后面兜底。',
181
185
  routeSmartNotice: '已接管:输入框的模型下拉只作参考,实际请求按本卡规则走(实时结果见聊天框左侧 chip)。',
182
186
  routeManualNotice: '未接管:请求还是用你在模型下拉里选的;点「智能路由」才按本卡规则自动切。',
183
- routeEditHint: '编辑后点保存写入 settings.dsh-llm-hub.routing',
187
+ routeEditHint: '改动会自动保存到 settings.dsh-llm-hub.routing',
184
188
  routeSave: '保存',
185
189
  routeSaved: '已保存',
186
190
  routeSaveFailed: '保存失败',
191
+ routeSaving: '保存中…',
192
+ routeAutoSaved: '已自动保存',
193
+ routeRetry: '重试',
194
+ routeEdit: '编辑',
195
+ routeCollapse: '收起',
196
+ routeMakePrimary: '设为主力',
197
+ routeNotePlaceholder: '备注',
198
+ routeGroupNotePlaceholder: '这组的备注(比如:公司报销额度,先用它)',
187
199
  routeAddCandidate: '+ 添加候选',
188
200
  routeAddProvider: '提供方',
189
201
  routeAddModel: '模型',
@@ -318,6 +330,8 @@ window.__ModuleLoader__.load({
318
330
  routingPrimary: 'primary',
319
331
  routingNoRecommendation: 'none available',
320
332
  routingLoading: 'resolving routing…',
333
+ routingNav: 'Routing',
334
+ routingPageDesc: 'Each group is a priority-ordered list: the first is primary, the rest follow as fallbacks. Use “Make primary” or swap the model in a dropdown to change the order.',
321
335
  routeChipSmart: 'Smart',
322
336
  routeChipManual: 'Manual',
323
337
  routeChipModeSmart: 'Smart routing',
@@ -330,7 +344,15 @@ window.__ModuleLoader__.load({
330
344
  routeRuleText: 'First usable candidate in group order wins; failed probes and exhausted quotas are skipped; if none is usable the original selection is kept (fail-open). Order is priority — fixed-cost plans first, metered pay-as-you-go last.',
331
345
  routeSmartNotice: 'Takeover is on: the composer model dropdown is only a reference; requests follow this card (live result shows in the chip beside the composer).',
332
346
  routeManualNotice: 'Not taking over: requests still use the model you picked in the dropdown. Switch to Smart routing to auto-select.',
333
- routeEditHint: 'Save writes settings.dsh-llm-hub.routing',
347
+ routeEditHint: 'Changes auto-save to settings.dsh-llm-hub.routing',
348
+ routeSaving: 'Saving…',
349
+ routeAutoSaved: 'Auto-saved',
350
+ routeRetry: 'Retry',
351
+ routeEdit: 'Edit',
352
+ routeCollapse: 'Collapse',
353
+ routeMakePrimary: 'Make primary',
354
+ routeNotePlaceholder: 'Note',
355
+ routeGroupNotePlaceholder: 'Group note (e.g. company quota, use first)',
334
356
  routeSave: 'Save',
335
357
  routeSaved: 'Saved',
336
358
  routeSaveFailed: 'Save failed',
@@ -589,15 +611,20 @@ window.__ModuleLoader__.load({
589
611
  '.dsh-llm-hub-routing__active{color:var(--dsw-alias-label-primary)}',
590
612
  '.dsh-llm-hub-routing__rows{display:flex;flex-direction:column;gap:2px}',
591
613
  '.dsh-llm-hub-routing__row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px}',
592
- '.dsh-llm-hub-routing__role{display:inline-flex;align-items:center;height:16px;padding:0 5px;',
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;',
593
615
  'border-radius:3px;border:.5px solid var(--dsw-alias-border-l2);',
594
- 'background:transparent;font-size:10px;color:var(--dsw-alias-label-secondary)}',
616
+ 'background:transparent;font-size:10.5px;color:var(--dsw-alias-label-secondary);transition:all .15s ease}',
617
+ // 主力:品牌色徽标,与兜底行的中性色拉开差距
618
+ '.dsh-llm-hub-routing__role.is-primary{',
619
+ 'color:var(--dsw-alias-brand-primary,#3b82f6);',
620
+ 'border-color:var(--dsw-alias-brand-primary,#3b82f6);',
621
+ 'background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.06));font-weight:600}',
595
622
  '.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
596
623
  // capability 标识:思考 / 多模态。两枚 chip 同形,左思考、右图像;
597
624
  // 边色走 state 提示色,跟健康状态 chip 区分开 —— 一个说「能干什么」,
598
625
  // 一个说「能不能用」。
599
626
  '.dsh-llm-hub-routing__reasoning,.dsh-llm-hub-routing__multimodal{',
600
- 'display:inline-flex;align-items:center;height:16px;padding:0 6px;',
627
+ 'display:inline-flex;align-items:center;justify-content:center;height:18px;min-width:20px;padding:0 3px;',
601
628
  'border-radius:3px;font-size:10px;line-height:14px;font-weight:500;',
602
629
  'background:transparent;cursor:help}',
603
630
  // 思考:走 warn 主题色(消耗更多 token、需要警觉)
@@ -643,31 +670,99 @@ window.__ModuleLoader__.load({
643
670
  'font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;line-height:1.4}',
644
671
  // 智能路由编辑器(设置页路由卡)
645
672
  '.dsh-llm-hub-routing__modes{display:flex;gap:4px;margin-left:auto}',
646
- '.dsh-llm-hub-routing__rule{display:flex;gap:6px;align-items:flex-start;font-size:10.5px;line-height:1.5;',
647
- 'color:var(--dsw-alias-label-secondary);padding:6px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2)}',
648
- '.dsh-llm-hub-routing__rule-tag{flex:none;font-weight:600;color:var(--dsw-alias-label-primary)}',
673
+ // 规则说明:去掉盒子背景,只留淡灰单行注脚,视觉权重要低于第 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
+ '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)}',
649
677
  '.dsh-llm-hub-routing__now{font-size:11px;color:var(--dsw-alias-label-primary);font-weight:600}',
650
678
  '.dsh-llm-hub-routing__groups{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
651
- '.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}',
652
- '.dsh-llm-hub-routing__group.is-active{border-color:var(--dsw-alias-state-warn-primary)}',
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
+ // 当前组:边框降一档 + 顶部 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)}',
653
682
  '.dsh-llm-hub-routing__group-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
654
- '.dsh-llm-hub-routing__group-toggle{border:0;background:0 0;padding:0;cursor:pointer;font:inherit;font-size:11px;color:var(--dsw-alias-label-secondary)}',
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
+ 'border:.5px solid var(--dsw-alias-border-l1);border-radius:4px;background:transparent;cursor:pointer;',
685
+ '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)}',
655
687
  '.dsh-llm-hub-routing__group-order{display:flex;align-items:center;gap:2px;flex:none}',
656
- '.dsh-llm-hub-routing__group-move{border:0;background:0 0;padding:0 2px;cursor:pointer;font:inherit;font-size:10px;',
657
- 'color:var(--dsw-alias-label-tertiary);line-height:1}',
658
- '.dsh-llm-hub-routing__group-move:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
688
+ '.dsh-llm-hub-routing__group-move{display:inline-flex;align-items:center;justify-content:center;width:18px;height:22px;',
689
+ 'border:0;background:transparent;padding:0;cursor:pointer;font:inherit;font-size:12px;line-height:1;',
690
+ '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)}',
659
692
  '.dsh-llm-hub-routing__group-move:disabled{opacity:.25;cursor:default}',
660
- '.dsh-llm-hub-routing__group-state b{font-weight:600;color:var(--dsw-alias-label-primary)}',
661
- '.dsh-llm-hub-routing__group-more{color:var(--dsw-alias-label-tertiary)}',
662
- '.dsh-llm-hub-routing__group-would{color:var(--dsw-alias-state-warn-primary);margin-left:5px}',
663
693
  '.dsh-llm-hub-routing__group-empty{font-style:normal;color:var(--dsw-alias-label-tertiary)}',
664
- '.dsh-llm-hub-routing__group-label{flex:0 0 128px;width:128px;box-sizing:border-box;height:22px;padding:0 6px;border-radius:4px;',
665
- 'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px}',
666
- '.dsh-llm-hub-routing__group-state{flex:1;min-width:0;font-size:10.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
694
+ // 折叠行(1.6.2):组名 + 「将路由到 X」+ 主力 + 兜底条数,一行读完。
695
+ // 组名给固定宽度,与下面展开态的 label 输入框同宽 —— 收起/展开时这一列不跳。
696
+ '.dsh-llm-hub-routing__group-name{flex:0 0 112px;min-width:0;overflow:hidden;text-overflow:ellipsis;',
697
+ '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
+ 'border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);',
700
+ '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
+ // 允许换行,不允许截断(1.6.2 浏览器实测后改的)。设置内容区只有 564px,
703
+ // 扣掉序号/组名/两个按钮,摘要只剩 ~210px;而真实数据的 provider/model
704
+ // 要 200-215px —— 差的这几十像素,省不掉,只能换行。之前用 nowrap+ellipsis
705
+ // 把「→ modelgo-gateway/claude-sonnet-5」砍成「→ modelgo-gatewa…」,
706
+ // 砍掉的正好是判断路由结果最需要的那段:看不出是 sonnet-5 还是别的。
707
+ // 组头因此高一行,是「读得全」该付的代价。
708
+ '.dsh-llm-hub-routing__group-state{flex:1;min-width:0;font-size:10.5px;line-height:1.5;',
709
+ 'color:var(--dsw-alias-label-secondary);display:flex;flex-wrap:wrap;align-items:center;gap:2px 8px}',
710
+ // 断行点放在 provider 与 model 之间的斜杠上:读起来是两条完整信息,
711
+ // 而不是把一个词从中间劈开。break-all 是兜底 —— provider id 里也有长词。
712
+ '.dsh-llm-hub-routing__group-would{font-weight:600;color:var(--dsw-alias-label-primary);',
713
+ 'min-width:0;word-break:break-word;overflow-wrap:anywhere}',
714
+ // 可用/总:紧跟在「将路由到」后面,且**不参与压缩** —— 压它等于把
715
+ // 「还剩几个能兜」这个答案藏掉,而它只要 30px。
716
+ '.dsh-llm-hub-routing__group-count{flex:none;display:inline-flex;align-items:center;gap:3px;',
717
+ '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}',
667
719
  '.dsh-llm-hub-routing__candidates{display:flex;flex-direction:column;gap:3px;padding-top:2px}',
720
+ // 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
+ '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
+ // 全组共用一张网格:行与 pick 都是 display:contents,列宽由整组一起决定 ——
724
+ // 各行自成网格时,右侧标签列按本行内容伸缩,模型下拉的右边缘每行都不一样(浏览器实测 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
+ // 标签区给固定宽度并靠右:有无「思考/图像」标签的行,备注框都一样宽,右边缘对齐。
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
+ // 半可用单独一色:全绿和全红之间还有「还剩几个」这个状态,
743
+ // 混成全绿会让人以为不用管,混成全红会误报故障。
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
+ // 同理固定宽度:主力行没有「设为主力」,按钮区只有 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
+ 'border:.5px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);',
750
+ '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
+ // 候选区是纵向 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)}',
668
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}',
669
- '.dsh-llm-hub-routing__select{box-sizing:border-box;height:22px;padding:0 4px;border-radius:4px;border:.5px solid var(--dsw-alias-border-l1);',
670
- 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;max-width:170px}',
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
+ 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;max-width:170px;outline:none;',
764
+ 'transition:border-color .15s ease;cursor:pointer}',
765
+ '.dsh-llm-hub-routing__select:focus{border-color:var(--dsw-alias-brand-primary,#3b82f6)}',
671
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}',
672
767
  '.dsh-llm-hub-routing__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
673
768
  '.dsh-llm-hub-routing__mm{display:inline-flex;align-items:center;margin-left:4px;font-size:10px;cursor:help;',
@@ -1675,6 +1770,8 @@ window.__ModuleLoader__.load({
1675
1770
  const [state, setState] = React.useState(null)
1676
1771
  const [open, setOpen] = React.useState(false)
1677
1772
  const [busy, setBusy] = React.useState(false)
1773
+ // 浮层根节点:判断「这次点击算不算点在浮层外面」要用它。
1774
+ const rootRef = React.useRef(null)
1678
1775
  const query = React.useCallback(() => {
1679
1776
  fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
1680
1777
  .then((res) => res.json())
@@ -1686,6 +1783,25 @@ window.__ModuleLoader__.load({
1686
1783
  const interval = setInterval(query, 30000)
1687
1784
  return () => clearInterval(interval)
1688
1785
  }, [query])
1786
+ // 点浮层外面 / 按 Esc 收起来。少了这条,用户在浮层里做完选择后
1787
+ // 手会先伸向输入框 —— 浮层却还盖在上面,不点第二次 chip 就挡着视线。
1788
+ // 只在 open 时挂监听:关着的时候不留全局 handler。
1789
+ // 必须写在下面那个 `state === null` 早返回之前 —— hooks 不能条件调用。
1790
+ React.useEffect(() => {
1791
+ if (!open) return undefined
1792
+ const onPointerDown = (event) => {
1793
+ const root = rootRef.current
1794
+ if (root !== null && root !== undefined && typeof root.contains === 'function' && root.contains(event.target)) return
1795
+ setOpen(false)
1796
+ }
1797
+ const onKeyDown = (event) => { if (event.key === 'Escape') setOpen(false) }
1798
+ document.addEventListener('mousedown', onPointerDown)
1799
+ document.addEventListener('keydown', onKeyDown)
1800
+ return () => {
1801
+ document.removeEventListener('mousedown', onPointerDown)
1802
+ document.removeEventListener('keydown', onKeyDown)
1803
+ }
1804
+ }, [open])
1689
1805
  // 一个组都没有:chip 无事可做,不渲染(配置在 settings.yaml 里加)。
1690
1806
  if (state === null || !Array.isArray(state.groups) || state.groups.length === 0) return null
1691
1807
  ensureStyle()
@@ -1708,7 +1824,10 @@ window.__ModuleLoader__.load({
1708
1824
  body: JSON.stringify(patch)
1709
1825
  })
1710
1826
  .then((res) => res.json())
1711
- .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) setState(body) })
1827
+ // 选完即收:浮层是「选一个值」的一次性动作,不是常驻面板。
1828
+ // 留着不关,用户会以为没点上,只能再点一次 chip 才收得回去。
1829
+ // 写失败时**不关** —— 状态没变,收起来等于把失败藏了。
1830
+ .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) { setState(body); setOpen(false) } })
1712
1831
  .catch(() => {})
1713
1832
  .finally(() => { setBusy(false); query() })
1714
1833
  }
@@ -1724,12 +1843,14 @@ window.__ModuleLoader__.load({
1724
1843
  h('button', {
1725
1844
  type: 'button',
1726
1845
  className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
1727
- onClick: () => { if (!isSmart) select({ mode: 'smart' }) }
1846
+ // 点当前已生效的那个模式:没有请求要发,但**照样收起浮层**。
1847
+ // 否则「点了完全没反应」,用户会当成界面卡住反复点。
1848
+ onClick: () => { if (isSmart) setOpen(false); else select({ mode: 'smart' }) }
1728
1849
  }, `⚡ ${t('routeChipModeSmart')}`),
1729
1850
  h('button', {
1730
1851
  type: 'button',
1731
1852
  className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
1732
- onClick: () => { if (isSmart) select({ mode: 'manual' }) }
1853
+ onClick: () => { if (!isSmart) setOpen(false); else select({ mode: 'manual' }) }
1733
1854
  }, t('routeChipModeManual'))),
1734
1855
  ...state.groups.map((group) => h('button', {
1735
1856
  key: group.id,
@@ -1752,7 +1873,7 @@ window.__ModuleLoader__.load({
1752
1873
  ? h('span', { className: 'dsh-llm-hub-route__hint' }, group.reason)
1753
1874
  : null)),
1754
1875
  h('div', { className: 'dsh-llm-hub-route__hint' }, t('routeRuleText'))) : null
1755
- return h('div', { className: 'dsh-llm-hub-route' },
1876
+ return h('div', { className: 'dsh-llm-hub-route', ref: rootRef },
1756
1877
  h('button', {
1757
1878
  type: 'button',
1758
1879
  className: chipClass,
@@ -1793,22 +1914,29 @@ window.__ModuleLoader__.load({
1793
1914
  // candidates[0] = 主力(primary),其余是 fallback —— 与 settings 形态一一对应。
1794
1915
  const [draft, setDraft] = React.useState(null)
1795
1916
  const [activeGroup, setActiveGroup] = React.useState('')
1796
- const [expanded, setExpanded] = React.useState('')
1917
+ // null = 还没手动开合过,此时默认展开当前组 —— 一进来就能看到、能改。
1918
+ const [expanded, setExpanded] = React.useState(null)
1797
1919
  const [picker, setPicker] = React.useState({ provider: '', model: '' })
1798
1920
  const [busy, setBusy] = React.useState(false)
1799
1921
  const [notice, setNotice] = React.useState(null)
1800
1922
  // 保存过/刚拉回的 draft 指纹:用它判断「脏」,避免后台刷新把用户正在编辑的内容冲掉。
1801
1923
  const [savedKey, setSavedKey] = React.useState('')
1802
1924
  // label 归一成「空 = null」:草稿里是 '',服务端回的是 null,不归一会永远显示「未保存」。
1803
- const draftKeyOf = (report) => JSON.stringify((report?.groups ?? []).map((group) => [
1804
- group.id,
1805
- group.label ? group.label : null,
1806
- (group.candidates ?? []).map((candidate) => `${candidate.provider}/${candidate.model}`)
1807
- ]))
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
+ ]))
1808
1935
  const draftFrom = (report) => (report.groups ?? []).map((group) => ({
1809
1936
  id: group.id,
1810
1937
  label: group.label ?? '',
1811
- candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model }))
1938
+ note: group.note ?? '',
1939
+ candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model, note: candidate.note ?? '' }))
1812
1940
  }))
1813
1941
  // force=true 只用在一处:保存成功后的回读 —— 那时服务端就是权威。
1814
1942
  // 其余时候(挂载、切模式、轮询)保留用户正在编辑的草稿。
@@ -1831,16 +1959,25 @@ window.__ModuleLoader__.load({
1831
1959
  .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true && Array.isArray(body.providers)) setCatalog(body.providers) })
1832
1960
  .catch(() => {})
1833
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])
1834
1971
  if (state === null) {
1835
- return h('div', { className: 'dsh-llm-hub-routing' },
1836
- h('div', { className: 'dsh-llm-hub-routing__head' },
1837
- h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
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')),
1838
1975
  h('span', null, t('routingLoading'))))
1839
1976
  }
1840
1977
  ensureStyle()
1841
1978
  const isSmart = state.mode === 'smart'
1842
1979
  const groups = draft ?? []
1843
- const isDirty = draft !== null && draftKeyOf({ groups: groups.map((group) => ({ id: group.id, label: group.label, candidates: group.candidates })) }) !== savedKey
1980
+ const isDirty = draft !== null && draftKeyOf({ groups }) !== savedKey
1844
1981
  const post = (url, payload) => fetch(url, {
1845
1982
  method: 'POST',
1846
1983
  headers: { 'content-type': 'application/json', accept: 'application/json' },
@@ -1859,18 +1996,23 @@ window.__ModuleLoader__.load({
1859
1996
  .then((body) => { if (body?.ok === true) adopt(body) })
1860
1997
  .catch(() => setNotice('fail'))
1861
1998
  }
1999
+ // 自动保存(1.7.0):原来要去卡片最底下点「保存」,改完就走的人会丢改动。
2000
+ // 草稿变「脏」后 800ms 防抖落盘;回读**不强制**覆盖草稿 —— 用户可能还在打字。
2001
+ // 只提交有候选的组(空组服务端本来就丢弃),草稿里的空组留在页面上。
1862
2002
  const save = () => {
1863
- if (groups.length === 0) return
2003
+ const payload = groups.filter((group) => group.candidates.length > 0)
2004
+ if (payload.length === 0) return
1864
2005
  setBusy(true)
1865
- setNotice(null)
1866
- post(ROUTING_CONFIG_URL, { groups, activeGroup })
2006
+ setNotice('saving')
2007
+ post(ROUTING_CONFIG_URL, { groups: payload, activeGroup })
1867
2008
  .then((body) => {
1868
- if (body?.ok === true) { adopt(body, true); setNotice('saved') }
2009
+ if (body?.ok === true) { adopt(body); setNotice('saved') }
1869
2010
  else setNotice('fail')
1870
2011
  })
1871
2012
  .catch(() => setNotice('fail'))
1872
2013
  .finally(() => setBusy(false))
1873
2014
  }
2015
+ saveRef.current = save
1874
2016
  // ── 草稿编辑(全部是纯函数式更新,不碰服务端;保存才落盘)──
1875
2017
  const editGroup = (id, mutate) => setDraft(groups.map((group) => (group.id === id ? mutate(group) : group)))
1876
2018
  const moveCandidate = (id, index, delta) => editGroup(id, (group) => {
@@ -1900,26 +2042,64 @@ window.__ModuleLoader__.load({
1900
2042
  next.splice(target, 0, item)
1901
2043
  setDraft(next)
1902
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
+ })
1903
2059
  const removeCandidate = (id, index) => editGroup(id, (group) => ({ ...group, candidates: group.candidates.filter((_, i) => i !== index) }))
1904
2060
  const addCandidate = (id) => {
1905
2061
  const provider = picker.provider
1906
2062
  const model = picker.model
1907
2063
  if (provider === '' || model === '') return
1908
- editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model }] }))
2064
+ editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model, note: '' }] }))
1909
2065
  setPicker({ provider: '', model: '' })
1910
2066
  }
1911
2067
  const addGroup = () => {
1912
2068
  let index = groups.length + 1
1913
2069
  let id = `group-${index}`
1914
2070
  while (groups.some((group) => group.id === id)) { index += 1; id = `group-${index}` }
1915
- setDraft([...groups, { id, label: '', candidates: [] }])
2071
+ setDraft([...groups, { id, label: '', note: '', candidates: [] }])
1916
2072
  setExpanded(id)
1917
2073
  }
1918
2074
  const removeGroup = (id) => {
1919
2075
  setDraft(groups.filter((group) => group.id !== id))
1920
- if (expanded === id) setExpanded('')
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
1921
2082
  }
1922
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
+ }
1923
2103
  /**
1924
2104
  * 一个候选是否多模态(输入含 text 之外的模态,典型是 image)。
1925
2105
  *
@@ -1963,8 +2143,9 @@ window.__ModuleLoader__.load({
1963
2143
  if (group?.wouldRoute) return `${t('routeChipWouldRoute')}: ${group.wouldRoute.provider}/${group.wouldRoute.model}`
1964
2144
  return group?.reason ?? t('routeChipReasonNone')
1965
2145
  }
1966
- const header = h('div', { className: 'dsh-llm-hub-routing__head' },
1967
- h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
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')),
1968
2149
  h('div', { className: 'dsh-llm-hub-routing__modes' },
1969
2150
  h('button', {
1970
2151
  type: 'button',
@@ -1976,25 +2157,29 @@ window.__ModuleLoader__.load({
1976
2157
  className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
1977
2158
  onClick: () => { if (isSmart) setMode('manual') }
1978
2159
  }, t('routeChipModeManual'))))
1979
- const rule = h('div', { className: 'dsh-llm-hub-routing__rule' },
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' },
1980
2162
  h('span', { className: 'dsh-llm-hub-routing__rule-tag' }, t('routeRuleTitle')),
1981
2163
  h('span', null, t('routeRuleText')))
1982
- const noticeLine = h('div', { className: 'dsh-llm-hub-routing__reason' },
1983
- isSmart ? t('routeSmartNotice') : t('routeManualNotice'))
2164
+ // 只在智能路由真的接管时提示。手动模式下那行「请求还是用你在模型下拉里
2165
+ // 选的」讲的是当前高亮的按钮本身,没有新信息,却常驻占一整行;
2166
+ // 而接管后「下拉只作参考」是按钮推不出来的,必须留着。
2167
+ const noticeLine = isSmart
2168
+ ? h('div', { className: 'dsh-llm-hub-routing__reason' }, t('routeSmartNotice'))
2169
+ : null
1984
2170
  const nowLine = h('div', { className: 'dsh-llm-hub-routing__now' }, resolvedText())
1985
2171
  const groupRows = groups.map((group, groupIndex) => {
1986
2172
  const live = state.groups.find((item) => item.id === group.id)
2173
+ // 始终折叠,展开才编辑(1.6.2)。原先是「当前组默认展开」,于是这页一进来
2174
+ // 就是一屏逐条候选的表单 —— 而进来看的是「这几组各自会路由到哪」,
2175
+ // 那是折叠行就能答完的问题。编辑是低频动作,折叠是默认态。
1987
2176
  const isOpen = expanded === group.id
1988
2177
  const candidates = group.candidates
1989
2178
  return h('div', { key: group.id, className: `dsh-llm-hub-routing__group${activeGroup === group.id ? ' is-active' : ''}` },
1990
2179
  h('div', { className: 'dsh-llm-hub-routing__group-top' },
1991
2180
  // 顺序即优先级,所以序号 + 上下移都得露在折叠态 —— 折叠着也能改顺序。
1992
2181
  h('div', { className: 'dsh-llm-hub-routing__group-order' },
1993
- h('button', {
1994
- type: 'button',
1995
- className: 'dsh-llm-hub-routing__group-toggle',
1996
- onClick: () => setExpanded(isOpen ? '' : group.id)
1997
- }, `${isOpen ? '▾' : '▸'} ${groupIndex + 1}`),
2182
+ h('span', { className: 'dsh-llm-hub-routing__group-index' }, `${groupIndex + 1}`),
1998
2183
  h('button', {
1999
2184
  type: 'button',
2000
2185
  className: 'dsh-llm-hub-routing__group-move',
@@ -2009,42 +2194,78 @@ window.__ModuleLoader__.load({
2009
2194
  disabled: groupIndex === groups.length - 1,
2010
2195
  onClick: () => moveGroup(groupIndex, 1)
2011
2196
  }, '↓')),
2012
- h('input', {
2013
- className: 'dsh-llm-hub-routing__group-label',
2014
- value: group.label ?? '',
2015
- placeholder: t('routeGroupLabelPlaceholder'),
2016
- onChange: (event) => editGroup(group.id, (current) => ({ ...current, label: event.target.value }))
2017
- }),
2018
- // 折叠态给出「这一组会按什么顺序试」的摘要:主力 + 兜底条数。
2019
- // 之前顺序完全藏在折叠区里,用户看不到自己在排什么。
2020
- h('span', { className: 'dsh-llm-hub-routing__group-state' },
2021
- candidates.length === 0
2022
- ? h('em', { className: 'dsh-llm-hub-routing__group-empty' }, t('routeNoCandidate'))
2023
- : [
2024
- h('b', { key: 'p' }, `${candidates[0].provider}/${candidates[0].model}`),
2025
- candidates.length > 1
2026
- ? h('span', { key: 'n', className: 'dsh-llm-hub-routing__group-more' },
2027
- ` + ${candidates.length - 1} ${t('routeFallbackCount')}`)
2028
- : null,
2029
- // 只在「实际会切到非主模型」时才提示 —— 跟主模型相同的箭头是纯冗余,
2030
- // 还会把摘要挤到省略号里。真正有信息量的只有「兜底被选上了」。
2031
- live?.wouldRoute && `${live.wouldRoute.provider}/${live.wouldRoute.model}` !== `${candidates[0].provider}/${candidates[0].model}`
2032
- ? h('span', { key: 'w', className: 'dsh-llm-hub-routing__group-would' },
2033
- `→ ${live.wouldRoute.provider}/${live.wouldRoute.model}`)
2034
- : null,
2035
- live?.reason
2036
- ? h('span', { key: 'r', className: 'dsh-llm-hub-routing__group-would' }, live.reason)
2037
- : null,
2038
- ]),
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,
2039
2238
  activeGroup === group.id
2040
2239
  ? h('span', { className: 'dsh-llm-hub-routing__role' }, t('routeActiveTag'))
2041
2240
  : h('button', {
2042
2241
  type: 'button',
2043
2242
  className: 'dsh-llm-hub-foot__link',
2044
2243
  onClick: () => markActive(group.id)
2045
- }, t('routeSetActive'))),
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')}`)),
2046
2261
  isOpen
2047
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
+ }),
2048
2269
  ...candidates.map((candidate, index) => {
2049
2270
  const liveCandidate = live?.candidates?.find((item) => item.provider === candidate.provider && item.model === candidate.model)
2050
2271
  const multimodal = isMultimodal(candidate.provider, candidate.model)
@@ -2052,28 +2273,67 @@ window.__ModuleLoader__.load({
2052
2273
  const reasoningTip = reasoning === undefined
2053
2274
  ? undefined
2054
2275
  : `${t('routeReasoningTip')}\n${reasoning.map((level) => ` · ${level.name} → ${level.id}`).join('\n')}`
2055
- return h('div', { key: `${candidate.provider}/${candidate.model}/${index}`, className: 'dsh-llm-hub-routing__row' },
2056
- h('span', { className: 'dsh-llm-hub-routing__role' }, index === 0 ? t('routePrimaryTag') : `${t('routeFallbackTag')}${index}`),
2057
- h('span', { className: 'dsh-llm-hub-routing__name' }, `${candidate.provider}/${candidate.model}`),
2058
- reasoning !== undefined
2059
- ? h('span', {
2060
- key: 'reasoning',
2061
- className: 'dsh-llm-hub-routing__reasoning',
2062
- title: reasoningTip
2063
- }, `💭 ${t('routeReasoningShort')} ${reasoningShort(reasoning)}`)
2064
- : null,
2065
- multimodal === true
2066
- ? h('span', {
2067
- key: 'multimodal',
2068
- className: 'dsh-llm-hub-routing__multimodal',
2069
- title: t('routeMultimodalTip')
2070
- }, '👁 ' + t('routeMultimodalShort'))
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'))
2071
2332
  : null,
2072
- h('span', { className: stateChipClass(liveCandidate?.state) }, liveCandidate?.state === 'available' ? '可用' : (liveCandidate?.state === 'unknown' ? '未探明' : '不可用')),
2073
- h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeUp'), onClick: () => moveCandidate(group.id, index, -1) }, '↑'),
2074
- h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeDown'), onClick: () => moveCandidate(group.id, index, 1) }, '↓'),
2075
- h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeRemove'), onClick: () => removeCandidate(group.id, index) }, '✕'))
2076
- }),
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
+ }),
2077
2337
  h('div', { className: 'dsh-llm-hub-routing__add' },
2078
2338
  h('span', null, t('routeAddProvider')),
2079
2339
  catalog.length > 0
@@ -2123,26 +2383,31 @@ window.__ModuleLoader__.load({
2123
2383
  className: 'dsh-llm-hub-btn',
2124
2384
  onClick: () => addCandidate(group.id)
2125
2385
  }, t('routeAdd'))),
2126
- catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null,
2127
- h('button', {
2128
- type: 'button',
2129
- className: 'dsh-llm-hub-foot__link',
2130
- onClick: () => removeGroup(group.id)
2131
- }, t('routeDeleteGroup')))
2386
+ catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null)
2132
2387
  : null)
2133
2388
  })
2389
+ // 空闲态不再写死「已保存」—— 那三个字跟「刚刚存成功」长得一模一样,
2390
+ // 读起来像一条一直挂着的成功提示,而它其实什么都没说。空闲时干脆不占位,
2391
+ // 真存过 / 存到一半 / 存失败才给反馈。
2392
+ const noticing = notice === 'fail' || notice === 'saving' || notice === 'saved'
2134
2393
  const footer = h('div', { className: 'dsh-llm-hub-routing__actions' },
2135
2394
  h('button', { type: 'button', className: 'dsh-llm-hub-btn', onClick: addGroup }, t('routeNewGroup')),
2136
- h('button', {
2137
- type: 'button',
2138
- className: 'dsh-llm-hub-btn',
2139
- disabled: busy || !isDirty,
2140
- onClick: save
2141
- }, isDirty ? t('routeSave') : t('routeSaved')),
2142
- notice === 'fail' ? h('span', { className: 'dsh-llm-hub-routing__reason' }, t('routeSaveFailed')) : null,
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,
2143
2407
  h('span', { className: 'dsh-llm-hub-routing__hint' }, t('routeEditHint')))
2144
- return h('div', { className: 'dsh-llm-hub-routing' },
2408
+ return h('div', { className: 'dsh-llm-hub-routing dsh-llm-hub-routing-page' },
2145
2409
  header,
2410
+ pageDesc,
2146
2411
  rule,
2147
2412
  noticeLine,
2148
2413
  nowLine,
@@ -2150,6 +2415,14 @@ window.__ModuleLoader__.load({
2150
2415
  footer)
2151
2416
  }
2152
2417
 
2418
+ /**
2419
+ * 「路由」设置页:页头(标题 + 一句怎么用)+ 路由编辑器。
2420
+ * 直接调用 RoutingCard(props) 而不是 h(RoutingCard):少一层组件,hooks 顺序不变。
2421
+ */
2422
+ function RoutingSection(props) {
2423
+ return RoutingCard(props)
2424
+ }
2425
+
2153
2426
  /**
2154
2427
  * 格式化 token 数字 —— 大数带千分位,>9999 折成 1.2万。
2155
2428
  * @param value - token 数。
@@ -2639,13 +2912,17 @@ window.__ModuleLoader__.load({
2639
2912
  locale: NS,
2640
2913
  inject: () => ({ t, availability })
2641
2914
  }, HealthCard))
2642
- ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2643
- name: 'settings.models.footer',
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',
2644
2920
  id: 'dsh-llm-hub-routing',
2645
- order: 75,
2921
+ order: 12,
2922
+ label: () => t('routingNav'),
2646
2923
  locale: NS,
2647
2924
  inject: () => ({ t })
2648
- }, RoutingCard))
2925
+ }, RoutingSection))
2649
2926
  ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
2650
2927
  name: 'settings.models.footer',
2651
2928
  id: 'dsh-llm-hub-usage',
package/lib/index.js CHANGED
@@ -291,6 +291,35 @@ export function parseRouteEntryPure(entry) {
291
291
  * @param {object|undefined|null} routing - settings.routing 段。
292
292
  * @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
293
293
  */
294
+ /**
295
+ * 备注清洗:只收字符串,去首尾空白,空串视为没有,最长 120 字。
296
+ * @param {unknown} value - 原始备注。
297
+ * @returns {string | null}
298
+ */
299
+ export function cleanRouteNote(value) {
300
+ if (typeof value !== 'string') return null
301
+ const note = value.trim().slice(0, 120)
302
+ return note === '' ? null : note
303
+ }
304
+
305
+ /**
306
+ * 候选备注表:只保留键是合法 `provider/model`、且确实在本组候选里的条目 ——
307
+ * 删掉候选时它的备注一并消失,不在 settings 里留孤儿。
308
+ * @param {unknown} raw - settings 里的 notes 字典。
309
+ * @param {Array<{provider: string, model: string}>} entries - 本组全部候选。
310
+ * @returns {Record<string, string> | null}
311
+ */
312
+ function cleanRouteNotes(raw, entries) {
313
+ if (raw === null || typeof raw !== 'object') return null
314
+ const keys = new Set(entries.map((entry) => `${entry.provider}/${entry.model}`))
315
+ const out = {}
316
+ for (const [key, value] of Object.entries(raw)) {
317
+ const note = cleanRouteNote(value)
318
+ if (note !== null && keys.has(key)) out[key] = note
319
+ }
320
+ return Object.keys(out).length > 0 ? out : null
321
+ }
322
+
294
323
  export function parseRoutingGroups(routing) {
295
324
  const r = routing !== null && typeof routing === 'object' ? routing : {}
296
325
  const mode = r.mode === 'smart' ? 'smart' : 'manual'
@@ -306,11 +335,15 @@ export function parseRoutingGroups(routing) {
306
335
  .filter((entry) => entry !== undefined)
307
336
  if (primary === null && fallbacks.length === 0) continue
308
337
  seen.add(id)
338
+ const note = cleanRouteNote(raw.note)
339
+ const notes = cleanRouteNotes(raw.notes, [...(primary !== null ? [primary] : []), ...fallbacks])
309
340
  groups.push({
310
341
  id,
311
342
  label: typeof raw.label === 'string' && raw.label.trim() !== '' ? raw.label.trim() : null,
312
343
  primary,
313
- fallbacks
344
+ fallbacks,
345
+ ...(note !== null ? { note } : {}),
346
+ ...(notes !== null ? { notes } : {})
314
347
  })
315
348
  }
316
349
  if (groups.length === 0) {
@@ -422,11 +455,22 @@ export function normalizeRoutingGroupsInput(raw) {
422
455
  }
423
456
  if (primary === null && fallbacks.length === 0) { dropped.push(`组 ${id} 没有任何合法候选`); continue }
424
457
  seen.add(id)
458
+ // 候选备注两种来源:编辑器形态里每个候选对象自带 note;落盘形态是组上的 notes 字典。
459
+ const noteSource = { ...(item.notes !== null && typeof item.notes === 'object' ? item.notes : {}) }
460
+ for (const candidate of ordered ?? []) {
461
+ const entry = asEntry(candidate)
462
+ const note = candidate !== null && typeof candidate === 'object' ? cleanRouteNote(candidate.note) : null
463
+ if (entry !== undefined && note !== null) noteSource[`${entry.provider}/${entry.model}`] = note
464
+ }
465
+ const note = cleanRouteNote(item.note)
466
+ const notes = cleanRouteNotes(noteSource, [...(primary !== null ? [primary] : []), ...fallbacks])
425
467
  groups.push({
426
468
  id,
427
469
  label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim().slice(0, 32) : null,
428
470
  primary,
429
- fallbacks
471
+ fallbacks,
472
+ ...(note !== null ? { note } : {}),
473
+ ...(notes !== null ? { notes } : {})
430
474
  })
431
475
  }
432
476
  return { groups, dropped }
@@ -638,7 +682,9 @@ export function apply(ctx) {
638
682
  try {
639
683
  const mod = await import('@deepseek-ai/schemastery')
640
684
  const z = (mod && typeof mod === 'object' ? mod.default ?? mod : mod)
641
- const groupItem = z.object({ id: z.string().required(), label: z.string(), primary: z.string(), fallbacks: z.array(z.string()) })
685
+ // note / notes(1.7.0):页面上写的备注。YAML 注释在页面保存时会被整段替换冲掉,
686
+ // 所以说明要落成字段才留得住。notes 以 'provider/model' 为键。
687
+ const groupItem = z.object({ id: z.string().required(), label: z.string(), note: z.string(), primary: z.string(), fallbacks: z.array(z.string()), notes: z.dict(z.string()) })
642
688
  schema = z.object({
643
689
  routing: z.object({
644
690
  mode: z.union(['manual', 'smart']).default('manual'),
@@ -1294,7 +1340,12 @@ export function apply(ctx) {
1294
1340
  auth: 'raw',
1295
1341
  shape: (body) => {
1296
1342
  if (body?.success === false || (typeof body?.code === 'number' && body.code !== 200)) {
1297
- return { unavailable: label(body?.msg) ?? 'quota unavailable' }
1343
+ // 服务端用一份完整的 JSON 明确回答「你这个号没有套餐」
1344
+ // (2026-09-29 实测:`当前用户不存在coding plan`)。
1345
+ // 这跟 `fail()` 那几条「连不上 / 看不懂 / 没这个接口」不是一回事:
1346
+ // 前者是确知不可用,值得判死并隐藏;后者是拿不准,必须 fail-open 保留。
1347
+ // 不标 definitive 的话,欠费会永远停在「显示着但一用就报错」。
1348
+ return { unavailable: label(body?.msg) ?? 'quota unavailable', definitive: true }
1298
1349
  }
1299
1350
  const limits = Array.isArray(body?.data?.limits) ? body.data.limits : []
1300
1351
  const items = []
@@ -1361,6 +1412,13 @@ export function apply(ctx) {
1361
1412
  return undefined
1362
1413
  }
1363
1414
 
1415
+ /**
1416
+ * provider -> 最近一次余额信封。排序要用它判计费模式,而排序是同步的
1417
+ * (`llm.listProviders` 不能 await),所以只能读探测顺手存下来的快照。
1418
+ * @type {Map<string, object>}
1419
+ */
1420
+ const balanceSnapshot = new Map()
1421
+
1364
1422
  /**
1365
1423
  * 查询某 provider 的余额/配额。统一信封:`{ ok, kind, available, items?, reason? }`;
1366
1424
  * 匹配不到适配器给 `{ ok, supported:false }`,无 key / 上游失败都折叠成 `available:false`
@@ -1419,9 +1477,12 @@ export function apply(ctx) {
1419
1477
  }
1420
1478
  const shaped = adapter.shape(body)
1421
1479
  if (shaped.unavailable !== undefined) {
1422
- return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable }
1480
+ // definitive 只在「上游结构化回答了不可用」时为真;网络/解析失败走 fail(),没有这个字段。
1481
+ return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable, definitive: shaped.definitive === true }
1423
1482
  }
1424
- return { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
1483
+ const envelope = { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
1484
+ balanceSnapshot.set(provider, envelope)
1485
+ return envelope
1425
1486
  }
1426
1487
 
1427
1488
  /**
@@ -1665,9 +1726,18 @@ export function apply(ctx) {
1665
1726
  return undefined
1666
1727
  }
1667
1728
  if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true || envelope.supported === false) return undefined
1668
- // available=false 只表示「这次余额查询没成功」;原因千差万别(没 key、查不通、
1669
- // 没有套餐),除开 key 缺失(已由凭据那步覆盖)都不构成隐藏理由。
1670
- if (envelope.available === false) return undefined
1729
+ // available=false 有两种,混在一起处理会让欠费永远漏网:
1730
+ // · definitive —— 上游用完整 JSON 确知「这个号没有套餐」,判死并隐藏;
1731
+ // · 其余(没 key / 连不上 / 看不懂 / 没这个接口)—— 只是这次没查成,
1732
+ // 按 fail-open 保留,否则一次网络抖动就把能用的模型藏了。
1733
+ // 2026-09-29 实测智谱 coding 端点就栽在这:余额接口白纸黑字回了
1734
+ // 「当前用户不存在coding plan」,却因为和「查不通」共用 available=false 而被放过。
1735
+ if (envelope.available === false) {
1736
+ if (envelope.definitive === true) {
1737
+ return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: label(envelope.reason) ?? '账户没有可用套餐' }
1738
+ }
1739
+ return undefined
1740
+ }
1671
1741
  const items = Array.isArray(envelope.items) ? envelope.items : []
1672
1742
  if (envelope.kind === 'cash') {
1673
1743
  const amount = items[0]?.amount
@@ -1993,7 +2063,13 @@ export function apply(ctx) {
1993
2063
  if (profile?.billingMode === 'usage' || profile?.billingMode === 'subscription') {
1994
2064
  return profile.billingMode
1995
2065
  }
1996
- return detectBillingMode(profile || { provider: cleanId })
2066
+ // ⚠️ balance 必须传:少了它,`detectBillingMode` 里认 `kind: 'plan' | 'quota'`、
2067
+ // `resetsAt`、`weeklyPercent` 的那几条分支全是死代码,最后一律落到兜底的
2068
+ // `return 'usage'`。2026-09-29 实测:MiniMax 余额信封明明是
2069
+ // `{kind:'plan', items:[{resetsAt:'15:00', weeklyPercent:97}]}`,下拉里却被当成
2070
+ // 按量付费排在套餐后面 —— 配置卡片显示对了(client 传了 balance),
2071
+ // 下拉排序却是错的(host 没传),两边对不上就是这个漏传造成的。
2072
+ return detectBillingMode(profile || { provider: cleanId }, profile, balanceSnapshot.get(cleanId) ?? balanceSnapshot.get(id))
1997
2073
  }
1998
2074
 
1999
2075
  const filtered = function (...args) {
@@ -2707,9 +2783,11 @@ export function apply(ctx) {
2707
2783
  return {
2708
2784
  id: group.id,
2709
2785
  label: group.label,
2786
+ note: group.note ?? null,
2710
2787
  candidates: candidates.map((candidate) => ({
2711
2788
  provider: candidate.provider,
2712
2789
  model: candidate.model,
2790
+ note: group.notes?.[`${candidate.provider}/${candidate.model}`] ?? null,
2713
2791
  state: stateFor(candidate.provider, candidate.model)
2714
2792
  })),
2715
2793
  wouldRoute: winner.to,
@@ -2877,8 +2955,10 @@ export function apply(ctx) {
2877
2955
  groups: normalized.groups.map((group) => ({
2878
2956
  id: group.id,
2879
2957
  ...(group.label !== null ? { label: group.label } : {}),
2958
+ ...(group.note ? { note: group.note } : {}),
2880
2959
  ...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
2881
- fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
2960
+ fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`),
2961
+ ...(group.notes ? { notes: group.notes } : {})
2882
2962
  }))
2883
2963
  }
2884
2964
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plugins/dsh-llm-hub",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -85,8 +85,17 @@ const walkStrings = (v) => {
85
85
  for (const field of ['main', 'types', 'module', 'bin', 'exports']) walkStrings(pkg[field])
86
86
  if (pkg.dsh && typeof pkg.dsh === 'object') walkStrings(pkg.dsh.bundle)
87
87
 
88
+ // exports 允许「子路径模式」:`"./styles/*": "./styles/*"`。
89
+ // 这是 npm 官方推荐的整目录可 import 写法,不是错字 —— 2026-09-22 的 retro 记过:
90
+ // 白名单式把每个子路径列一遍,漏一个下游构建就直接 `Missing "./styles/x.css"` 失败。
91
+ // 所以带 * 的条目必须按 glob 去匹配 tarball,而不是要求存在一个名叫 `styles/*` 的文件。
92
+ // 但「模式一个文件都匹配不到」仍要拦 —— 那说明目录被删了或没进 files[],模式是死的。
93
+ const entryGlobRe = (g) => new RegExp(
94
+ '^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
95
+ )
88
96
  for (const p of entryPaths) {
89
- if (!inPack(p)) {
97
+ const ok = /[*?]/.test(p) ? packFiles.some(f => entryGlobRe(p).test(f)) : inPack(p)
98
+ if (!ok) {
90
99
  errors.push(`package.json 的入口字段引用了 '${p}',但 tarball 里没有\n → 装包的人会在 require/import 时直接失败`)
91
100
  }
92
101
  }