@dsh-plugins/dsh-llm-hub 1.5.3 → 1.5.5

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,52 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.5.5] - 2026-09-27
6
+
7
+ ### 🪧 路由卡:每条候选的能力 chip(思考 + 多模态)
8
+
9
+ 之前 routing 卡里只把「多模态」用一个 `👁` emoji 标在 model id 后面,**思考档位完全没标** —— 用户必须查文档才能知道这个模型能思考、能用哪些 effort。这一版把这条缝上:
10
+
11
+ - **`routingCatalogOf()`**(`lib/index.js:2789`)每个 model 多带一条 `reasoning: { levels: [{ id, name }, …] } | null`,从 settings 的 `reasoningEfforts` 抽出;键名是人话(`off` / `low` / `high` / `max`),值是适配器认的 effort id,`null` 值的档位(强制思考、不能关的 `off`)不进 chip 文本。
12
+ - **`routing card`**(`lib/client.js` RoutingCard):
13
+ - 每个候选后面追加两枚 chip(仅在能力存在时出现):`💭 思考 low/high/max`、`👁 图像`;思考档位 >3 个时折成 `low/medium/high+2`。
14
+ - 多模态 chip 之前是裸 emoji,现在与思考 chip 共用一套样式(`.dsh-llm-hub-routing__reasoning / __multimodal`),按 `state-info` / `state-success` 主题色上边框 —— 一眼分得清「能干什么」。
15
+ - 添加候选的下拉 `<option>` 同步带 `💭 … 👁` 后缀(同一 catalog 数据源)。
16
+ - **i18n**:`routeMultimodalShort` / `routeReasoningShort` / `routeReasoningTip` 加进 zh + en;思考档位的 tooltip 列出 `name → id` 的映射,方便排查 `off: null` 之类的「明明配置了 off 却不能用」的情况。
17
+ - **测试**:`test/client-cards.test.mjs` 的多模态 chip 用例改成查新类名(`dsh-llm-hub-routing__multimodal`),`node --test` 131 项全过。
18
+
19
+ 数据真源仍是 settings —— 不打网关、不改路由逻辑,只是把已经在那儿的事实亮出来。
20
+
21
+ ## [1.5.4] - 2026-09-27
22
+
23
+ ### 🛡️ 智能路由接管的两处加固(1.5.0 的补丁)
24
+
25
+ 1.5.0 让 `smart` 模式用 `agent/request` waterfall 真实改写请求的 provider/model。
26
+ 两处会在真机上把会话打挂的边界,这一版补上:
27
+
28
+ #### 未注册 / 未探测到的 provider 不当路由目标
29
+
30
+ - **病灶**:`stateFor` 把「不认识这个 provider」与「探测结果是 unknown」都折成
31
+ `unknown`,而 `unknown` 在裁决里是**可用**的(首次路由需要它兜底)。于是配置里
32
+ 写错一个 provider 名(或该 provider 还没注册)时,接管会把请求指向一个不存在的
33
+ adapter,请求以 `NO_ADAPTER` 直接失败 —— 比不接管更糟。
34
+ - **修法**:新增 `routableStateFor` —— 探测表里查不到的 provider 一律视为
35
+ `unavailable`,候选被跳过、路由保持 fail-open(不动原配置)。手动模式下的建议
36
+ 列表仍用旧的 `stateFor`(那里 unknown 是诚实的信息,不是路由目标)。
37
+ - **验证**:`test/host-routing-groups.test.mjs` 覆盖组解析与裁决;`node --test`
38
+ 120 项全过。
39
+
40
+ #### 首次请求预热可用性探测
41
+
42
+ - **病灶**:生产环境里可用性探测此前只由客户端触发(打开设置页 / 下拉重探)。
43
+ 开机后如果没人开设置页,探测表是空的 → smart 模式全部候选被判 `unavailable`
44
+ → 路由长期静默不生效,表现为「开了智能模式但一直在用原模型」。
45
+ - **修法**:`agent/request` 里若 `baseVerdicts` 为空,后台热一把
46
+ `ensureAvailabilityFresh()`(不阻塞当次请求),这次仍 fail-open 走原配置。
47
+
48
+ **没做**:没有发布任何新功能,也没有改配置格式 —— `groups` / `mode` / `activeGroup`
49
+ 与切组入口(composer 路由 chip)与 1.5.0–1.5.3 完全一致。
50
+
5
51
  ## [1.5.3] - 2026-09-26
6
52
 
7
53
  ### 🛂 工程纪律对齐
package/lib/client.js CHANGED
@@ -77,6 +77,10 @@ window.__ModuleLoader__.load({
77
77
  const ROUTING_STATE_URL = '/api/dsh-llm-hub/routing/state'
78
78
  /** 路由组切换:POST { mode?, activeGroup? },持久化进 settings。 */
79
79
  const ROUTING_SELECT_URL = '/api/dsh-llm-hub/routing/select'
80
+ /** 可路由目录:settings 里已配置的 provider 与模型(离线读,不打网关)。 */
81
+ const ROUTING_CATALOG_URL = '/api/dsh-llm-hub/routing/catalog'
82
+ /** 路由组编辑保存:POST { groups, mode?, activeGroup? }。 */
83
+ const ROUTING_CONFIG_URL = '/api/dsh-llm-hub/routing/config'
80
84
  /** 模型别名:把长 id 显示成短名(人话)。 */
81
85
  const ALIASES_URL = '/api/dsh-llm-hub/aliases'
82
86
  const BILLING_MODE_URL = '/api/dsh-llm-hub/billing-mode'
@@ -172,6 +176,34 @@ window.__ModuleLoader__.load({
172
176
  routeChipLastRoute: '最近实际',
173
177
  routeChipReasonNone: '候选全部不可用',
174
178
  routeChipFailOpen: '路由失败时按原配置发出',
179
+ routeRuleTitle: '规则',
180
+ routeRuleText: '按组内顺序取第一个可用;探测失败或额度耗尽的自动跳过;全不可用则保持原样(fail-open)。顺序即优先级 —— 固定成本(包月/订阅)放前面,按量付费放后面兜底。',
181
+ routeSmartNotice: '已接管:输入框的模型下拉只作参考,实际请求按本卡规则走(实时结果见聊天框左侧 chip)。',
182
+ routeManualNotice: '未接管:请求还是用你在模型下拉里选的;点「智能路由」才按本卡规则自动切。',
183
+ routeEditHint: '编辑后点保存写入 settings.dsh-llm-hub.routing',
184
+ routeSave: '保存',
185
+ routeSaved: '已保存',
186
+ routeSaveFailed: '保存失败',
187
+ routeAddCandidate: '+ 添加候选',
188
+ routeAddProvider: '提供方',
189
+ routeAddModel: '模型',
190
+ routeAdd: '添加',
191
+ routeNewGroup: '+ 新建组',
192
+ routeDeleteGroup: '删除该组',
193
+ routeUp: '上移',
194
+ routeDown: '下移',
195
+ routeRemove: '移除',
196
+ routeGroupLabelPlaceholder: '组名(公司 / 个人)',
197
+ routeActiveTag: '当前组',
198
+ routeSetActive: '设为当前组',
199
+ routePrimaryTag: '主力',
200
+ routeFallbackTag: '备',
201
+ routeNoCatalog: '读不到 provider 目录 —— 先在设置里配好 provider 再来加候选',
202
+ routeDropped: '有配置被忽略',
203
+ routeMultimodalTip: '多模态:输入含图像等非文本模态(来自 settings 的模态声明)',
204
+ routeMultimodalShort: '图像',
205
+ routeReasoningTip: '思考链:该模型声明的推理档位(来自 settings 的 reasoningEfforts)',
206
+ routeReasoningShort: '思考',
175
207
  aliasesMissing: '尚未配置别名',
176
208
  heroTitle: 'LLM 矩阵概览',
177
209
  heroProviders: '活跃提供方',
@@ -296,6 +328,34 @@ window.__ModuleLoader__.load({
296
328
  routeChipLastRoute: 'Last used',
297
329
  routeChipReasonNone: 'All candidates unavailable',
298
330
  routeChipFailOpen: 'Falls back to your selection when routing fails',
331
+ routeRuleTitle: 'Rule',
332
+ 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.',
333
+ 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).',
334
+ routeManualNotice: 'Not taking over: requests still use the model you picked in the dropdown. Switch to Smart routing to auto-select.',
335
+ routeEditHint: 'Save writes settings.dsh-llm-hub.routing',
336
+ routeSave: 'Save',
337
+ routeSaved: 'Saved',
338
+ routeSaveFailed: 'Save failed',
339
+ routeAddCandidate: '+ Add candidate',
340
+ routeAddProvider: 'Provider',
341
+ routeAddModel: 'Model',
342
+ routeAdd: 'Add',
343
+ routeNewGroup: '+ New group',
344
+ routeDeleteGroup: 'Delete group',
345
+ routeUp: 'Move up',
346
+ routeDown: 'Move down',
347
+ routeRemove: 'Remove',
348
+ routeGroupLabelPlaceholder: 'Group name (work / personal)',
349
+ routeActiveTag: 'active',
350
+ routeSetActive: 'Set active',
351
+ routePrimaryTag: 'primary',
352
+ routeFallbackTag: 'alt',
353
+ routeNoCatalog: 'No provider directory — configure a provider first',
354
+ routeDropped: 'Some entries ignored',
355
+ routeMultimodalTip: 'Multimodal: accepts non-text input such as images (from the modality declared in settings)',
356
+ routeMultimodalShort: 'vision',
357
+ routeReasoningTip: 'Reasoning chain: effort levels declared by this model (from settings.reasoningEfforts)',
358
+ routeReasoningShort: 'think',
299
359
  aliasesMissing: 'no aliases configured',
300
360
  heroTitle: 'LLM Matrix Overview',
301
361
  heroProviders: 'Active providers',
@@ -537,6 +597,21 @@ window.__ModuleLoader__.load({
537
597
  'border-radius:3px;border:.5px solid var(--dsw-alias-border-l2);',
538
598
  'background:transparent;font-size:10px;color:var(--dsw-alias-label-secondary)}',
539
599
  '.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
600
+ // capability 标识:思考 / 多模态。两枚 chip 同形,左思考、右图像;
601
+ // 边色走 state 提示色,跟健康状态 chip 区分开 —— 一个说「能干什么」,
602
+ // 一个说「能不能用」。
603
+ '.dsh-llm-hub-routing__reasoning,.dsh-llm-hub-routing__multimodal{',
604
+ 'display:inline-flex;align-items:center;height:16px;padding:0 6px;',
605
+ 'border-radius:3px;font-size:10px;line-height:14px;font-weight:500;',
606
+ 'background:transparent;cursor:help}',
607
+ // 思考:走 warn 主题色(消耗更多 token、需要警觉)
608
+ '.dsh-llm-hub-routing__reasoning{',
609
+ 'border:.5px solid var(--dsw-alias-state-warn-primary);',
610
+ 'color:var(--dsw-alias-state-warn-primary)}',
611
+ // 多模态:走 success 主题色(输入更丰富 = 好)
612
+ '.dsh-llm-hub-routing__multimodal{',
613
+ 'border:.5px solid var(--dsw-alias-state-success-primary);',
614
+ 'color:var(--dsw-alias-state-success-primary)}',
540
615
  '.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);padding-top:2px}',
541
616
  '.dsh-llm-hub-routing__reason{color:var(--dsw-alias-state-error-primary);padding-top:2px}',
542
617
  // composer 快切 chip(conversation.input.left)
@@ -567,6 +642,30 @@ window.__ModuleLoader__.load({
567
642
  '.dsh-llm-hub-route__group-name{font-weight:600;font-size:11px}',
568
643
  '.dsh-llm-hub-route__state{font-size:10px;color:var(--dsw-alias-label-secondary);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
569
644
  '.dsh-llm-hub-route__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
645
+ '.dsh-llm-hub-route__order{font-size:10px;color:var(--dsw-alias-label-secondary);',
646
+ 'font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;line-height:1.4}',
647
+ // 智能路由编辑器(设置页路由卡)
648
+ '.dsh-llm-hub-routing__modes{display:flex;gap:4px;margin-left:auto}',
649
+ '.dsh-llm-hub-routing__rule{display:flex;gap:6px;align-items:flex-start;font-size:10.5px;line-height:1.5;',
650
+ 'color:var(--dsw-alias-label-secondary);padding:6px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2)}',
651
+ '.dsh-llm-hub-routing__rule-tag{flex:none;font-weight:600;color:var(--dsw-alias-label-primary)}',
652
+ '.dsh-llm-hub-routing__now{font-size:11px;color:var(--dsw-alias-label-primary);font-weight:600}',
653
+ '.dsh-llm-hub-routing__groups{display:flex;flex-direction:column;gap:6px;margin-top:8px}',
654
+ '.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}',
655
+ '.dsh-llm-hub-routing__group.is-active{border-color:var(--dsw-alias-state-warn-primary)}',
656
+ '.dsh-llm-hub-routing__group-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
657
+ '.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)}',
658
+ '.dsh-llm-hub-routing__group-label{flex:1;min-width:110px;box-sizing:border-box;height:22px;padding:0 6px;border-radius:4px;',
659
+ '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}',
660
+ '.dsh-llm-hub-routing__group-state{font-size:10.5px;color:var(--dsw-alias-label-secondary);max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
661
+ '.dsh-llm-hub-routing__candidates{display:flex;flex-direction:column;gap:3px;padding-top:2px}',
662
+ '.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}',
663
+ '.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);',
664
+ 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;max-width:170px}',
665
+ '.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}',
666
+ '.dsh-llm-hub-routing__hint{font-size:10px;color:var(--dsw-alias-label-tertiary)}',
667
+ '.dsh-llm-hub-routing__mm{display:inline-flex;align-items:center;margin-left:4px;font-size:10px;cursor:help;',
668
+ 'color:var(--dsw-alias-state-success-primary,#10b981)}',
570
669
  // Hero 全景统计大横条
571
670
  '.dsh-llm-hub-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin:12px 0 6px 0}',
572
671
  '.dsh-llm-hub-hero__card{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.02));backdrop-filter:blur(6px)}',
@@ -1649,7 +1748,15 @@ window.__ModuleLoader__.load({
1649
1748
  },
1650
1749
  h('span', { className: 'dsh-llm-hub-route__group-top' },
1651
1750
  h('span', { className: 'dsh-llm-hub-route__group-name' }, group.label ?? group.id),
1652
- h('span', { className: 'dsh-llm-hub-route__state' }, stateText(group)))))) : null
1751
+ h('span', { className: 'dsh-llm-hub-route__state' }, stateText(group))),
1752
+ // 顺序即优先级:把候选按组内顺序摊出来(① = 主力),让人一眼看到
1753
+ // 「智能」到底怎么个智能法,而不是只有一个 wouldRoute 结果。
1754
+ h('span', { className: 'dsh-llm-hub-route__order' },
1755
+ (group.candidates ?? []).map((candidate, index) => `${index === 0 ? '①' : index === 1 ? '②' : index === 2 ? '③' : `(${index + 1})`}${candidate.provider}/${candidate.model}${candidate.state === 'unavailable' ? '✗' : ''}`).join(' → ')),
1756
+ group.reason !== null && group.reason !== undefined && group.wouldRoute === null
1757
+ ? h('span', { className: 'dsh-llm-hub-route__hint' }, group.reason)
1758
+ : null)),
1759
+ h('div', { className: 'dsh-llm-hub-route__hint' }, t('routeRuleText'))) : null
1653
1760
  return h('div', { className: 'dsh-llm-hub-route' },
1654
1761
  h('button', {
1655
1762
  type: 'button',
@@ -1685,56 +1792,314 @@ window.__ModuleLoader__.load({
1685
1792
  */
1686
1793
  function RoutingCard(props) {
1687
1794
  const t = typeof props.t === 'function' ? props.t : fallbackT
1688
- const [report, setReport] = React.useState(null)
1689
- const query = React.useCallback((active) => {
1690
- fetch(ROUTING_URL, {
1691
- method: 'POST',
1692
- headers: { 'content-type': 'application/json', accept: 'application/json' },
1693
- body: JSON.stringify({ active: typeof active === 'string' && active.length > 0 ? active : null })
1694
- })
1695
- .then((res) => res.json())
1696
- .then((body) => { setReport(body !== null && typeof body === 'object' ? body : null) })
1697
- .catch(() => setReport(null))
1795
+ const [state, setState] = React.useState(null)
1796
+ const [catalog, setCatalog] = React.useState([])
1797
+ // draft 是编辑器的工作副本:[{ id, label, candidates: [{provider, model}] }],
1798
+ // candidates[0] = 主力(primary),其余是 fallback —— 与 settings 形态一一对应。
1799
+ const [draft, setDraft] = React.useState(null)
1800
+ const [activeGroup, setActiveGroup] = React.useState('')
1801
+ const [expanded, setExpanded] = React.useState('')
1802
+ const [picker, setPicker] = React.useState({ provider: '', model: '' })
1803
+ const [busy, setBusy] = React.useState(false)
1804
+ const [notice, setNotice] = React.useState(null)
1805
+ // 保存过/刚拉回的 draft 指纹:用它判断「脏」,避免后台刷新把用户正在编辑的内容冲掉。
1806
+ const [savedKey, setSavedKey] = React.useState('')
1807
+ // label 归一成「空 = null」:草稿里是 '',服务端回的是 null,不归一会永远显示「未保存」。
1808
+ const draftKeyOf = (report) => JSON.stringify((report?.groups ?? []).map((group) => [
1809
+ group.id,
1810
+ group.label ? group.label : null,
1811
+ (group.candidates ?? []).map((candidate) => `${candidate.provider}/${candidate.model}`)
1812
+ ]))
1813
+ const draftFrom = (report) => (report.groups ?? []).map((group) => ({
1814
+ id: group.id,
1815
+ label: group.label ?? '',
1816
+ candidates: group.candidates.map((candidate) => ({ provider: candidate.provider, model: candidate.model }))
1817
+ }))
1818
+ // force=true 只用在一处:保存成功后的回读 —— 那时服务端就是权威。
1819
+ // 其余时候(挂载、切模式、轮询)保留用户正在编辑的草稿。
1820
+ const adopt = React.useCallback((report, force) => {
1821
+ setState(report)
1822
+ setActiveGroup(report.activeGroup)
1823
+ setSavedKey(draftKeyOf(report))
1824
+ setDraft((prev) => (force === true || prev === null ? draftFrom(report) : prev))
1698
1825
  }, [])
1699
- React.useEffect(() => { query(null) }, [query])
1700
- if (report === null) {
1826
+ const loadState = React.useCallback(() => {
1827
+ fetch(ROUTING_STATE_URL, { headers: { accept: 'application/json' } })
1828
+ .then((res) => res.json())
1829
+ .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true) adopt(body) })
1830
+ .catch(() => {})
1831
+ }, [adopt])
1832
+ React.useEffect(() => {
1833
+ loadState()
1834
+ fetch(ROUTING_CATALOG_URL, { headers: { accept: 'application/json' } })
1835
+ .then((res) => res.json())
1836
+ .then((body) => { if (body !== null && typeof body === 'object' && body.ok === true && Array.isArray(body.providers)) setCatalog(body.providers) })
1837
+ .catch(() => {})
1838
+ }, [loadState])
1839
+ if (state === null) {
1701
1840
  return h('div', { className: 'dsh-llm-hub-routing' },
1702
1841
  h('div', { className: 'dsh-llm-hub-routing__head' },
1703
1842
  h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
1704
1843
  h('span', null, t('routingLoading'))))
1705
1844
  }
1706
- if (report.ok !== true) return null
1707
- const candidates = Array.isArray(report.candidates) ? report.candidates : []
1708
- if (candidates.length === 0) {
1709
- return null
1710
- }
1711
1845
  ensureStyle()
1712
- const active = report.active
1713
- const recommendation = report.recommendation
1714
- const activeKey = active === null ? null : `${active.provider}/${active.model}`
1715
- const recKey = recommendation === null ? null : `${recommendation.provider}/${recommendation.model}`
1716
- const showSuggestion = recommendation !== null && recKey !== activeKey
1717
- const chipClass = (state) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${state ?? 'unknown'}`
1718
- const head = h('div', { className: 'dsh-llm-hub-routing__head' },
1846
+ const isSmart = state.mode === 'smart'
1847
+ const groups = draft ?? []
1848
+ const isDirty = draft !== null && draftKeyOf({ groups: groups.map((group) => ({ id: group.id, label: group.label, candidates: group.candidates })) }) !== savedKey
1849
+ const post = (url, payload) => fetch(url, {
1850
+ method: 'POST',
1851
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1852
+ body: JSON.stringify(payload)
1853
+ }).then((res) => res.json())
1854
+ const setMode = (mode) => {
1855
+ setNotice(null)
1856
+ post(ROUTING_SELECT_URL, { mode })
1857
+ .then((body) => { if (body?.ok === true) adopt(body, true) })
1858
+ .catch(() => setNotice('fail'))
1859
+ }
1860
+ const markActive = (id) => {
1861
+ setActiveGroup(id)
1862
+ setNotice(null)
1863
+ post(ROUTING_SELECT_URL, { activeGroup: id })
1864
+ .then((body) => { if (body?.ok === true) adopt(body) })
1865
+ .catch(() => setNotice('fail'))
1866
+ }
1867
+ const save = () => {
1868
+ if (groups.length === 0) return
1869
+ setBusy(true)
1870
+ setNotice(null)
1871
+ post(ROUTING_CONFIG_URL, { groups, activeGroup })
1872
+ .then((body) => {
1873
+ if (body?.ok === true) { adopt(body, true); setNotice('saved') }
1874
+ else setNotice('fail')
1875
+ })
1876
+ .catch(() => setNotice('fail'))
1877
+ .finally(() => setBusy(false))
1878
+ }
1879
+ // ── 草稿编辑(全部是纯函数式更新,不碰服务端;保存才落盘)──
1880
+ const editGroup = (id, mutate) => setDraft(groups.map((group) => (group.id === id ? mutate(group) : group)))
1881
+ const moveCandidate = (id, index, delta) => editGroup(id, (group) => {
1882
+ const next = [...group.candidates]
1883
+ const target = index + delta
1884
+ if (target < 0 || target >= next.length) return group
1885
+ const [item] = next.splice(index, 1)
1886
+ next.splice(target, 0, item)
1887
+ return { ...group, candidates: next }
1888
+ })
1889
+ const removeCandidate = (id, index) => editGroup(id, (group) => ({ ...group, candidates: group.candidates.filter((_, i) => i !== index) }))
1890
+ const addCandidate = (id) => {
1891
+ const provider = picker.provider
1892
+ const model = picker.model
1893
+ if (provider === '' || model === '') return
1894
+ editGroup(id, (group) => ({ ...group, candidates: [...group.candidates, { provider, model }] }))
1895
+ setPicker({ provider: '', model: '' })
1896
+ }
1897
+ const addGroup = () => {
1898
+ let index = groups.length + 1
1899
+ let id = `group-${index}`
1900
+ while (groups.some((group) => group.id === id)) { index += 1; id = `group-${index}` }
1901
+ setDraft([...groups, { id, label: '', candidates: [] }])
1902
+ setExpanded(id)
1903
+ }
1904
+ const removeGroup = (id) => {
1905
+ setDraft(groups.filter((group) => group.id !== id))
1906
+ if (expanded === id) setExpanded('')
1907
+ }
1908
+ const stateChipClass = (value) => `dsh-llm-hub-health__chip dsh-llm-hub-health__chip--${value ?? 'unknown'}`
1909
+ /**
1910
+ * 一个候选是否多模态(输入含 text 之外的模态,典型是 image)。
1911
+ *
1912
+ * 数据来自目录接口的 `input` 字段(settings 里本来就有);目录读不到时
1913
+ * 返回 undefined —— UI 不标而不是瞎猜。
1914
+ */
1915
+ const isMultimodal = (provider, modelId) => {
1916
+ const entry = catalog.find((item) => item.id === provider)
1917
+ const model = Array.isArray(entry?.models) ? entry.models.find((item) => item.id === modelId) : undefined
1918
+ const input = Array.isArray(model?.input) ? model.input : []
1919
+ return input.length > 0 && input.some((kind) => kind !== 'text')
1920
+ }
1921
+ /**
1922
+ * 一个候选的推理档位列表(来自 settings 的 reasoningEfforts 字段);
1923
+ * 没声明推理能力时返回 undefined —— UI 不标。
1924
+ * 返回的是档位对象的浅拷贝(UI 用),外面别 mutate。
1925
+ */
1926
+ const reasoningLevels = (provider, modelId) => {
1927
+ const entry = catalog.find((item) => item.id === provider)
1928
+ const model = Array.isArray(entry?.models) ? entry.models.find((item) => item.id === modelId) : undefined
1929
+ const levels = Array.isArray(model?.reasoning?.levels) ? model.reasoning.levels : []
1930
+ return levels.length > 0 ? levels : undefined
1931
+ }
1932
+ /**
1933
+ * 推理档位的人话短文本:超 3 个档位时压成 `a/b/c+2`,避免长列表把行挤爆。
1934
+ */
1935
+ const reasoningShort = (levels) => {
1936
+ if (levels === undefined) return ''
1937
+ const names = levels.map((level) => level.name)
1938
+ if (names.length <= 3) return names.join('/')
1939
+ return `${names.slice(0, 3).join('/')}+${names.length - 3}`
1940
+ }
1941
+ // 「现在会路由到哪」:smart 模式看 lastDecision(真实请求),否则按组顺序的推演。
1942
+ const resolvedText = () => {
1943
+ if (isSmart) {
1944
+ const decision = state.lastDecision
1945
+ if (decision?.to) return `${t('routeChipLastRoute')}: ${decision.to.provider}/${decision.to.model}`
1946
+ return decision?.reason ?? t('routeChipFailOpen')
1947
+ }
1948
+ const group = state.groups.find((item) => item.id === activeGroup) ?? state.groups[0]
1949
+ if (group?.wouldRoute) return `${t('routeChipWouldRoute')}: ${group.wouldRoute.provider}/${group.wouldRoute.model}`
1950
+ return group?.reason ?? t('routeChipReasonNone')
1951
+ }
1952
+ const header = h('div', { className: 'dsh-llm-hub-routing__head' },
1719
1953
  h('span', { className: 'dsh-llm-hub-routing__title' }, t('routingTitle')),
1720
- active !== null
1721
- ? h('span', { className: 'dsh-llm-hub-routing__active' },
1722
- `${t('routingActive')}: ${activeKey}`,
1723
- h('span', { className: chipClass(active.state) }, active.state === 'available' ? '可用' : '不可用'))
1724
- : h('span', null, t('routingUnconfigured')))
1725
- const rows = candidates.map((entry) => h('div', { key: `${entry.provider}/${entry.model}`, className: 'dsh-llm-hub-routing__row' },
1726
- h('span', { className: 'dsh-llm-hub-routing__role' }, entry.role === 'primary' ? t('routingPrimary') : t('routingFallback')),
1727
- h('span', { className: 'dsh-llm-hub-routing__name' }, `${entry.provider}/${entry.model}`),
1728
- h('span', { className: chipClass(entry.state) }, entry.state === 'available' ? '可用' : '不可用')))
1954
+ h('div', { className: 'dsh-llm-hub-routing__modes' },
1955
+ h('button', {
1956
+ type: 'button',
1957
+ className: `dsh-llm-hub-route__mode${isSmart ? ' is-on' : ''}`,
1958
+ onClick: () => { if (!isSmart) setMode('smart') }
1959
+ }, `⚡ ${t('routeChipModeSmart')}`),
1960
+ h('button', {
1961
+ type: 'button',
1962
+ className: `dsh-llm-hub-route__mode${!isSmart ? ' is-on' : ''}`,
1963
+ onClick: () => { if (isSmart) setMode('manual') }
1964
+ }, t('routeChipModeManual'))))
1965
+ const rule = h('div', { className: 'dsh-llm-hub-routing__rule' },
1966
+ h('span', { className: 'dsh-llm-hub-routing__rule-tag' }, t('routeRuleTitle')),
1967
+ h('span', null, t('routeRuleText')))
1968
+ const noticeLine = h('div', { className: 'dsh-llm-hub-routing__reason' },
1969
+ isSmart ? t('routeSmartNotice') : t('routeManualNotice'))
1970
+ const nowLine = h('div', { className: 'dsh-llm-hub-routing__now' }, resolvedText())
1971
+ const groupRows = groups.map((group, groupIndex) => {
1972
+ const live = state.groups.find((item) => item.id === group.id)
1973
+ const isOpen = expanded === group.id
1974
+ const candidates = group.candidates
1975
+ return h('div', { key: group.id, className: `dsh-llm-hub-routing__group${activeGroup === group.id ? ' is-active' : ''}` },
1976
+ h('div', { className: 'dsh-llm-hub-routing__group-top' },
1977
+ h('button', {
1978
+ type: 'button',
1979
+ className: 'dsh-llm-hub-routing__group-toggle',
1980
+ onClick: () => setExpanded(isOpen ? '' : group.id)
1981
+ }, `${isOpen ? '▾' : '▸'} ${groupIndex + 1}`),
1982
+ h('input', {
1983
+ className: 'dsh-llm-hub-routing__group-label',
1984
+ value: group.label ?? '',
1985
+ placeholder: t('routeGroupLabelPlaceholder'),
1986
+ onChange: (event) => editGroup(group.id, (current) => ({ ...current, label: event.target.value }))
1987
+ }),
1988
+ live?.wouldRoute
1989
+ ? h('span', { className: 'dsh-llm-hub-routing__group-state' }, `${live.wouldRoute.provider}/${live.wouldRoute.model}`)
1990
+ : h('span', { className: 'dsh-llm-hub-routing__group-state' }, live?.reason ?? t('routeChipReasonNone')),
1991
+ activeGroup === group.id
1992
+ ? h('span', { className: 'dsh-llm-hub-routing__role' }, t('routeActiveTag'))
1993
+ : h('button', {
1994
+ type: 'button',
1995
+ className: 'dsh-llm-hub-foot__link',
1996
+ onClick: () => markActive(group.id)
1997
+ }, t('routeSetActive'))),
1998
+ isOpen
1999
+ ? h('div', { className: 'dsh-llm-hub-routing__candidates' },
2000
+ ...candidates.map((candidate, index) => {
2001
+ const liveCandidate = live?.candidates?.find((item) => item.provider === candidate.provider && item.model === candidate.model)
2002
+ const multimodal = isMultimodal(candidate.provider, candidate.model)
2003
+ const reasoning = reasoningLevels(candidate.provider, candidate.model)
2004
+ const reasoningTip = reasoning === undefined
2005
+ ? undefined
2006
+ : `${t('routeReasoningTip')}\n${reasoning.map((level) => ` · ${level.name} → ${level.id}`).join('\n')}`
2007
+ return h('div', { key: `${candidate.provider}/${candidate.model}/${index}`, className: 'dsh-llm-hub-routing__row' },
2008
+ h('span', { className: 'dsh-llm-hub-routing__role' }, index === 0 ? t('routePrimaryTag') : `${t('routeFallbackTag')}${index}`),
2009
+ h('span', { className: 'dsh-llm-hub-routing__name' }, `${candidate.provider}/${candidate.model}`),
2010
+ reasoning !== undefined
2011
+ ? h('span', {
2012
+ key: 'reasoning',
2013
+ className: 'dsh-llm-hub-routing__reasoning',
2014
+ title: reasoningTip
2015
+ }, `💭 ${t('routeReasoningShort')} ${reasoningShort(reasoning)}`)
2016
+ : null,
2017
+ multimodal === true
2018
+ ? h('span', {
2019
+ key: 'multimodal',
2020
+ className: 'dsh-llm-hub-routing__multimodal',
2021
+ title: t('routeMultimodalTip')
2022
+ }, '👁 ' + t('routeMultimodalShort'))
2023
+ : null,
2024
+ h('span', { className: stateChipClass(liveCandidate?.state) }, liveCandidate?.state === 'available' ? '可用' : (liveCandidate?.state === 'unknown' ? '未探明' : '不可用')),
2025
+ h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeUp'), onClick: () => moveCandidate(group.id, index, -1) }, '↑'),
2026
+ h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeDown'), onClick: () => moveCandidate(group.id, index, 1) }, '↓'),
2027
+ h('button', { type: 'button', className: 'dsh-llm-hub-foot__link', title: t('routeRemove'), onClick: () => removeCandidate(group.id, index) }, '✕'))
2028
+ }),
2029
+ h('div', { className: 'dsh-llm-hub-routing__add' },
2030
+ h('span', null, t('routeAddProvider')),
2031
+ catalog.length > 0
2032
+ ? h('select', {
2033
+ className: 'dsh-llm-hub-routing__select',
2034
+ value: picker.provider,
2035
+ onChange: (event) => {
2036
+ const provider = event.target.value
2037
+ const entry = catalog.find((item) => item.id === provider)
2038
+ setPicker({ provider, model: entry?.models?.[0]?.id ?? '' })
2039
+ }
2040
+ },
2041
+ h('option', { value: '' }, '—'),
2042
+ ...catalog.map((entry) => h('option', { key: entry.id, value: entry.id }, `${entry.displayName ?? entry.id}`)))
2043
+ // 目录读不到(host 还没重启 / settings 里没有 provider)时退回手输 ——
2044
+ // 不让「拉不到目录」变成「编辑不了」。
2045
+ : h('input', {
2046
+ className: 'dsh-llm-hub-routing__select',
2047
+ placeholder: 'provider',
2048
+ value: picker.provider,
2049
+ onChange: (event) => setPicker({ provider: event.target.value, model: picker.model })
2050
+ }),
2051
+ h('span', null, t('routeAddModel')),
2052
+ catalog.find((item) => item.id === picker.provider) !== undefined
2053
+ ? h('select', {
2054
+ className: 'dsh-llm-hub-routing__select',
2055
+ value: picker.model,
2056
+ onChange: (event) => setPicker({ provider: picker.provider, model: event.target.value })
2057
+ },
2058
+ h('option', { value: '' }, '—'),
2059
+ ...((catalog.find((item) => item.id === picker.provider)?.models) ?? []).map((model) => {
2060
+ const multimodal = Array.isArray(model?.input) && model.input.some((kind) => kind !== 'text')
2061
+ const reasoningLevels = Array.isArray(model?.reasoning?.levels) ? model.reasoning.levels : []
2062
+ const flags = []
2063
+ if (reasoningLevels.length > 0) flags.push(`💭 ${reasoningShort(reasoningLevels)}`)
2064
+ if (multimodal === true) flags.push('👁')
2065
+ return h('option', { key: model.id, value: model.id }, flags.length === 0 ? model.id : `${model.id} ${flags.join(' ')}`)
2066
+ }))
2067
+ : h('input', {
2068
+ className: 'dsh-llm-hub-routing__select',
2069
+ placeholder: 'model',
2070
+ value: picker.model,
2071
+ onChange: (event) => setPicker({ provider: picker.provider, model: event.target.value })
2072
+ }),
2073
+ h('button', {
2074
+ type: 'button',
2075
+ className: 'dsh-llm-hub-btn',
2076
+ onClick: () => addCandidate(group.id)
2077
+ }, t('routeAdd'))),
2078
+ catalog.length === 0 ? h('div', { className: 'dsh-llm-hub-routing__rule' }, t('routeNoCatalog')) : null,
2079
+ h('button', {
2080
+ type: 'button',
2081
+ className: 'dsh-llm-hub-foot__link',
2082
+ onClick: () => removeGroup(group.id)
2083
+ }, t('routeDeleteGroup')))
2084
+ : null)
2085
+ })
2086
+ const footer = h('div', { className: 'dsh-llm-hub-routing__actions' },
2087
+ h('button', { type: 'button', className: 'dsh-llm-hub-btn', onClick: addGroup }, t('routeNewGroup')),
2088
+ h('button', {
2089
+ type: 'button',
2090
+ className: 'dsh-llm-hub-btn',
2091
+ disabled: busy || !isDirty,
2092
+ onClick: save
2093
+ }, isDirty ? t('routeSave') : t('routeSaved')),
2094
+ notice === 'fail' ? h('span', { className: 'dsh-llm-hub-routing__reason' }, t('routeSaveFailed')) : null,
2095
+ h('span', { className: 'dsh-llm-hub-routing__hint' }, t('routeEditHint')))
1729
2096
  return h('div', { className: 'dsh-llm-hub-routing' },
1730
- head,
1731
- h('div', { className: 'dsh-llm-hub-routing__rows' }, rows),
1732
- showSuggestion
1733
- ? h('div', { className: 'dsh-llm-hub-routing__suggestion' }, `${t('routingSuggestion')}: ${recKey}`)
1734
- : null,
1735
- recommendation === null && candidates.length > 0
1736
- ? h('div', { className: 'dsh-llm-hub-routing__reason' }, t('routingNoRecommendation'))
1737
- : null)
2097
+ header,
2098
+ rule,
2099
+ noticeLine,
2100
+ nowLine,
2101
+ h('div', { className: 'dsh-llm-hub-routing__groups' }, groupRows),
2102
+ footer)
1738
2103
  }
1739
2104
 
1740
2105
  /**
package/lib/index.js CHANGED
@@ -169,13 +169,18 @@ const WARNING_CHECK_PATH = '/api/dsh-llm-hub/warning/check'
169
169
  /**
170
170
  * 智能路由建议:把当前可用性 + 用户声明的 fallback 顺序折成「该用哪个」。
171
171
  *
172
- * **不做自动切换** —— DSH 的 `conversation.input.model` 是 single + user-controlled
173
- * slot(`replaceRisk: shadows-shipped-ui`),没有供插件改的 setter;强行覆盖会
174
- * 撞 README 红线。这条路由返回的是**建议**,真值为用户在 UI 里点一下。
172
+ * 1.5.0 起语义升级:这条路由仍然只**建议**(composer 的 `conversation.input.model`
173
+ * 是 single + user-controlled slot,插件不碰它);真正的自动接管走
174
+ * `agent/request` waterfall(见本文件「智能路由组 + 模式接管」段)—— manual 模式
175
+ * 下这里就是全部,smart 模式下这里变成「读一眼路由现状」的视图。
175
176
  *
176
- * 请求体:当前已激活用户的 provider/model(如 'modelgo' / 'gpt-4o'),返回:
177
+ * 请求体:当前已激活的 provider/model(如 'modelgo' / 'gpt-4o'),可选 `group`
178
+ * 指定组;返回:
177
179
  * {
178
180
  * ok: true,
181
+ * mode: 'manual'|'smart',
182
+ * activeGroup: 'company',
183
+ * groups: [{ id, label, candidateCount }, ...],
179
184
  * active: { provider, model, state: 'available'|'unknown'|'unavailable' },
180
185
  * candidates: [{ provider, model, state, ... }, ...], // 主 → fallback → 备用
181
186
  * recommendation: { provider, model } | null, // 第一个 available 的
@@ -197,6 +202,16 @@ const ROUTING_STATE_PATH = '/api/dsh-llm-hub/routing/state'
197
202
  * `dsh-llm-hub.routing` 段 —— 状态持久化,重启不丢。
198
203
  */
199
204
  const ROUTING_SELECT_PATH = '/api/dsh-llm-hub/routing/select'
205
+ /**
206
+ * 路由组编辑:`GET /api/dsh-llm-hub/routing/catalog` 列可路由的 provider 与模型,
207
+ * `POST /api/dsh-llm-hub/routing/config` 整体写回 `routing.groups`。
208
+ *
209
+ * 为什么要这两条:1.5.0 只给了 composer 的切组 chip,配置本身仍只能手改
210
+ * settings.yaml —— 「有入口、没闭环」。设置页的路由卡用 catalog 渲染候选选择器,
211
+ * 用 config 落盘编辑结果。
212
+ */
213
+ const ROUTING_CATALOG_PATH = '/api/dsh-llm-hub/routing/catalog'
214
+ const ROUTING_CONFIG_PATH = '/api/dsh-llm-hub/routing/config'
200
215
  /** 插件自有的 settings 命名空间(1.5.0 起注册)—— routing / warning / aliases 都住这里。 */
201
216
  const HUB_NS = 'dsh-llm-hub'
202
217
  /** 模型别名:客户端渲染 model id 时显示短名(典型场景:deepseek-reasoner → 推理)。 */
@@ -343,6 +358,80 @@ export function pickSmartRoute(entries, stateFor, active) {
343
358
  return { to: { provider: winner.provider, model: winner.model }, reason: null, switched: true }
344
359
  }
345
360
 
361
+ /**
362
+ * 校验并规范化「路由组编辑」提交的 groups(真源,供 config 路由与测试共用)。
363
+ *
364
+ * 两种输入形态都收,因为两种调用方都在:
365
+ * - `candidates: [a, b, c]` —— 顺序数组,**首个即主力**。设置页编辑器提交的就是
366
+ * 这个形态(UI 里对用户呈现的是「有序候选列表」,不区分 primary/fallback 概念);
367
+ * - `primary: 'a/b', fallbacks: [...]` —— settings 落盘形态,手工调用方友好。
368
+ * 两者同时出现时以 `candidates` 为准(它是更精确的顺序表达)。
369
+ *
370
+ * 判据:
371
+ * - 组 id:非空、去重(后出现的重名组丢弃)、最长 64;
372
+ * - label 可选,纯空白视为没有;
373
+ * - primary / fallbacks[] 必须是合法 `provider/model`;非法项**过滤**而不是
374
+ * 整单拒绝 —— 用户手敲错一行不应该让整次保存失败(UI 会把合法项显示出来);
375
+ * - 没有 primary 也没有 fallback 的组丢弃(空组无法路由);
376
+ * - 上限:≤ 20 组、每组 ≤ 20 个候选(防呆,手抖粘贴不会把 settings 撑爆)。
377
+ *
378
+ * @param {unknown} raw - 客户端提交的 groups 数组。
379
+ * @returns {{ groups: Array, dropped: string[] }} 规范化结果与被丢弃项的原因说明。
380
+ */
381
+ export function normalizeRoutingGroupsInput(raw) {
382
+ const dropped = []
383
+ if (!Array.isArray(raw)) return { groups: [], dropped: ['groups 不是数组'] }
384
+ /**
385
+ * 候选的两种写法都收:字符串 `'provider/model'`(settings 落盘形态)与对象
386
+ * `{ provider, model }`(编辑器 / 状态接口形态)。对象经此折回字符串再走同一套
387
+ * 正则校验 —— 只有一份合法性判据。
388
+ * @param value - 一个候选。
389
+ * @returns {{ provider: string, model: string } | undefined}
390
+ */
391
+ const asEntry = (value) => {
392
+ if (typeof value === 'string') return parseRouteEntryPure(value)
393
+ if (value !== null && typeof value === 'object') {
394
+ const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
395
+ const model = typeof value.model === 'string' ? value.model.trim() : ''
396
+ if (provider === '' || model === '') return undefined
397
+ return parseRouteEntryPure(`${provider}/${model}`)
398
+ }
399
+ return undefined
400
+ }
401
+ /** 非法项的说明文本:对象要展开,否则日志里只有 [object Object]。 */
402
+ const describe = (value) => (typeof value === 'string' ? value : JSON.stringify(value))
403
+ const groups = []
404
+ const seen = new Set()
405
+ for (const item of raw) {
406
+ if (groups.length >= 20) { dropped.push('超过 20 组,其余忽略'); break }
407
+ if (item === null || typeof item !== 'object') { dropped.push('组不是对象'); continue }
408
+ const id = typeof item.id === 'string' ? item.id.trim().slice(0, 64) : ''
409
+ if (id === '') { dropped.push('组缺少 id'); continue }
410
+ if (seen.has(id)) { dropped.push(`组 id 重复:${id}`); continue }
411
+ // candidates[](编辑器形态)优先;否则读 primary + fallbacks[](落盘形态)。
412
+ const ordered = Array.isArray(item.candidates) ? item.candidates : null
413
+ const rawPrimary = ordered === null ? item.primary : ordered[0]
414
+ const rawFallbacks = ordered === null ? item.fallbacks : ordered.slice(1)
415
+ const primary = asEntry(rawPrimary) ?? null
416
+ const fallbacks = []
417
+ for (const entry of Array.isArray(rawFallbacks) ? rawFallbacks : []) {
418
+ if (fallbacks.length >= 20) { dropped.push(`组 ${id} 的 fallback 超过 20 个`); break }
419
+ const parsed = asEntry(entry)
420
+ if (parsed !== undefined) fallbacks.push(parsed)
421
+ else dropped.push(`组 ${id} 的非法候选被忽略:${describe(entry)}`)
422
+ }
423
+ if (primary === null && fallbacks.length === 0) { dropped.push(`组 ${id} 没有任何合法候选`); continue }
424
+ seen.add(id)
425
+ groups.push({
426
+ id,
427
+ label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim().slice(0, 32) : null,
428
+ primary,
429
+ fallbacks
430
+ })
431
+ }
432
+ return { groups, dropped }
433
+ }
434
+
346
435
  /** 路由配置从 settings 哪一段读 —— 顶层 dsh-llm-hub 段下,与 warning 同级。 */
347
436
  const ROUTING_KEY = 'routing'
348
437
  /** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
@@ -2473,6 +2562,23 @@ export function apply(ctx) {
2473
2562
  return 'unknown'
2474
2563
  }
2475
2564
 
2565
+ /**
2566
+ * 路由目标专线的可用性:**探测表里没有的 provider 视为不可用**。
2567
+ *
2568
+ * `stateFor` 把「不认识这个 provider」和「探测结果是 unknown」都折成 unknown,
2569
+ * 后者可以路由,前者路由过去只会撞 NO_ADAPTER —— 请求直接失败。所以智能路由
2570
+ * 用这一版:配置里写错 provider 名 / 该 provider 还没注册时,候选被跳过,
2571
+ * 路由保持 fail-open(不动原配置),而不是把会话打挂。
2572
+ * @param provider - provider id。
2573
+ * @returns 'available' | 'unknown' | 'unavailable'。
2574
+ */
2575
+ const routableStateFor = (provider) => {
2576
+ for (const verdict of baseVerdicts.values()) {
2577
+ if (verdict.provider === provider) return verdict.state
2578
+ }
2579
+ return 'unavailable'
2580
+ }
2581
+
2476
2582
  /**
2477
2583
  * 路由解析:给客户端返回「该用哪个 + 原因」(1.5.0 起按组解析)。
2478
2584
  * @param active - 客户端 POST 进来的当前已激活 (provider, model)。
@@ -2572,7 +2678,7 @@ export function apply(ctx) {
2572
2678
  ...(group.primary !== null ? [group.primary] : []),
2573
2679
  ...group.fallbacks
2574
2680
  ]
2575
- const winner = pickSmartRoute(candidates, (provider) => stateFor(provider, ''), null)
2681
+ const winner = pickSmartRoute(candidates, (provider) => routableStateFor(provider), null)
2576
2682
  return {
2577
2683
  id: group.id,
2578
2684
  label: group.label,
@@ -2629,6 +2735,179 @@ export function apply(ctx) {
2629
2735
  }
2630
2736
  }
2631
2737
 
2738
+ /**
2739
+ * 可路由目录:settings 里已配置的 provider 与各自的模型 id(离线,不打网关)。
2740
+ *
2741
+ * 数据源是**配置段**而不是 `ctx.llm.listProviders()`:后者会被本插件的隐藏
2742
+ * 过滤器影响,拿它当候选来源会「隐藏了就再也加不回来」。
2743
+ * @returns {{ providers: Array<{ id, displayName, ns, models: string[] }> }}
2744
+ */
2745
+ /**
2746
+ * 从 settings 里的一个 model 条目读输入模态。
2747
+ *
2748
+ * 两种字段名都收:llm-deepseek 段写 `inputModalities: [text, image]`,
2749
+ * llm-pi-ai 段写 `input: [text, image]`。读不到时为空数组 —— 空数组表示
2750
+ * 「不知道」,UI 不标(而不是猜一个)。
2751
+ * @param model - settings 里的 model 条目对象。
2752
+ * @returns {string[]}
2753
+ */
2754
+ const modalitiesOf = (model) => {
2755
+ const raw = Array.isArray(model?.inputModalities) ? model.inputModalities : (Array.isArray(model?.input) ? model.input : [])
2756
+ return raw.filter((item) => typeof item === 'string')
2757
+ }
2758
+
2759
+ /**
2760
+ * 从 settings 里的一个 model 条目读推理(reasoning)能力。
2761
+ *
2762
+ * 形态是 `{ off: null, low: 'low', high: 'high', max: 'max' }`:键名 = 人类可读
2763
+ * 的档位名,值 = 适配器真正认的 effort id(`null` 表示「这个档位不开放」,
2764
+ * 比如 `off: null` 就是该模型强制思考、不能关)。模型没有这条字段时返回 null
2765
+ * —— UI 不标,而不是猜一个(用户没声明「能思考」就当不能)。
2766
+ *
2767
+ * 只暴露非空档位;off/null 这类「不可用」档位不进 chip 文本,免得显示「思考 · off」
2768
+ * 自相矛盾。
2769
+ * @param model - settings 里的 model 条目对象。
2770
+ * @returns {{ levels: Array<{ id: string, name: string }> } | null}
2771
+ */
2772
+ const reasoningOf = (model) => {
2773
+ const efforts = model?.reasoningEfforts
2774
+ if (efforts === null || typeof efforts !== 'object') return null
2775
+ const levels = []
2776
+ for (const [name, id] of Object.entries(efforts)) {
2777
+ if (id === null || id === undefined || id === '') continue
2778
+ levels.push({ id: String(id), name: String(name) })
2779
+ }
2780
+ if (levels.length === 0) return null
2781
+ return { levels }
2782
+ }
2783
+
2784
+ /**
2785
+ * 可路由目录:settings 里已配置的 provider 与各自的模型 id(离线,不打网关)。
2786
+ *
2787
+ * 数据源是**配置段**而不是 `ctx.llm.listProviders()`:后者会被本插件的隐藏
2788
+ * 过滤器影响,拿它当候选来源会「隐藏了就再也加不回来」。
2789
+ *
2790
+ * 每个模型带两条 capability 事实:
2791
+ * · `input: string[]` —— 输入模态(text / image …);空数组表示「不知道」。
2792
+ * · `reasoning: { levels: [{ id, name }, …] } | null` —— 推理档位;
2793
+ * 非空 = 模型能思考,UI 标「思考 · low/high/max」;null = 不标。
2794
+ * 这两条都是 settings 里原本就有的事实(2026-09-27 用户反馈),不亮出来
2795
+ * 用户就只能靠背 —— 「这个模型支持多模态吗」「它能思考吗」每次都得查文档。
2796
+ * @returns {{ providers: Array<{ id, displayName, ns, models: Array<{ id, input: string[], reasoning: object | null }> }> }}
2797
+ */
2798
+ const routingCatalogOf = () => {
2799
+ const out = []
2800
+ for (const [id, profile] of piaiProviders()) {
2801
+ const models = (Array.isArray(profile.models) ? profile.models : [])
2802
+ .map((model) => (model !== null && typeof model === 'object' && typeof model.id === 'string' ? { id: model.id, input: modalitiesOf(model), reasoning: reasoningOf(model) } : undefined))
2803
+ .filter((model) => model !== undefined)
2804
+ out.push({
2805
+ id,
2806
+ displayName: typeof profile.displayName === 'string' && profile.displayName.trim() !== '' ? profile.displayName : id,
2807
+ ns: PIAI_NS,
2808
+ models
2809
+ })
2810
+ }
2811
+ const deepseek = sectionOf()
2812
+ if (deepseek !== undefined) {
2813
+ const models = (Array.isArray(deepseek.models) ? deepseek.models : [])
2814
+ .map((model) => (model !== null && typeof model === 'object' && typeof model.id === 'string' ? { id: model.id, input: modalitiesOf(model), reasoning: reasoningOf(model) } : undefined))
2815
+ .filter((model) => model !== undefined)
2816
+ out.push({
2817
+ id: DEEPSEEK_PROVIDER,
2818
+ displayName: typeof deepseek.displayName === 'string' && deepseek.displayName.trim() !== '' ? deepseek.displayName : 'DeepSeek 官方直连',
2819
+ ns: NS,
2820
+ models
2821
+ })
2822
+ }
2823
+ out.sort((a, b) => a.id.localeCompare(b.id))
2824
+ return { providers: out }
2825
+ }
2826
+
2827
+ /** 写回整份路由组(设置页编辑器保存路径),并返回最新状态。 */
2828
+ const applyRoutingConfig = async (payload) => {
2829
+ const settings = ctx.get('settings')
2830
+ if (settings === undefined || hubRegistered !== true) {
2831
+ return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
2832
+ }
2833
+ const normalized = normalizeRoutingGroupsInput(payload?.groups)
2834
+ if (normalized.groups.length === 0) {
2835
+ return { ok: false, error: '至少需要一组有效候选(provider/model)', dropped: normalized.dropped }
2836
+ }
2837
+ const current = routingObjOf()
2838
+ // 生效组若在新配置里没了,落到第一组 —— 不让 settings 里留一个悬空的 activeGroup。
2839
+ const requested = typeof payload?.activeGroup === 'string' ? payload.activeGroup.trim() : ''
2840
+ const fallbackActive = normalized.groups.some((group) => group.id === requested)
2841
+ ? requested
2842
+ : (normalized.groups.some((group) => group.id === current.activeGroup) ? current.activeGroup : normalized.groups[0].id)
2843
+ const mode = payload?.mode === 'smart' || payload?.mode === 'manual'
2844
+ ? payload.mode
2845
+ : (current.mode === 'smart' ? 'smart' : 'manual')
2846
+ try {
2847
+ await settings.update(HUB_NS, {
2848
+ routing: {
2849
+ ...current,
2850
+ mode,
2851
+ activeGroup: fallbackActive,
2852
+ groups: normalized.groups.map((group) => ({
2853
+ id: group.id,
2854
+ ...(group.label !== null ? { label: group.label } : {}),
2855
+ ...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
2856
+ fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
2857
+ }))
2858
+ }
2859
+ })
2860
+ return { ok: true, dropped: normalized.dropped, ...smartStateOf() }
2861
+ } catch (error) {
2862
+ return { ok: false, error: `写不进 settings(${error?.message ?? error})` }
2863
+ }
2864
+ }
2865
+
2866
+ ctx.effect(() => ctx.webServer.register({
2867
+ kind: 'exact',
2868
+ path: ROUTING_CATALOG_PATH,
2869
+ handler: (request, response) => {
2870
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
2871
+ response.writeHead(405, { allow: 'GET, HEAD' })
2872
+ response.end()
2873
+ return
2874
+ }
2875
+ if (!sameOrigin(request)) {
2876
+ sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
2877
+ return
2878
+ }
2879
+ if (request.method === 'HEAD') {
2880
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
2881
+ response.end()
2882
+ return
2883
+ }
2884
+ sendJson(response, 200, { ok: true, ...routingCatalogOf() })
2885
+ }
2886
+ }), 'dsh-llm-hub: routing catalog route')
2887
+
2888
+ ctx.effect(() => ctx.webServer.register({
2889
+ kind: 'exact',
2890
+ path: ROUTING_CONFIG_PATH,
2891
+ handler: async (request, response) => {
2892
+ if (request.method !== 'POST') {
2893
+ response.writeHead(405, { allow: 'POST' })
2894
+ response.end()
2895
+ return
2896
+ }
2897
+ if (!sameOrigin(request)) {
2898
+ sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
2899
+ return
2900
+ }
2901
+ const payload = await readPostBody(request)
2902
+ if (payload === null) {
2903
+ sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
2904
+ return
2905
+ }
2906
+ const result = await applyRoutingConfig(payload)
2907
+ sendJson(response, result.ok === true ? 200 : 400, result)
2908
+ }
2909
+ }), 'dsh-llm-hub: routing config route')
2910
+
2632
2911
  ctx.effect(() => ctx.webServer.register({
2633
2912
  kind: 'exact',
2634
2913
  path: ROUTING_STATE_PATH,
@@ -2688,13 +2967,16 @@ export function apply(ctx) {
2688
2967
  if (config === null || typeof config !== 'object') return config
2689
2968
  const state = routingGroupsOf()
2690
2969
  if (state.mode !== 'smart' || state.groups.length === 0) return config
2970
+ // 探测表空 = 还没人探过。后台热一把(不阻塞这次请求),这次照 fail-open 走原配
2971
+ // —— 否则「刚开机就发请求」会让智能路由一直等到用户开设置页才生效。
2972
+ if (baseVerdicts.size === 0) ensureAvailabilityFresh()
2691
2973
  const group = state.groups.find((candidate) => candidate.id === state.activeGroup) ?? state.groups[0]
2692
2974
  const candidates = [
2693
2975
  ...(group.primary !== null ? [group.primary] : []),
2694
2976
  ...group.fallbacks
2695
2977
  ]
2696
2978
  const active = { provider: config.provider, model: config.model }
2697
- const decision = pickSmartRoute(candidates, (provider) => stateFor(provider, ''), active)
2979
+ const decision = pickSmartRoute(candidates, (provider) => routableStateFor(provider), active)
2698
2980
  lastSmartDecision.at = Date.now()
2699
2981
  lastSmartDecision.group = group.id
2700
2982
  lastSmartDecision.from = active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plugins/dsh-llm-hub",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,92 +1,249 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * prepublish-gate —— 在 `npm publish` 之前把三条硬契约拦在本地。
3
+ * prepublish-gate —— 发版前的机械门禁(四条契约)
4
4
  *
5
- * 为什么是门禁而不是文档:
6
- * 2026-09-26 推 1.5.0 时, CHANGELOG 写了、assets/ 落了、版本号 bump 了,
7
- * 但 package.json 的 files 数组忘了加 'assets' —— 推上 npm 后 122kB
8
- * tarball 不含 3 张 SVG, 从 npm 读 CHANGELOG.md 的人看不到截图。
9
- * 同样那次, 1.5.1 推上去时 publish.yml 在「GH Release」那一步挂了:
10
- * CHANGELOG 里没 [1.5.1] 段, 脚本 process.exit(1), 整个 job 标红。
11
- * npm publish 已经过 —— 1.5.1 在 npm 上, 但 GH Release 没建, 走到 README
12
- * 想挂 release link 时发现 404。
5
+ * 🔴 这个文件是**拷贝**,真源在 CortexOS:
6
+ * CortexOS/scripts/release-gate/prepublish-gate.mjs
7
+ * 同步检查:`node $CORTEXOS_ROOT/scripts/check-release-gate-sync.mjs`
8
+ * 改逻辑请改真源,然后跑同步脚本把各仓拷一遍 —— 不要在各仓就地改。
13
9
  *
14
- * 两条都是「约定没做成门禁」。本脚本把三条契约变成前置门:
15
- * 1. tarball 必含 paths in MUST_INCLUDE
16
- * 2. CHANGELOG 顶部必含 '## [<当前 package.json version>]' 段
17
- * 3. assets/ 目录里至少有一张 .svg 推广图 (不允许空目录占位)
10
+ * 为什么是门禁而不是文档:2026-09-26 一天里给 dsh-llm-hub 发了 1.5.0/1.5.1/1.5.2/1.5.3
11
+ * 四个版本号,用户可感知的变化只有 1.5.0 一个;同一轮还漏了 bloom-theme 的 assets
12
+ * (README 引用 11 个相对路径截图、tarball 里 0 个 → npm 页面上全是坏图;那 11 张合计
13
+ * 10MB,正确修法是换成图床直链而不是把 10MB 塞进每个安装包)。共同点是
14
+ * 「约定没做成门禁」,所以把判据做成能跑的:
18
15
  *
19
- * 任一条挂了: process.exit(1) + stderr 一行指明错在哪。
16
+ * 契约 1 产物完整性 —— package.json 必须在;`files` 里每个字面量条目至少命中一个文件;
17
+ * main / types / exports / dsh 引用的相对路径文件必须存在。
18
+ * 契约 2 图片引用 —— README 里以**相对路径**引用的 assets/* 必须在 tarball 里。
19
+ * 否则 npm 页面上是坏图(bloom-theme 就是这么坏的)。
20
+ * 只认 `](assets/…)` 与 `src="assets/…"`,不认 URL 里的 assets/
21
+ * (raw.githubusercontent 那种在 npm 上照样能显示,不算坏图)。
22
+ * 契约 3 CHANGELOG —— 存在 CHANGELOG.md 时,必含 `## [<当前 version>]` 段。
23
+ * 契约 4 发版语义 —— 运行期载荷必须与上一个已发布版本**不同**。
24
+ * 见 docs/rules/release-semantics.md;ALLOW_METADATA_ONLY=1 是逃生舱。
20
25
  *
21
- * `npm publish` 调用 prepublishOnly 时, 本脚本非零退出 → publish 整体
22
- * 失败, tarball 不会被推上 registry, 也就不会出现「npm 上了但本地行为
23
- * 与预期不一致」的漂移。
26
+ * 任一条挂了 → exit 1 + stderr 指明错在哪、怎么修。
24
27
  */
25
- import { readFileSync } from 'node:fs'
28
+ import { readFileSync, mkdtempSync, rmSync, readdirSync, statSync, existsSync } from 'node:fs'
26
29
  import { execFileSync } from 'node:child_process'
27
- import { dirname, join } from 'node:path'
30
+ import { dirname, join, relative } from 'node:path'
28
31
  import { fileURLToPath } from 'node:url'
32
+ import { tmpdir } from 'node:os'
33
+ import { createHash } from 'node:crypto'
34
+
35
+ const GATE_VERSION = 1
29
36
 
30
37
  const here = dirname(fileURLToPath(import.meta.url))
31
38
  const pkgRoot = dirname(here)
32
39
 
33
40
  const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'))
34
41
  const version = pkg.version
35
-
36
- const MUST_INCLUDE = [
37
- 'package.json',
38
- 'cordis.patch.yml',
39
- 'lib/client.js',
40
- 'lib/index.js',
41
- 'lib/harness.js',
42
- 'README.md',
43
- 'LICENSE'
44
- ]
45
-
46
- const ASSETS_MIN = 1 // 至少一张 .svg, 防止目录被空提交
47
-
48
42
  const errors = []
49
43
 
50
- // ── 1. tarball 路径断言 ────────────────────────────────────────────────────
44
+ // ── 0. 打包清单 ────────────────────────────────────────────────────────────
51
45
  let packFiles
52
46
  try {
53
47
  const out = execFileSync('npm', ['pack', '--dry-run', '--json'], {
54
- cwd: pkgRoot,
55
- encoding: 'utf8',
56
- stdio: ['ignore', 'pipe', 'inherit']
48
+ cwd: pkgRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit']
57
49
  })
58
50
  packFiles = JSON.parse(out)[0].files.map(f => f.path)
59
51
  } catch (err) {
60
52
  console.error('[prepublish-gate] npm pack --dry-run 失败:', err.message)
61
53
  process.exit(2)
62
54
  }
55
+ const inPack = (p) => packFiles.includes(p)
56
+
57
+ // ── 1. 产物完整性 ──────────────────────────────────────────────────────────
58
+ if (!inPack('package.json')) errors.push('tarball 里没有 package.json')
59
+
60
+ // files[] 里的字面量条目(不含 glob)必须命中至少一个文件
61
+ for (const entry of pkg.files ?? []) {
62
+ if (/[*?[\]{}!]/.test(entry)) continue // glob 跳过
63
+ const e = entry.replace(/\/$/, '')
64
+ const hit = packFiles.some(f => f === e || f.startsWith(e + '/'))
65
+ if (!hit) {
66
+ errors.push(`package.json#files 声明了 '${entry}',但 tarball 里一个文件都没有\n → 条目过期或文件被删/被 ignore`)
67
+ }
68
+ }
69
+
70
+ // main / types / module / bin / exports / dsh.bundle 引用的路径必须在 tarball 里。
71
+ // 只走这几个字段,因为 package.json 里别的字符串不是路径 —— 典型陷阱:
72
+ // `dsh.client.platform: "web"` 和 `dsh.client.inject: ["@deepseek-ai/..."]`
73
+ // 一旦被当成路径就会误报。
74
+ const entryPaths = new Set()
75
+ const addPath = (v) => {
76
+ if (typeof v !== 'string' || v === '') return
77
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('/')) return // URL / 绝对路径
78
+ entryPaths.add(v.replace(/^\.\//, ''))
79
+ }
80
+ const walkStrings = (v) => {
81
+ if (typeof v === 'string') addPath(v)
82
+ else if (v && typeof v === 'object') for (const x of Object.values(v)) walkStrings(x)
83
+ }
84
+ for (const field of ['main', 'types', 'module', 'bin', 'exports']) walkStrings(pkg[field])
85
+ if (pkg.dsh && typeof pkg.dsh === 'object') walkStrings(pkg.dsh.bundle)
86
+
87
+ for (const p of entryPaths) {
88
+ if (!inPack(p)) {
89
+ errors.push(`package.json 的入口字段引用了 '${p}',但 tarball 里没有\n → 装包的人会在 require/import 时直接失败`)
90
+ }
91
+ }
92
+
93
+ // ── 2. README 里相对引用的 assets/* 必须在 tarball 里 ──────────────────────
94
+ const readmes = ['README.md', 'README.en.md', 'README.zh.md', 'README.zh-CN.md']
95
+ .filter(f => existsSync(join(pkgRoot, f)))
63
96
 
64
- const missing = MUST_INCLUDE.filter(p => !packFiles.includes(p))
65
- if (missing.length > 0) {
66
- errors.push(`tarball 缺少必含文件: ${missing.join(', ')}\n → 在 package.json 'files' 数组里列出, 或确认文件实际存在`)
97
+ const referencedAssets = new Set()
98
+ for (const f of readmes) {
99
+ const text = readFileSync(join(pkgRoot, f), 'utf8')
100
+ // 只认相对引用:](assets/x) 与 src="assets/x"。URL 里的 assets/ 不算。
101
+ for (const m of text.matchAll(/\]\((assets\/[^)\s]+)\)/g)) referencedAssets.add(m[1])
102
+ for (const m of text.matchAll(/src=["'](assets\/[^"']+)["']/g)) referencedAssets.add(m[1])
67
103
  }
68
104
 
69
- // ── 2. assets/ 目录必含至少一张 SVG ─────────────────────────────────────────
70
- const assetSvgs = packFiles.filter(p => p.startsWith('assets/') && p.endsWith('.svg'))
71
- if (assetSvgs.length < ASSETS_MIN) {
72
- errors.push(`tarball assets/ 里 .svg < ${ASSETS_MIN}: 找到 ${assetSvgs.length} 张\n → 在 assets/ 下放图, 并确认 package.json 'files' 含 'assets'`)
105
+ const missingAssets = [...referencedAssets].filter(a => existsSync(join(pkgRoot, a)) && !inPack(a))
106
+ if (missingAssets.length > 0) {
107
+ errors.push(
108
+ `README 引用了 ${missingAssets.length} 个 assets 文件,但 tarball 里没有 —— npm 页面上会是坏图:\n` +
109
+ missingAssets.slice(0, 5).map(a => ` · ${a}`).join('\n') +
110
+ (missingAssets.length > 5 ? `\n …(共 ${missingAssets.length} 个)` : '') +
111
+ `\n → 二选一:(a) 把 'assets' 加进 package.json 的 'files'(图小就用这个);` +
112
+ `\n (b) 把 README 改成图床绝对 URL 并确认真能打开(图大就用这个,别把十几 MB 塞进每个安装包)`
113
+ )
73
114
  }
74
115
 
75
- // ── 3. CHANGELOG 顶部必含 '## [<当前 version>]' 段 ─────────────────────────
116
+ // ── 3. CHANGELOG 必须有当前版本段 ──────────────────────────────────────────
117
+ // 标题写法各仓不一(`## [1.5.3]` / `## 3.7.0 (2026-09-16)` /
118
+ // `## [0.14.0](compare-link) (2026-09-25)` / `## v0.14.0`),
119
+ // 只认「二级标题 + 版本号本身」,不强制 keep-a-changelog 的方括号。
120
+ // 版本号后用负向前瞻收尾,避免 0.14.0 命中 0.14.01 这种前缀。
76
121
  const changelogPath = join(pkgRoot, 'CHANGELOG.md')
77
- let changelogText
78
- try {
79
- changelogText = readFileSync(changelogPath, 'utf8')
80
- } catch (err) {
81
- errors.push(`CHANGELOG.md 读取失败: ${err.message}`)
82
- changelogText = ''
122
+ if (existsSync(changelogPath)) {
123
+ const text = readFileSync(changelogPath, 'utf8')
124
+ const esc = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
125
+ const heading = new RegExp(`^##\\s*\\[?v?${esc}(?![\\d.])`, 'm')
126
+ if (!heading.test(text)) {
127
+ errors.push(
128
+ `CHANGELOG.md 缺少当前版本(${version})的二级标题段\n` +
129
+ ` → 发版正文(GitHub Release / npm 页)直接取这一段;缺了会出现「npm 上了 / Release 没建」的漂移`
130
+ )
131
+ }
132
+ }
133
+
134
+ // ── 4. 发版语义:运行期载荷必须与上一已发布版本不同 ────────────────────────
135
+ //
136
+ // 「运行期载荷」= tarball 里**除了文档/元数据/开发工具之外**的每一个文件,
137
+ // 外加 package.json 的运行期字段。判据要能机械执行 —— 不用「CHANGELOG 里有没有
138
+ // feat/fix 关键字」这种启发式(自省段落里出现一个「修」字就会误放行)。
139
+ //
140
+ // 详见 docs/rules/release-semantics.md。
141
+ const EXCLUDE = [
142
+ /^readme(\.[^/]*)?$/i,
143
+ /^changelog(\.[^/]*)?$/i,
144
+ /^(license|notice|contributing)(\.[^/]*)?$/i,
145
+ /^docs\//i,
146
+ /^assets\//i,
147
+ /^scripts\//i,
148
+ /^\.github\//i,
149
+ /^\.githooks\//i,
150
+ /^tsconfig[^/]*\.json$/i,
151
+ /^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i,
152
+ /^(screenshots\.json|llms\.txt|dev_notes[^/]*)$/i,
153
+ /^(\.npmignore|\.gitignore|\.gitattributes)$/i
154
+ ]
155
+ const isRuntime = (p) => !EXCLUDE.some(re => re.test(p))
156
+
157
+ const RUNTIME_PKG_FIELDS = [
158
+ 'name', 'type', 'main', 'module', 'types', 'exports', 'bin', 'dsh', 'files',
159
+ 'dependencies', 'peerDependencies', 'optionalDependencies', 'engines'
160
+ ]
161
+
162
+ /** 运行期载荷指纹: 排序后的 "路径:哈希" 列表。 */
163
+ function runtimeFingerprint(root, list) {
164
+ const entries = []
165
+ for (const rel of list) {
166
+ if (!isRuntime(rel)) continue
167
+ if (rel === 'package.json') continue
168
+ try {
169
+ entries.push([rel, createHash('sha256').update(readFileSync(join(root, rel))).digest('hex')])
170
+ } catch { /* 目录项或读不到,跳过 */ }
171
+ }
172
+ try {
173
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
174
+ const runtimePkg = {}
175
+ for (const f of RUNTIME_PKG_FIELDS) {
176
+ if (manifest[f] !== undefined) runtimePkg[f] = manifest[f]
177
+ }
178
+ entries.push(['package.json(运行期字段)', createHash('sha256').update(JSON.stringify(runtimePkg)).digest('hex')])
179
+ } catch { /* 读不到是别处的问题 */ }
180
+ return entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
181
+ }
182
+
183
+ if (process.env.ALLOW_METADATA_ONLY === '1') {
184
+ console.warn('[prepublish-gate] ⚠️ ALLOW_METADATA_ONLY=1 —— 跳过契约 4(纯文档/元数据版本)')
185
+ } else {
186
+ try {
187
+ const raw = execFileSync('npm', ['view', pkg.name, 'versions', '--json'], {
188
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
189
+ })
190
+ const parsed = JSON.parse(raw)
191
+ const list = Array.isArray(parsed) ? parsed : [parsed]
192
+ const previous = list.filter(v => v !== version).pop()
193
+
194
+ if (previous === undefined) {
195
+ console.log(`[prepublish-gate] · 契约 4: ${pkg.name} 尚无更早版本, 跳过载荷比对`)
196
+ } else {
197
+ const tarballUrl = execFileSync('npm', ['view', `${pkg.name}@${previous}`, 'dist.tarball'], {
198
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
199
+ }).trim()
200
+ const stage = mkdtempSync(join(tmpdir(), 'prepublish-gate-'))
201
+ try {
202
+ const tgz = join(stage, 'prev.tgz')
203
+ const res = await fetch(tarballUrl)
204
+ if (!res.ok) throw new Error(`下载 ${previous} tarball 失败: HTTP ${res.status}`)
205
+ const { writeFileSync } = await import('node:fs')
206
+ writeFileSync(tgz, Buffer.from(await res.arrayBuffer()))
207
+ execFileSync('tar', ['-xzf', tgz, '-C', stage], { stdio: 'ignore' })
208
+ const prevRoot = join(stage, 'package')
209
+
210
+ const before = runtimeFingerprint(prevRoot, collectAllFiles(prevRoot))
211
+ const after = runtimeFingerprint(pkgRoot, packFiles)
212
+ if (JSON.stringify(before) === JSON.stringify(after)) {
213
+ errors.push(
214
+ `运行期载荷与上一个已发布版本 ${previous} **完全相同** —— 这个 semver 号没有承载任何用户可感知的变化\n` +
215
+ ` → 本次只动了: ${packFiles.filter(p => !isRuntime(p)).slice(0, 6).join(', ') || '(仅 package.json 非运行期字段)'}\n` +
216
+ ` → semver 是对用户的契约: 流程修补 / 元数据修正 / 自省笔记不该占版本号(docs/rules/release-semantics.md)。\n` +
217
+ ` 确实要发纯文档版时显式设 ALLOW_METADATA_ONLY=1`
218
+ )
219
+ } else {
220
+ console.log(`[prepublish-gate] · 契约 4: 运行期载荷相对 ${previous} 有变化(${before.length} 项比对)`)
221
+ }
222
+ } finally {
223
+ rmSync(stage, { recursive: true, force: true })
224
+ }
225
+ }
226
+ } catch (err) {
227
+ // 网络/registry 不可用时不阻断发版:门禁不该因为一次查询失败就卡住真实发布。
228
+ console.warn(`[prepublish-gate] ⚠️ 契约 4 未能执行(不阻断): ${err.message}`)
229
+ }
83
230
  }
84
231
 
85
- if (changelogText) {
86
- const expectedHeader = `## [${version}]`
87
- if (!changelogText.includes(expectedHeader)) {
88
- errors.push(`CHANGELOG.md 缺少 '${expectedHeader}' 段\n → publish.yml 的 release body 直接 grep 这一行; 缺了 GH Release 创建会失败, npm 上却有版本号, 出现「npm 上了 / Release 没建」的漂移`)
232
+ /** 递归收集目录下所有文件(相对路径),用于对上一版本解包目录做指纹。 */
233
+ function collectAllFiles(root) {
234
+ const out = []
235
+ const walk = (rel) => {
236
+ const abs = rel ? join(root, rel) : root
237
+ let st
238
+ try { st = statSync(abs) } catch { return }
239
+ if (st.isDirectory()) {
240
+ for (const e of readdirSync(abs)) walk(rel ? join(rel, e) : e)
241
+ } else {
242
+ out.push(rel)
243
+ }
89
244
  }
245
+ walk('')
246
+ return out
90
247
  }
91
248
 
92
249
  // ── 报告 ──────────────────────────────────────────────────────────────────
@@ -100,6 +257,7 @@ if (errors.length > 0) {
100
257
  process.exit(1)
101
258
  }
102
259
 
103
- console.log(`[prepublish-gate] ✓ @${pkg.name}@${version} 三条契约通过`)
104
- console.log(`[prepublish-gate] · tarball: ${packFiles.length} 个文件, 含 ${assetSvgs.length} 张 assets/*.svg`)
105
- console.log(`[prepublish-gate] · CHANGELOG 含 '## [${version}]' 段`)
260
+ console.log(`[prepublish-gate] ✓ ${pkg.name}@${version} 四条契约通过(gate v${GATE_VERSION})`)
261
+ console.log(`[prepublish-gate] · tarball ${packFiles.length} 个文件;运行期载荷 ${packFiles.filter(isRuntime).length} 个`)
262
+ if (referencedAssets.size > 0) console.log(`[prepublish-gate] · README 引用的 ${referencedAssets.size} 个 assets 均在包内`)
263
+ console.log(`[prepublish-gate] · CHANGELOG 含当前版本段`)