@dsh-plugins/dsh-llm-hub 1.5.5 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.en.md +4 -2
- package/README.md +36 -19
- package/lib/client.js +80 -13
- package/lib/index.js +31 -6
- package/package.json +2 -2
- package/scripts/gate-lib.mjs +88 -0
- package/scripts/prepublish-gate.mjs +22 -38
- package/scripts/readme-gate.mjs +144 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.6.0] - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### ✨ 智能路由:组可排序 + 折叠摘要
|
|
8
|
+
|
|
9
|
+
- 路由组(公司 / 个人 …)新增 ↑↓ 上下移。组的先后决定智能路由先用哪个池子,此前只能手改 `settings.yaml`
|
|
10
|
+
- 折叠态直接显示「主力模型 + N 条兜底」,不用展开也知道这一组会按什么顺序试;空组显示「还没加候选」
|
|
11
|
+
- 序号与上下移按钮在折叠态也可用;首组 ↑ / 末组 ↓ 自动禁用
|
|
12
|
+
|
|
13
|
+
## [1.5.6] - 2026-09-27
|
|
14
|
+
|
|
15
|
+
### 📛 包装升级:首屏补齐居中标题 + 接入 README 门禁
|
|
16
|
+
|
|
17
|
+
- 新增 `<h1 align="center">` 居中标题与双语 slogan
|
|
18
|
+
- 8 个徽章从 `flat-square` 换成 `for-the-badge`
|
|
19
|
+
- 新增 `scripts/readme-gate.mjs`,挂在 `prepublishOnly` 上
|
|
20
|
+
- 真源:`cs rule open_source_project_baseline`(README 首屏金字塔)
|
|
21
|
+
- 4 条契约:居中品牌 / `for-the-badge` 徽章 / 前 80 行有 Why This 对比表 / 有 `README.en.md`
|
|
22
|
+
- 逃生舱:`README_GATE=off`(仅限明确不需要包装的内部包)
|
|
23
|
+
|
|
24
|
+
### 🛣️ 智能路由:`routableStateFor` 把"探测表里没记录"细分成 unknown / unavailable
|
|
25
|
+
|
|
26
|
+
之前一刀切返回 `unavailable`,导致**冷启动 / 用户刚加 provider / 探针刚清空**时被路由跳过,
|
|
27
|
+
即便 settings 里有配、key 也填了,照样不让 smart 接管。典型踩坑:zai-coding-cn 这类
|
|
28
|
+
无 baseURL 的 provider,第一次请求时被 `routableStateFor` 当 unavailable 跳过去。
|
|
29
|
+
|
|
30
|
+
新语义(`lib/index.js:2554-2580`):
|
|
31
|
+
|
|
32
|
+
- 探测表里有 verdict → 用 verdict.state(available / unknown / unavailable)
|
|
33
|
+
- 探测表里没有 + settings 里**有**配 → `unknown`(routable,请求撞真上游,撞完探针补 verdict)
|
|
34
|
+
- 探测表里没有 + settings 里**没**配 → `unavailable`(真不认识,撞 NO_ADAPTER,fail-open)
|
|
35
|
+
|
|
36
|
+
测试新增 1 条(`pickSmartRoute:未探测(unknown)且候选里 → 仍接管`),全 132 项过。
|
|
37
|
+
|
|
38
|
+
### 🛣️ 智能路由:`routableStateFor` 把"探测表里没记录"细分成 unknown / unavailable
|
|
39
|
+
|
|
40
|
+
之前一刀切返回 `unavailable`,导致**冷启动 / 用户刚加 provider / 探针刚清空**时被路由跳过,
|
|
41
|
+
即便 settings 里有配、key 也填了,照样不让 smart 接管。典型踩坑:zai-coding-cn 这类
|
|
42
|
+
无 baseURL 的 provider,第一次请求时被 `routableStateFor` 当 unavailable 跳过去。
|
|
43
|
+
|
|
44
|
+
新语义(`lib/index.js:2554-2580`):
|
|
45
|
+
|
|
46
|
+
- 探测表里有 verdict → 用 verdict.state(available / unknown / unavailable)
|
|
47
|
+
- 探测表里没有 + settings 里**有**配 → `unknown`(routable,请求撞真上游,撞完探针补 verdict)
|
|
48
|
+
- 探测表里没有 + settings 里**没**配 → `unavailable`(真不认识,撞 NO_ADAPTER,fail-open)
|
|
49
|
+
|
|
50
|
+
测试新增 1 条(`pickSmartRoute:未探测(unknown)且候选里 → 仍接管`),全 132 项过。
|
|
51
|
+
|
|
5
52
|
## [1.5.5] - 2026-09-27
|
|
6
53
|
|
|
7
54
|
### 🪧 路由卡:每条候选的能力 chip(思考 + 多模态)
|
package/README.en.md
CHANGED
|
@@ -27,11 +27,13 @@ On DSH's Models page, the official adapters leave half the job undone. This plug
|
|
|
27
27
|
|
|
28
28
|
## Install
|
|
29
29
|
|
|
30
|
+
> Only Node.js is required — **no global dsh install**; upstream's official entry is `npx @deepseek-ai/dsh`. If dsh is installed globally, use `dsh` instead.
|
|
31
|
+
|
|
30
32
|
```sh
|
|
31
|
-
dsh plugin --profile web add @dsh-plugins/dsh-llm-hub
|
|
33
|
+
npx @deepseek-ai/dsh plugin --profile web add @dsh-plugins/dsh-llm-hub
|
|
32
34
|
```
|
|
33
35
|
|
|
34
|
-
Add `@dsh-plugins/dsh-llm-hub` to `dsh.profile.bundles` in `~/.dsh/profiles/web/package.json`, then
|
|
36
|
+
Add `@dsh-plugins/dsh-llm-hub` to `dsh.profile.bundles` in `~/.dsh/profiles/web/package.json`, then restart DSH (stop it and rerun `npx @deepseek-ai/dsh web`).
|
|
35
37
|
Open **Settings → Models** — a new row appears under the provider cards.
|
|
36
38
|
|
|
37
39
|
<sub>A boot-graph change requires a restart; hot reload won't pick it up. `cordis.patch.yml` is inserted automatically by the bundle mechanism.</sub>
|
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
|
|
|
@@ -38,12 +45,14 @@ DSH 的模型页上,官方适配器有一半事情没做。这个插件把它
|
|
|
38
45
|
|
|
39
46
|
## 装
|
|
40
47
|
|
|
48
|
+
> 前提:装好 Node.js 即可,**无需全局安装 dsh** —— 官方入口就是 `npx @deepseek-ai/dsh`。已全局安装的可把它换成 `dsh`。
|
|
49
|
+
|
|
41
50
|
```sh
|
|
42
|
-
dsh plugin --profile web add @dsh-plugins/dsh-llm-hub
|
|
51
|
+
npx @deepseek-ai/dsh plugin --profile web add @dsh-plugins/dsh-llm-hub
|
|
43
52
|
```
|
|
44
53
|
|
|
45
54
|
再把 `@dsh-plugins/dsh-llm-hub` 加进 `~/.dsh/profiles/web/package.json` 的 `dsh.profile.bundles` 数组,
|
|
46
|
-
|
|
55
|
+
然后重启 DSH(结束当前进程后重新运行 `npx @deepseek-ai/dsh web`)。打开**设置 → 模型**,provider 卡片下方会多出一行。
|
|
47
56
|
|
|
48
57
|
<sub>boot graph 变了必须重启,热载不生效;`cordis.patch.yml` 由 bundle 机制自动 insert。</sub>
|
|
49
58
|
|
|
@@ -75,7 +84,7 @@ dsh plugin --profile web add @dsh-plugins/dsh-llm-hub
|
|
|
75
84
|
升级:
|
|
76
85
|
|
|
77
86
|
```sh
|
|
78
|
-
dsh plugin --profile web update @dsh-plugins/dsh-llm-hub@^1.5.1
|
|
87
|
+
npx @deepseek-ai/dsh plugin --profile web update @dsh-plugins/dsh-llm-hub@^1.5.1
|
|
79
88
|
# 重启 DSH
|
|
80
89
|
```
|
|
81
90
|
|
|
@@ -477,16 +486,24 @@ publish。Release 的存在性单独探测,所以「npm 发成功但 Release
|
|
|
477
486
|
`name` 完全一致**,否则 DSH 拒绝注册。React 由 `factory(require)` 提供,不打包进产物。
|
|
478
487
|
当前体量无需构建步骤;若将来拆多文件,再加 esbuild(`format: 'iife'`,React 等标 external)。
|
|
479
488
|
|
|
480
|
-
## 🧩
|
|
489
|
+
## 🧩 DSH 插件全家桶
|
|
490
|
+
|
|
491
|
+
<p align="center">
|
|
492
|
+
<img src="https://img.webkubor.online/projects/dsh-plugins/dsh-plugins-family.png" alt="DSH 插件全家桶:主题美化 / 模型管理 / 用户记忆 / 电脑环境" width="100%" />
|
|
493
|
+
</p>
|
|
494
|
+
|
|
495
|
+
<p align="center">
|
|
496
|
+
<a href="https://github.com/webkubor/dsh-bloom-theme">🎨 主题美化</a> ·
|
|
497
|
+
<a href="https://github.com/webkubor/dsh-llm-hub">⚡ 模型管理</a> ·
|
|
498
|
+
<a href="https://github.com/webkubor/dsh-mirror">🪞 用户记忆</a> ·
|
|
499
|
+
<a href="https://github.com/webkubor/dsh-env-inspector">🖥️ 电脑环境</a>
|
|
500
|
+
</p>
|
|
481
501
|
|
|
482
|
-
|
|
502
|
+
一行装齐(只需 Node.js),装完重启 DSH 即可:
|
|
483
503
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
| [⚡ **dsh-llm-hub**](https://github.com/webkubor/dsh-llm-hub) | 智能路由 | 多模型厂商聚合、秒级切换与故障智能重试 | `dsh plugin install @dsh-plugins/dsh-llm-hub` |
|
|
488
|
-
| [🪞 **dsh-user-mirror**](https://github.com/webkubor/dsh-mirror) | 角色记忆 | 用户数字画像、习惯偏好与记忆沉淀网络 | `dsh plugin install @dsh-plugins/dsh-user-mirror` |
|
|
489
|
-
| [🖥️ **dsh-env-inspector**](https://github.com/webkubor/dsh-env-inspector) | 运行环境 | 活跃端口一键释放、CLI 工具链与开发凭据大屏 | `dsh plugin install @dsh-plugins/dsh-env-inspector` |
|
|
504
|
+
```bash
|
|
505
|
+
npx -y @deepseek-ai/dsh plugin --profile web add dsh-bloom-theme @dsh-plugins/dsh-llm-hub @dsh-plugins/dsh-user-mirror @dsh-plugins/dsh-env-inspector && node -e 'const f=(process.env.DSH_HOME||require("os").homedir()+"/.dsh")+"/profiles/web/package.json",p=require(f),b=p.dsh.profile.bundles;for(const n of Object.keys(p.dependencies))if(/^(dsh-bloom-theme|@dsh-plugins\/)/.test(n)&&!b.includes(n))b.push(n);require("fs").writeFileSync(f,JSON.stringify(p,null,2)+"\n")'
|
|
506
|
+
```
|
|
490
507
|
|
|
491
508
|
---
|
|
492
509
|
|
package/lib/client.js
CHANGED
|
@@ -192,6 +192,8 @@ window.__ModuleLoader__.load({
|
|
|
192
192
|
routeDeleteGroup: '删除该组',
|
|
193
193
|
routeUp: '上移',
|
|
194
194
|
routeDown: '下移',
|
|
195
|
+
routeNoCandidate: '(还没加候选)',
|
|
196
|
+
routeFallbackCount: '条兜底',
|
|
195
197
|
routeRemove: '移除',
|
|
196
198
|
routeGroupLabelPlaceholder: '组名(公司 / 个人)',
|
|
197
199
|
routeActiveTag: '当前组',
|
|
@@ -344,6 +346,8 @@ window.__ModuleLoader__.load({
|
|
|
344
346
|
routeDeleteGroup: 'Delete group',
|
|
345
347
|
routeUp: 'Move up',
|
|
346
348
|
routeDown: 'Move down',
|
|
349
|
+
routeNoCandidate: '(no candidates yet)',
|
|
350
|
+
routeFallbackCount: 'fallbacks',
|
|
347
351
|
routeRemove: 'Remove',
|
|
348
352
|
routeGroupLabelPlaceholder: 'Group name (work / personal)',
|
|
349
353
|
routeActiveTag: 'active',
|
|
@@ -613,7 +617,7 @@ window.__ModuleLoader__.load({
|
|
|
613
617
|
'border:.5px solid var(--dsw-alias-state-success-primary);',
|
|
614
618
|
'color:var(--dsw-alias-state-success-primary)}',
|
|
615
619
|
'.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);padding-top:2px}',
|
|
616
|
-
'.dsh-llm-hub-routing__reason{color:var(--dsw-alias-
|
|
620
|
+
'.dsh-llm-hub-routing__reason{font-size:10.5px;line-height:1.5;color:var(--dsw-alias-label-tertiary);padding-top:2px}',
|
|
617
621
|
// composer 快切 chip(conversation.input.left)
|
|
618
622
|
'.dsh-llm-hub-route{position:relative;display:inline-flex}',
|
|
619
623
|
'.dsh-llm-hub-route__btn{box-sizing:border-box;display:inline-flex;align-items:center;gap:4px;height:22px;',
|
|
@@ -629,8 +633,9 @@ window.__ModuleLoader__.load({
|
|
|
629
633
|
'background:var(--dsw-alias-bg-layer-1,rgba(15,15,15,.96));backdrop-filter:blur(14px);',
|
|
630
634
|
'box-shadow:0 8px 24px rgba(0,0,0,.28);display:flex;flex-direction:column;gap:8px;font-size:11px}',
|
|
631
635
|
'.dsh-llm-hub-route__modes{display:flex;gap:4px}',
|
|
632
|
-
'.dsh-llm-hub-route__mode{flex:
|
|
633
|
-
'background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:10.5px;
|
|
636
|
+
'.dsh-llm-hub-route__mode{flex:0 0 auto;padding:4px 9px;border-radius:5px;border:.5px solid var(--dsw-alias-border-l1);',
|
|
637
|
+
'background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:10.5px;',
|
|
638
|
+
'transition:all .15s ease;white-space:nowrap;line-height:1.5}',
|
|
634
639
|
'.dsh-llm-hub-route__mode.is-on{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);',
|
|
635
640
|
'background:var(--dsw-alias-bg-layer-2)}',
|
|
636
641
|
'.dsh-llm-hub-route__group{display:flex;flex-direction:column;gap:3px;padding:6px 7px;border-radius:6px;',
|
|
@@ -655,9 +660,18 @@ window.__ModuleLoader__.load({
|
|
|
655
660
|
'.dsh-llm-hub-routing__group.is-active{border-color:var(--dsw-alias-state-warn-primary)}',
|
|
656
661
|
'.dsh-llm-hub-routing__group-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
657
662
|
'.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-
|
|
663
|
+
'.dsh-llm-hub-routing__group-order{display:flex;align-items:center;gap:2px;flex:none}',
|
|
664
|
+
'.dsh-llm-hub-routing__group-move{border:0;background:0 0;padding:0 2px;cursor:pointer;font:inherit;font-size:10px;',
|
|
665
|
+
'color:var(--dsw-alias-label-tertiary);line-height:1}',
|
|
666
|
+
'.dsh-llm-hub-routing__group-move:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
|
|
667
|
+
'.dsh-llm-hub-routing__group-move:disabled{opacity:.25;cursor:default}',
|
|
668
|
+
'.dsh-llm-hub-routing__group-state b{font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
669
|
+
'.dsh-llm-hub-routing__group-more{color:var(--dsw-alias-label-tertiary)}',
|
|
670
|
+
'.dsh-llm-hub-routing__group-would{color:var(--dsw-alias-state-warn-primary);margin-left:5px}',
|
|
671
|
+
'.dsh-llm-hub-routing__group-empty{font-style:normal;color:var(--dsw-alias-label-tertiary)}',
|
|
672
|
+
'.dsh-llm-hub-routing__group-label{flex:0 0 128px;width:128px;box-sizing:border-box;height:22px;padding:0 6px;border-radius:4px;',
|
|
659
673
|
'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);
|
|
674
|
+
'.dsh-llm-hub-routing__group-state{flex:1;min-width:0;font-size:10.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
661
675
|
'.dsh-llm-hub-routing__candidates{display:flex;flex-direction:column;gap:3px;padding-top:2px}',
|
|
662
676
|
'.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
677
|
'.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);',
|
|
@@ -1886,6 +1900,25 @@ window.__ModuleLoader__.load({
|
|
|
1886
1900
|
next.splice(target, 0, item)
|
|
1887
1901
|
return { ...group, candidates: next }
|
|
1888
1902
|
})
|
|
1903
|
+
/**
|
|
1904
|
+
* 组之间上下移。
|
|
1905
|
+
*
|
|
1906
|
+
* 为什么需要:候选(组内的 provider/model)早就能用 ↑↓ 排,但组本身
|
|
1907
|
+
* (公司 / 个人 …)此前没有任何排序入口 —— 顺序只能靠手改 settings.yaml。
|
|
1908
|
+
* 而组的先后是有语义的:activeGroup 之外的组在智能路由下也会按顺序兜底,
|
|
1909
|
+
* 「先用哪个池子」必须在页面上能配。
|
|
1910
|
+
*
|
|
1911
|
+
* @param {number} index - 当前组下标。
|
|
1912
|
+
* @param {number} delta - -1 上移 / +1 下移。
|
|
1913
|
+
*/
|
|
1914
|
+
const moveGroup = (index, delta) => {
|
|
1915
|
+
const target = index + delta
|
|
1916
|
+
if (target < 0 || target >= groups.length) return
|
|
1917
|
+
const next = [...groups]
|
|
1918
|
+
const [item] = next.splice(index, 1)
|
|
1919
|
+
next.splice(target, 0, item)
|
|
1920
|
+
setDraft(next)
|
|
1921
|
+
}
|
|
1889
1922
|
const removeCandidate = (id, index) => editGroup(id, (group) => ({ ...group, candidates: group.candidates.filter((_, i) => i !== index) }))
|
|
1890
1923
|
const addCandidate = (id) => {
|
|
1891
1924
|
const provider = picker.provider
|
|
@@ -1974,20 +2007,54 @@ window.__ModuleLoader__.load({
|
|
|
1974
2007
|
const candidates = group.candidates
|
|
1975
2008
|
return h('div', { key: group.id, className: `dsh-llm-hub-routing__group${activeGroup === group.id ? ' is-active' : ''}` },
|
|
1976
2009
|
h('div', { className: 'dsh-llm-hub-routing__group-top' },
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
2010
|
+
// 顺序即优先级,所以序号 + 上下移都得露在折叠态 —— 折叠着也能改顺序。
|
|
2011
|
+
h('div', { className: 'dsh-llm-hub-routing__group-order' },
|
|
2012
|
+
h('button', {
|
|
2013
|
+
type: 'button',
|
|
2014
|
+
className: 'dsh-llm-hub-routing__group-toggle',
|
|
2015
|
+
onClick: () => setExpanded(isOpen ? '' : group.id)
|
|
2016
|
+
}, `${isOpen ? '▾' : '▸'} ${groupIndex + 1}`),
|
|
2017
|
+
h('button', {
|
|
2018
|
+
type: 'button',
|
|
2019
|
+
className: 'dsh-llm-hub-routing__group-move',
|
|
2020
|
+
title: t('routeUp'),
|
|
2021
|
+
disabled: groupIndex === 0,
|
|
2022
|
+
onClick: () => moveGroup(groupIndex, -1)
|
|
2023
|
+
}, '↑'),
|
|
2024
|
+
h('button', {
|
|
2025
|
+
type: 'button',
|
|
2026
|
+
className: 'dsh-llm-hub-routing__group-move',
|
|
2027
|
+
title: t('routeDown'),
|
|
2028
|
+
disabled: groupIndex === groups.length - 1,
|
|
2029
|
+
onClick: () => moveGroup(groupIndex, 1)
|
|
2030
|
+
}, '↓')),
|
|
1982
2031
|
h('input', {
|
|
1983
2032
|
className: 'dsh-llm-hub-routing__group-label',
|
|
1984
2033
|
value: group.label ?? '',
|
|
1985
2034
|
placeholder: t('routeGroupLabelPlaceholder'),
|
|
1986
2035
|
onChange: (event) => editGroup(group.id, (current) => ({ ...current, label: event.target.value }))
|
|
1987
2036
|
}),
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
2037
|
+
// 折叠态给出「这一组会按什么顺序试」的摘要:主力 + 兜底条数。
|
|
2038
|
+
// 之前顺序完全藏在折叠区里,用户看不到自己在排什么。
|
|
2039
|
+
h('span', { className: 'dsh-llm-hub-routing__group-state' },
|
|
2040
|
+
candidates.length === 0
|
|
2041
|
+
? h('em', { className: 'dsh-llm-hub-routing__group-empty' }, t('routeNoCandidate'))
|
|
2042
|
+
: [
|
|
2043
|
+
h('b', { key: 'p' }, `${candidates[0].provider}/${candidates[0].model}`),
|
|
2044
|
+
candidates.length > 1
|
|
2045
|
+
? h('span', { key: 'n', className: 'dsh-llm-hub-routing__group-more' },
|
|
2046
|
+
` + ${candidates.length - 1} ${t('routeFallbackCount')}`)
|
|
2047
|
+
: null,
|
|
2048
|
+
// 只在「实际会切到非主模型」时才提示 —— 跟主模型相同的箭头是纯冗余,
|
|
2049
|
+
// 还会把摘要挤到省略号里。真正有信息量的只有「兜底被选上了」。
|
|
2050
|
+
live?.wouldRoute && `${live.wouldRoute.provider}/${live.wouldRoute.model}` !== `${candidates[0].provider}/${candidates[0].model}`
|
|
2051
|
+
? h('span', { key: 'w', className: 'dsh-llm-hub-routing__group-would' },
|
|
2052
|
+
`→ ${live.wouldRoute.provider}/${live.wouldRoute.model}`)
|
|
2053
|
+
: null,
|
|
2054
|
+
live?.reason
|
|
2055
|
+
? h('span', { key: 'r', className: 'dsh-llm-hub-routing__group-would' }, live.reason)
|
|
2056
|
+
: null,
|
|
2057
|
+
]),
|
|
1991
2058
|
activeGroup === group.id
|
|
1992
2059
|
? h('span', { className: 'dsh-llm-hub-routing__role' }, t('routeActiveTag'))
|
|
1993
2060
|
: h('button', {
|
package/lib/index.js
CHANGED
|
@@ -2563,12 +2563,37 @@ export function apply(ctx) {
|
|
|
2563
2563
|
}
|
|
2564
2564
|
|
|
2565
2565
|
/**
|
|
2566
|
-
*
|
|
2566
|
+
* 一个 provider 是否在配置里"存在":要么 `llm-pi-ai` 段有它的 profile,要么
|
|
2567
|
+
* `llm-deepseek` 段存在(即官方直连适配器挂上了)。
|
|
2567
2568
|
*
|
|
2568
|
-
* `
|
|
2569
|
-
*
|
|
2570
|
-
*
|
|
2571
|
-
*
|
|
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 保留原配置,比打挂请求更稳)
|
|
2594
|
+
*
|
|
2595
|
+
* `stateFor` 把前两种情况都折成 'unknown',所以信息面板里仍是"待探明",无歧义;
|
|
2596
|
+
* 区别只在路由**目标**的取与舍 —— 这是 routable vs stateful 的本意。
|
|
2572
2597
|
* @param provider - provider id。
|
|
2573
2598
|
* @returns 'available' | 'unknown' | 'unavailable'。
|
|
2574
2599
|
*/
|
|
@@ -2576,7 +2601,7 @@ export function apply(ctx) {
|
|
|
2576
2601
|
for (const verdict of baseVerdicts.values()) {
|
|
2577
2602
|
if (verdict.provider === provider) return verdict.state
|
|
2578
2603
|
}
|
|
2579
|
-
return 'unavailable'
|
|
2604
|
+
return knownConfiguredProviders().has(provider) ? 'unknown' : 'unavailable'
|
|
2580
2605
|
}
|
|
2581
2606
|
|
|
2582
2607
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plugins/dsh-llm-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发版门禁的纯判据 —— 从 prepublish-gate.mjs 抽出来的可测部分。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要抽:门禁脚本顶层会读 package.json、跑 npm pack、连 registry,
|
|
5
|
+
* 没法直接 import 进来做单元测试;而契约 4 的「纯文档版放行」判据恰恰是
|
|
6
|
+
* 最需要测的逻辑(判错的两个方向都很难看:误拦文档版 → 逼人用逃生舱;
|
|
7
|
+
* 误放代码版 → 契约 4 形同虚设)。
|
|
8
|
+
*
|
|
9
|
+
* 这些函数**不含任何副作用**,只做字符串/文件内容比对。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 「运行期载荷」= tarball 里**除了文档/元数据/开发工具之外**的每一个文件。
|
|
17
|
+
*
|
|
18
|
+
* 判据必须能机械执行 —— 不用「CHANGELOG 里有没有 feat/fix 关键字」这种启发式
|
|
19
|
+
* (自省段落里出现一个「修」字就会误放行)。详见 docs/rules/release-semantics.md。
|
|
20
|
+
*/
|
|
21
|
+
export const EXCLUDE = [
|
|
22
|
+
/^readme(\.[^/]*)?$/i,
|
|
23
|
+
/^changelog(\.[^/]*)?$/i,
|
|
24
|
+
/^(license|notice|contributing)(\.[^/]*)?$/i,
|
|
25
|
+
/^docs\//i,
|
|
26
|
+
/^assets\//i,
|
|
27
|
+
/^scripts\//i,
|
|
28
|
+
/^\.github\//i,
|
|
29
|
+
/^\.githooks\//i,
|
|
30
|
+
/^tsconfig[^/]*\.json$/i,
|
|
31
|
+
/^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i,
|
|
32
|
+
/^(screenshots\.json|llms\.txt|dev_notes[^/]*)$/i,
|
|
33
|
+
/^(\.npmignore|\.gitignore|\.gitattributes)$/i
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export const isRuntime = (p) => !EXCLUDE.some((re) => re.test(p))
|
|
37
|
+
|
|
38
|
+
/** 递归收集目录下所有文件(相对路径)。 */
|
|
39
|
+
export function collectAllFiles(root) {
|
|
40
|
+
const out = []
|
|
41
|
+
const walk = (rel) => {
|
|
42
|
+
const abs = rel ? join(root, rel) : root
|
|
43
|
+
let st
|
|
44
|
+
try { st = statSync(abs) } catch { return }
|
|
45
|
+
if (st.isDirectory()) {
|
|
46
|
+
for (const e of readdirSync(abs)) walk(rel ? join(rel, e) : e)
|
|
47
|
+
} else {
|
|
48
|
+
out.push(rel)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
walk('')
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 找出「本次包里内容与上一已发布版不同」的运行期文件。
|
|
57
|
+
*
|
|
58
|
+
* 只服务于契约 4 的「载荷指纹相同」分支:指纹相同说明集合级没变,
|
|
59
|
+
* 这时要再问一句「那到底动了什么」——
|
|
60
|
+
* · 动的是运行期文件 → 说明内容变了但指纹没体现(改注释 / 重构不生效分支),拦
|
|
61
|
+
* · 全是文档 → 纯文档版,放行
|
|
62
|
+
*
|
|
63
|
+
* 比对策略:逐文件 sha256。新增/删除也算「变了」。
|
|
64
|
+
*/
|
|
65
|
+
export function detectChangedRuntime(prevRoot, curRoot, curFiles) {
|
|
66
|
+
const changed = []
|
|
67
|
+
const prevFiles = new Set(collectAllFiles(prevRoot))
|
|
68
|
+
|
|
69
|
+
const hashOf = (root, rel) => {
|
|
70
|
+
try { return createHash('sha256').update(readFileSync(join(root, rel))).digest('hex') }
|
|
71
|
+
catch { return null }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const rel of curFiles) {
|
|
75
|
+
if (rel === 'package.json') continue
|
|
76
|
+
if (!isRuntime(rel)) continue
|
|
77
|
+
const cur = hashOf(curRoot, rel)
|
|
78
|
+
if (cur === null) continue
|
|
79
|
+
const prev = prevFiles.has(rel) ? hashOf(prevRoot, rel) : null
|
|
80
|
+
if (prev !== cur) changed.push(rel)
|
|
81
|
+
}
|
|
82
|
+
for (const rel of prevFiles) {
|
|
83
|
+
if (rel === 'package.json') continue
|
|
84
|
+
if (!isRuntime(rel)) continue
|
|
85
|
+
if (!curFiles.includes(rel)) changed.push(rel + ' (本次已移除)')
|
|
86
|
+
}
|
|
87
|
+
return changed
|
|
88
|
+
}
|
|
@@ -31,6 +31,7 @@ import { dirname, join, relative } from 'node:path'
|
|
|
31
31
|
import { fileURLToPath } from 'node:url'
|
|
32
32
|
import { tmpdir } from 'node:os'
|
|
33
33
|
import { createHash } from 'node:crypto'
|
|
34
|
+
import { EXCLUDE, isRuntime, collectAllFiles, detectChangedRuntime } from './gate-lib.mjs'
|
|
34
35
|
|
|
35
36
|
const GATE_VERSION = 1
|
|
36
37
|
|
|
@@ -138,21 +139,6 @@ if (existsSync(changelogPath)) {
|
|
|
138
139
|
// feat/fix 关键字」这种启发式(自省段落里出现一个「修」字就会误放行)。
|
|
139
140
|
//
|
|
140
141
|
// 详见 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
142
|
|
|
157
143
|
const RUNTIME_PKG_FIELDS = [
|
|
158
144
|
'name', 'type', 'main', 'module', 'types', 'exports', 'bin', 'dsh', 'files',
|
|
@@ -210,12 +196,27 @@ if (process.env.ALLOW_METADATA_ONLY === '1') {
|
|
|
210
196
|
const before = runtimeFingerprint(prevRoot, collectAllFiles(prevRoot))
|
|
211
197
|
const after = runtimeFingerprint(pkgRoot, packFiles)
|
|
212
198
|
if (JSON.stringify(before) === JSON.stringify(after)) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
)
|
|
199
|
+
// 载荷相同有两种情况,必须分开处理(2026-09-27 补):
|
|
200
|
+
//
|
|
201
|
+
// (a) 本次改动**全是文档**(README / LICENSE / CHANGELOG / assets)——
|
|
202
|
+
// 这正是 README 包装升级要发版的场景,拦下来只会逼人用逃生舱,
|
|
203
|
+
// 而 CI 跑 `npm publish` 时根本传不了环境变量。
|
|
204
|
+
// (b) 本次动了**代码但载荷碰巧没变**(改了注释、重构了不生效的分支)——
|
|
205
|
+
// 这才是契约 4 要拦的:「diff 看着有东西,用户其实感知不到」。
|
|
206
|
+
//
|
|
207
|
+
// 判据:拿「本次 tarball 里与上一版内容不同的文件」去比对 isRuntime。
|
|
208
|
+
// 有任何一个是运行期文件 → 走 (b) 拦下;全是文档 → 走 (a) 放行。
|
|
209
|
+
const changedRuntime = detectChangedRuntime(prevRoot, pkgRoot, packFiles)
|
|
210
|
+
if (changedRuntime.length > 0) {
|
|
211
|
+
errors.push(
|
|
212
|
+
`运行期载荷与上一个已发布版本 ${previous} **完全相同** —— 这个 semver 号没有承载任何用户可感知的变化\n` +
|
|
213
|
+
` → 本次改动的运行期文件: ${changedRuntime.slice(0, 6).join(', ')}\n` +
|
|
214
|
+
` → 内容变了但载荷指纹没变(改了注释 / 重构了不生效的分支?),用户感知不到任何差异。\n` +
|
|
215
|
+
` → semver 是对用户的契约: 流程修补 / 元数据修正 / 自省笔记不该占版本号(docs/rules/release-semantics.md)。`
|
|
216
|
+
)
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`[prepublish-gate] · 契约 4: 本次为纯文档版(载荷相对 ${previous} 无运行期变化,放行)`)
|
|
219
|
+
}
|
|
219
220
|
} else {
|
|
220
221
|
console.log(`[prepublish-gate] · 契约 4: 运行期载荷相对 ${previous} 有变化(${before.length} 项比对)`)
|
|
221
222
|
}
|
|
@@ -229,23 +230,6 @@ if (process.env.ALLOW_METADATA_ONLY === '1') {
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
|
|
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
|
-
}
|
|
244
|
-
}
|
|
245
|
-
walk('')
|
|
246
|
-
return out
|
|
247
|
-
}
|
|
248
|
-
|
|
249
233
|
// ── 报告 ──────────────────────────────────────────────────────────────────
|
|
250
234
|
if (errors.length > 0) {
|
|
251
235
|
console.error(`\n[prepublish-gate] ${errors.length} 条契约不满足, 拒绝发版:\n`)
|
|
@@ -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)
|