dsh-connect-modelscope-token-plan 0.6.5 → 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,28 @@
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
+
29
51
  ## [0.6.5] — 2026-10-21
30
52
 
31
53
  **出图:被拒的图片地址不再原样回显**
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({
@@ -3013,15 +3095,109 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3013
3095
  return parts.filter((part) => part !== null).join(" · ");
3014
3096
  }
3015
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
+ /**
3016
3181
  * 内层:只在会话选中本插件的模型时挂载。轮询 hook 在这里,被挂/摘即起停——
3017
3182
  * 非归属 provider 的会话不发任何请求。
3183
+ *
3184
+ * 展开状态也住在这里(而不是外层门控):外层每帧都要调 `useProjection` 才能
3185
+ * 决定挂谁,把 `useState` 加到外层会让「非归属 → 归属」这个切换改变 hook 顺序
3186
+ * (外层先 return null 再挂内层,Rules of Hooks 会炸)。内层被挂/摘时它自己的
3187
+ * hook 序是完整的,所以 open 放这里安全。
3018
3188
  */
3019
3189
  function ModelscopePill(props) {
3020
3190
  const { tt } = props;
3021
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);
3022
3197
  const balance = data?.balance ?? null;
3023
3198
  const available = availableOf(balance);
3024
3199
  const usedToday = usedTodayOf(data);
3200
+ const newModels = newModelsOf(data);
3025
3201
  const cadenceMs = statedCadenceMs(data?.pollSeconds, DEFAULT_CADENCE_MS);
3026
3202
  const stale = staleOf({
3027
3203
  error,
@@ -3038,11 +3214,15 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3038
3214
  usedToday,
3039
3215
  stale
3040
3216
  });
3041
- return h("button", {
3217
+ const trigger = h("button", {
3042
3218
  type: "button",
3043
3219
  className: "dsh-modelscope-composer-pill",
3044
3220
  title,
3045
3221
  "aria-label": title,
3222
+ "aria-expanded": open,
3223
+ "aria-haspopup": "dialog",
3224
+ "aria-controls": open ? POPOVER_ID : void 0,
3225
+ onClick: toggle,
3046
3226
  style: S.pillTrigger
3047
3227
  }, h("span", {
3048
3228
  className: "dsh-modelscope-composer-pill-dot",
@@ -3060,7 +3240,24 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3060
3240
  }, pending ? "…" : readingText(available, usedToday, tt)), stale ? h("span", {
3061
3241
  className: "dsh-modelscope-composer-pill-stale",
3062
3242
  style: S.pillStale
3063
- }, ` · ${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);
3064
3261
  }
3065
3262
  /**
3066
3263
  * 外层门控:固定一处调 `useProjection`,再决定内层挂谁。非归属 provider 返回
@@ -3071,7 +3268,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3071
3268
  if (selectedProviderOf(selection) !== "modelscope-token-plan") return null;
3072
3269
  return h(ModelscopePill, { tt: props.tt });
3073
3270
  }
3074
- var MODELSCOPE_PROVIDER, DEFAULT_CADENCE_MS, STALE_CADENCES, TIER_INK;
3271
+ var MODELSCOPE_PROVIDER, DEFAULT_CADENCE_MS, STALE_CADENCES, TIER_INK, POPOVER_ID;
3075
3272
  var init_composer_usage_pill = __esmMin((() => {
3076
3273
  init_runtime();
3077
3274
  init_styles();
@@ -3085,6 +3282,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3085
3282
  exhausted: S.pillTierExhausted,
3086
3283
  unknown: S.pillTierUnknown
3087
3284
  };
3285
+ POPOVER_ID = "dsh-modelscope-composer-pill-popover";
3088
3286
  }));
3089
3287
 
3090
3288
  //#endregion
@@ -3208,7 +3406,8 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3208
3406
  balanceText,
3209
3407
  callsText,
3210
3408
  readingText,
3211
- staleOf
3409
+ staleOf,
3410
+ newModelsOf
3212
3411
  }),
3213
3412
  components: Object.freeze({
3214
3413
  BalanceCard,
@@ -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.5";
1144
+ const PLUGIN_VERSION = "0.6.6";
1145
1145
  /**
1146
1146
  * 默认值。刻意不含额度常数——理由见本文件头。
1147
1147
  * 面板头条是官方魔粒余额,次数口径的推算已在面板收敛时删除,这里若留着
package/lib/index.js CHANGED
@@ -1540,8 +1540,8 @@ function createCoalescedFetch(options = {}) {
1540
1540
  * 最大值:个别网关把窗口抄大(如 openrouter 抄 1M)不代表魔搭侧真开那么宽。
1541
1541
  *
1542
1542
  * 维护纪律:
1543
- * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-16 实拉 `/v1/models` 全集
1544
- * 37 个中筛出 19 个);目录下架的条目应随手删除,表不是历史档案。
1543
+ * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-23 实拉 `/v1/models` 全集
1544
+ * 37 个中筛出 21 个);目录下架的条目应随手删除,表不是历史档案。
1545
1545
  * 2. 每条注明来源 host 与核对日期;有多个数据源时写主流值并留离群说明。
1546
1546
  * 3. 覆盖优先级由 `curatedWindowOf` 的调用方(inference-client 打标)保证:
1547
1547
  * 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
@@ -1555,14 +1555,14 @@ function createCoalescedFetch(options = {}) {
1555
1555
  * 而使用者无从判断一个值是三个月前还是三年前核的。快照侧据此在表过期时给一条
1556
1556
  * shapeWarning(见 {@link curatedCatalogStale})。
1557
1557
  */
1558
- const CURATED_AS_OF = "2026-10-16";
1558
+ const CURATED_AS_OF = "2026-10-23";
1559
1559
  /**
1560
1560
  * 策展表距上次核对已经过去多少天;日期解析不了返回 `null`。
1561
1561
  * @param {Date} [now] - 时钟源(测试注入)。
1562
1562
  * @returns {number|null} 天数(向下取整),或 null。
1563
1563
  */
1564
1564
  function curatedCatalogAgeDays(now = /* @__PURE__ */ new Date()) {
1565
- const asOf = Date.parse("2026-10-16T00:00:00Z");
1565
+ const asOf = Date.parse("2026-10-23T00:00:00Z");
1566
1566
  if (Number.isNaN(asOf)) return null;
1567
1567
  const delta = now.getTime() - asOf;
1568
1568
  return delta < 0 ? 0 : Math.floor(delta / 864e5);
@@ -1586,6 +1586,7 @@ const CURATED_CONTEXT_WINDOWS = {
1586
1586
  "deepseek-ai/DeepSeek-V4-Pro": 1048576,
1587
1587
  "deepseek-ai/DeepSeek-V4-Pro-0813": 1048576,
1588
1588
  "deepseek-ai/DeepSeek-V4.1-Flash": 1048576,
1589
+ "deepseek-ai/DeepSeek-V4.1-Flash-1017": 1048576,
1589
1590
  "meituan-longcat/LongCat-Flash-Lite": 256e3,
1590
1591
  "MiniMax/MiniMax-M1-80k": 1e6,
1591
1592
  "MiniMax/MiniMax-M3": 1048576,
@@ -1599,6 +1600,7 @@ const CURATED_CONTEXT_WINDOWS = {
1599
1600
  "Qwen/Qwen3.8-Flash-Next": 262144,
1600
1601
  "stepfun-ai/Step-3.5-Flash": 262144,
1601
1602
  "stepfun-ai/Step-3.7-Flash": 262144,
1603
+ "stepfun-ai/Step-5-Preview": 1e6,
1602
1604
  "ZhipuAI/GLM-4.7-Flash": 2e5,
1603
1605
  "ZhipuAI/GLM-5.2": 1e6
1604
1606
  };
@@ -2026,7 +2028,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
2026
2028
  * collapses here, so the three call sites that produce it stay in step instead
2027
2029
  * of each spelling out the same three `null`s.
2028
2030
  */
2029
- const EMPTY_PAYLOAD$2 = {
2031
+ const EMPTY_PAYLOAD$3 = {
2030
2032
  enabled: null,
2031
2033
  enabledIds: null,
2032
2034
  contextWindow: null,
@@ -2255,7 +2257,7 @@ function createFileProviderStore(options = {}) {
2255
2257
  * `null` branch collapses here, in one place, rather than at every caller.
2256
2258
  * @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
2257
2259
  */
2258
- const readPayload = async () => await read() ?? EMPTY_PAYLOAD$2;
2260
+ const readPayload = async () => await read() ?? EMPTY_PAYLOAD$3;
2259
2261
  /**
2260
2262
  * Read one payload, then hand it to {@link writePayload} — the read-modify-
2261
2263
  * write that keeps the switch and the list from clobbering each other.
@@ -2479,7 +2481,7 @@ function createFileProviderStore(options = {}) {
2479
2481
  const DRAW_STORE_VERSION = 1;
2480
2482
  /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2481
2483
  const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
2482
- const EMPTY_PAYLOAD$1 = {
2484
+ const EMPTY_PAYLOAD$2 = {
2483
2485
  enabled: null,
2484
2486
  modelId: null,
2485
2487
  version: null
@@ -2578,7 +2580,7 @@ function createFileDrawStore(options = {}) {
2578
2580
  * 读当前 payload。
2579
2581
  * @returns {Promise<DrawPayload>}
2580
2582
  */
2581
- const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
2583
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$2;
2582
2584
  /**
2583
2585
  * 读-改-写一份补丁,**整段排队**。
2584
2586
  *
@@ -2836,7 +2838,7 @@ function switchSource(panel) {
2836
2838
  const USAGE_PREFS_VERSION = 1;
2837
2839
  /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2838
2840
  const KNOWN_USAGE_PREFS_VERSIONS = Object.freeze([1]);
2839
- const EMPTY_PAYLOAD = {
2841
+ const EMPTY_PAYLOAD$1 = {
2840
2842
  windowDays: null,
2841
2843
  version: null
2842
2844
  };
@@ -2955,7 +2957,7 @@ function createFileUsagePrefsStore(options = {}) {
2955
2957
  * 读当前 payload。
2956
2958
  * @returns {Promise<UsagePrefsPayload>}
2957
2959
  */
2958
- const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
2960
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
2959
2961
  /**
2960
2962
  * 读-改-写,**整段排队**。
2961
2963
  *
@@ -3014,6 +3016,258 @@ function createFileUsagePrefsStore(options = {}) {
3014
3016
  };
3015
3017
  }
3016
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
+
3017
3271
  //#endregion
3018
3272
  //#region src/host/draw.ts
3019
3273
  /**
@@ -4920,7 +5174,7 @@ function syncSignaturesAfterPublish(state) {
4920
5174
  * @returns {{reconcile: () => Promise<void>; health: () => {{ lastError: unknown }} }}
4921
5175
  */
4922
5176
  function createProviderReconciler(deps) {
4923
- const { settings, providerStore, inference, publisher, usageEvents, logger } = deps;
5177
+ const { settings, providerStore, inference, publisher, usageEvents, logger, rosterSeenStore } = deps;
4924
5178
  /**
4925
5179
  * 把一次 store 读取收成「值或 null」——**同步抛与异步拒收都降级**。
4926
5180
  *
@@ -5003,6 +5257,11 @@ function createProviderReconciler(deps) {
5003
5257
  return;
5004
5258
  }
5005
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
+ }
5006
5265
  const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
5007
5266
  const unavailable = await readUnavailable();
5008
5267
  try {
@@ -5116,7 +5375,7 @@ async function soft(promise) {
5116
5375
  }
5117
5376
  /** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
5118
5377
  async function buildSnapshotBody(wiring) {
5119
- const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, publisher } = wiring;
5378
+ const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, rosterSeenStore, publisher } = wiring;
5120
5379
  const panelWindow = await usagePrefsStore.windowDays().catch(() => null);
5121
5380
  const windowDays = resolveWindowDays(panelWindow, settings.trendDays, settings.retentionDays);
5122
5381
  const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
@@ -5149,6 +5408,8 @@ async function buildSnapshotBody(wiring) {
5149
5408
  if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
5150
5409
  const windowNote = await soft((async () => usagePrefsStore.versionNote())());
5151
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);
5152
5413
  if (curatedCatalogStale()) {
5153
5414
  const age = curatedCatalogAgeDays();
5154
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");
@@ -5164,6 +5425,7 @@ async function buildSnapshotBody(wiring) {
5164
5425
  const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
5165
5426
  const contextWindow = await providerStore.contextWindow().catch(() => null);
5166
5427
  const catalogNote = models.ok ? null : `模型目录读不到:${models.error}`;
5428
+ const newModelIds = await rosterSeenStore.fresh().catch(() => []);
5167
5429
  return {
5168
5430
  enabled,
5169
5431
  source,
@@ -5175,6 +5437,7 @@ async function buildSnapshotBody(wiring) {
5175
5437
  allowed: allowedList.mode,
5176
5438
  enabledIds,
5177
5439
  contextWindow,
5440
+ newModelIds,
5178
5441
  roster
5179
5442
  };
5180
5443
  })());
@@ -5410,7 +5673,8 @@ function withOrigin(handler, allowedHosts) {
5410
5673
  */
5411
5674
  /**
5412
5675
  * 注册快照路由。wiring 子集:settings / configError / tokenStore /
5413
- * usageStore / inference / providerStore / publisher / logger。
5676
+ * usageStore / inference / providerStore / drawStore / usagePrefsStore /
5677
+ * rosterSeenStore / publisher / logger。
5414
5678
  * @returns {Function} off() 注销回调。
5415
5679
  */
5416
5680
  function registerSnapshotRoute(ctx, wiring) {
@@ -5699,7 +5963,7 @@ async function readEnabledIds(store, published) {
5699
5963
  * @returns {Function} 依次调用三个 `off()` 的注销回调。
5700
5964
  */
5701
5965
  function registerProviderRoute(ctx, wiring) {
5702
- const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
5966
+ const { settings, providerStore, publisher, reconcile, inference, rosterSeenStore, logger } = wiring;
5703
5967
  /**
5704
5968
  * 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
5705
5969
  * 状态是什么」说两套话。
@@ -5727,6 +5991,7 @@ function registerProviderRoute(ctx, wiring) {
5727
5991
  const roster = rosterWithAvailability(entries, publisher.state.unavailableIds ?? [], settings.maxTokensOverrides);
5728
5992
  const allowedList = parseAllowedList(enabledIds);
5729
5993
  const modelCount = roster.length;
5994
+ const newModelIds = rosterSeenStore === void 0 ? [] : await optional(rosterSeenStore.fresh(), []);
5730
5995
  return {
5731
5996
  ok: true,
5732
5997
  enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
@@ -5739,6 +6004,7 @@ function registerProviderRoute(ctx, wiring) {
5739
6004
  allowed: allowedList.mode,
5740
6005
  enabledIds,
5741
6006
  contextWindow,
6007
+ newModelIds,
5742
6008
  roster,
5743
6009
  ...catalogError !== null ? { catalogError } : {},
5744
6010
  ...extra
@@ -6272,6 +6538,10 @@ function apply(ctx, config = {}, deps = {}) {
6272
6538
  profile,
6273
6539
  logger: ctx.logger
6274
6540
  });
6541
+ const rosterSeenStore = createFileRosterSeenStore({
6542
+ profile,
6543
+ logger: ctx.logger
6544
+ });
6275
6545
  const publisher = createProviderPublisher({
6276
6546
  settings,
6277
6547
  panelSwitch: () => providerStore.enabled(),
@@ -6294,6 +6564,7 @@ function apply(ctx, config = {}, deps = {}) {
6294
6564
  inference,
6295
6565
  publisher,
6296
6566
  usageEvents: () => usageStore.events(),
6567
+ rosterSeenStore,
6297
6568
  logger: ctx.logger
6298
6569
  });
6299
6570
  const wiring = {
@@ -6306,6 +6577,7 @@ function apply(ctx, config = {}, deps = {}) {
6306
6577
  drawStore,
6307
6578
  drawJobs,
6308
6579
  usagePrefsStore,
6580
+ rosterSeenStore,
6309
6581
  publisher,
6310
6582
  reconcile,
6311
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.5",
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": {
@@ -49,6 +50,7 @@
49
50
  "host-range:check": "node tools/update-host-range.mjs --check",
50
51
  "market-rules:update": "node tools/check-market-rules.mjs",
51
52
  "market-rules:check": "node tools/check-market-rules.mjs --check",
53
+ "context-catalog:check": "node tools/check-context-catalog.mjs",
52
54
  "build": "tsdown -c tsdown.config.mjs",
53
55
  "prepack": "npm run build"
54
56
  },