@dsh-plugins/dsh-llm-hub 1.5.4 → 1.5.6
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 +41 -0
- package/README.md +15 -8
- package/lib/client.js +407 -42
- package/lib/index.js +288 -6
- package/package.json +2 -2
- package/scripts/readme-gate.mjs +144 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.5.6] - 2026-09-27
|
|
6
|
+
|
|
7
|
+
### 📛 包装升级:首屏补齐居中标题 + 接入 README 门禁
|
|
8
|
+
|
|
9
|
+
- 新增 `<h1 align="center">` 居中标题与双语 slogan
|
|
10
|
+
- 8 个徽章从 `flat-square` 换成 `for-the-badge`
|
|
11
|
+
- 新增 `scripts/readme-gate.mjs`,挂在 `prepublishOnly` 上
|
|
12
|
+
- 真源:`cs rule open_source_project_baseline`(README 首屏金字塔)
|
|
13
|
+
- 4 条契约:居中品牌 / `for-the-badge` 徽章 / 前 80 行有 Why This 对比表 / 有 `README.en.md`
|
|
14
|
+
- 逃生舱:`README_GATE=off`(仅限明确不需要包装的内部包)
|
|
15
|
+
|
|
16
|
+
### 🛣️ 智能路由:`routableStateFor` 把"探测表里没记录"细分成 unknown / unavailable
|
|
17
|
+
|
|
18
|
+
之前一刀切返回 `unavailable`,导致**冷启动 / 用户刚加 provider / 探针刚清空**时被路由跳过,
|
|
19
|
+
即便 settings 里有配、key 也填了,照样不让 smart 接管。典型踩坑:zai-coding-cn 这类
|
|
20
|
+
无 baseURL 的 provider,第一次请求时被 `routableStateFor` 当 unavailable 跳过去。
|
|
21
|
+
|
|
22
|
+
新语义(`lib/index.js:2554-2580`):
|
|
23
|
+
|
|
24
|
+
- 探测表里有 verdict → 用 verdict.state(available / unknown / unavailable)
|
|
25
|
+
- 探测表里没有 + settings 里**有**配 → `unknown`(routable,请求撞真上游,撞完探针补 verdict)
|
|
26
|
+
- 探测表里没有 + settings 里**没**配 → `unavailable`(真不认识,撞 NO_ADAPTER,fail-open)
|
|
27
|
+
|
|
28
|
+
测试新增 1 条(`pickSmartRoute:未探测(unknown)且候选里 → 仍接管`),全 132 项过。
|
|
29
|
+
|
|
30
|
+
## [1.5.5] - 2026-09-27
|
|
31
|
+
|
|
32
|
+
### 🪧 路由卡:每条候选的能力 chip(思考 + 多模态)
|
|
33
|
+
|
|
34
|
+
之前 routing 卡里只把「多模态」用一个 `👁` emoji 标在 model id 后面,**思考档位完全没标** —— 用户必须查文档才能知道这个模型能思考、能用哪些 effort。这一版把这条缝上:
|
|
35
|
+
|
|
36
|
+
- **`routingCatalogOf()`**(`lib/index.js:2789`)每个 model 多带一条 `reasoning: { levels: [{ id, name }, …] } | null`,从 settings 的 `reasoningEfforts` 抽出;键名是人话(`off` / `low` / `high` / `max`),值是适配器认的 effort id,`null` 值的档位(强制思考、不能关的 `off`)不进 chip 文本。
|
|
37
|
+
- **`routing card`**(`lib/client.js` RoutingCard):
|
|
38
|
+
- 每个候选后面追加两枚 chip(仅在能力存在时出现):`💭 思考 low/high/max`、`👁 图像`;思考档位 >3 个时折成 `low/medium/high+2`。
|
|
39
|
+
- 多模态 chip 之前是裸 emoji,现在与思考 chip 共用一套样式(`.dsh-llm-hub-routing__reasoning / __multimodal`),按 `state-info` / `state-success` 主题色上边框 —— 一眼分得清「能干什么」。
|
|
40
|
+
- 添加候选的下拉 `<option>` 同步带 `💭 … 👁` 后缀(同一 catalog 数据源)。
|
|
41
|
+
- **i18n**:`routeMultimodalShort` / `routeReasoningShort` / `routeReasoningTip` 加进 zh + en;思考档位的 tooltip 列出 `name → id` 的映射,方便排查 `off: null` 之类的「明明配置了 off 却不能用」的情况。
|
|
42
|
+
- **测试**:`test/client-cards.test.mjs` 的多模态 chip 用例改成查新类名(`dsh-llm-hub-routing__multimodal`),`node --test` 131 项全过。
|
|
43
|
+
|
|
44
|
+
数据真源仍是 settings —— 不打网关、不改路由逻辑,只是把已经在那儿的事实亮出来。
|
|
45
|
+
|
|
5
46
|
## [1.5.4] - 2026-09-27
|
|
6
47
|
|
|
7
48
|
### 🛡️ 智能路由接管的两处加固(1.5.0 的补丁)
|
package/README.md
CHANGED
|
@@ -2,18 +2,25 @@
|
|
|
2
2
|
<img src="https://img.webkubor.online/oss/dsh-llm-hub/banner.png" alt="dsh-llm-hub" width="100%" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
+
|
|
6
|
+
<h1 align="center">🔌 dsh-llm-hub</h1>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<strong>给 DSH 模型页补上官方适配器缺的那半。</strong><br/>
|
|
10
|
+
<sub>Gateway health, model catalog, balance — the half the built-in adapter leaves out.</sub>
|
|
11
|
+
</p>
|
|
5
12
|
<p align="center">
|
|
6
|
-
<a href="https://www.npmjs.com/package/@dsh-plugins/dsh-llm-hub"><img src="https://img.shields.io/npm/v/%40dsh-plugins%2Fdsh-llm-hub?style=
|
|
7
|
-
<a href="https://www.npmjs.com/package/@dsh-plugins/dsh-llm-hub"><img src="https://img.shields.io/npm/dm/%40dsh-plugins%2Fdsh-llm-hub?style=
|
|
8
|
-
<img src="https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2-4d6bfe?style=
|
|
9
|
-
<img src="https://img.shields.io/badge/runtime_deps-0-5A9E6F?style=
|
|
10
|
-
<img src="https://img.shields.io/badge/license-MIT-777?style=
|
|
13
|
+
<a href="https://www.npmjs.com/package/@dsh-plugins/dsh-llm-hub"><img src="https://img.shields.io/npm/v/%40dsh-plugins%2Fdsh-llm-hub?style=for-the-badge&color=4C7EF3&logo=npm&label=npm" alt="npm" /></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/@dsh-plugins/dsh-llm-hub"><img src="https://img.shields.io/npm/dm/%40dsh-plugins%2Fdsh-llm-hub?style=for-the-badge&color=6d7f9c&label=downloads" alt="downloads" /></a>
|
|
15
|
+
<img src="https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2-4d6bfe?style=for-the-badge" alt="DSH" />
|
|
16
|
+
<img src="https://img.shields.io/badge/runtime_deps-0-5A9E6F?style=for-the-badge" alt="zero deps" />
|
|
17
|
+
<img src="https://img.shields.io/badge/license-MIT-777?style=for-the-badge" alt="MIT" />
|
|
11
18
|
</p>
|
|
12
19
|
|
|
13
20
|
<p align="center">
|
|
14
|
-
<a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/DeepSeek_Harness-Plugin-4d6bfe?style=
|
|
15
|
-
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-4d6bfe?style=
|
|
16
|
-
<a href="https://github.com/topics/llm-gateway"><img src="https://img.shields.io/badge/topic-llm--gateway-4C7EF3?style=
|
|
21
|
+
<a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/DeepSeek_Harness-Plugin-4d6bfe?style=for-the-badge" alt="DSH Plugin" /></a>
|
|
22
|
+
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-4d6bfe?style=for-the-badge" alt="dsh-plugin" /></a>
|
|
23
|
+
<a href="https://github.com/topics/llm-gateway"><img src="https://img.shields.io/badge/topic-llm--gateway-4C7EF3?style=for-the-badge" alt="llm-gateway" /></a>
|
|
17
24
|
· <a href="README.en.md">English</a> · <a href="CHANGELOG.md">更新日志</a>
|
|
18
25
|
</p>
|
|
19
26
|
|
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)))
|
|
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 [
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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.
|
|
1700
|
-
|
|
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
|
|
1713
|
-
const
|
|
1714
|
-
const
|
|
1715
|
-
const
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
@@ -202,6 +202,16 @@ const ROUTING_STATE_PATH = '/api/dsh-llm-hub/routing/state'
|
|
|
202
202
|
* `dsh-llm-hub.routing` 段 —— 状态持久化,重启不丢。
|
|
203
203
|
*/
|
|
204
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'
|
|
205
215
|
/** 插件自有的 settings 命名空间(1.5.0 起注册)—— routing / warning / aliases 都住这里。 */
|
|
206
216
|
const HUB_NS = 'dsh-llm-hub'
|
|
207
217
|
/** 模型别名:客户端渲染 model id 时显示短名(典型场景:deepseek-reasoner → 推理)。 */
|
|
@@ -348,6 +358,80 @@ export function pickSmartRoute(entries, stateFor, active) {
|
|
|
348
358
|
return { to: { provider: winner.provider, model: winner.model }, reason: null, switched: true }
|
|
349
359
|
}
|
|
350
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
|
+
|
|
351
435
|
/** 路由配置从 settings 哪一段读 —— 顶层 dsh-llm-hub 段下,与 warning 同级。 */
|
|
352
436
|
const ROUTING_KEY = 'routing'
|
|
353
437
|
/** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
|
|
@@ -2479,12 +2563,37 @@ export function apply(ctx) {
|
|
|
2479
2563
|
}
|
|
2480
2564
|
|
|
2481
2565
|
/**
|
|
2482
|
-
*
|
|
2566
|
+
* 一个 provider 是否在配置里"存在":要么 `llm-pi-ai` 段有它的 profile,要么
|
|
2567
|
+
* `llm-deepseek` 段存在(即官方直连适配器挂上了)。
|
|
2568
|
+
*
|
|
2569
|
+
* 与 `routableStateFor` 配合用:探测表里没记录的 provider,
|
|
2570
|
+
* 若在此集合里 → 算 unknown(routable,让请求试一把);
|
|
2571
|
+
* 不在此集合里 → 才算 unavailable(真不认识,路由过去会撞 NO_ADAPTER)。
|
|
2572
|
+
* @returns 已知 provider id 的 Set。
|
|
2573
|
+
*/
|
|
2574
|
+
const knownConfiguredProviders = () => {
|
|
2575
|
+
const ids = new Set()
|
|
2576
|
+
for (const [id, profile] of piaiProviders()) {
|
|
2577
|
+
if (profile !== null && typeof profile === 'object') ids.add(id)
|
|
2578
|
+
}
|
|
2579
|
+
if (sectionOf() !== undefined) ids.add(DEEPSEEK_PROVIDER)
|
|
2580
|
+
return ids
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/**
|
|
2584
|
+
* 路由目标专线的可用性。
|
|
2585
|
+
*
|
|
2586
|
+
* 判定梯度(决定 `pickSmartRoute` 是跳过还是接管):
|
|
2587
|
+
* · 探测表里有 verdict:用 verdict.state(available / unknown / unavailable)
|
|
2588
|
+
* · 探测表里没有 + provider 在 settings 里配了 → 'unknown'
|
|
2589
|
+
* (冷启动 / 用户刚加 provider / 探针刚清空 —— 不阻塞路由,让请求发出去
|
|
2590
|
+
* 撞真上游;撞完探针会立刻补 verdict,下一轮就准了)
|
|
2591
|
+
* · 探测表里没有 + provider **没在** settings 里 → 'unavailable'
|
|
2592
|
+
* (典型:配置里写错名 / 该 provider 还没注册;路由过去只会撞 NO_ADAPTER,
|
|
2593
|
+
* fail-open 保留原配置,比打挂请求更稳)
|
|
2483
2594
|
*
|
|
2484
|
-
* `stateFor`
|
|
2485
|
-
*
|
|
2486
|
-
* 用这一版:配置里写错 provider 名 / 该 provider 还没注册时,候选被跳过,
|
|
2487
|
-
* 路由保持 fail-open(不动原配置),而不是把会话打挂。
|
|
2595
|
+
* `stateFor` 把前两种情况都折成 'unknown',所以信息面板里仍是"待探明",无歧义;
|
|
2596
|
+
* 区别只在路由**目标**的取与舍 —— 这是 routable vs stateful 的本意。
|
|
2488
2597
|
* @param provider - provider id。
|
|
2489
2598
|
* @returns 'available' | 'unknown' | 'unavailable'。
|
|
2490
2599
|
*/
|
|
@@ -2492,7 +2601,7 @@ export function apply(ctx) {
|
|
|
2492
2601
|
for (const verdict of baseVerdicts.values()) {
|
|
2493
2602
|
if (verdict.provider === provider) return verdict.state
|
|
2494
2603
|
}
|
|
2495
|
-
return 'unavailable'
|
|
2604
|
+
return knownConfiguredProviders().has(provider) ? 'unknown' : 'unavailable'
|
|
2496
2605
|
}
|
|
2497
2606
|
|
|
2498
2607
|
/**
|
|
@@ -2651,6 +2760,179 @@ export function apply(ctx) {
|
|
|
2651
2760
|
}
|
|
2652
2761
|
}
|
|
2653
2762
|
|
|
2763
|
+
/**
|
|
2764
|
+
* 可路由目录:settings 里已配置的 provider 与各自的模型 id(离线,不打网关)。
|
|
2765
|
+
*
|
|
2766
|
+
* 数据源是**配置段**而不是 `ctx.llm.listProviders()`:后者会被本插件的隐藏
|
|
2767
|
+
* 过滤器影响,拿它当候选来源会「隐藏了就再也加不回来」。
|
|
2768
|
+
* @returns {{ providers: Array<{ id, displayName, ns, models: string[] }> }}
|
|
2769
|
+
*/
|
|
2770
|
+
/**
|
|
2771
|
+
* 从 settings 里的一个 model 条目读输入模态。
|
|
2772
|
+
*
|
|
2773
|
+
* 两种字段名都收:llm-deepseek 段写 `inputModalities: [text, image]`,
|
|
2774
|
+
* llm-pi-ai 段写 `input: [text, image]`。读不到时为空数组 —— 空数组表示
|
|
2775
|
+
* 「不知道」,UI 不标(而不是猜一个)。
|
|
2776
|
+
* @param model - settings 里的 model 条目对象。
|
|
2777
|
+
* @returns {string[]}
|
|
2778
|
+
*/
|
|
2779
|
+
const modalitiesOf = (model) => {
|
|
2780
|
+
const raw = Array.isArray(model?.inputModalities) ? model.inputModalities : (Array.isArray(model?.input) ? model.input : [])
|
|
2781
|
+
return raw.filter((item) => typeof item === 'string')
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/**
|
|
2785
|
+
* 从 settings 里的一个 model 条目读推理(reasoning)能力。
|
|
2786
|
+
*
|
|
2787
|
+
* 形态是 `{ off: null, low: 'low', high: 'high', max: 'max' }`:键名 = 人类可读
|
|
2788
|
+
* 的档位名,值 = 适配器真正认的 effort id(`null` 表示「这个档位不开放」,
|
|
2789
|
+
* 比如 `off: null` 就是该模型强制思考、不能关)。模型没有这条字段时返回 null
|
|
2790
|
+
* —— UI 不标,而不是猜一个(用户没声明「能思考」就当不能)。
|
|
2791
|
+
*
|
|
2792
|
+
* 只暴露非空档位;off/null 这类「不可用」档位不进 chip 文本,免得显示「思考 · off」
|
|
2793
|
+
* 自相矛盾。
|
|
2794
|
+
* @param model - settings 里的 model 条目对象。
|
|
2795
|
+
* @returns {{ levels: Array<{ id: string, name: string }> } | null}
|
|
2796
|
+
*/
|
|
2797
|
+
const reasoningOf = (model) => {
|
|
2798
|
+
const efforts = model?.reasoningEfforts
|
|
2799
|
+
if (efforts === null || typeof efforts !== 'object') return null
|
|
2800
|
+
const levels = []
|
|
2801
|
+
for (const [name, id] of Object.entries(efforts)) {
|
|
2802
|
+
if (id === null || id === undefined || id === '') continue
|
|
2803
|
+
levels.push({ id: String(id), name: String(name) })
|
|
2804
|
+
}
|
|
2805
|
+
if (levels.length === 0) return null
|
|
2806
|
+
return { levels }
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/**
|
|
2810
|
+
* 可路由目录:settings 里已配置的 provider 与各自的模型 id(离线,不打网关)。
|
|
2811
|
+
*
|
|
2812
|
+
* 数据源是**配置段**而不是 `ctx.llm.listProviders()`:后者会被本插件的隐藏
|
|
2813
|
+
* 过滤器影响,拿它当候选来源会「隐藏了就再也加不回来」。
|
|
2814
|
+
*
|
|
2815
|
+
* 每个模型带两条 capability 事实:
|
|
2816
|
+
* · `input: string[]` —— 输入模态(text / image …);空数组表示「不知道」。
|
|
2817
|
+
* · `reasoning: { levels: [{ id, name }, …] } | null` —— 推理档位;
|
|
2818
|
+
* 非空 = 模型能思考,UI 标「思考 · low/high/max」;null = 不标。
|
|
2819
|
+
* 这两条都是 settings 里原本就有的事实(2026-09-27 用户反馈),不亮出来
|
|
2820
|
+
* 用户就只能靠背 —— 「这个模型支持多模态吗」「它能思考吗」每次都得查文档。
|
|
2821
|
+
* @returns {{ providers: Array<{ id, displayName, ns, models: Array<{ id, input: string[], reasoning: object | null }> }> }}
|
|
2822
|
+
*/
|
|
2823
|
+
const routingCatalogOf = () => {
|
|
2824
|
+
const out = []
|
|
2825
|
+
for (const [id, profile] of piaiProviders()) {
|
|
2826
|
+
const models = (Array.isArray(profile.models) ? profile.models : [])
|
|
2827
|
+
.map((model) => (model !== null && typeof model === 'object' && typeof model.id === 'string' ? { id: model.id, input: modalitiesOf(model), reasoning: reasoningOf(model) } : undefined))
|
|
2828
|
+
.filter((model) => model !== undefined)
|
|
2829
|
+
out.push({
|
|
2830
|
+
id,
|
|
2831
|
+
displayName: typeof profile.displayName === 'string' && profile.displayName.trim() !== '' ? profile.displayName : id,
|
|
2832
|
+
ns: PIAI_NS,
|
|
2833
|
+
models
|
|
2834
|
+
})
|
|
2835
|
+
}
|
|
2836
|
+
const deepseek = sectionOf()
|
|
2837
|
+
if (deepseek !== undefined) {
|
|
2838
|
+
const models = (Array.isArray(deepseek.models) ? deepseek.models : [])
|
|
2839
|
+
.map((model) => (model !== null && typeof model === 'object' && typeof model.id === 'string' ? { id: model.id, input: modalitiesOf(model), reasoning: reasoningOf(model) } : undefined))
|
|
2840
|
+
.filter((model) => model !== undefined)
|
|
2841
|
+
out.push({
|
|
2842
|
+
id: DEEPSEEK_PROVIDER,
|
|
2843
|
+
displayName: typeof deepseek.displayName === 'string' && deepseek.displayName.trim() !== '' ? deepseek.displayName : 'DeepSeek 官方直连',
|
|
2844
|
+
ns: NS,
|
|
2845
|
+
models
|
|
2846
|
+
})
|
|
2847
|
+
}
|
|
2848
|
+
out.sort((a, b) => a.id.localeCompare(b.id))
|
|
2849
|
+
return { providers: out }
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/** 写回整份路由组(设置页编辑器保存路径),并返回最新状态。 */
|
|
2853
|
+
const applyRoutingConfig = async (payload) => {
|
|
2854
|
+
const settings = ctx.get('settings')
|
|
2855
|
+
if (settings === undefined || hubRegistered !== true) {
|
|
2856
|
+
return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
|
|
2857
|
+
}
|
|
2858
|
+
const normalized = normalizeRoutingGroupsInput(payload?.groups)
|
|
2859
|
+
if (normalized.groups.length === 0) {
|
|
2860
|
+
return { ok: false, error: '至少需要一组有效候选(provider/model)', dropped: normalized.dropped }
|
|
2861
|
+
}
|
|
2862
|
+
const current = routingObjOf()
|
|
2863
|
+
// 生效组若在新配置里没了,落到第一组 —— 不让 settings 里留一个悬空的 activeGroup。
|
|
2864
|
+
const requested = typeof payload?.activeGroup === 'string' ? payload.activeGroup.trim() : ''
|
|
2865
|
+
const fallbackActive = normalized.groups.some((group) => group.id === requested)
|
|
2866
|
+
? requested
|
|
2867
|
+
: (normalized.groups.some((group) => group.id === current.activeGroup) ? current.activeGroup : normalized.groups[0].id)
|
|
2868
|
+
const mode = payload?.mode === 'smart' || payload?.mode === 'manual'
|
|
2869
|
+
? payload.mode
|
|
2870
|
+
: (current.mode === 'smart' ? 'smart' : 'manual')
|
|
2871
|
+
try {
|
|
2872
|
+
await settings.update(HUB_NS, {
|
|
2873
|
+
routing: {
|
|
2874
|
+
...current,
|
|
2875
|
+
mode,
|
|
2876
|
+
activeGroup: fallbackActive,
|
|
2877
|
+
groups: normalized.groups.map((group) => ({
|
|
2878
|
+
id: group.id,
|
|
2879
|
+
...(group.label !== null ? { label: group.label } : {}),
|
|
2880
|
+
...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
|
|
2881
|
+
fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
|
|
2882
|
+
}))
|
|
2883
|
+
}
|
|
2884
|
+
})
|
|
2885
|
+
return { ok: true, dropped: normalized.dropped, ...smartStateOf() }
|
|
2886
|
+
} catch (error) {
|
|
2887
|
+
return { ok: false, error: `写不进 settings(${error?.message ?? error})` }
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2892
|
+
kind: 'exact',
|
|
2893
|
+
path: ROUTING_CATALOG_PATH,
|
|
2894
|
+
handler: (request, response) => {
|
|
2895
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
2896
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
2897
|
+
response.end()
|
|
2898
|
+
return
|
|
2899
|
+
}
|
|
2900
|
+
if (!sameOrigin(request)) {
|
|
2901
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
2902
|
+
return
|
|
2903
|
+
}
|
|
2904
|
+
if (request.method === 'HEAD') {
|
|
2905
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
2906
|
+
response.end()
|
|
2907
|
+
return
|
|
2908
|
+
}
|
|
2909
|
+
sendJson(response, 200, { ok: true, ...routingCatalogOf() })
|
|
2910
|
+
}
|
|
2911
|
+
}), 'dsh-llm-hub: routing catalog route')
|
|
2912
|
+
|
|
2913
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2914
|
+
kind: 'exact',
|
|
2915
|
+
path: ROUTING_CONFIG_PATH,
|
|
2916
|
+
handler: async (request, response) => {
|
|
2917
|
+
if (request.method !== 'POST') {
|
|
2918
|
+
response.writeHead(405, { allow: 'POST' })
|
|
2919
|
+
response.end()
|
|
2920
|
+
return
|
|
2921
|
+
}
|
|
2922
|
+
if (!sameOrigin(request)) {
|
|
2923
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
|
|
2924
|
+
return
|
|
2925
|
+
}
|
|
2926
|
+
const payload = await readPostBody(request)
|
|
2927
|
+
if (payload === null) {
|
|
2928
|
+
sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
|
|
2929
|
+
return
|
|
2930
|
+
}
|
|
2931
|
+
const result = await applyRoutingConfig(payload)
|
|
2932
|
+
sendJson(response, result.ok === true ? 200 : 400, result)
|
|
2933
|
+
}
|
|
2934
|
+
}), 'dsh-llm-hub: routing config route')
|
|
2935
|
+
|
|
2654
2936
|
ctx.effect(() => ctx.webServer.register({
|
|
2655
2937
|
kind: 'exact',
|
|
2656
2938
|
path: ROUTING_STATE_PATH,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plugins/dsh-llm-hub",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.6",
|
|
4
4
|
"description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"preview": "npm run deploy && open http://127.0.0.1:3080",
|
|
33
33
|
"check": "for f in lib/*.js; do node --check \"$f\" || exit 1; done",
|
|
34
34
|
"test": "node --test",
|
|
35
|
-
"prepublishOnly": "npm run check && npm test && node scripts/prepublish-gate.mjs"
|
|
35
|
+
"prepublishOnly": "npm run check && npm test && node scripts/prepublish-gate.mjs && node scripts/readme-gate.mjs"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"dsh",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* readme-gate —— 开源包装的首屏门禁
|
|
4
|
+
*
|
|
5
|
+
* 🔴 这个文件是**拷贝**,真源在 CortexOS:
|
|
6
|
+
* CortexOS/scripts/release-gate/readme-gate.mjs
|
|
7
|
+
* 同步检查:node $CORTEXOS_ROOT/scripts/check-release-gate-sync.mjs
|
|
8
|
+
* 改逻辑请改真源,然后跑同步脚本把各仓拷一遍 —— 不要在各仓就地改。
|
|
9
|
+
*
|
|
10
|
+
* 为什么是门禁而不是文档:cs rule open_source_project_baseline 早就写明了
|
|
11
|
+
* README 首屏金字塔(居中品牌 → for-the-badge → Why This 对比表 → 30 秒上手),
|
|
12
|
+
* 但 2026-09-27 一查四个插件,规则说的和仓库里躺的完全是两回事:
|
|
13
|
+
*
|
|
14
|
+
* · 徽章:四个仓全用 style=flat-square(8/10/8/7 个),规则要求 for-the-badge
|
|
15
|
+
* · 居中标题:bloom-theme / llm-hub 用 <p align="center"> 而非 <h1 align="center">
|
|
16
|
+
* · Why This 对比表:dsh-user-mirror 缺
|
|
17
|
+
* · 英文 README:dsh-env-inspector 缺
|
|
18
|
+
*
|
|
19
|
+
* 共同点还是那句「约定没做成门禁」。所以把判据做成能跑的:
|
|
20
|
+
*
|
|
21
|
+
* 契约 1 居中品牌 —— 存在 <h1 align="center"> 开头且带 emoji/品牌名的标题
|
|
22
|
+
* 契约 2 徽章规格 —— 至少 1 个 for-the-badge;且不得残留 flat-square
|
|
23
|
+
* 契约 3 对比矩阵 —— 首屏(前 80 行)内有 Why This 对比表(Markdown 表格)
|
|
24
|
+
* 契约 4 双语 —— 存在 README.en.md(生态面向全球,英文是默认入场券)
|
|
25
|
+
*
|
|
26
|
+
* 逃生舱:README_GATE=off 跳过全部检查(仅限明确不需要包装的内部包)。
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
29
|
+
import { dirname, join } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
|
|
32
|
+
const GATE_VERSION = 1
|
|
33
|
+
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
35
|
+
const pkgRoot = dirname(here)
|
|
36
|
+
|
|
37
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'))
|
|
38
|
+
const errors = []
|
|
39
|
+
const notes = []
|
|
40
|
+
|
|
41
|
+
if (String(process.env.README_GATE).toLowerCase() === 'off') {
|
|
42
|
+
console.log('[readme-gate] ↩ README_GATE=off —— 跳过首屏包装检查')
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const readmePath = join(pkgRoot, 'README.md')
|
|
47
|
+
if (!existsSync(readmePath)) {
|
|
48
|
+
console.error('[readme-gate] ✖ 找不到 README.md —— 一个没有 README 的包不可能被人用起来')
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}
|
|
51
|
+
const readme = readFileSync(readmePath, 'utf8')
|
|
52
|
+
const lines = readme.split(/\r?\n/)
|
|
53
|
+
|
|
54
|
+
// ── 1. 居中品牌标题 ────────────────────────────────────────────────────────
|
|
55
|
+
// 接受 <h1 align="center">…</h1>;<p align="center"><h1> 也算(有些仓这么写)。
|
|
56
|
+
const h1Centered =
|
|
57
|
+
/<h1[^>]*align=["']center["'][^>]*>/i.test(readme) ||
|
|
58
|
+
/<p[^>]*align=["']center["'][^>]*>\s*<h1/i.test(readme)
|
|
59
|
+
|
|
60
|
+
if (!h1Centered) {
|
|
61
|
+
errors.push(
|
|
62
|
+
'README 缺少居中的 <h1 align="center"> 标题\n' +
|
|
63
|
+
' → 首屏第一眼必须是品牌:<h1 align="center">🔐 项目名</h1>,紧跟一句居中 slogan'
|
|
64
|
+
)
|
|
65
|
+
} else {
|
|
66
|
+
// 标题里带 emoji 或品牌名才像样,光 <h1 align="center"></h1> 是空的
|
|
67
|
+
const m = readme.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)
|
|
68
|
+
if (m && m[1].replace(/<[^>]+>/g, '').trim().length < 2) {
|
|
69
|
+
errors.push('README 的居中标题是空的 —— 补上 emoji + 项目名')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── 2. 徽章规格:for-the-badge,且不残留 flat-square ───────────────────────
|
|
74
|
+
const badgeCount = (readme.match(/for-the-badge/g) ?? []).length
|
|
75
|
+
const flatSquare = (readme.match(/flat-square/g) ?? []).length
|
|
76
|
+
|
|
77
|
+
if (badgeCount === 0) {
|
|
78
|
+
errors.push(
|
|
79
|
+
'README 没有任何 style=for-the-badge 徽章\n' +
|
|
80
|
+
' → 规则要求立体大徽章(style=for-the-badge),版本/许可证/DSH 版本/Build 这几个是基本盘'
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
if (flatSquare > 0) {
|
|
84
|
+
errors.push(
|
|
85
|
+
'README 里有 ' + flatSquare + ' 个 style=flat-square 徽章\n' +
|
|
86
|
+
' → 统一换成 style=for-the-badge(把 URL 里的 flat-square 直接替换即可),' +
|
|
87
|
+
'否则首屏是细长方块,视觉重量撑不起品牌'
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── 3. Why This 对比矩阵必须在首屏 ─────────────────────────────────────────
|
|
92
|
+
// 「首屏」= 前 80 行。表格判定:连续的 | ... | 行,至少 3 行(表头+分隔+1 行数据)。
|
|
93
|
+
const HEAD_LINES = 80
|
|
94
|
+
const head = lines.slice(0, HEAD_LINES).join('\n')
|
|
95
|
+
const tableBlocks = []
|
|
96
|
+
let run = 0
|
|
97
|
+
for (const l of lines.slice(0, HEAD_LINES)) {
|
|
98
|
+
if (/^\s*\|.*\|\s*$/.test(l)) { run++; if (run === 3) tableBlocks.push(1) }
|
|
99
|
+
else run = 0
|
|
100
|
+
}
|
|
101
|
+
const hasWhyTable = tableBlocks.length > 0
|
|
102
|
+
|
|
103
|
+
if (!hasWhyTable) {
|
|
104
|
+
errors.push(
|
|
105
|
+
'README 首屏(前 ' + HEAD_LINES + ' 行)没有对比表格\n' +
|
|
106
|
+
' → Why This Table 是转化率的关键:用 ✅ / ❌ 说明「为什么用你这个而不是别的」。' +
|
|
107
|
+
'同类方案、原生能力、手动配置都值得列成列'
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 4. 英文 README ─────────────────────────────────────────────────────────
|
|
112
|
+
const hasEn = ['README.en.md', 'README.en-US.md', 'README.en_US.md']
|
|
113
|
+
.some((f) => existsSync(join(pkgRoot, f)))
|
|
114
|
+
|
|
115
|
+
if (!hasEn) {
|
|
116
|
+
errors.push(
|
|
117
|
+
'缺少英文 README(README.en.md)\n' +
|
|
118
|
+
' → dsh-plugin 生态面向全球,英文是默认入场券;中文 README 首行加上 [English](README.en.md) | 中文'
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 额外提示(不拦,但值得知道)───────────────────────────────────────────
|
|
123
|
+
const shotCount = ['assets']
|
|
124
|
+
.flatMap((d) => {
|
|
125
|
+
const p = join(pkgRoot, d)
|
|
126
|
+
if (!existsSync(p)) return []
|
|
127
|
+
return readdirSync(p).filter((f) => /\.(png|jpe?g|webp|gif)$/i.test(f))
|
|
128
|
+
}).length
|
|
129
|
+
if (shotCount === 0) {
|
|
130
|
+
notes.push('assets/ 里没有截图 —— 插件页没图等于没人点,建议至少 1 张真实界面截图')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── 汇总 ───────────────────────────────────────────────────────────────────
|
|
134
|
+
if (errors.length > 0) {
|
|
135
|
+
console.error('[readme-gate] ✖ README 首屏不合规(gate v' + GATE_VERSION + '):')
|
|
136
|
+
for (const e of errors) console.error(' · ' + e)
|
|
137
|
+
console.error('\n 真源:cs rule open_source_project_baseline(README 金字塔标准)')
|
|
138
|
+
console.error(' 逃生舱:README_GATE=off(仅限内部包)')
|
|
139
|
+
process.exit(1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log('[readme-gate] ✓ README 首屏四条契约通过(gate v' + GATE_VERSION + ')')
|
|
143
|
+
console.log(' · for-the-badge 徽章 ' + badgeCount + ' 个;首屏对比表 ' + tableBlocks.length + ' 个;英文版 ✓')
|
|
144
|
+
for (const n of notes) console.log(' · 提示:' + n)
|