dsh-connect-modelscope-token-plan 0.6.4 → 0.6.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 CHANGED
@@ -26,6 +26,47 @@
26
26
  > **发版时**:顶部开 `## [X.Y.Z] — YYYY-MM-DD`,完整流程见
27
27
  > [RELEASING.md](RELEASING.md) §3(本文开头这段是**写作者规范**,那是**发布操作流程**)。
28
28
 
29
+ ## [0.6.6] — 2026-10-23
30
+
31
+ **对话框胶囊可以点开,还多了「· N 新模型」**
32
+
33
+ - 胶囊此前只是读数、点它没反应;现在点开是一张小卡:官方余额、今日本地调用、数据
34
+ 时间与口径说明都在里面,向上弹不遮草稿,Esc 或点外面收起。
35
+ - 目录里出现本机第一次见到的模型时,胶囊末尾多一段 `· 2 新模型`,面板里列出名字。
36
+ **口径是本机首见,不是网关新增**——目录不返回「何时上架」,只能记「我何时第一次
37
+ 看到它」。只报最近 7 天内首见的;首次运行只播种、不宣布,不会把整个目录报成新。
38
+
39
+ **诊断:多认一个状态文件**
40
+
41
+ - `npm run doctor` 现在也盘点 `roster-seen.json`(每个 profile 记了多少模型、其中几个
42
+ 是最近 7 天内首见)。文件损坏或版本不认识时与其他状态文件同款报出,不再只是「少了
43
+ 那句标记」而没有痕迹。
44
+
45
+ **模型窗口:两个新上架模型不再停在 128k 兜底**
46
+
47
+ - DeepSeek-V4.1-Flash-1017(视觉)与 Step-5-Preview 上架时不在内置窗口策展表里,
48
+ 面板只能显示 128k 兜底,而它们在 models.dev 各厂 host 下的共识是 1M。现在按上游
49
+ 共识显示 1M。
50
+
51
+ ## [0.6.5] — 2026-10-21
52
+
53
+ **出图:被拒的图片地址不再原样回显**
54
+
55
+ - 输入的图片地址校验不过时,此前原样回显你给的地址——它可能带着签名串或令牌,
56
+ 于是凭据进日志也进面板。现在先脱敏再截断:诊断信息(协议、主机、路径)留着,
57
+ 凭据那部分显示为 [REDACTED]。
58
+
59
+ **面板:计数写盘失败不再悄悄冻住**
60
+
61
+ - 本地计数的写入失败此前被吞掉,面板继续显示上一次成功写下的数字——数字看着在涨,
62
+ 其实早就冻住,且没有半点痕迹。现在写入失败会记一条异常送进面板(并留一条日志),
63
+ 只有真正落盘的写入才算数。
64
+
65
+ **工程:依赖范围放宽**
66
+
67
+ - 插件声明的 AI 运行时依赖范围从 `^0.85.1` 放宽到 `>=0.85.0 <2.0.0`,与当前宿主
68
+ 随附的版本对齐,装上时不再被标成「超出上界」。
69
+
29
70
  ## [0.6.4] — 2026-10-19
30
71
 
31
72
  **出图:上游边缘抖动不再杀死任务**
package/README.md CHANGED
@@ -1,4 +1,22 @@
1
- # dsh-connect-modelscope-token-plan
1
+ <h1 align="center">dsh-connect-modelscope-token-plan</h1>
2
+
3
+ <p align="center"><b>把魔搭社区(ModelScope)API-Inference 免费额度的面板接进 DSH,并可选把魔搭模型注册成 LLM provider。</b></p>
4
+
5
+ <p align="center">
6
+ <a href="#安装">安装</a> ·
7
+ <a href="#面板长什么样">工作原理</a> ·
8
+ <a href="CHANGELOG.md">更新日志</a> ·
9
+ <a href="https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/issues">问题反馈</a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/dsh-connect-modelscope-token-plan"><img src="https://img.shields.io/npm/v/dsh-connect-modelscope-token-plan?style=flat-square&label=npm&color=cb3837" alt="npm version"></a>
14
+ <a href="https://www.npmjs.com/package/dsh-connect-modelscope-token-plan"><img src="https://img.shields.io/npm/d18m/dsh-connect-modelscope-token-plan?style=flat-square&label=downloads&color=cb3837" alt="npm downloads"></a>
15
+ <a href="https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/eghrhegpe/dsh-connect-modelscope-token-plan/ci.yml?branch=main&style=flat-square&label=tests" alt="test status"></a>
16
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/eghrhegpe/dsh-connect-modelscope-token-plan?style=flat-square" alt="MIT license"></a>
17
+ <a href="https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/stargazers"><img src="https://img.shields.io/github/stars/eghrhegpe/dsh-connect-modelscope-token-plan?style=flat-square" alt="GitHub stars"></a>
18
+ <a href="https://dshfind.com/plugins/eghrhegpe/dsh-connect-modelscope-token-plan"><img src="https://dshfind.com/api/badge/eghrhegpe/dsh-connect-modelscope-token-plan" alt="dshfind plugin"></a>
19
+ </p>
2
20
 
3
21
  把魔搭社区(modelscope.cn)**API-Inference 免费额度**的面板接进 DeepSeek Harness 的 **Plugins 页**(插件卡内联,三个 tab:额度 / 模型 / 接入),并可选把魔搭模型注册成 **DSH 的 LLM provider**(id `modelscope-token-plan`,OpenAI 兼容)——注册后魔搭模型进 DSH 模型选择器,可直接对话调用;还能开**出图工具**做文生图 / 图生图。
4
22
 
@@ -46,6 +64,8 @@
46
64
  - **只在当前选中的 provider 是 `modelscope-token-plan` 时出现**,切到别的 provider 整块消失、不发任何请求。
47
65
  - **没有百分比、没有黄色档**:魔粒是存量、官方没有发布上限或低额阈值,造进度条 / 阈值都是给沒人公布过的分母配数。三档只有绿(正)/ 红(官方确为 0)/ 灰(读不到)。
48
66
  - **读不到 ≠ 0**:读不到时该段渲染「—」,0 只在官方真的给 0 时出现并走告警色;读失败保留上次数值并标「上次数据」。
67
+ - **点开是详情面板**:胶囊可点,向上弹一张小卡——官方余额、今日本地调用、数据时间与口径说明都在里面,不遮草稿。
68
+ - **「· N 新模型」**(v0.6.6 起):目录里出现本机**第一次见到**的模型时,胶囊末尾多一段 `· 2 新模型`,详情面板里列出名字。**口径是本机首见,不是网关新增**——魔搭目录不返回「何时上架」,只能记「我什么时候第一次看到它」,两者混称就是编数据。只报最近 7 天内首见的,且只记目录 id;促销 / 活动变化不在射程内(那些是页面上的实时信息,不是目录事实)。首次运行只播种、不宣布:装上这个版本后的第一轮刷新不会把整个目录报成「新」。
49
69
 
50
70
  ## 安装
51
71
 
@@ -60,7 +80,7 @@
60
80
 
61
81
  3. 打开 DSH 的 **Plugins 页**,找到本插件的插件卡(面板是页内联卡片,**不在侧边栏**)。
62
82
 
63
- > 装到 npm 的那一份**不含 `docs/`**(`package.json` 的 `files` 白名单只带 `lib/` / `client.js` / `assets/` / `README.md` / `CHANGELOG.md` / `LICENSE` 等),下文指向 `docs/*` 的链接在已安装副本上打不开,请到 GitHub 仓库看。
83
+ > **分发面**:npm 包只含 `package.json#files` 那 11 项(`lib/`、`client.js`、`icon.svg`、`locale/`、`assets/`、`cordis.patch.yml`、`README.md`、`CHANGELOG.md`、`LICENSE`、`THIRD_PARTY_NOTICES.md`、`screenshots.json`)。`docs/`、`AGENTS.md`、`RELEASING.md`、`tools/`、`test/` **不在包里**——下文指向它们的链接(以及「文档」一节的两个开发者入口)在 registry 页面上会 404,请到 GitHub 仓库看;截图因 `assets/` 在包内,相对路径在两边都能显示。
64
84
 
65
85
  ## 免费额度怎么算(2026-10 快照,以官方为准)
66
86
 
@@ -96,16 +116,10 @@ npm run doctor:json # 机器读:JSON(可进你的巡检 / 工单脚本
96
116
 
97
117
  ## 文档
98
118
 
99
- - [docs/SPIKE.md](docs/SPIKE.md) — 上游到底有没有额度信号(实测记录 + 结论)
100
- - [docs/SPIKE-image-gen.md](docs/SPIKE-image-gen.md) — 出图协议实测(2026-10-10;v0.2 起已实现)
101
- - [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md) — provider 接入实现契约(设计裁定都在这里)
102
- - [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) — 这版怎么做出来的、踩过什么坑
103
- - [docs/REFERENCES.md](docs/REFERENCES.md)、[docs/REFERENCE-modelsdev.md](docs/REFERENCE-modelsdev.md) — 上游端点全景 / 第三方方案对比
104
- - [docs/ROADMAP.md](docs/ROADMAP.md) — v1 要做什么、做到哪了
105
- - [docs/archive/](docs/archive/) — 已冻结的逐版修复档案
119
+ **文档地图只有一份:[`AGENTS.md` §去哪查](https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/blob/master/AGENTS.md)**(每份文档一行「何时查」)。这里不再列第二遍——两处索引必然分叉,而分叉之后没人知道哪份是对的。
106
120
 
107
- AI 协作会话请先读 [AGENTS.md](AGENTS.md)(验证入口、红线与文档地图);开发者校验工具(变异测试 / 覆盖率)见 [tools/dev/README.md](tools/dev/README.md);发布流程见 [RELEASING.md](RELEASING.md)。
121
+ 开发者校验工具(变异测试 / 覆盖率)见 [`tools/dev/README.md`](https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/blob/master/tools/dev/README.md),发布流程见 [`RELEASING.md`](https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/blob/master/RELEASING.md)。配置字段的人读清单见 [docs/CONFIG.md](docs/CONFIG.md)(`drawEnabled` 这类「只有 YAML 注释」的键都在那里)。
108
122
 
109
123
  ## 许可证
110
124
 
111
- MIT License,全文见 [LICENSE](LICENSE)。
125
+ MIT License,全文见 [LICENSE](LICENSE);第三方内容(`docs/modelscope.cn-openapi/` 的魔搭 OpenAPI 转储等)见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
@@ -0,0 +1,32 @@
1
+ # 第三方致谢与合规(Third-Party Notices)
2
+
3
+ 本插件的源码与文档均为本仓库原创,按根目录 [LICENSE](./LICENSE)(MIT)授权。下列是**唯一的**
4
+ 第三方内容:
5
+
6
+ ## 1. `docs/modelscope.cn-openapi/` —— 魔搭站点 OpenAPI 规格转储
7
+
8
+ | 项 | 内容 |
9
+ |---|---|
10
+ | 出处 | 魔搭社区站点 `https://modelscope.cn/openapi/v1`(机读规格),2026-10-10 单提交入库(`6b9f1c0`),此后未更新 |
11
+ | 内容 | `modelscope.cn-openapi.md`(可读全文)与 `modelscope.cn-openapi.json.gz`(原始规格);目录内的 `README.md` 由本仓编写,说明转储口径 |
12
+ | 用途 | **契约参照**:核对面板外链/接口字段时查,不是数据源,也不参与任何请求 |
13
+ | 代码是否消费 | **否**——`test/docs.test.mjs` 的 `isVendoredSpec` 按目录前缀把整个目录排除在文档断言之外 |
14
+ | 权利 | 文档内容版权归魔搭社区 / 阿里巴巴所有,此处按「引用与核对」用途收录,未做商业再分发 |
15
+ | 是否随 npm 包发布 | **否**。`package.json#files` 不含 `docs/` |
16
+
17
+ ## 2. 与本插件通信的上游服务
18
+
19
+ - **魔搭 API-Inference**(`api-inference.modelscope.cn`)与站点(`modelscope.cn`):插件以使用者
20
+ 自己的访问令牌调用其模型目录、对话接口与官方余额接口。这些**不是**本仓库的代码,也不随包分发;
21
+ 使用须遵守魔搭的服务条款。
22
+ - **DSH(DeepSeek Harness)**:宿主运行时提供 `webServer` / `settings` / `llm` / `credentials`
23
+ 等 peer 服务,由宿主安装,不随本包分发。
24
+
25
+ ## 3. 开发期工具链
26
+
27
+ 构建与测试使用的开源包(如 `tsdown`、`typescript`、`react` 作为 peer)按其各自许可证使用,
28
+ **不复制其源码进本仓库**;依赖清单见 `package.json`,锁文件入库。
29
+
30
+ ## 4. 与厂商的关系
31
+
32
+ 本项目为个人集成作品,与魔搭社区、阿里巴巴、DeepSeek 均无关联,未获其背书。
package/client.js CHANGED
@@ -286,7 +286,16 @@ var dsh_connect_modelscope_token_plan_client = (function() {
286
286
  "composer.pillTitle": "魔搭额度:官方魔粒余额 + 今日本地调用次数",
287
287
  "composer.balanceUnit": "魔粒",
288
288
  "composer.localCalls": "今日本地 {calls} 次",
289
- "composer.stale": "上次数据"
289
+ "composer.stale": "上次数据",
290
+ "composer.panelTitle": "魔搭额度详情",
291
+ "composer.panelBalance": "官方魔粒余额",
292
+ "composer.panelCalls": "今日本地调用",
293
+ "composer.panelNote": "余额来自官方接口,调用次数由本机按本地日统计——两者口径不同,不互相换算。",
294
+ "composer.newModels": "新模型",
295
+ "composer.newModelsRow": "本机首次见到的模型",
296
+ "composer.newModelsTitle": "本机首次见到的模型:",
297
+ "composer.newModelsSep": "、",
298
+ "composer.newModelsNote": "「新」指本机首次记到它,与上游何时上架无关。只列最近 7 天内首见的。"
290
299
  };
291
300
  en = {
292
301
  "panel.refresh": "Refresh",
@@ -440,7 +449,16 @@ var dsh_connect_modelscope_token_plan_client = (function() {
440
449
  "composer.pillTitle": "ModelScope quota: official Magicube balance + today's local call count",
441
450
  "composer.balanceUnit": "Magicubes",
442
451
  "composer.localCalls": "local today {calls}",
443
- "composer.stale": "stale data"
452
+ "composer.stale": "stale data",
453
+ "composer.panelTitle": "ModelScope quota details",
454
+ "composer.panelBalance": "Official Magicube balance",
455
+ "composer.panelCalls": "Calls today (local)",
456
+ "composer.panelNote": "The balance comes from the official endpoint; the call count is tallied on this device by local day — different sources, so they are not converted into each other.",
457
+ "composer.newModels": "new",
458
+ "composer.newModelsRow": "First seen on this device",
459
+ "composer.newModelsTitle": "First seen on this device: ",
460
+ "composer.newModelsSep": ", ",
461
+ "composer.newModelsNote": "\"New\" means first recorded on this device, not newly listed by the gateway. Only models first seen within the last 7 days are listed."
444
462
  };
445
463
  }));
446
464
 
@@ -998,7 +1016,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
998
1016
  fontSize: 12,
999
1017
  fontWeight: 600,
1000
1018
  lineHeight: "18px",
1001
- cursor: "default",
1019
+ cursor: "pointer",
1002
1020
  flexShrink: 0,
1003
1021
  whiteSpace: "nowrap"
1004
1022
  },
@@ -1017,7 +1035,69 @@ var dsh_connect_modelscope_token_plan_client = (function() {
1017
1035
  pillTierOk: "#22c55e",
1018
1036
  pillTierExhausted: "#ef4444",
1019
1037
  pillTierUnknown: "#9ca3af",
1020
- pillTierExhaustedText: { color: "var(--dsw-alias-state-error-primary)" }
1038
+ pillTierExhaustedText: { color: "var(--dsw-alias-state-error-primary)" },
1039
+ pillNewTag: {
1040
+ color: "var(--dsw-alias-label-secondary)",
1041
+ flexShrink: 0
1042
+ },
1043
+ pillPopoverAnchor: {
1044
+ position: "relative",
1045
+ display: "inline-flex"
1046
+ },
1047
+ pillPopover: {
1048
+ position: "absolute",
1049
+ bottom: "100%",
1050
+ right: 0,
1051
+ zIndex: 100,
1052
+ border: "1px solid var(--dsw-alias-border-l1)",
1053
+ borderRadius: 10,
1054
+ background: "var(--dsw-alias-bg-layer-1)",
1055
+ boxShadow: "0 6px 24px rgba(0, 0, 0, 0.18)",
1056
+ width: "min(320px, calc(100vw - 32px))",
1057
+ padding: "10px 12px",
1058
+ marginBottom: 8,
1059
+ textAlign: "left",
1060
+ color: "var(--dsw-alias-label-primary)",
1061
+ fontSize: 12,
1062
+ lineHeight: "18px",
1063
+ fontFamily: "inherit",
1064
+ boxSizing: "border-box"
1065
+ },
1066
+ pillPopoverHead: {
1067
+ display: "flex",
1068
+ alignItems: "center",
1069
+ gap: 6,
1070
+ flexWrap: "wrap",
1071
+ marginBottom: 8
1072
+ },
1073
+ pillPopoverTitle: {
1074
+ fontSize: 12,
1075
+ fontWeight: 600,
1076
+ color: "var(--dsw-alias-label-primary)"
1077
+ },
1078
+ pillPopoverStale: {
1079
+ color: "var(--dsw-alias-label-tertiary)",
1080
+ fontStyle: "italic"
1081
+ },
1082
+ pillPopoverRow: { padding: "6px 0" },
1083
+ pillPopoverRowLine: {
1084
+ display: "flex",
1085
+ alignItems: "baseline",
1086
+ gap: 8
1087
+ },
1088
+ pillPopoverLabel: {
1089
+ fontSize: 12,
1090
+ color: "var(--dsw-alias-label-secondary)"
1091
+ },
1092
+ pillPopoverFigure: {
1093
+ marginLeft: "auto",
1094
+ fontVariantNumeric: "tabular-nums",
1095
+ color: "var(--dsw-alias-label-primary)"
1096
+ },
1097
+ pillPopoverNote: {
1098
+ marginTop: 8,
1099
+ color: "var(--dsw-alias-label-tertiary)"
1100
+ }
1021
1101
  };
1022
1102
  }));
1023
1103
 
@@ -1193,6 +1273,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
1193
1273
  allowed: p.allowed === "none" ? "none" : p.allowed === "list" ? "list" : "all",
1194
1274
  enabledIds: ids,
1195
1275
  contextWindow: typeof p.contextWindow === "number" && Number.isFinite(p.contextWindow) && p.contextWindow > 0 ? p.contextWindow : null,
1276
+ newModelIds: Array.isArray(p.newModelIds) ? p.newModelIds.map((id) => String(id ?? "")).filter((id) => id !== "") : [],
1196
1277
  roster
1197
1278
  };
1198
1279
  }
@@ -1286,6 +1367,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
1286
1367
  allowed: "all",
1287
1368
  enabledIds: [],
1288
1369
  contextWindow: null,
1370
+ newModelIds: [],
1289
1371
  roster: []
1290
1372
  });
1291
1373
  DEGRADED_DRAW = Object.freeze({
@@ -2130,7 +2212,8 @@ var dsh_connect_modelscope_token_plan_client = (function() {
2130
2212
  *
2131
2213
  * **与 ProviderCard 的关键差异,也是这张卡存在的主要理由**:provider 开关翻转会
2132
2214
  * 立即重注册,而出图工具的开关翻转**要等下次 Host 启动**——peer 的 tools registry
2133
- * 没有 unregister。所以这张卡必须把这件事说出来(`draw.restartHint`),否则用户
2215
+ * 没有 unregister(完整论述见 src/host/draw-register.ts 文件头注)。所以这张卡必须
2216
+ * 把这件事说出来(`draw.restartHint`),否则用户
2134
2217
  * 翻开关后看不到任何变化,会以为坏了。
2135
2218
  *
2136
2219
  * 缺席原因是**分开渲染**的:`no-tools-service` 是「这个 Host 没提供该服务」(正常),
@@ -3012,15 +3095,109 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3012
3095
  return parts.filter((part) => part !== null).join(" · ");
3013
3096
  }
3014
3097
  /**
3098
+ * 「本机首次见到的模型」清单:`snapshot.provider.newModelIds` 的净味版。
3099
+ *
3100
+ * 三道过滤都是承重的:
3101
+ * - 非数组(老 Host / 降级快照)→ 空清单,不抛;
3102
+ * - 非字符串 / 空串 → 丢掉,否则面板会画出一个空名字的「新模型」;
3103
+ * - 去重 → Host 侧已经去过重,但客户端不能依赖对端,重复项会让「3 个新模型」
3104
+ * 变成 4 行同名。
3105
+ */
3106
+ function newModelsOf(snapshot) {
3107
+ const raw = snapshot?.provider?.newModelIds;
3108
+ if (!Array.isArray(raw)) return [];
3109
+ const seen = /* @__PURE__ */ new Set();
3110
+ const ids = [];
3111
+ for (const value of raw) {
3112
+ if (typeof value !== "string") continue;
3113
+ const id = value.trim();
3114
+ if (id === "" || seen.has(id)) continue;
3115
+ seen.add(id);
3116
+ ids.push(id);
3117
+ }
3118
+ return ids;
3119
+ }
3120
+ /**
3121
+ * 点面板外面或按 Escape 关面板。
3122
+ *
3123
+ * 三处细节各有理由:
3124
+ * - `pointerdown` 而不是 `click`:`click` 触发时按钮自己的 onClick 已经跑过,
3125
+ * 一个把触发器算作「里面」的 click 监听会先关再被 toggle 打开,面板永远关不掉;
3126
+ * - 包含判定读**锚节点**而不是面板:点触发器本身是一次 toggle,把它算作「里面」
3127
+ * 才不会和 toggle 抢;
3128
+ * - 鸭子类型判 `nodeType` 而不是 `instanceof Node`:本模块在 jsdom 里也跑,
3129
+ * 而 jsdom 只把 `document` 镜像进 Node realm,`Node` 构造函数在那儿不存在。
3130
+ *
3131
+ * `open === false` 时不挂监听(没有面板要关,挂上就是白跑)。
3132
+ */
3133
+ function useDismissOn(open, close, anchorRef) {
3134
+ useEffect(() => {
3135
+ if (!open) return;
3136
+ if (typeof document === "undefined" || !("addEventListener" in document)) return;
3137
+ const nodeLike = (value) => typeof value === "object" && value !== null && typeof value.nodeType === "number";
3138
+ const anchorLike = anchorRef.current;
3139
+ const insideAnchor = (target) => nodeLike(target) && typeof anchorLike?.contains === "function" && anchorLike.contains(target);
3140
+ const onPointerDown = (event) => {
3141
+ if (insideAnchor(event.target)) return;
3142
+ close();
3143
+ };
3144
+ const onKeyDown = (event) => {
3145
+ if (event.key !== "Escape") return;
3146
+ close();
3147
+ };
3148
+ document.addEventListener("pointerdown", onPointerDown, true);
3149
+ document.addEventListener("keydown", onKeyDown);
3150
+ return () => {
3151
+ document.removeEventListener("pointerdown", onPointerDown, true);
3152
+ document.removeEventListener("keydown", onKeyDown);
3153
+ };
3154
+ }, [
3155
+ open,
3156
+ close,
3157
+ anchorRef
3158
+ ]);
3159
+ }
3160
+ /**
3161
+ * 展开面板:把胶囊正文那两段摊成两行,再补胶囊装不下的东西(口径注脚、
3162
+ * 读取时间、新模型清单)。
3163
+ *
3164
+ * 面板是触发器的**兄弟**而不是子节点:`<button>` 里塞 `<div role="dialog">`
3165
+ * 是非法嵌套(按钮的内容模型是 phrasing content,交互内容更糟)。锚壳用
3166
+ * `<span>` 同理。
3167
+ */
3168
+ function QuotaPopover(props) {
3169
+ const { tt, title, stale, available, usedToday, balance, newModels } = props;
3170
+ const fetchedAt = balance?.fetchedAt;
3171
+ const failure = balance?.error;
3172
+ return h("div", {
3173
+ className: "dsh-modelscope-composer-pill-popover",
3174
+ id: POPOVER_ID,
3175
+ role: "dialog",
3176
+ "aria-label": title,
3177
+ style: S.pillPopover
3178
+ }, h("div", { style: S.pillPopoverHead }, h("span", { style: S.pillPopoverTitle }, tt("composer.panelTitle")), stale ? h("span", { style: S.pillPopoverStale }, tt("composer.stale")) : null), h("div", { style: S.pillPopoverRow }, h("div", { style: S.pillPopoverRowLine }, h("span", { style: S.pillPopoverLabel }, tt("composer.panelBalance")), h("span", { style: S.pillPopoverFigure }, balanceText(available, tt)))), h("div", { style: S.pillPopoverRow }, h("div", { style: S.pillPopoverRowLine }, h("span", { style: S.pillPopoverLabel }, tt("composer.panelCalls")), h("span", { style: S.pillPopoverFigure }, usedToday === null ? "—" : format(tt("composer.localCalls"), { calls: count(usedToday) })))), newModels.length === 0 ? null : h("div", { style: S.pillPopoverRow }, h("div", { style: S.pillPopoverRowLine }, h("span", { style: S.pillPopoverLabel }, tt("composer.newModelsRow")), h("span", { style: S.pillPopoverFigure }, newModels.join(tt("composer.newModelsSep"))))), failure !== void 0 && typeof failure === "string" && failure !== "" ? h("div", { style: S.pillPopoverNote }, format(tt("balance.unavailable"), { error: failure })) : null, fetchedAt !== void 0 && typeof fetchedAt === "string" && fetchedAt !== "" ? h("div", { style: S.pillPopoverNote }, format(tt("balance.fetched"), { time: isoTime(fetchedAt) })) : null, h("div", { style: S.pillPopoverNote }, tt("composer.panelNote")), newModels.length === 0 ? null : h("div", { style: S.pillPopoverNote }, tt("composer.newModelsNote")));
3179
+ }
3180
+ /**
3015
3181
  * 内层:只在会话选中本插件的模型时挂载。轮询 hook 在这里,被挂/摘即起停——
3016
3182
  * 非归属 provider 的会话不发任何请求。
3183
+ *
3184
+ * 展开状态也住在这里(而不是外层门控):外层每帧都要调 `useProjection` 才能
3185
+ * 决定挂谁,把 `useState` 加到外层会让「非归属 → 归属」这个切换改变 hook 顺序
3186
+ * (外层先 return null 再挂内层,Rules of Hooks 会炸)。内层被挂/摘时它自己的
3187
+ * hook 序是完整的,所以 open 放这里安全。
3017
3188
  */
3018
3189
  function ModelscopePill(props) {
3019
3190
  const { tt } = props;
3020
3191
  const { data, error, loadedOnce, updatedAt } = useSnapshotPolling();
3192
+ const [open, setOpen] = useState(false);
3193
+ const anchorRef = useRef(null);
3194
+ const close = useCallback(() => setOpen(false), []);
3195
+ const toggle = useCallback(() => setOpen((current) => !current), []);
3196
+ useDismissOn(open, close, anchorRef);
3021
3197
  const balance = data?.balance ?? null;
3022
3198
  const available = availableOf(balance);
3023
3199
  const usedToday = usedTodayOf(data);
3200
+ const newModels = newModelsOf(data);
3024
3201
  const cadenceMs = statedCadenceMs(data?.pollSeconds, DEFAULT_CADENCE_MS);
3025
3202
  const stale = staleOf({
3026
3203
  error,
@@ -3037,11 +3214,15 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3037
3214
  usedToday,
3038
3215
  stale
3039
3216
  });
3040
- return h("button", {
3217
+ const trigger = h("button", {
3041
3218
  type: "button",
3042
3219
  className: "dsh-modelscope-composer-pill",
3043
3220
  title,
3044
3221
  "aria-label": title,
3222
+ "aria-expanded": open,
3223
+ "aria-haspopup": "dialog",
3224
+ "aria-controls": open ? POPOVER_ID : void 0,
3225
+ onClick: toggle,
3045
3226
  style: S.pillTrigger
3046
3227
  }, h("span", {
3047
3228
  className: "dsh-modelscope-composer-pill-dot",
@@ -3059,7 +3240,24 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3059
3240
  }, pending ? "…" : readingText(available, usedToday, tt)), stale ? h("span", {
3060
3241
  className: "dsh-modelscope-composer-pill-stale",
3061
3242
  style: S.pillStale
3062
- }, ` · ${tt("composer.stale")}`) : null);
3243
+ }, ` · ${tt("composer.stale")}`) : null, newModels.length === 0 ? null : h("span", {
3244
+ className: "dsh-modelscope-composer-pill-new",
3245
+ style: S.pillNewTag,
3246
+ title: `${tt("composer.newModelsTitle")}${newModels.slice(0, 3).join(tt("composer.newModelsSep"))}${newModels.length > 3 ? "…" : ""}`
3247
+ }, ` · ${newModels.length} ${tt("composer.newModels")}`));
3248
+ return h("span", {
3249
+ className: "dsh-modelscope-composer-pill-anchor",
3250
+ style: S.pillPopoverAnchor,
3251
+ ref: anchorRef
3252
+ }, trigger, open ? h(QuotaPopover, {
3253
+ tt,
3254
+ title,
3255
+ stale,
3256
+ available,
3257
+ usedToday,
3258
+ balance,
3259
+ newModels
3260
+ }) : null);
3063
3261
  }
3064
3262
  /**
3065
3263
  * 外层门控:固定一处调 `useProjection`,再决定内层挂谁。非归属 provider 返回
@@ -3070,7 +3268,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3070
3268
  if (selectedProviderOf(selection) !== "modelscope-token-plan") return null;
3071
3269
  return h(ModelscopePill, { tt: props.tt });
3072
3270
  }
3073
- var MODELSCOPE_PROVIDER, DEFAULT_CADENCE_MS, STALE_CADENCES, TIER_INK;
3271
+ var MODELSCOPE_PROVIDER, DEFAULT_CADENCE_MS, STALE_CADENCES, TIER_INK, POPOVER_ID;
3074
3272
  var init_composer_usage_pill = __esmMin((() => {
3075
3273
  init_runtime();
3076
3274
  init_styles();
@@ -3084,6 +3282,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3084
3282
  exhausted: S.pillTierExhausted,
3085
3283
  unknown: S.pillTierUnknown
3086
3284
  };
3285
+ POPOVER_ID = "dsh-modelscope-composer-pill-popover";
3087
3286
  }));
3088
3287
 
3089
3288
  //#endregion
@@ -3207,7 +3406,8 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3207
3406
  balanceText,
3208
3407
  callsText,
3209
3408
  readingText,
3210
- staleOf
3409
+ staleOf,
3410
+ newModelsOf
3211
3411
  }),
3212
3412
  components: Object.freeze({
3213
3413
  BalanceCard,
package/cordis.patch.yml CHANGED
@@ -62,8 +62,8 @@
62
62
  # 适配器出去,每一次调用与每一条 429/错误事件都由 src/host/usage-observer.ts
63
63
  # 在流的出口记进同一个 usage-store。口径是**本客户端**:桌面端与 Web 端是
64
64
  # 两个 profile,各记各的账、互不可见(`npm run doctor` 并排列出各 profile),
65
- # 别把它当跨端总量。直连魔搭的其它客户端同样不计入(本插件看不见它们),
66
- # 消耗总量以官方魔粒余额为准。
65
+ # 口径的历史与裁定见 docs/PROVIDER-M4.md;别把它当跨端总量。直连魔搭的
66
+ # 其它客户端同样不计入(本插件看不见它们),消耗总量以官方魔粒余额为准。
67
67
  #
68
68
  # 偏差方向是**按流计次**:重试不在流内部发生,而是流被消费完后由
69
69
  # agent/request-error 瀑布决定重发,因而每次重试各记一次(按「魔搭按
@@ -103,7 +103,8 @@
103
103
  # 出图计数——那会变成拿 chat 的口径给生图记账。
104
104
  #
105
105
  # 生效时机:开关值立即保存,但**工具的挂载发生在下次 Host 启动**——
106
- # peer 的 tools registry 没有 unregister,工具一旦注册就摘不下来。
106
+ # peer 的 tools registry 没有 unregister,工具一旦注册就摘不下来(机制与
107
+ # 后果的完整论述见 src/host/draw-register.ts 文件头注)。
107
108
  # 面板会就这一点给出提示(draw.restartHint)。
108
109
  drawEnabled: false
109
110
  # 出图默认模型(空 = 自动取目录里第一个出图模型)。
@@ -1141,7 +1141,7 @@ const inject = ["webServer"];
1141
1141
  * 产物反而引入第二份真源;这里用常量、由 test/config.test.mjs 钉住与
1142
1142
  * package.json 一致。
1143
1143
  */
1144
- const PLUGIN_VERSION = "0.6.4";
1144
+ const PLUGIN_VERSION = "0.6.6";
1145
1145
  /**
1146
1146
  * 默认值。刻意不含额度常数——理由见本文件头。
1147
1147
  * 面板头条是官方魔粒余额,次数口径的推算已在面板收敛时删除,这里若留着
package/lib/index.js CHANGED
@@ -985,6 +985,15 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
985
985
  const file = join(dir, "usage.json");
986
986
  /** 最近一次解析异常(损坏即忽略的痕迹),快照转 shapeWarnings。 */
987
987
  let anomaly = null;
988
+ /**
989
+ * 最近一次**写**失败的痕迹。
990
+ *
991
+ * 与 {@link anomaly} 分开记,因为两者的寿命不同:解析异常是**读**出来的,下一次
992
+ * 读就会覆盖它(读到好的就恢复);写失败是**写**出来的,只有一次成功的写才能
993
+ * 抹掉它——读得动旧值不代表上一次写落了盘(读回来的永远是上一次**成功**写下去
994
+ * 的那一份)。
995
+ */
996
+ let writeAnomaly = null;
988
997
  /** 只读闸:磁盘版本比本构建新时置位——读照常、写拒绝,绝不 clobber。 */
989
998
  let readOnly = false;
990
999
  /** 最近一次确认过的内存态;读路径拿它,写路径以它为基线。 */
@@ -1046,10 +1055,29 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1046
1055
  return parsed.payload;
1047
1056
  };
1048
1057
  const cache = createStateReadCache(readThrough, { now: () => Date.now() });
1049
- /** 把内存态写盘(原子),失败不抛——计数是尽力而为的观测,不是事务。 */
1058
+ /**
1059
+ * 把内存态写盘(原子),失败不抛——计数是尽力而为的观测,不是事务。
1060
+ *
1061
+ * 但「不抛」不等于「不说」。这一句注释原先与实现相反:writeStateFile 失败会**抛**
1062
+ * (state-store.ts 的 writeStateFile 在 catch 里重抛),抛给谁?本仓的用法是
1063
+ * index.ts 那句 `.catch(() => {})`(理由见那里:观测失败不是对话失败)——于是
1064
+ * 一次写失败被彻底咽掉:面板继续显示上一次成功落盘的数字,计数看起来是活的,
1065
+ * 其实早就冻住了,而**没有任何人知道**(没有日志,没有 shapeWarning)。
1066
+ *
1067
+ * 所以失败要留两处痕迹:日志(给操作者)与 {@link writeAnomaly}(进快照的
1068
+ * shapeWarnings,给面板)。吞掉的是失败,不是失败的原因。
1069
+ */
1050
1070
  const persist = async (payload) => {
1051
- await ensureStateDir(dir);
1052
- await writeStateFile(file, JSON.stringify(payload), { temporary: temporaryOf(dir, "usage.json") });
1071
+ try {
1072
+ await ensureStateDir(dir);
1073
+ await writeStateFile(file, JSON.stringify(payload), { temporary: temporaryOf(dir, "usage.json") });
1074
+ } catch (error) {
1075
+ const note = `usage-state: write failed (${redactSecrets(errMsg(error))})`;
1076
+ writeAnomaly = note;
1077
+ logger?.warn?.(`${name}: ${note} — the local counters are not reaching disk; the panel still shows the last values that did`);
1078
+ return;
1079
+ }
1080
+ writeAnomaly = null;
1053
1081
  cache.remember(payload);
1054
1082
  };
1055
1083
  /**
@@ -1266,9 +1294,14 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1266
1294
  if (payload === null) return await pathExists(file) ? null : [];
1267
1295
  return [...payload.events ?? []];
1268
1296
  },
1269
- /** 最近一次解析异常(无异常为 null)——快照转 shapeWarnings。 */
1297
+ /**
1298
+ * 最近一次异常(无异常为 null)——快照转 shapeWarnings。
1299
+ *
1300
+ * 写失败的痕迹优先于读侧的解析异常:它更近、也更严重(解析异常说「这份文件
1301
+ * 读不懂」,写失败说「我们刚写的东西没落盘」)。
1302
+ */
1270
1303
  anomaly() {
1271
- return anomaly;
1304
+ return writeAnomaly ?? anomaly;
1272
1305
  }
1273
1306
  };
1274
1307
  }
@@ -1507,8 +1540,8 @@ function createCoalescedFetch(options = {}) {
1507
1540
  * 最大值:个别网关把窗口抄大(如 openrouter 抄 1M)不代表魔搭侧真开那么宽。
1508
1541
  *
1509
1542
  * 维护纪律:
1510
- * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-16 实拉 `/v1/models` 全集
1511
- * 37 个中筛出 19 个);目录下架的条目应随手删除,表不是历史档案。
1543
+ * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-23 实拉 `/v1/models` 全集
1544
+ * 37 个中筛出 21 个);目录下架的条目应随手删除,表不是历史档案。
1512
1545
  * 2. 每条注明来源 host 与核对日期;有多个数据源时写主流值并留离群说明。
1513
1546
  * 3. 覆盖优先级由 `curatedWindowOf` 的调用方(inference-client 打标)保证:
1514
1547
  * 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
@@ -1522,14 +1555,14 @@ function createCoalescedFetch(options = {}) {
1522
1555
  * 而使用者无从判断一个值是三个月前还是三年前核的。快照侧据此在表过期时给一条
1523
1556
  * shapeWarning(见 {@link curatedCatalogStale})。
1524
1557
  */
1525
- const CURATED_AS_OF = "2026-10-16";
1558
+ const CURATED_AS_OF = "2026-10-23";
1526
1559
  /**
1527
1560
  * 策展表距上次核对已经过去多少天;日期解析不了返回 `null`。
1528
1561
  * @param {Date} [now] - 时钟源(测试注入)。
1529
1562
  * @returns {number|null} 天数(向下取整),或 null。
1530
1563
  */
1531
1564
  function curatedCatalogAgeDays(now = /* @__PURE__ */ new Date()) {
1532
- const asOf = Date.parse("2026-10-16T00:00:00Z");
1565
+ const asOf = Date.parse("2026-10-23T00:00:00Z");
1533
1566
  if (Number.isNaN(asOf)) return null;
1534
1567
  const delta = now.getTime() - asOf;
1535
1568
  return delta < 0 ? 0 : Math.floor(delta / 864e5);
@@ -1553,6 +1586,7 @@ const CURATED_CONTEXT_WINDOWS = {
1553
1586
  "deepseek-ai/DeepSeek-V4-Pro": 1048576,
1554
1587
  "deepseek-ai/DeepSeek-V4-Pro-0813": 1048576,
1555
1588
  "deepseek-ai/DeepSeek-V4.1-Flash": 1048576,
1589
+ "deepseek-ai/DeepSeek-V4.1-Flash-1017": 1048576,
1556
1590
  "meituan-longcat/LongCat-Flash-Lite": 256e3,
1557
1591
  "MiniMax/MiniMax-M1-80k": 1e6,
1558
1592
  "MiniMax/MiniMax-M3": 1048576,
@@ -1566,6 +1600,7 @@ const CURATED_CONTEXT_WINDOWS = {
1566
1600
  "Qwen/Qwen3.8-Flash-Next": 262144,
1567
1601
  "stepfun-ai/Step-3.5-Flash": 262144,
1568
1602
  "stepfun-ai/Step-3.7-Flash": 262144,
1603
+ "stepfun-ai/Step-5-Preview": 1e6,
1569
1604
  "ZhipuAI/GLM-4.7-Flash": 2e5,
1570
1605
  "ZhipuAI/GLM-5.2": 1e6
1571
1606
  };
@@ -1993,7 +2028,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1993
2028
  * collapses here, so the three call sites that produce it stay in step instead
1994
2029
  * of each spelling out the same three `null`s.
1995
2030
  */
1996
- const EMPTY_PAYLOAD$2 = {
2031
+ const EMPTY_PAYLOAD$3 = {
1997
2032
  enabled: null,
1998
2033
  enabledIds: null,
1999
2034
  contextWindow: null,
@@ -2222,7 +2257,7 @@ function createFileProviderStore(options = {}) {
2222
2257
  * `null` branch collapses here, in one place, rather than at every caller.
2223
2258
  * @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
2224
2259
  */
2225
- const readPayload = async () => await read() ?? EMPTY_PAYLOAD$2;
2260
+ const readPayload = async () => await read() ?? EMPTY_PAYLOAD$3;
2226
2261
  /**
2227
2262
  * Read one payload, then hand it to {@link writePayload} — the read-modify-
2228
2263
  * write that keeps the switch and the list from clobbering each other.
@@ -2446,7 +2481,7 @@ function createFileProviderStore(options = {}) {
2446
2481
  const DRAW_STORE_VERSION = 1;
2447
2482
  /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2448
2483
  const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
2449
- const EMPTY_PAYLOAD$1 = {
2484
+ const EMPTY_PAYLOAD$2 = {
2450
2485
  enabled: null,
2451
2486
  modelId: null,
2452
2487
  version: null
@@ -2545,7 +2580,7 @@ function createFileDrawStore(options = {}) {
2545
2580
  * 读当前 payload。
2546
2581
  * @returns {Promise<DrawPayload>}
2547
2582
  */
2548
- const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
2583
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$2;
2549
2584
  /**
2550
2585
  * 读-改-写一份补丁,**整段排队**。
2551
2586
  *
@@ -2803,7 +2838,7 @@ function switchSource(panel) {
2803
2838
  const USAGE_PREFS_VERSION = 1;
2804
2839
  /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2805
2840
  const KNOWN_USAGE_PREFS_VERSIONS = Object.freeze([1]);
2806
- const EMPTY_PAYLOAD = {
2841
+ const EMPTY_PAYLOAD$1 = {
2807
2842
  windowDays: null,
2808
2843
  version: null
2809
2844
  };
@@ -2922,7 +2957,7 @@ function createFileUsagePrefsStore(options = {}) {
2922
2957
  * 读当前 payload。
2923
2958
  * @returns {Promise<UsagePrefsPayload>}
2924
2959
  */
2925
- const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
2960
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
2926
2961
  /**
2927
2962
  * 读-改-写,**整段排队**。
2928
2963
  *
@@ -2981,6 +3016,258 @@ function createFileUsagePrefsStore(options = {}) {
2981
3016
  };
2982
3017
  }
2983
3018
 
3019
+ //#endregion
3020
+ //#region src/host/roster-seen-store.ts
3021
+ /**
3022
+ * 「本机第一次见到这个模型是什么时候」——胶囊那句「· N 新模型」的唯一事实源。
3023
+ *
3024
+ * ## 它回答什么,不回答什么
3025
+ *
3026
+ * 只记**首见时间戳**:每个 id 第一次出现在本机拉到的目录里的那一刻。由此派生的
3027
+ * 那句话必须说成「本机首次见到的模型」,**不能**说成「网关新增」——上游的
3028
+ * `/v1/models` 不提供「上架时间」,本插件没有任何字段能证实某个模型是这周才上
3029
+ * 的。能证实的只有「本机从哪天开始看见它」,那就只说这一句。
3030
+ *
3031
+ * 也**不**记录活动/促销变化(限免、错峰、签到):那些是实时读数,面板的余额块与
3032
+ * 胶囊 popover 已经直接画上游给的现值,再造一本「促销 diff 账」就是同一事实两本
3033
+ * 账,两本迟早说两套话(ADR-017 的口径)。
3034
+ *
3035
+ * ## 一次调用同时 diff 与落盘
3036
+ *
3037
+ * {@link RosterSeenStore.note} 不能拆成「先 diff 缓存、稍后再写」:那样两个并发
3038
+ * 的收敛轮可能都基于同一份旧 map 判断,后写的那次会把先写的那批模型再宣布一遍。
3039
+ * 读、算、写在一个调用里完成。
3040
+ *
3041
+ * ## 首次写入是**播种**,不是宣布
3042
+ *
3043
+ * 升级到带本文件的构建时,磁盘上没有历史——此时**知道**目录里有这些模型,但
3044
+ * **不知道**其中哪些是新的。把整个目录宣布成「刚上架」正是本文件要避免的谎,
3045
+ * 所以播种那一轮把每个 id 都盖成「已过期」的戳(`now - 窗口`),`newIds` 返回
3046
+ * 空。这个自愈是刻意的:某个 id 要先离开目录再回来,才配得到一个真实的首见戳。
3047
+ *
3048
+ * ## 200 个 id 以上是**形状漂移**,不是「保留最新 200」
3049
+ *
3050
+ * 按「保留最新」淘汰一个仍在目录里的 id,会让它下一轮看起来像没见过 → 重新盖戳
3051
+ * → 再被宣布一次;而被淘汰的集合每轮轮换,于是「新模型」计数会**震荡并变大**
3052
+ * (实测 250 个 id 对 200 上限:50 → 100 → …)。会自己发明新模型的历史文件比
3053
+ * 没有历史文件更糟,所以超限一律读作形状漂移:**不写、不宣布**,旧 map 原样
3054
+ * 保留,标记在异常期间消失,目录回到合理规模后自愈。
3055
+ *
3056
+ * ## 分段与 legacy
3057
+ *
3058
+ * 落点与 provider / draw / usage-prefs 同一套分段规则:
3059
+ * `$DSH_HOME/state/<profile>/<plugin>/roster-seen.json`(无 profile 时落共享目录)。
3060
+ * **不搬**:本文件晚于 profile 分段引入,共享目录里不存在它的旧布局可继承——
3061
+ * 与 usage-prefs-store 的「legacy 继承」相反,这里没有 legacy 缝,也不该长出一条。
3062
+ *
3063
+ * 完整性三件套与其余 store 同源(全部上收在 `state-store.ts`,本文件只做调用方,
3064
+ * 论述见 `draw-store.ts` 头注):写串行化({@link createStateWriteQueue})、写守卫
3065
+ * ({@link guardStateWrite},ADR-006)、读侧说明({@link stateVersionNote})。
3066
+ *
3067
+ * doctor 覆盖:{@link describeRosterSeenPayload} 给只读速览,`doctor.ts` 据此把
3068
+ * roster-seen.json 的「损坏 / 未知版本」报进症状表。
3069
+ *
3070
+ * @module dsh-connect-modelscope-token-plan/roster-seen-store
3071
+ */
3072
+ /** 本 store 落盘 payload 的版本。 */
3073
+ const ROSTER_SEEN_VERSION = 1;
3074
+ /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
3075
+ const KNOWN_ROSTER_SEEN_VERSIONS = Object.freeze([1]);
3076
+ /**
3077
+ * 「新」的时效窗口:首见戳落在这个窗口内就算新。
3078
+ *
3079
+ * 7 天是有意的尺度——它要盖住「用户几天没开面板、回来时发现多了两个模型」这个
3080
+ * 真实场景,又不能长到让一次批量上架在半个月后还挂着标记。窗口在这里是**常量**
3081
+ * 而不是配置:一个能被调成 365 天的「新模型」标记等于没有语义。
3082
+ */
3083
+ const ROSTER_SEEN_NEW_WINDOW_MS = 6048e5;
3084
+ /**
3085
+ * 目录规模上限。超过即视为形状漂移(不是淘汰队列,理由见模块头注)。
3086
+ *
3087
+ * 取值与姊妹插件的同类实现一致:真实目录在数百个模型以内,200 给「这一侧目录
3088
+ * 突然返回了整站列表」这类事故留出足够间距。
3089
+ */
3090
+ const ROSTER_SEEN_MAX_IDS = 200;
3091
+ const EMPTY_PAYLOAD = {
3092
+ seen: {},
3093
+ version: null
3094
+ };
3095
+ /**
3096
+ * 本 build 读不读得懂这个版本。
3097
+ *
3098
+ * 与其余 store 的 `isKnown*Version` 同形:**白名单**判据,不与
3099
+ * {@link ROSTER_SEEN_VERSION} 比较——升级后旧号仍要可读。读侧、报告侧问的都是
3100
+ * 这一个函数。
3101
+ * @param {unknown} version - 盘上读到的值(可能根本不是数字)。
3102
+ * @returns {boolean}
3103
+ */
3104
+ function isKnownRosterSeenVersion(version) {
3105
+ return typeof version === "number" && KNOWN_ROSTER_SEEN_VERSIONS.includes(version);
3106
+ }
3107
+ /**
3108
+ * 归一一张首见表:只留「id 是非空字符串、戳是有限正数」的项。
3109
+ *
3110
+ * 丢掉而不是修补坏项是有意的:一个戳为 0 或负数的项会让 `now - stamp` 变成
3111
+ * 几十年,那个 id 于是永远算「新」;宁可让它下次出现时重新盖一个真实戳。
3112
+ * @param {unknown} raw - 盘上读到的 `seen` 值。
3113
+ * @returns {RosterSeen} 归一后的表(可能是空对象)。
3114
+ */
3115
+ function normalizeRosterSeen(raw) {
3116
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
3117
+ const out = {};
3118
+ for (const [id, stamp] of Object.entries(raw)) {
3119
+ if (id === "") continue;
3120
+ if (typeof stamp !== "number" || !Number.isFinite(stamp) || stamp <= 0) continue;
3121
+ out[id] = stamp;
3122
+ }
3123
+ return out;
3124
+ }
3125
+ /** 本 store 的目录(与 provider / draw / usage-prefs 同一套分段规则)。 */
3126
+ function rosterSeenDir(profile, dir) {
3127
+ if (typeof dir === "string" && dir !== "") return dir;
3128
+ return profile ? profileStateDir(name, profile) : stateDir(name);
3129
+ }
3130
+ /**
3131
+ * 从一张首见表里挑出「还算新」的 id,按首见时间升序(最老的在前)。
3132
+ *
3133
+ * 升序是有意的:调用方只展示前几个名字(胶囊 tooltip / popover 的预算),先看到
3134
+ * 的应该是**最早**变成新的那几个——它们离过期最近,也是读者最可能已经见过、
3135
+ * 需要被告知「其实才刚出现」的那批。
3136
+ * @param {RosterSeen} seen - 归一后的首见表。
3137
+ * @param {number} nowMs - 当前时刻。
3138
+ * @returns {string[]} 窗口内的 id 列表。
3139
+ */
3140
+ function freshIdsOf(seen, nowMs) {
3141
+ return Object.entries(seen).filter(([, first]) => nowMs - first < ROSTER_SEEN_NEW_WINDOW_MS).sort((left, right) => left[1] - right[1]).map(([id]) => id);
3142
+ }
3143
+ /**
3144
+ * 文件支撑的首见表。
3145
+ * @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
3146
+ * @returns {object} store 句柄。
3147
+ */
3148
+ function createFileRosterSeenStore(options = {}) {
3149
+ const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
3150
+ const stateDir = rosterSeenDir(profile, dir);
3151
+ const filePath = join(stateDir, "roster-seen.json");
3152
+ /**
3153
+ * 写一份 payload 并落盘。
3154
+ *
3155
+ * ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
3156
+ * 返回拒绝理由(已 degrade 记录),不落笔。
3157
+ * @param {object} body - 要持久化的 JSON 体。
3158
+ * @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
3159
+ */
3160
+ const writePayload = async (body) => {
3161
+ const refusal = await guardStateWrite(filePath, KNOWN_ROSTER_SEEN_VERSIONS, {
3162
+ label: "roster-seen-state",
3163
+ logger
3164
+ });
3165
+ if (refusal !== null) return refusal;
3166
+ await ensureStateDir(stateDir);
3167
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "roster-seen.json") });
3168
+ return null;
3169
+ };
3170
+ /** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue})。 */
3171
+ const writeQueue = createStateWriteQueue();
3172
+ /**
3173
+ * 解析一份路径上的 payload;认不出的版本读作「空表」但**记住**版本号——读侧
3174
+ * 宽容、versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是
3175
+ * ADR-006)。
3176
+ */
3177
+ const parseAt = async (path) => {
3178
+ const source = obj(await readStateJson(path));
3179
+ const version = typeof source.version === "number" ? source.version : null;
3180
+ if (!isKnownRosterSeenVersion(version)) return {
3181
+ seen: {},
3182
+ version
3183
+ };
3184
+ return {
3185
+ seen: normalizeRosterSeen(source.seen),
3186
+ version
3187
+ };
3188
+ };
3189
+ const cache = createStateReadCache(() => parseAt(filePath), { ttlMs });
3190
+ /** 读当前 payload。 */
3191
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
3192
+ return {
3193
+ /**
3194
+ * 记下这一轮的完整目录,并回答「其中哪些还在新窗口内」。
3195
+ *
3196
+ * 一次调用同时 diff 与落盘(理由见模块头注)。首轮写入是**播种**:把每个 id
3197
+ * 盖成已过期的戳、返回空 `newIds`。
3198
+ * @param {readonly string[]} ids - 上游这一轮列出的**完整**目录 id。
3199
+ * @param {number} [nowMs] - 时钟(测试注入)。
3200
+ * @returns {Promise<{newIds: string[]}>} 窗口内的 id,升序;播种那轮为空。
3201
+ */
3202
+ async note(ids, nowMs = Date.now()) {
3203
+ const list = Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id !== "") : [];
3204
+ const unique = [...new Set(list)];
3205
+ if (unique.length === 0) return { newIds: [] };
3206
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
3207
+ return writeQueue.enqueue(async () => {
3208
+ const current = await parseAt(filePath);
3209
+ if (current.version !== null && !isKnownRosterSeenVersion(current.version)) return { newIds: [] };
3210
+ const previous = current.seen;
3211
+ const seeded = current.version === null;
3212
+ const next = {};
3213
+ for (const id of unique) {
3214
+ const first = previous[id];
3215
+ next[id] = typeof first === "number" && first > 0 ? Math.min(first, now) : seeded ? now - ROSTER_SEEN_NEW_WINDOW_MS : now;
3216
+ }
3217
+ if (Object.keys(next).length > 200) return { newIds: [] };
3218
+ if (seeded || Object.keys(previous).length !== Object.keys(next).length || Object.keys(next).some((id) => previous[id] !== next[id])) {
3219
+ const refusal = await writePayload({
3220
+ version: 1,
3221
+ seen: next,
3222
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3223
+ });
3224
+ if (refusal !== null) throw new Error(refusal);
3225
+ cache.remember({
3226
+ seen: next,
3227
+ version: 1
3228
+ });
3229
+ }
3230
+ return { newIds: seeded ? [] : freshIdsOf(next, now) };
3231
+ });
3232
+ },
3233
+ /**
3234
+ * 只读:此刻还算「新」的 id(升序)。
3235
+ *
3236
+ * **不写盘**:快照与 provider 路由都是读路径,写只发生在收敛器的 `note`。
3237
+ * 读路径若顺手补戳,一次面板轮询就能把「谁是新模型」这件事改写掉。
3238
+ * @param {number} [nowMs] - 时钟(测试注入)。
3239
+ * @returns {Promise<string[]>}
3240
+ */
3241
+ async fresh(nowMs = Date.now()) {
3242
+ return freshIdsOf((await readPayload()).seen, Number.isFinite(nowMs) ? nowMs : Date.now());
3243
+ },
3244
+ /** 这张表有没有写过(区分「播种」与「真的没有新模型」)。 */
3245
+ async isSet() {
3246
+ return (await readPayload()).version !== null;
3247
+ },
3248
+ /**
3249
+ * 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
3250
+ *
3251
+ * 读侧宽容(未知版本读作空表)的另一半:快照聚合把它转进 `shapeWarnings`,
3252
+ * doctor 报同一份文件。没有它,「首见表被忽略」在面板上就是「没有新模型」。
3253
+ * @returns {Promise<string|null>}
3254
+ */
3255
+ async versionNote() {
3256
+ const version = (await readPayload()).version;
3257
+ if (version === null || isKnownRosterSeenVersion(version)) return null;
3258
+ return stateVersionNote({
3259
+ label: "roster-seen-state",
3260
+ file: filePath,
3261
+ version,
3262
+ known: KNOWN_ROSTER_SEEN_VERSIONS,
3263
+ consequence: "the first-sighting history is being answered as empty, so the pill shows no new-model marker."
3264
+ });
3265
+ },
3266
+ /** 落盘路径(诊断与测试用)。 */
3267
+ filePath
3268
+ };
3269
+ }
3270
+
2984
3271
  //#endregion
2985
3272
  //#region src/host/draw.ts
2986
3273
  /**
@@ -3780,7 +4067,10 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3780
4067
  const images = [];
3781
4068
  for (const raw of rawImages) {
3782
4069
  const normalized = normalizeImageUrlInput(raw);
3783
- if (normalized === null) throw new Error(`imageUrl 不可用:${raw.slice(0, 80)} —— 只接受公网 http(s) 链接或 data:image/...;base64 内联数据(本地文件上游取不到,请先上传)`);
4070
+ if (normalized === null) {
4071
+ const safe = redactSecrets(raw).slice(0, 80);
4072
+ throw new Error(`imageUrl 不可用:${safe} —— 只接受公网 http(s) 链接或 data:image/...;base64 内联数据(本地文件上游取不到,请先上传)`);
4073
+ }
3784
4074
  images.push(normalized);
3785
4075
  }
3786
4076
  try {
@@ -4884,7 +5174,7 @@ function syncSignaturesAfterPublish(state) {
4884
5174
  * @returns {{reconcile: () => Promise<void>; health: () => {{ lastError: unknown }} }}
4885
5175
  */
4886
5176
  function createProviderReconciler(deps) {
4887
- const { settings, providerStore, inference, publisher, usageEvents, logger } = deps;
5177
+ const { settings, providerStore, inference, publisher, usageEvents, logger, rosterSeenStore } = deps;
4888
5178
  /**
4889
5179
  * 把一次 store 读取收成「值或 null」——**同步抛与异步拒收都降级**。
4890
5180
  *
@@ -4967,6 +5257,11 @@ function createProviderReconciler(deps) {
4967
5257
  return;
4968
5258
  }
4969
5259
  const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
5260
+ if (rosterSeenStore !== void 0) try {
5261
+ await rosterSeenStore.note(entries.map((entry) => String(entry?.id ?? "")));
5262
+ } catch (error) {
5263
+ logger?.warn?.(`${name}: roster first-sighting write failed (${redactSecrets(errMsg(error))}) — the new-model marker may miss this pass`);
5264
+ }
4970
5265
  const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
4971
5266
  const unavailable = await readUnavailable();
4972
5267
  try {
@@ -5080,7 +5375,7 @@ async function soft(promise) {
5080
5375
  }
5081
5376
  /** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
5082
5377
  async function buildSnapshotBody(wiring) {
5083
- const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, publisher } = wiring;
5378
+ const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, rosterSeenStore, publisher } = wiring;
5084
5379
  const panelWindow = await usagePrefsStore.windowDays().catch(() => null);
5085
5380
  const windowDays = resolveWindowDays(panelWindow, settings.trendDays, settings.retentionDays);
5086
5381
  const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
@@ -5113,6 +5408,8 @@ async function buildSnapshotBody(wiring) {
5113
5408
  if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
5114
5409
  const windowNote = await soft((async () => usagePrefsStore.versionNote())());
5115
5410
  if (windowNote.ok && windowNote.value !== null) shapeWarnings.push(windowNote.value);
5411
+ const rosterNote = await soft((async () => rosterSeenStore.versionNote())());
5412
+ if (rosterNote.ok && rosterNote.value !== null) shapeWarnings.push(rosterNote.value);
5116
5413
  if (curatedCatalogStale()) {
5117
5414
  const age = curatedCatalogAgeDays();
5118
5415
  shapeWarnings.push(`context-catalog: curated windows were last verified on ${CURATED_AS_OF}` + (age === null ? "" : ` (${age} days ago)`) + " — re-check them against the current catalogue");
@@ -5128,6 +5425,7 @@ async function buildSnapshotBody(wiring) {
5128
5425
  const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
5129
5426
  const contextWindow = await providerStore.contextWindow().catch(() => null);
5130
5427
  const catalogNote = models.ok ? null : `模型目录读不到:${models.error}`;
5428
+ const newModelIds = await rosterSeenStore.fresh().catch(() => []);
5131
5429
  return {
5132
5430
  enabled,
5133
5431
  source,
@@ -5139,6 +5437,7 @@ async function buildSnapshotBody(wiring) {
5139
5437
  allowed: allowedList.mode,
5140
5438
  enabledIds,
5141
5439
  contextWindow,
5440
+ newModelIds,
5142
5441
  roster
5143
5442
  };
5144
5443
  })());
@@ -5374,7 +5673,8 @@ function withOrigin(handler, allowedHosts) {
5374
5673
  */
5375
5674
  /**
5376
5675
  * 注册快照路由。wiring 子集:settings / configError / tokenStore /
5377
- * usageStore / inference / providerStore / publisher / logger。
5676
+ * usageStore / inference / providerStore / drawStore / usagePrefsStore /
5677
+ * rosterSeenStore / publisher / logger。
5378
5678
  * @returns {Function} off() 注销回调。
5379
5679
  */
5380
5680
  function registerSnapshotRoute(ctx, wiring) {
@@ -5663,7 +5963,7 @@ async function readEnabledIds(store, published) {
5663
5963
  * @returns {Function} 依次调用三个 `off()` 的注销回调。
5664
5964
  */
5665
5965
  function registerProviderRoute(ctx, wiring) {
5666
- const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
5966
+ const { settings, providerStore, publisher, reconcile, inference, rosterSeenStore, logger } = wiring;
5667
5967
  /**
5668
5968
  * 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
5669
5969
  * 状态是什么」说两套话。
@@ -5691,6 +5991,7 @@ function registerProviderRoute(ctx, wiring) {
5691
5991
  const roster = rosterWithAvailability(entries, publisher.state.unavailableIds ?? [], settings.maxTokensOverrides);
5692
5992
  const allowedList = parseAllowedList(enabledIds);
5693
5993
  const modelCount = roster.length;
5994
+ const newModelIds = rosterSeenStore === void 0 ? [] : await optional(rosterSeenStore.fresh(), []);
5694
5995
  return {
5695
5996
  ok: true,
5696
5997
  enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
@@ -5703,6 +6004,7 @@ function registerProviderRoute(ctx, wiring) {
5703
6004
  allowed: allowedList.mode,
5704
6005
  enabledIds,
5705
6006
  contextWindow,
6007
+ newModelIds,
5706
6008
  roster,
5707
6009
  ...catalogError !== null ? { catalogError } : {},
5708
6010
  ...extra
@@ -5848,7 +6150,8 @@ function registerProviderRoute(ctx, wiring) {
5848
6150
  *
5849
6151
  * 一处**与 provider 路由不同**的地方必须说清:provider 开关翻转会**立即**重注册
5850
6152
  * (publish 是活的重建),而出图工具的开关翻转**要等下次 Host 启动**——peer 的
5851
- * tools registry 没有 unregister,工具一旦注册就摘不下来。所以本路由保存成功后
6153
+ * tools registry 没有 unregister,工具一旦注册就摘不下来(完整论述见
6154
+ * draw-register.ts 文件头注)。所以本路由保存成功后
5852
6155
  * 不改挂载状态,只在响应里给出 `mountPending`,让面板能说清这件事,而不是让用户
5853
6156
  * 以为开关坏了。
5854
6157
  *
@@ -6235,6 +6538,10 @@ function apply(ctx, config = {}, deps = {}) {
6235
6538
  profile,
6236
6539
  logger: ctx.logger
6237
6540
  });
6541
+ const rosterSeenStore = createFileRosterSeenStore({
6542
+ profile,
6543
+ logger: ctx.logger
6544
+ });
6238
6545
  const publisher = createProviderPublisher({
6239
6546
  settings,
6240
6547
  panelSwitch: () => providerStore.enabled(),
@@ -6257,6 +6564,7 @@ function apply(ctx, config = {}, deps = {}) {
6257
6564
  inference,
6258
6565
  publisher,
6259
6566
  usageEvents: () => usageStore.events(),
6567
+ rosterSeenStore,
6260
6568
  logger: ctx.logger
6261
6569
  });
6262
6570
  const wiring = {
@@ -6269,6 +6577,7 @@ function apply(ctx, config = {}, deps = {}) {
6269
6577
  drawStore,
6270
6578
  drawJobs,
6271
6579
  usagePrefsStore,
6580
+ rosterSeenStore,
6272
6581
  publisher,
6273
6582
  reconcile,
6274
6583
  logger: ctx.logger
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-modelscope-token-plan",
3
3
  "displayName": "DSH Connect ModelScope Token Plan",
4
4
  "icon": "./icon.svg",
5
- "version": "0.6.4",
5
+ "version": "0.6.6",
6
6
  "homepage": "https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan",
7
7
  "bugs": {
8
8
  "url": "https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/issues"
@@ -35,6 +35,7 @@
35
35
  "README.md",
36
36
  "CHANGELOG.md",
37
37
  "LICENSE",
38
+ "THIRD_PARTY_NOTICES.md",
38
39
  "screenshots.json"
39
40
  ],
40
41
  "scripts": {
@@ -45,6 +46,11 @@
45
46
  "test:mutate": "node tools/dev/mutate.mjs tools/dev/mutate-spec.json",
46
47
  "doctor": "node tools/doctor.mjs",
47
48
  "doctor:json": "node tools/doctor.mjs --json",
49
+ "host-range:update": "node tools/update-host-range.mjs",
50
+ "host-range:check": "node tools/update-host-range.mjs --check",
51
+ "market-rules:update": "node tools/check-market-rules.mjs",
52
+ "market-rules:check": "node tools/check-market-rules.mjs --check",
53
+ "context-catalog:check": "node tools/check-context-catalog.mjs",
48
54
  "build": "tsdown -c tsdown.config.mjs",
49
55
  "prepack": "npm run build"
50
56
  },
@@ -74,22 +80,48 @@
74
80
  "@deepseek-ai/dsh-settings": ">=0.1.5 <0.3",
75
81
  "@deepseek-ai/dsh-tools": ">=0.1.5 <0.3",
76
82
  "@deepseek-ai/schemastery": "^3.18.2",
77
- "@earendil-works/pi-ai": "^0.85.1"
83
+ "@earendil-works/pi-ai": ">=0.85.0 <2.0.0"
78
84
  },
79
85
  "peerDependenciesMeta": {
80
- "@deepseek-ai/cordis": { "optional": true },
81
- "@deepseek-ai/dsh-client-locale": { "optional": true },
82
- "@deepseek-ai/dsh-client-ui-layout": { "optional": true },
83
- "@deepseek-ai/dsh-client-ui-renderer": { "optional": true },
84
- "@deepseek-ai/dsh-credentials": { "optional": true },
85
- "@deepseek-ai/dsh-home-paths": { "optional": true },
86
- "@deepseek-ai/dsh-host-webserver": { "optional": true },
87
- "@deepseek-ai/dsh-llm": { "optional": true },
88
- "@deepseek-ai/dsh-llm-pi-ai": { "optional": true },
89
- "@deepseek-ai/dsh-settings": { "optional": true },
90
- "@deepseek-ai/dsh-tools": { "optional": true },
91
- "@deepseek-ai/schemastery": { "optional": true },
92
- "@earendil-works/pi-ai": { "optional": true }
86
+ "@deepseek-ai/cordis": {
87
+ "optional": true
88
+ },
89
+ "@deepseek-ai/dsh-client-locale": {
90
+ "optional": true
91
+ },
92
+ "@deepseek-ai/dsh-client-ui-layout": {
93
+ "optional": true
94
+ },
95
+ "@deepseek-ai/dsh-client-ui-renderer": {
96
+ "optional": true
97
+ },
98
+ "@deepseek-ai/dsh-credentials": {
99
+ "optional": true
100
+ },
101
+ "@deepseek-ai/dsh-home-paths": {
102
+ "optional": true
103
+ },
104
+ "@deepseek-ai/dsh-host-webserver": {
105
+ "optional": true
106
+ },
107
+ "@deepseek-ai/dsh-llm": {
108
+ "optional": true
109
+ },
110
+ "@deepseek-ai/dsh-llm-pi-ai": {
111
+ "optional": true
112
+ },
113
+ "@deepseek-ai/dsh-settings": {
114
+ "optional": true
115
+ },
116
+ "@deepseek-ai/dsh-tools": {
117
+ "optional": true
118
+ },
119
+ "@deepseek-ai/schemastery": {
120
+ "optional": true
121
+ },
122
+ "@earendil-works/pi-ai": {
123
+ "optional": true
124
+ }
93
125
  },
94
126
  "engines": {
95
127
  "node": "^22.19.0 || >=24.0.0"
@@ -101,6 +133,7 @@
101
133
  "license": "MIT",
102
134
  "devDependencies": {
103
135
  "@types/node": "^22.19.0",
136
+ "js-yaml": "^5.3.0",
104
137
  "jscpd": "^5.4.0",
105
138
  "jsdom": "^30.1.3",
106
139
  "react": "18.3.1",