@dsh-plugins/dsh-llm-hub 1.6.1 → 1.8.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,67 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.8.0] - 2026-09-29
6
+
7
+ ### 🔧 适配 DSH 0.1.7 的 settings 架构(升级到 0.1.7 后配置读不到的那版)
8
+
9
+ **症状**:升到 DSH 0.1.7 后,路由组「凭空消失」—— chip 不渲染,设置页路由卡是空的,
10
+ 写接口一律拒绝。数据其实好好躺在配置里,只是没人读。
11
+
12
+ **根因**:0.1.7 把 settings 架构整个换掉了。`settings.register(ns, schema)` 和
13
+ `settings.get(ns)` 双双消失(0.1.5 的 `dsh-settings` 里有 20 处 `register` 引用,
14
+ 0.1.7 是 0 处)。老代码里 `settings.register(...)` 抛 `TypeError` 被 catch 吞掉,
15
+ `hubRegistered` 恒为 false,于是整个 `dsh-llm-hub` 段的读写全部短路。
16
+
17
+ **改造**
18
+ - 导出 `Config`,配置改由 `apply(ctx, config)` 注入(0.1.7 的官方姿势,
19
+ `@deepseek-ai/dsh-llm-pi-ai` 的 providers 段同款)
20
+ - 读自己的段走 `config.routing.get()`;读**别人**的段(`llm-pi-ai` / `llm-deepseek`)
21
+ 走 `settings.describe()` —— 0.1.7 里那是唯一的跨插件读法
22
+ - 拆掉 `hubRegistered` 守卫(该概念已不存在)
23
+ - 三个字段全部标 `.volatile()`:`settings.update()` 拒绝「没有 volatile 字段的条目」
24
+ (原话:`Plugin entry "dsh-llm-hub" has no volatile fields`),而 volatile 的语义
25
+ 正是本插件要的 —— 路由组/预警/别名都是每次请求惰性重读的,切一次路由不该重跑 apply
26
+ - `@deepseek-ai/schemastery` 加为 **devDependency**(静态 import 需要它才能加载;
27
+ 运行时依赖仍是零 —— 它由 DSH 宿主提供)
28
+
29
+ **配置迁移**:0.1.7 启动时把全局 `settings.yaml` 迁进 profile 的 `cordis.patch.yml`
30
+ 并把原文件改名为 `settings.yaml.imported`。本插件的段需要一条 `dsh-llm-hub` entry;
31
+ 未迁移时该段读为空,也就是上面那个症状。
32
+
33
+ **验证**:157 个单测 + 门禁全过;真机确认路由组、设置页路由卡、跨插件读(balance 能
34
+ 解析出真实 key)、写路径(`routing/select` 200)全部恢复。
35
+
36
+ ## [1.7.0] - 2026-09-29
37
+
38
+ ### 🧭 路由设置卡:始终折叠,展开才编辑
39
+
40
+ - **默认全折叠**,当前组也不例外(反了 1.7.0 之前的「当前组默认展开」)。进这页要回答的是「这几组各自会路由到哪」,折叠行就答完了;逐条候选是低频编辑动作,默认展开等于一进来就糊一屏表单
41
+ - 折叠行折叠时组名是**纯文本**,展开才给输入框 —— 折叠行还摆着输入框,会让「在编辑」和「在显示」长得一样
42
+ - 折叠行只留三件事:**会路由到哪** · **可用/总** · 组名。为此砍掉两处:
43
+ - 「主力 provider/model」—— 答的是「第一个是谁」,展开态第一行就有
44
+ - 「+N 条兜底」—— 与「4/5」同义重复
45
+ - 新增组健康点三色:全绿(都可用)/ 琥珀(还剩几个)/ 红(一个不剩)。半可用混成全绿会让人以为不用管,混成全红会误报故障
46
+ - 全不可用时,不可用原因顶替「会路由到」那个位置 —— 两者是同一个槽位,不是两件事
47
+
48
+ **踩坑(都靠浏览器实测发现,单测一条都拦不住)**
49
+
50
+ - 设置内容区只有 564px,组头扣掉序号/组名/两个按钮后摘要只剩 ~210px,真实数据的 provider/model 要 200-215px。差的这几十像素省不掉:先砍内容,仍不够才改成**换行**而不是截断 —— 砍掉的正好是判断路由最需要的模型名
51
+ - `.dot--degraded` 一开始写在基础 `.dot` 规则**之前**:同特异度下后者生效,灰底把琥珀色盖掉,`4/5` 显示成灰点。已移到基础规则之后
52
+ - DSH 的 client bundle 缓存很硬:`Page.reload --ignoreCache` 不足以刷新样式表,必须 `Network.clearBrowserCache`。中途因此把「源码已改、页面没变」误判成 CSS 没写对
53
+
54
+ ## [1.6.2] - 2026-09-29
55
+
56
+ ### 🐛 修复:路由快切 chip 的浮层点完不消失
57
+
58
+ - 症状:在浮层里点「智能路由 / 手动模型」或点某个组,按钮高亮变了、状态也切了,浮层却纹丝不动地盖在输入框上,只能**再点一次 chip** 才收得回去(2026-09-29 浏览器实测)
59
+ - 根因:`open` 只有 chip 按钮那一个 toggle 在写 —— 菜单内所有 `onClick` 都只调 `select()`,从不 `setOpen(false)`,也没有「点外部 / Esc」的兜底
60
+ - 修复:
61
+ - 点组 / 切模式**写成功后立刻收起**浮层(写失败**不收** —— 状态没变,收起来等于把失败藏了)
62
+ - 点当前**已生效**的那个模式(本来就没请求可发)也收起,否则「点了完全没反应」会被当成界面卡住
63
+ - 补上「点浮层外面」与「Esc」两条关闭路径;只在 `open` 时挂 document 监听,卸载时卸干净
64
+ - 测试:新增 7 条回归。React 桩补上 setter / ref / effect 记录 —— 「点完浮层有没有收起」看渲染树验不出来,发生在事件回调里,只能观察状态被写成了什么
65
+
5
66
  ## [1.6.1] - 2026-09-28
6
67
 
7
68
  ### 🧹 去掉模型页底部的「热门大模型一键装配」,改为添加时按厂商匹配
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
@@ -33,12 +33,14 @@
33
33
  /** 官方直连适配器拥有的 settings 命名空间。 */
34
34
  import { readFileSync } from 'node:fs'
35
35
  import { fileURLToPath } from 'node:url'
36
+ import z from '@deepseek-ai/schemastery'
36
37
  import { applyHarness, harnessReport } from './harness.js'
37
38
  import { detectBillingMode, sortProviders } from './sorter.js'
38
39
  import { AvailabilityStore, isDropDeadHidden, classifyFailure } from './availability-store.js'
39
40
 
40
41
  export { detectBillingMode, sortProviders } from './sorter.js'
41
42
  export { AvailabilityStore, isDropDeadHidden, classifyFailure } from './availability-store.js'
43
+ export { Config }
42
44
 
43
45
  /**
44
46
  * 用量统计 domain 的 spec。
@@ -291,6 +293,35 @@ export function parseRouteEntryPure(entry) {
291
293
  * @param {object|undefined|null} routing - settings.routing 段。
292
294
  * @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
293
295
  */
296
+ /**
297
+ * 备注清洗:只收字符串,去首尾空白,空串视为没有,最长 120 字。
298
+ * @param {unknown} value - 原始备注。
299
+ * @returns {string | null}
300
+ */
301
+ export function cleanRouteNote(value) {
302
+ if (typeof value !== 'string') return null
303
+ const note = value.trim().slice(0, 120)
304
+ return note === '' ? null : note
305
+ }
306
+
307
+ /**
308
+ * 候选备注表:只保留键是合法 `provider/model`、且确实在本组候选里的条目 ——
309
+ * 删掉候选时它的备注一并消失,不在 settings 里留孤儿。
310
+ * @param {unknown} raw - settings 里的 notes 字典。
311
+ * @param {Array<{provider: string, model: string}>} entries - 本组全部候选。
312
+ * @returns {Record<string, string> | null}
313
+ */
314
+ function cleanRouteNotes(raw, entries) {
315
+ if (raw === null || typeof raw !== 'object') return null
316
+ const keys = new Set(entries.map((entry) => `${entry.provider}/${entry.model}`))
317
+ const out = {}
318
+ for (const [key, value] of Object.entries(raw)) {
319
+ const note = cleanRouteNote(value)
320
+ if (note !== null && keys.has(key)) out[key] = note
321
+ }
322
+ return Object.keys(out).length > 0 ? out : null
323
+ }
324
+
294
325
  export function parseRoutingGroups(routing) {
295
326
  const r = routing !== null && typeof routing === 'object' ? routing : {}
296
327
  const mode = r.mode === 'smart' ? 'smart' : 'manual'
@@ -306,11 +337,15 @@ export function parseRoutingGroups(routing) {
306
337
  .filter((entry) => entry !== undefined)
307
338
  if (primary === null && fallbacks.length === 0) continue
308
339
  seen.add(id)
340
+ const note = cleanRouteNote(raw.note)
341
+ const notes = cleanRouteNotes(raw.notes, [...(primary !== null ? [primary] : []), ...fallbacks])
309
342
  groups.push({
310
343
  id,
311
344
  label: typeof raw.label === 'string' && raw.label.trim() !== '' ? raw.label.trim() : null,
312
345
  primary,
313
- fallbacks
346
+ fallbacks,
347
+ ...(note !== null ? { note } : {}),
348
+ ...(notes !== null ? { notes } : {})
314
349
  })
315
350
  }
316
351
  if (groups.length === 0) {
@@ -422,11 +457,22 @@ export function normalizeRoutingGroupsInput(raw) {
422
457
  }
423
458
  if (primary === null && fallbacks.length === 0) { dropped.push(`组 ${id} 没有任何合法候选`); continue }
424
459
  seen.add(id)
460
+ // 候选备注两种来源:编辑器形态里每个候选对象自带 note;落盘形态是组上的 notes 字典。
461
+ const noteSource = { ...(item.notes !== null && typeof item.notes === 'object' ? item.notes : {}) }
462
+ for (const candidate of ordered ?? []) {
463
+ const entry = asEntry(candidate)
464
+ const note = candidate !== null && typeof candidate === 'object' ? cleanRouteNote(candidate.note) : null
465
+ if (entry !== undefined && note !== null) noteSource[`${entry.provider}/${entry.model}`] = note
466
+ }
467
+ const note = cleanRouteNote(item.note)
468
+ const notes = cleanRouteNotes(noteSource, [...(primary !== null ? [primary] : []), ...fallbacks])
425
469
  groups.push({
426
470
  id,
427
471
  label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim().slice(0, 32) : null,
428
472
  primary,
429
- fallbacks
473
+ fallbacks,
474
+ ...(note !== null ? { note } : {}),
475
+ ...(notes !== null ? { notes } : {})
430
476
  })
431
477
  }
432
478
  return { groups, dropped }
@@ -613,64 +659,70 @@ export const name = 'dsh-llm-hub'
613
659
  * 用量统计走的是**局部注入**:`storageDomain` 缺席时不报错,本插件其余功能照常工作
614
660
  * —— 没有 storageDomain 也能用余额/可用性/harness,只是没法跨会话记 token 用量。
615
661
  */
662
+ /**
663
+ * 本插件的配置形态(DSH 0.1.7 的 settings 契约)。
664
+ *
665
+ * 0.1.7 起配置不再运行时 `settings.register()` 注册,而是由 Loader 按**条目 id**
666
+ * 从 profile 的 `cordis.patch.yml` 读取;插件导出这个 `Config`,`apply(ctx, config)`
667
+ * 收下 `config`,页面的表单与 `settings.update(ns, patch)` 也都按它生成。
668
+ * 字段与 1.5.0 起的段结构一一对应,所以老 settings.yaml 里的段能原样迁进 patch。
669
+ *
670
+ * note / notes:页面上写的备注。YAML 注释在页面保存时会被整段替换冲掉,
671
+ * 所以说明要落成字段才留得住。notes 以 'provider/model' 为键。
672
+ */
673
+ const groupItem = z.object({
674
+ id: z.string().required(),
675
+ label: z.string(),
676
+ note: z.string(),
677
+ primary: z.string(),
678
+ fallbacks: z.array(z.string()),
679
+ notes: z.dict(z.string())
680
+ })
681
+ // ⚠️ 三个字段都必须 .volatile():0.1.7 的 settings.update() 拒绝「没有 volatile
682
+ // 字段的条目」(原话:Plugin entry "dsh-llm-hub" has no volatile fields)。
683
+ // volatile 的语义正是本插件要的行为 —— 配置改动只换值、不重跑 apply:路由组、预警
684
+ // 阈值、别名全是**每次请求惰性重读**的(见 hubSectionOf 的注释),让切一次路由就去
685
+ // 重新 apply 整个插件是白付代价。官方 dsh-llm-pi-ai 的 providers 段同样标了 volatile。
686
+ const Config = z.object({
687
+ routing: z.object({
688
+ mode: z.union(['manual', 'smart']).default('manual'),
689
+ activeGroup: z.string().default(''),
690
+ groups: z.array(groupItem).default([])
691
+ }).default({}).volatile(),
692
+ warning: z.object({ cashCNY: z.number().default(10), planPercent: z.number().default(10) }).default({}).volatile(),
693
+ aliases: z.dict(z.string()).default({}).volatile()
694
+ })
695
+
616
696
  export const inject = ['llm', 'webServer']
617
697
 
618
698
  /**
619
699
  * 注册模型发现与余额路由。
620
700
  * @param ctx - 插件上下文。
621
701
  */
622
- export function apply(ctx) {
702
+ export function apply(ctx, config) {
623
703
 
624
704
  // ── 插件自有 settings 命名空间(dsh-llm-hub)────────────────────────────
625
705
  //
626
- // 1.5.0 起:routing / warning / aliases 的真源从 `llm-deepseek` 段迁到独立
627
- // 的 `dsh-llm-hub` 段 —— README 文档里写的顶层 `dsh-llm-hub:` 键此前**从未
628
- // 被读到过**(没注册过命名空间,settings.get 会抛),这是 1.1.0 起的历史
629
- // 缺陷,这里一起修掉。
630
- //
631
- // schema 尽量懒加载 `@deepseek-ai/schemastery`;失败时退回 `Object` 构造器
632
- // (schemastery 接受原生构造器作为 schema)。注册失败不 fatal:配置读取退
633
- // 回 legacy 路径,智能路由降级为「只建议」。
634
- let hubRegistered = false
635
- ctx.inject(['settings'], (settingsCtx) => {
636
- void (async () => {
637
- let schema
638
- try {
639
- const mod = await import('@deepseek-ai/schemastery')
640
- 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()) })
642
- schema = z.object({
643
- routing: z.object({
644
- mode: z.union(['manual', 'smart']).default('manual'),
645
- activeGroup: z.string().default(''),
646
- groups: z.array(groupItem).default([])
647
- }).default({}),
648
- warning: z.object({ cashCNY: z.number().default(10), planPercent: z.number().default(10) }).default({}),
649
- aliases: z.dict(z.string()).default({})
650
- })
651
- } catch {
652
- // 零依赖环境:schemastery 没解析到 —— 用原生构造器兜底。
653
- schema = Object
654
- }
655
- try {
656
- settingsCtx.settings.register(HUB_NS, schema)
657
- hubRegistered = true
658
- } catch (error) {
659
- ctx.logger.warn(`dsh-llm-hub: settings ns "dsh-llm-hub" register failed: ${error?.message ?? error}`)
660
- }
661
- })()
662
- })
706
+ // DSH 0.1.7 把 settings 架构整个换了:`settings.register(ns, schema)` 与
707
+ // `settings.get(ns)` 双双消失,配置改由 Loader 按**条目 id**从 profile 的
708
+ // cordis.patch.yml 读,插件自己导出 `Config`,再由 `apply(ctx, config)` 收下。
709
+ // 老写法在 0.1.7 上是 `TypeError: settings.register is not a function`,
710
+ // 被下面那个 catch 吞掉 → hubRegistered 恒 false → 整个 dsh-llm-hub 段读写
711
+ // 全死。对外表现是「路由组凭空消失」:数据好好躺在 patch 里,只是没人读。
663
712
 
664
713
  /**
665
714
  * 读 `dsh-llm-hub` 设置段(1.5.0 起的真源)。
666
715
  * @returns 已解析设置段;未注册 / 未配置时为 `undefined`。
667
716
  */
668
717
  const hubSectionOf = () => {
669
- const settings = ctx.get('settings')
670
- if (settings === undefined) return undefined
718
+ // 0.1.7:配置由 `apply(ctx, config)` 注入,惰性 .get() 每次拿当前修订。
719
+ // 不再走 settings.get(HUB_NS) —— 那个方法在 0.1.7 已被移除。
720
+ if (config === undefined || config === null) return undefined
671
721
  try {
672
- const value = settings.get(HUB_NS)
673
- return value !== null && typeof value === 'object' ? value : undefined
722
+ const routing = config.routing?.get?.()
723
+ const warning = config.warning?.get?.()
724
+ const aliases = config.aliases?.get?.()
725
+ return { routing, warning, aliases }
674
726
  } catch {
675
727
  return undefined
676
728
  }
@@ -683,13 +735,31 @@ export function apply(ctx) {
683
735
  * @returns 已解析的设置段;不可用时为 `undefined`。
684
736
  */
685
737
  const sectionOf = () => {
738
+ // 0.1.7:读**别的插件**的配置不再有 settings.get(ns)。改走
739
+ // settings.describe() —— 它返回所有条目的 ns + 实时 value。
686
740
  const settings = ctx.get('settings')
687
741
  if (settings === undefined) return undefined
688
742
  try {
689
- const value = settings.get(NS)
743
+ const row = settings.describe().find((item) => item.ns === NS)
744
+ const value = row?.value
745
+ return value !== null && typeof value === 'object' ? value : undefined
746
+ } catch {
747
+ return undefined
748
+ }
749
+ }
750
+
751
+ /**
752
+ * 读任意命名空间(跨插件)在 0.1.7 的唯一途径。
753
+ * @param {string} ns - profile 条目 id。
754
+ * @returns {object | undefined} 该条目的实时配置值。
755
+ */
756
+ const sectionOfNs = (ns) => {
757
+ const settings = ctx.get('settings')
758
+ if (settings === undefined) return undefined
759
+ try {
760
+ const value = settings.describe().find((item) => item.ns === ns)?.value
690
761
  return value !== null && typeof value === 'object' ? value : undefined
691
762
  } catch {
692
- // 命名空间尚未注册时会抛 —— 那是正常启动竞态,不是失败。
693
763
  return undefined
694
764
  }
695
765
  }
@@ -1079,7 +1149,7 @@ export function apply(ctx) {
1079
1149
  const settings = ctx.get('settings')
1080
1150
  if (settings === undefined) return []
1081
1151
  try {
1082
- const section = settings.get(PIAI_NS)
1152
+ const section = sectionOfNs(PIAI_NS)
1083
1153
  const providers = section !== null && typeof section === 'object' ? section.providers : undefined
1084
1154
  if (providers === null || typeof providers !== 'object') return []
1085
1155
  return Object.entries(providers).filter(([, profile]) => profile !== null && typeof profile === 'object')
@@ -1099,7 +1169,7 @@ export function apply(ctx) {
1099
1169
  const settings = ctx.get('settings')
1100
1170
  if (settings === undefined) return undefined
1101
1171
  try {
1102
- const section = settings.get(PIAI_NS)
1172
+ const section = sectionOfNs(PIAI_NS)
1103
1173
  const providers = section !== null && typeof section === 'object' ? section.providers : undefined
1104
1174
  const profile = providers !== null && typeof providers === 'object' ? providers[provider] : undefined
1105
1175
  return profile !== null && typeof profile === 'object' ? profile : undefined
@@ -1294,7 +1364,12 @@ export function apply(ctx) {
1294
1364
  auth: 'raw',
1295
1365
  shape: (body) => {
1296
1366
  if (body?.success === false || (typeof body?.code === 'number' && body.code !== 200)) {
1297
- return { unavailable: label(body?.msg) ?? 'quota unavailable' }
1367
+ // 服务端用一份完整的 JSON 明确回答「你这个号没有套餐」
1368
+ // (2026-09-29 实测:`当前用户不存在coding plan`)。
1369
+ // 这跟 `fail()` 那几条「连不上 / 看不懂 / 没这个接口」不是一回事:
1370
+ // 前者是确知不可用,值得判死并隐藏;后者是拿不准,必须 fail-open 保留。
1371
+ // 不标 definitive 的话,欠费会永远停在「显示着但一用就报错」。
1372
+ return { unavailable: label(body?.msg) ?? 'quota unavailable', definitive: true }
1298
1373
  }
1299
1374
  const limits = Array.isArray(body?.data?.limits) ? body.data.limits : []
1300
1375
  const items = []
@@ -1361,6 +1436,13 @@ export function apply(ctx) {
1361
1436
  return undefined
1362
1437
  }
1363
1438
 
1439
+ /**
1440
+ * provider -> 最近一次余额信封。排序要用它判计费模式,而排序是同步的
1441
+ * (`llm.listProviders` 不能 await),所以只能读探测顺手存下来的快照。
1442
+ * @type {Map<string, object>}
1443
+ */
1444
+ const balanceSnapshot = new Map()
1445
+
1364
1446
  /**
1365
1447
  * 查询某 provider 的余额/配额。统一信封:`{ ok, kind, available, items?, reason? }`;
1366
1448
  * 匹配不到适配器给 `{ ok, supported:false }`,无 key / 上游失败都折叠成 `available:false`
@@ -1419,9 +1501,12 @@ export function apply(ctx) {
1419
1501
  }
1420
1502
  const shaped = adapter.shape(body)
1421
1503
  if (shaped.unavailable !== undefined) {
1422
- return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable }
1504
+ // definitive 只在「上游结构化回答了不可用」时为真;网络/解析失败走 fail(),没有这个字段。
1505
+ return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable, definitive: shaped.definitive === true }
1423
1506
  }
1424
- return { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
1507
+ const envelope = { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
1508
+ balanceSnapshot.set(provider, envelope)
1509
+ return envelope
1425
1510
  }
1426
1511
 
1427
1512
  /**
@@ -1665,9 +1750,18 @@ export function apply(ctx) {
1665
1750
  return undefined
1666
1751
  }
1667
1752
  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
1753
+ // available=false 有两种,混在一起处理会让欠费永远漏网:
1754
+ // · definitive —— 上游用完整 JSON 确知「这个号没有套餐」,判死并隐藏;
1755
+ // · 其余(没 key / 连不上 / 看不懂 / 没这个接口)—— 只是这次没查成,
1756
+ // 按 fail-open 保留,否则一次网络抖动就把能用的模型藏了。
1757
+ // 2026-09-29 实测智谱 coding 端点就栽在这:余额接口白纸黑字回了
1758
+ // 「当前用户不存在coding plan」,却因为和「查不通」共用 available=false 而被放过。
1759
+ if (envelope.available === false) {
1760
+ if (envelope.definitive === true) {
1761
+ return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: label(envelope.reason) ?? '账户没有可用套餐' }
1762
+ }
1763
+ return undefined
1764
+ }
1671
1765
  const items = Array.isArray(envelope.items) ? envelope.items : []
1672
1766
  if (envelope.kind === 'cash') {
1673
1767
  const amount = items[0]?.amount
@@ -1993,7 +2087,13 @@ export function apply(ctx) {
1993
2087
  if (profile?.billingMode === 'usage' || profile?.billingMode === 'subscription') {
1994
2088
  return profile.billingMode
1995
2089
  }
1996
- return detectBillingMode(profile || { provider: cleanId })
2090
+ // ⚠️ balance 必须传:少了它,`detectBillingMode` 里认 `kind: 'plan' | 'quota'`、
2091
+ // `resetsAt`、`weeklyPercent` 的那几条分支全是死代码,最后一律落到兜底的
2092
+ // `return 'usage'`。2026-09-29 实测:MiniMax 余额信封明明是
2093
+ // `{kind:'plan', items:[{resetsAt:'15:00', weeklyPercent:97}]}`,下拉里却被当成
2094
+ // 按量付费排在套餐后面 —— 配置卡片显示对了(client 传了 balance),
2095
+ // 下拉排序却是错的(host 没传),两边对不上就是这个漏传造成的。
2096
+ return detectBillingMode(profile || { provider: cleanId }, profile, balanceSnapshot.get(cleanId) ?? balanceSnapshot.get(id))
1997
2097
  }
1998
2098
 
1999
2099
  const filtered = function (...args) {
@@ -2128,10 +2228,10 @@ export function apply(ctx) {
2128
2228
  // modelgo 这种 pi-ai 风格 provider 在 'llm-pi-ai' namespace 下;
2129
2229
  // dsh-llm-hub 自己的 NS 是 'llm-deepseek',但写回属于跨 namespace 兜底:
2130
2230
  // plugin 不是 pi-ai 的 owner,只是借用 settings 服务给 pi-ai 段补一份目录。
2131
- const source = settingsCtx.settings.get(PIAI_NS)
2231
+ const source = sectionOfNs(PIAI_NS)
2132
2232
  const updates = await populateEmptyProviderModels(source)
2133
2233
  if (Object.keys(updates).length === 0) return
2134
- const current = settingsCtx.settings.get(PIAI_NS)
2234
+ const current = sectionOfNs(PIAI_NS)
2135
2235
  for (const provider of Object.keys(updates)) {
2136
2236
  const live = current?.providers?.[provider]
2137
2237
  if (Array.isArray(live?.models) && live.models.length > 0) {
@@ -2707,9 +2807,11 @@ export function apply(ctx) {
2707
2807
  return {
2708
2808
  id: group.id,
2709
2809
  label: group.label,
2810
+ note: group.note ?? null,
2710
2811
  candidates: candidates.map((candidate) => ({
2711
2812
  provider: candidate.provider,
2712
2813
  model: candidate.model,
2814
+ note: group.notes?.[`${candidate.provider}/${candidate.model}`] ?? null,
2713
2815
  state: stateFor(candidate.provider, candidate.model)
2714
2816
  })),
2715
2817
  wouldRoute: winner.to,
@@ -2734,7 +2836,7 @@ export function apply(ctx) {
2734
2836
  /** 读 settings 的 routing 段并按需覆写 mode / activeGroup,持久化。 */
2735
2837
  const applyRoutingSelect = async (patch) => {
2736
2838
  const settings = ctx.get('settings')
2737
- if (settings === undefined || hubRegistered !== true) {
2839
+ if (settings === undefined) {
2738
2840
  return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
2739
2841
  }
2740
2842
  const current = routingObjOf()
@@ -2852,7 +2954,7 @@ export function apply(ctx) {
2852
2954
  /** 写回整份路由组(设置页编辑器保存路径),并返回最新状态。 */
2853
2955
  const applyRoutingConfig = async (payload) => {
2854
2956
  const settings = ctx.get('settings')
2855
- if (settings === undefined || hubRegistered !== true) {
2957
+ if (settings === undefined) {
2856
2958
  return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
2857
2959
  }
2858
2960
  const normalized = normalizeRoutingGroupsInput(payload?.groups)
@@ -2877,8 +2979,10 @@ export function apply(ctx) {
2877
2979
  groups: normalized.groups.map((group) => ({
2878
2980
  id: group.id,
2879
2981
  ...(group.label !== null ? { label: group.label } : {}),
2982
+ ...(group.note ? { note: group.note } : {}),
2880
2983
  ...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
2881
- fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
2984
+ fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`),
2985
+ ...(group.notes ? { notes: group.notes } : {})
2882
2986
  }))
2883
2987
  }
2884
2988
  })
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.8.0",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -79,5 +79,8 @@
79
79
  "lib",
80
80
  "scripts",
81
81
  "types"
82
- ]
82
+ ],
83
+ "devDependencies": {
84
+ "@deepseek-ai/schemastery": "3.18.4"
85
+ }
83
86
  }
@@ -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
  }