dsh-connect-modelscope-token-plan 0.6.2 → 0.6.3

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,52 @@
26
26
  > **发版时**:顶部开 `## [X.Y.Z] — YYYY-MM-DD`,完整流程见
27
27
  > [RELEASING.md](RELEASING.md) §3(本文开头这段是**写作者规范**,那是**发布操作流程**)。
28
28
 
29
+ ## [0.6.3] — 2026-10-19
30
+
31
+ **插件卡头部收敛:「魔搭模型接入」不再在同一屏出现两次**
32
+
33
+ - 卡内那个与外壳标题同名的大标题删掉了。插件名只由外壳在插件区正上方打印(取自
34
+ `locale/<lang>.json` 的 `meta.title`),卡内复述它会让同一个字符串在一屏出现两次、
35
+ 中间隔着外壳给所有插件的 32px 固定间隔,读起来像一个断成两半的表头。
36
+ - 原先独占一行、左半边空着的「更新于 / 刷新」并入 tab 行右侧;tab 条下方那条分隔线
37
+ 改由整行承担,激活 tab 的 2px 下划线正好压在线上。刷新按钮与「更新于」留在
38
+ `role="tablist"` 之外——读屏不会再把它们当成 tab 念。
39
+ - 卡内自带的 16/56 纵向内边距一并归零:它们叠加在外壳已给的 32px 段间距与 48px
40
+ 页底之上,使描述到 tab 之间空了 48px、页尾空了 104px。横向留白本来就由外壳负责,
41
+ 卡内一直是 0,本次不动。
42
+
43
+ **出图工具:失败文案不再替上游编原因,「停止」立即生效**
44
+
45
+ - 出图报 401/403 时不再一律附注「账号未完成邮箱验证/实名」:只有上游 body 点名了邮箱或
46
+ 实名(含英文 `email verification`)才照说,否则只说「令牌无效或无权访问该模型」。同一个
47
+ 401 也覆盖令牌过期,把实测遇到过一次的形态写成固定附注,等于替上游编造原因。
48
+ - 轮询遇到非 2xx 不再报「任务停在 RUNNING(非终态),已超过截止时间」:上一轮的读数
49
+ 只是上下文,本轮没读到就是没读到,HTTP 状态码与上游 body 照实带出。
50
+ - 等待中被撤下(插件卸载 / 热重挂)立即以 `pending` 收场并交回 `jobId`,不再等满等待
51
+ 预算;停止也能中止**在途的轮询请求**,不再只是中断本地 sleep。
52
+ - 出图作业表改由装配处统一创建与销毁:卸载后不再残留作业记录,`jobId` 续查读的也
53
+ 始终是同一张表。`jobId` 只在本次挂载内有效,pending 提示现在把这点写明(重启 DSH
54
+ 或重挂插件后要重新提交)。一次出图调用的硬超时不再可能比 `drawTimeoutMs` 上限多出 10 秒。
55
+ - 上游任务 FAILED 且响应体里**真写了**原因时照实报出(此前一律说「未给出失败原因」,
56
+ 把 `sensitive content detected` 这类 body 一起吞掉),且失败响应体一律附上——这是
57
+ 全函数唯一不附 body 的分支。
58
+
59
+ **面板与胶囊:读不到的时候不再假装知道**
60
+
61
+ - 面板读失败时错误行照常显示,不再要求「上一轮的数据还在」;数据还在时另起一句
62
+ 「下面是上次读到的数据」。此前首屏成功过、Host 随后挂掉时,界面是「上一轮的数字
63
+ + 完好的状态簇 + 一句沉默」:故障窗口越长,越像一份可信的实时读数——胶囊在同一
64
+ 情形下是挂「上次数据」标的,同一种「读不到」两种待遇。
65
+ - 模型目录读不到时 provider 卡现在说明原因。此前是「已注册 · 0 个模型」加一片空白:
66
+ 0 个模型是真的,而「为什么是 0」没人说,旧 roster 其实还在服务。
67
+ - 对话框胶囊只在**本帧真有读数**时才标「上次数据」。连续读失败时两个读数是「—」,
68
+ 此前仍给「—」挂这个标签——它在描述一份屏幕上并不存在的数据。
69
+ - 粘贴带前后空白的令牌不再存成一个发不出去的 `Authorization` 头(此前校验时忽略空白、
70
+ 落库时保留原值,面板还显示「已保存」)。
71
+
72
+ > **实施过程(病灶取证、判据、为何不抵消外壳间距)在
73
+ > [docs/archive/2026-10-19-0.6.3-process.md](docs/archive/2026-10-19-0.6.3-process.md)**。
74
+
29
75
  ## [0.6.2] — 2026-10-18
30
76
 
31
77
  **插件命名与卡片文案以「模型接入」为主题重排**
package/client.js CHANGED
@@ -29,7 +29,6 @@ var dsh_connect_modelscope_token_plan_client = (function() {
29
29
  "events",
30
30
  "trend",
31
31
  "models",
32
- "provider",
33
32
  "shapeWarnings",
34
33
  "quotaError"
35
34
  ]);
@@ -136,11 +135,11 @@ var dsh_connect_modelscope_token_plan_client = (function() {
136
135
  var zh, en;
137
136
  var init_i18n = __esmMin((() => {
138
137
  zh = {
139
- "panel.title": "魔搭模型接入",
140
138
  "panel.refresh": "刷新",
141
139
  "panel.updated": "更新于 {time}",
142
140
  "panel.loading": "加载中…",
143
141
  "panel.error": "读取失败:{error}",
142
+ "panel.staleData": "(下面是上次读到的数据)",
144
143
  "panel.configError": "插件配置有误:{error}",
145
144
  "panel.networkError": "无法连接本机 Host,通常是临时故障,下一轮自动刷新即可恢复。",
146
145
  "panel.timeout": "请求超时,下一轮自动刷新即可恢复。",
@@ -288,11 +287,11 @@ var dsh_connect_modelscope_token_plan_client = (function() {
288
287
  "composer.stale": "上次数据"
289
288
  };
290
289
  en = {
291
- "panel.title": "ModelScope Model Access",
292
290
  "panel.refresh": "Refresh",
293
291
  "panel.updated": "Updated {time}",
294
292
  "panel.loading": "Loading…",
295
293
  "panel.error": "Read failed: {error}",
294
+ "panel.staleData": "(showing the last data read successfully)",
296
295
  "panel.configError": "Plugin misconfiguration: {error}",
297
296
  "panel.networkError": "Cannot reach the local Host — usually transient, the next auto-refresh will recover.",
298
297
  "panel.timeout": "Request timed out; the next auto-refresh will recover.",
@@ -573,31 +572,24 @@ var dsh_connect_modelscope_token_plan_client = (function() {
573
572
  fontSize: 14,
574
573
  lineHeight: "22px"
575
574
  },
576
- headerBar: {
577
- flex: "none",
578
- background: "var(--dsw-alias-bg-base)",
579
- position: "relative",
580
- zIndex: 1
581
- },
582
- header: {
583
- display: "flex",
584
- alignItems: "center",
585
- gap: 12,
586
- padding: "16px 0 12px"
587
- },
588
575
  scroll: {
589
576
  flex: 1,
590
577
  minHeight: 0,
591
578
  overflowY: "auto",
592
579
  overflowX: "hidden"
593
580
  },
594
- content: { padding: "6px 0 56px" },
595
- tabBar: {
581
+ content: { padding: 0 },
582
+ tabRow: {
596
583
  display: "flex",
597
- gap: 4,
584
+ alignItems: "stretch",
585
+ gap: 12,
598
586
  borderBottom: "1px solid var(--dsw-alias-border-l1)",
599
587
  marginBottom: 4
600
588
  },
589
+ tabBar: {
590
+ display: "flex",
591
+ gap: 4
592
+ },
601
593
  tab: {
602
594
  appearance: "none",
603
595
  background: "none",
@@ -607,19 +599,15 @@ var dsh_connect_modelscope_token_plan_client = (function() {
607
599
  fontSize: 13,
608
600
  color: "var(--dsw-alias-label-secondary)",
609
601
  cursor: "pointer",
610
- outlineOffset: -2
602
+ outlineOffset: -2,
603
+ display: "flex",
604
+ alignItems: "center"
611
605
  },
612
606
  tabActive: {
613
607
  color: "var(--dsw-alias-label-primary)",
614
608
  fontWeight: 600,
615
609
  borderBottom: `2px solid ${BRAND}`
616
610
  },
617
- title: {
618
- margin: 0,
619
- fontSize: 20,
620
- fontWeight: 600,
621
- lineHeight: "28px"
622
- },
623
611
  updated: {
624
612
  color: "var(--dsw-alias-label-secondary)",
625
613
  fontSize: 12
@@ -630,7 +618,8 @@ var dsh_connect_modelscope_token_plan_client = (function() {
630
618
  alignItems: "center",
631
619
  gap: 12,
632
620
  flexWrap: "wrap",
633
- justifyContent: "flex-end"
621
+ justifyContent: "flex-end",
622
+ alignSelf: "center"
634
623
  },
635
624
  button: BUTTON,
636
625
  sectionTitle: {
@@ -2852,15 +2841,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
2852
2841
  return h("div", {
2853
2842
  style: S.page,
2854
2843
  "data-dsh-plugin": PANEL_ID
2855
- }, h("div", { style: S.headerBar }, h("div", { style: S.header }, h("h1", { style: S.title }, tt("panel.title")), h("span", { style: S.spacer }), h("span", { style: S.cluster }, data !== null ? h("span", { style: S.updated }, format(tt("panel.updated"), { time: isoTime(new Date(updatedAt).toISOString()) })) : null, failure !== null && data !== null ? h("span", {
2856
- style: S.error,
2857
- role: "status",
2858
- title: failure.message
2859
- }, format(tt("panel.error"), { error: failure.message })) : null, h("button", {
2860
- type: "button",
2861
- style: S.button,
2862
- onClick: () => void load()
2863
- }, tt("panel.refresh"))))), h("div", { style: S.scroll }, h("div", { style: S.content }, h("div", null, h("div", {
2844
+ }, h("div", { style: S.scroll }, h("div", { style: S.content }, h("div", null, h("div", { style: S.tabRow }, h("div", {
2864
2845
  style: S.tabBar,
2865
2846
  role: "tablist"
2866
2847
  }, h("button", {
@@ -2890,7 +2871,15 @@ var dsh_connect_modelscope_token_plan_client = (function() {
2890
2871
  ...activeTab === "access" ? S.tabActive : {}
2891
2872
  },
2892
2873
  onClick: () => setActiveTab("access")
2893
- }, tt("tab.access"))), data !== null && shapeWarnings.length > 0 ? h("div", {
2874
+ }, tt("tab.access"))), h("span", { style: S.spacer }), h("span", { style: S.cluster }, data !== null ? h("span", { style: S.updated }, format(tt("panel.updated"), { time: isoTime(new Date(updatedAt).toISOString()) })) : null, failure !== null ? h("span", {
2875
+ style: S.error,
2876
+ role: "status",
2877
+ title: failure.message
2878
+ }, format(tt("panel.error"), { error: failure.message })) : null, failure !== null && data !== null ? h("span", { style: S.updated }, tt("panel.staleData")) : null, h("button", {
2879
+ type: "button",
2880
+ style: S.button,
2881
+ onClick: () => void load()
2882
+ }, tt("panel.refresh")))), data !== null && shapeWarnings.length > 0 ? h("div", {
2894
2883
  style: S.formError,
2895
2884
  role: "status"
2896
2885
  }, format(tt("panel.shapeDrift"), { detail: shapeWarnings.join("; ") })) : null, activeTab === "quota" ? quotaBody() : activeTab === "models" ? modelsBody() : accessBody()))));
@@ -2978,10 +2967,17 @@ var dsh_connect_modelscope_token_plan_client = (function() {
2978
2967
  * ——失败退避或页面被挂起都会这样。取 3 个声明周期而不是 1 个:一次偶发失败就会
2979
2968
  * 拉长间隔,卡在 1 个周期上会让每一次抖动都把读数打成陈旧的。
2980
2969
  *
2981
- * `updatedAt === 0` 表示**从没读到过**,那不是「陈旧」而是「还没有」——调用方据此
2982
- * 决定不标;本函数只在有读数的前提下判断,故返回 false。
2970
+ * **`hasReading` 是承重输入**:`updatedAt > 0` 只说明**曾经**读到过,不说明屏幕上
2971
+ * 此刻有数字——读失败时轮询 hook 会 `setData(null)` 却保留 `updatedAt`
2972
+ * (`use-snapshot-polling.ts`),只看 updatedAt 就会在两个读数都是「—」时挂上
2973
+ * 「上次数据」,描述一份不在屏幕上的数据(2026-10-19 锐评实测)。调用方必须把
2974
+ * 「本帧有没有读数」传进来。
2975
+ *
2976
+ * `hasReading === false` 表示**从没读到过 / 这一帧没有读数**,那不是「陈旧」而是
2977
+ * 「还没有」——调用方据此不标;本函数据此返回 false。
2983
2978
  */
2984
2979
  function staleOf(input) {
2980
+ if (!input.hasReading) return false;
2985
2981
  if (input.error !== null && input.error !== void 0) return input.updatedAt > 0;
2986
2982
  return input.updatedAt > 0 && input.now - input.updatedAt > input.cadenceMs * STALE_CADENCES;
2987
2983
  }
@@ -3015,6 +3011,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
3015
3011
  const cadenceMs = statedCadenceMs(data?.pollSeconds, DEFAULT_CADENCE_MS);
3016
3012
  const stale = staleOf({
3017
3013
  error,
3014
+ hasReading: data !== null,
3018
3015
  updatedAt,
3019
3016
  now: Date.now(),
3020
3017
  cadenceMs
@@ -1,13 +1,13 @@
1
1
  //#region src/host/util.ts
2
- /** 读一个有限的正数,否则回退值。 */
3
- function num(value, fallback) {
2
+ /** 读一个有限的正数,否则回退值(调用方一律传 number 回退,返回值不再 any)。 */
3
+ function num(value, fallback = 0) {
4
4
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
5
5
  }
6
- /** 读一个非空字符串,否则回退值。 */
7
- function str(value, fallback) {
6
+ /** 读一个非空字符串,否则回退值(调用方一律传 string 回退,返回值收窄为 string)。 */
7
+ function str(value, fallback = "") {
8
8
  return typeof value === "string" && value.trim() !== "" ? value.trim() : fallback;
9
9
  }
10
- /** 读一个普通对象,否则 `{}`。 */
10
+ /** 读一个普通对象,否则 `{}`(返回值给具体形状:点号访问合法、赋值不再绕过检查)。 */
11
11
  function obj(value) {
12
12
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
13
13
  }
@@ -16,7 +16,7 @@ function errMsg(value) {
16
16
  return value instanceof Error ? value.message : String(value);
17
17
  }
18
18
  /** 原样读字符串(凭据专用:trim 是用户看不见的改动)。 */
19
- function verbatim(value, fallback) {
19
+ function verbatim(value, fallback = "") {
20
20
  return typeof value === "string" ? value : fallback;
21
21
  }
22
22
  /**
@@ -291,7 +291,20 @@ async function* reclassifyStream(source) {
291
291
  * 「点隐藏反而全放出来」。
292
292
  */
293
293
  const HIDE_ALL_MODELS = "__hide_all__";
294
- /** 钉死清单:任何新增顶层字段都必须先过 test/wire.test.mjs。 */
294
+ /**
295
+ * 钉死清单:任何新增顶层字段都必须先过 test/wire.test.mjs。
296
+ *
297
+ * **可选块不得进这份清单**——这不是排版洁癖,是 53c9902 的修复曾经被它架空的
298
+ * 事故的结论:`provider` 改成可选(Host 软失败时省略)之后仍留在这里,于是客户端
299
+ * 把「Host 按契约省略」当成契约破裂,先把 `DEGRADED_PROVIDER` 补进 body,再据此
300
+ * 判定「块在」,**兜底 `GET /provider` 一次都没跑**,还给用户挂一条假的漂移警告。
301
+ * `draw` 先可选、也先移出清单,所以它没有这个问题——两个块走同一条纪律,清单里
302
+ * 就必须同样缺席。
303
+ *
304
+ * 判据:`Snapshot` 接口里带 `?` 的顶层字段,这里**必须**没有。
305
+ * test/wire.test.mjs 机械解析该接口做三向交叉:必给字段集合 === 本清单、可选
306
+ * 字段 ∩ 清单 = ∅、可选字段集合 === 可选块名字清单——清单腐化或漏登记都判红。
307
+ */
295
308
  const SNAPSHOT_REQUIRED_KEYS = Object.freeze([
296
309
  "ok",
297
310
  "name",
@@ -305,7 +318,6 @@ const SNAPSHOT_REQUIRED_KEYS = Object.freeze([
305
318
  "events",
306
319
  "trend",
307
320
  "models",
308
- "provider",
309
321
  "shapeWarnings",
310
322
  "quotaError"
311
323
  ]);
@@ -1129,7 +1141,7 @@ const inject = ["webServer"];
1129
1141
  * 产物反而引入第二份真源;这里用常量、由 test/config.test.mjs 钉住与
1130
1142
  * package.json 一致。
1131
1143
  */
1132
- const PLUGIN_VERSION = "0.6.2";
1144
+ const PLUGIN_VERSION = "0.6.3";
1133
1145
  /**
1134
1146
  * 默认值。刻意不含额度常数——理由见本文件头。
1135
1147
  * 面板头条是官方魔粒余额,次数口径的推算已在面板收敛时删除,这里若留着
@@ -1441,4 +1453,4 @@ function isAdmitted(request, allowedHosts) {
1441
1453
  }
1442
1454
 
1443
1455
  //#endregion
1444
- export { errMsg as C, redactSecrets as D, pluginError as E, str as O, degrade as S, optional as T, parseAllowedList as _, resolveSettings as a, looksLikeRateLimit as b, LLM_PROVIDER_ID as c, imageGenModelIds as d, isVisionModel as f, enabledCountOf as g, rosterWithAvailability as h, name as i, verbatim as k, buildDescriptors as l, resolveDrawModelChoice as m, inject as n, LLM_API_KEY_NAME as o, normalizeEntry as p, isAdmitted as r, LLM_DISPLAY_NAME as s, PLUGIN_VERSION as t, drawCandidates as u, CODE as v, obj as w, reclassifyStream as x, hasHardQuotaWordingIn as y };
1456
+ export { verbatim as A, degrade as C, pluginError as D, optional as E, redactSecrets as O, reclassifyStream as S, obj as T, enabledCountOf as _, name as a, hasHardQuotaWordingIn as b, LLM_DISPLAY_NAME as c, drawCandidates as d, imageGenModelIds as f, rosterWithAvailability as g, resolveDrawModelChoice as h, isAdmitted as i, str as k, LLM_PROVIDER_ID as l, normalizeEntry as m, PLUGIN_VERSION as n, resolveSettings as o, isVisionModel as p, inject as r, LLM_API_KEY_NAME as s, DRAW_TIMEOUT_MAX_MS as t, buildDescriptors as u, parseAllowedList as v, errMsg as w, looksLikeRateLimit as x, CODE as y };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as errMsg, D as redactSecrets, E as pluginError, O as str, S as degrade, T as optional, _ as parseAllowedList, a as resolveSettings, b as looksLikeRateLimit, c as LLM_PROVIDER_ID, d as imageGenModelIds, f as isVisionModel, g as enabledCountOf, h as rosterWithAvailability, i as name, k as verbatim, m as resolveDrawModelChoice, n as inject, p as normalizeEntry, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as drawCandidates, w as obj, y as hasHardQuotaWordingIn } from "./host-config.js";
1
+ import { A as verbatim, C as degrade, D as pluginError, E as optional, O as redactSecrets, T as obj, _ as enabledCountOf, a as name, b as hasHardQuotaWordingIn, c as LLM_DISPLAY_NAME, d as drawCandidates, f as imageGenModelIds, g as rosterWithAvailability, h as resolveDrawModelChoice, i as isAdmitted, k as str, l as LLM_PROVIDER_ID, m as normalizeEntry, n as PLUGIN_VERSION, o as resolveSettings, p as isVisionModel, r as inject, t as DRAW_TIMEOUT_MAX_MS, v as parseAllowedList, w as errMsg, x as looksLikeRateLimit } from "./host-config.js";
2
2
  import { basename, join } from "node:path";
3
3
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
@@ -130,7 +130,13 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
130
130
  };
131
131
  return {
132
132
  /**
133
- * 把面板输入的令牌存成引用;原样存(不 trim),空白值视为没输入。
133
+ * 把面板输入的令牌存成引用;**两端空白先 trim**,空白值视为没输入。
134
+ *
135
+ * **为什么 trim 而不是原样存**:校验用的是 `value.trim()`,落库却曾是原值——
136
+ * 校验与落库必须是**同一个值**,否则「通过校验的值」与「发出去的值」不是一个东西。
137
+ * 实测(2026-10-19):`save(" ms-3f2a… ")` 会把带着前后空格的串存进凭据服务,
138
+ * 而它拼出的请求头是 `Bearer ms-… `——用户粘进一个空格就换来一个语焉不详的
139
+ * 401,面板却显示「已保存」。只 trim 两端,令牌中间的形状(分段连字符)不动。
134
140
  *
135
141
  * **形状校验**:只接受 `ms-` + 至少 16 位十六进制(可含连字符分段)。这不是
136
142
  * 洁癖——`redactSecrets` 的脱敏闸是**正则**,只能遮它认识的形状;一个
@@ -143,9 +149,9 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
143
149
  * 真机请求换来一个语焉不详的 401。
144
150
  */
145
151
  async save(token) {
146
- const value = verbatim(token, "");
147
- if (typeof value !== "string" || value.trim() === "") throw new Error("a ModelScope token is required");
148
- if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by at least 16 hex digits); if you are sure the token is correct, this plugin's shape check may be out of date");
152
+ const value = verbatim(token, "").trim();
153
+ if (value === "") throw new Error("a ModelScope token is required");
154
+ if (!isPlausibleToken(value)) throw new Error("that does not look like a ModelScope access token (expected ms- followed by at least 16 hex digits); if you are sure the token is correct, this plugin's shape check may be out of date");
149
155
  const service = resolveService();
150
156
  if (service !== null && typeof service.set === "function") {
151
157
  await service.set(TOKEN_REF, value);
@@ -424,17 +430,6 @@ function temporaryOf(dir, base, now = Date.now) {
424
430
  return join(dir, `${base}.${process.pid}.${now()}.${randomUUID()}.tmp`);
425
431
  }
426
432
  /**
427
- * Write one state file atomically: a 0600 temporary file, then a rename.
428
- *
429
- * The payload string is written with a trailing newline, exactly as every
430
- * store wrote before this module existed. Failures PROPAGATE — the callers
431
- * decide whether a read-only Home breaks their flow.
432
- * @param {string} file - the final file path.
433
- * @param {string} payload - the serialized body (JSON text).
434
- * @param {{temporary: string}} options - the temp path to write first.
435
- * @returns {Promise<void>}
436
- */
437
- /**
438
433
  * 写一个状态文件:一个 0600 临时文件,然后 rename。
439
434
  *
440
435
  * 载荷串以换行结尾,与本模块存在前每个 store 的写法一致。失败**向上传播** ——
@@ -1905,7 +1900,10 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1905
1900
  const extra = retryAfterMs !== null && retryAfterMs !== void 0 ? { retryAfterMs } : {};
1906
1901
  throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
1907
1902
  }
1908
- if (response.status >= 500) throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
1903
+ if (response.status >= 500) {
1904
+ discardBody(response);
1905
+ throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
1906
+ }
1909
1907
  response.body?.cancel();
1910
1908
  return {
1911
1909
  ok: true,
@@ -2615,6 +2613,62 @@ function createFileDrawStore(options = {}) {
2615
2613
  };
2616
2614
  }
2617
2615
 
2616
+ //#endregion
2617
+ //#region src/host/draw-jobs.ts
2618
+ /**
2619
+ * 建一张出图作业表。
2620
+ * @param {DrawJobsOptions} [options] - 容量上限。
2621
+ * @returns {DrawJobs} 作业表。
2622
+ */
2623
+ function createDrawJobs(options = {}) {
2624
+ const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
2625
+ /** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
2626
+ const jobs = /* @__PURE__ */ new Map();
2627
+ const evictIfNeeded = () => {
2628
+ while (jobs.size > maxJobs) {
2629
+ const oldest = jobs.keys().next();
2630
+ if (oldest.done === true) return;
2631
+ jobs.delete(oldest.value);
2632
+ }
2633
+ };
2634
+ return {
2635
+ create({ taskId, model, prompt, now }) {
2636
+ const jobId = taskId;
2637
+ const job = {
2638
+ jobId,
2639
+ taskId,
2640
+ model,
2641
+ prompt,
2642
+ submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
2643
+ polls: 0
2644
+ };
2645
+ const previous = jobs.get(jobId);
2646
+ if (previous !== void 0) job.polls = previous.polls;
2647
+ jobs.set(jobId, job);
2648
+ evictIfNeeded();
2649
+ return { ...job };
2650
+ },
2651
+ get(jobId) {
2652
+ const found = jobs.get(jobId);
2653
+ return found === void 0 ? null : { ...found };
2654
+ },
2655
+ notePolls(jobId, polls) {
2656
+ const found = jobs.get(jobId);
2657
+ if (found === void 0) return;
2658
+ found.polls += Math.max(0, Math.floor(polls));
2659
+ },
2660
+ drop(jobId) {
2661
+ jobs.delete(jobId);
2662
+ },
2663
+ dispose() {
2664
+ jobs.clear();
2665
+ },
2666
+ size() {
2667
+ return jobs.size;
2668
+ }
2669
+ };
2670
+ }
2671
+
2618
2672
  //#endregion
2619
2673
  //#region src/host/switch-precedence.ts
2620
2674
  /**
@@ -2917,9 +2971,11 @@ function createFileUsagePrefsStore(options = {}) {
2917
2971
  * 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
2918
2972
  * 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
2919
2973
  * `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
2920
- * 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
2921
- * 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
2922
- * `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
2974
+ * 3. **`FAILED` 常常没有可分诊的形状**:实测样本 `errors.code` 为 `0`、
2975
+ * `errors.message` 为空串、没有顶层 `message`。所以不能承诺「一定读得出原因」——
2976
+ * 本模块的 `describeDrawFailure` 只报**这个 body 里确实写着**的东西:读得到
2977
+ * `errors.message` 就报它,读不到就明说原因不可知(2026-10-19 修补:旧写法把
2978
+ * 「实测样本为空」当成了「永远为空」,非空原因会被一并报成「未给出原因」)。
2923
2979
  *
2924
2980
  * 关于两个小工具(`str` / `redactSecrets`):它们曾以**本地私有副本**的形式活在
2925
2981
  * 本文件里,理由是「避免为 3 行代码引 util 的依赖面」。那条理由不成立——util.ts
@@ -3120,13 +3176,31 @@ function normalizeImageUrlInput(value) {
3120
3176
  return null;
3121
3177
  }
3122
3178
  /**
3179
+ * 从上游 body 里取第一句**可引用的原因**:`errors.message`,退回顶层 `message`。
3180
+ *
3181
+ * 取的是**未截断**的原文——`describeDrawFailure` 的 tail 会截到 300 字符,截断后的
3182
+ * JSON 解析不出来。解析不出来(非 JSON / 字段为空 / 字段不是字符串)一律返回 `""`:
3183
+ * 这里**不猜**,没有原因就说没有原因。
3184
+ */
3185
+ function upstreamMessageOf(raw) {
3186
+ if (raw === "") return "";
3187
+ try {
3188
+ const parsed = JSON.parse(raw);
3189
+ for (const candidate of [parsed?.errors?.message, parsed?.message]) if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim();
3190
+ } catch {}
3191
+ return "";
3192
+ }
3193
+ /**
3123
3194
  * 把一次失败转成 agent 与面板都能读的一句话。
3124
3195
  *
3125
- * **关于魔搭 `FAILED` 的诚实边界**:实测唯一的失败样本是
3126
- * `{"errors":{"code":0,"message":""}, "task_status":"FAILED"}`——**原因不可知**。
3127
- * 所以这里只报「可确定的形态事实」(HTTP 码 / 轮询未出终态 / 明确的原因文本),
3128
- * **不从空错误里编造原因**,并在原因确实拿不到时明说「上游未给出原因」。这是本仓库
3129
- * README「诚实声明」的要求。
3196
+ * **关于魔搭 `FAILED` 的诚实边界**:实测见过的失败样本是
3197
+ * `{"errors":{"code":0,"message":""}, "task_status":"FAILED"}`——那一份**原因不可知**。
3198
+ * 但样本量是 1,不是恒定形态:上游也可能给出非空的 `errors.message`
3199
+ * (2026-10-19 锐评实测 `sensitive content detected`、
3200
+ * `image_url is required for model MusePublic/Qwen-Image-Edit` 这类原因会被旧写法
3201
+ * 一并报成「未给出失败原因」,且 body 被丢掉,2/2 误报)。所以判据是**逐个 body 判**:
3202
+ * 有原因就报原因,确实没有才明说「上游未给出原因」;任何情况下都不从空错误里编造
3203
+ * 原因,两种情况**都附脱敏后的 body**。这是本仓库 README「诚实声明」的要求。
3130
3204
  *
3131
3205
  * 凭据永不入消息:body 文本可能回显 `ms-` 令牌,过 `redactSecrets` 后再截断。
3132
3206
  * @param {object} failure - 失败信息。
@@ -3137,17 +3211,22 @@ function normalizeImageUrlInput(value) {
3137
3211
  * @returns {string} 一行说明。
3138
3212
  */
3139
3213
  function describeDrawFailure(failure) {
3140
- const text = redactSecrets(typeof failure.bodyText === "string" ? failure.bodyText : "").slice(0, 300);
3214
+ const raw = typeof failure.bodyText === "string" ? failure.bodyText : "";
3215
+ const text = redactSecrets(raw).slice(0, 300);
3141
3216
  const status = Number.isFinite(failure.status) ? Number(failure.status) : 0;
3142
3217
  const tail = text === "" ? "" : `; body: ${text}`;
3143
- if (status === 401 || status === 403) return `出图失败:HTTP ${status} —— 访问令牌无效、无权访问该模型,或账号未完成邮箱验证/实名(后者是实测遇到过的真实拦截)。请在面板确认令牌与账号状态${tail}`;
3218
+ if (status === 401 || status === 403) return `出图失败:HTTP ${status} —— ${/邮箱|实名|email[^\n]{0,40}verif|verif\w*[^\n]{0,40}email|complete\s+your\s+profile/i.test(text) ? "访问令牌无效、无权访问该模型,或账号未完成邮箱验证/实名(上游 body 提到了账号态)" : "访问令牌无效或无权访问该模型(上游未指明具体原因)"}。请在面板确认令牌与账号状态${tail}`;
3144
3219
  if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
3145
3220
  if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
3146
3221
  if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
3147
3222
  const cause = str(failure.cause, "");
3148
- if (cause !== "") return `出图失败:${cause}${tail}`;
3223
+ if (cause !== "") return `出图失败:${redactSecrets(cause)}${tail}`;
3149
3224
  const taskStatus = str(failure.taskStatus, "");
3150
- if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
3225
+ if (taskStatus === "FAILED") {
3226
+ const upstream = upstreamMessageOf(raw);
3227
+ if (upstream !== "") return `出图失败:上游任务 FAILED —— ${redactSecrets(upstream)}${tail}`;
3228
+ return `出图失败:上游任务 FAILED,但未给出失败原因(上游 errors.message 为空),换模型或稍后重试${tail}`;
3229
+ }
3151
3230
  if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
3152
3231
  return `出图失败:上游未给出可诊断的失败信息${tail}`;
3153
3232
  }
@@ -3178,62 +3257,6 @@ function createDrawCooldown(cooldownMs) {
3178
3257
  };
3179
3258
  }
3180
3259
 
3181
- //#endregion
3182
- //#region src/host/draw-jobs.ts
3183
- /**
3184
- * 建一张出图作业表。
3185
- * @param {DrawJobsOptions} [options] - 容量上限。
3186
- * @returns {DrawJobs} 作业表。
3187
- */
3188
- function createDrawJobs(options = {}) {
3189
- const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
3190
- /** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
3191
- const jobs = /* @__PURE__ */ new Map();
3192
- const evictIfNeeded = () => {
3193
- while (jobs.size > maxJobs) {
3194
- const oldest = jobs.keys().next();
3195
- if (oldest.done === true) return;
3196
- jobs.delete(oldest.value);
3197
- }
3198
- };
3199
- return {
3200
- create({ taskId, model, prompt, now }) {
3201
- const jobId = taskId;
3202
- const job = {
3203
- jobId,
3204
- taskId,
3205
- model,
3206
- prompt,
3207
- submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
3208
- polls: 0
3209
- };
3210
- const previous = jobs.get(jobId);
3211
- if (previous !== void 0) job.polls = previous.polls;
3212
- jobs.set(jobId, job);
3213
- evictIfNeeded();
3214
- return { ...job };
3215
- },
3216
- get(jobId) {
3217
- const found = jobs.get(jobId);
3218
- return found === void 0 ? null : { ...found };
3219
- },
3220
- notePolls(jobId, polls) {
3221
- const found = jobs.get(jobId);
3222
- if (found === void 0) return;
3223
- found.polls += Math.max(0, Math.floor(polls));
3224
- },
3225
- drop(jobId) {
3226
- jobs.delete(jobId);
3227
- },
3228
- dispose() {
3229
- jobs.clear();
3230
- },
3231
- size() {
3232
- return jobs.size;
3233
- }
3234
- };
3235
- }
3236
-
3237
3260
  //#endregion
3238
3261
  //#region src/host/draw-runtime.ts
3239
3262
  /**
@@ -3271,6 +3294,13 @@ const DRAW_TOOL_NAME = "modelscope_draw_image";
3271
3294
  /** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
3272
3295
  const DRAW_FAILURE_COOLDOWN_MS = 6e4;
3273
3296
  /**
3297
+ * 工具级硬护栏比单次等待预算多出的余量。
3298
+ *
3299
+ * 它保证**等待循环先返回**:护栏若与等待预算同值,正常路径会以 peer 的超时错误
3300
+ * 结束,而不是带 `jobId` 的 `pending`——那会把续查这条唯一的把手换成一个死胡同。
3301
+ */
3302
+ const DRAW_TOOL_TIMEOUT_MARGIN_MS = 1e4;
3303
+ /**
3274
3304
  * 把 OpenAI 兼容 base 拼成出图端点。
3275
3305
  *
3276
3306
  * `…/v1` → `…/v1/images/generations`;已带该后缀则原样;更深路径回卷到 `/v1`。
@@ -3372,9 +3402,12 @@ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, image
3372
3402
  * @param {string} options.taskId - 上游任务 id。
3373
3403
  * @param {number} [options.waitMs] - 本次调用的等待预算。
3374
3404
  * @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
3405
+ * @param {() => boolean} [options.shouldStop] - 每轮轮询前问一次「还要不要继续」
3406
+ * (撤下闸)。`signal` 只能表达**已经**中止,问不出「刚刚被撤下」——等待循环是
3407
+ * 本模块唯一的循环,撤下只有在这里能被周期性看见。
3375
3408
  * @returns {Promise<DrawAwait>} 三种结局之一。
3376
3409
  */
3377
- async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal }) {
3410
+ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal, shouldStop }) {
3378
3411
  const headers = {
3379
3412
  Authorization: `Bearer ${apiKey}`,
3380
3413
  "Content-Type": "application/json"
@@ -3388,6 +3421,10 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
3388
3421
  kind: "pending",
3389
3422
  polls
3390
3423
  };
3424
+ if (shouldStop?.() === true) return {
3425
+ kind: "pending",
3426
+ polls
3427
+ };
3391
3428
  try {
3392
3429
  await sleep(interval, signal);
3393
3430
  } catch {
@@ -3404,20 +3441,28 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
3404
3441
  headers: {
3405
3442
  ...headers,
3406
3443
  [TASK_TYPE_HEADER]: TASK_TYPE_IMAGE_GENERATION
3407
- }
3444
+ },
3445
+ ...signal !== void 0 ? { signal } : {}
3408
3446
  });
3409
3447
  } catch {
3448
+ if (signal?.aborted === true) return {
3449
+ kind: "pending",
3450
+ polls
3451
+ };
3410
3452
  continue;
3411
3453
  }
3412
3454
  const pollText = await safeText(polled);
3413
- if (!polled.ok) return {
3414
- kind: "failed",
3415
- message: describeDrawFailure({
3416
- ...polled.status !== void 0 ? { status: polled.status } : {},
3417
- ...pollText !== "" ? { bodyText: pollText } : {},
3418
- ...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
3419
- })
3420
- };
3455
+ if (!polled.ok) {
3456
+ const previous = lastStatus !== void 0 ? `(上一轮读到 ${lastStatus})` : "";
3457
+ return {
3458
+ kind: "failed",
3459
+ message: describeDrawFailure({
3460
+ ...polled.status !== void 0 ? { status: polled.status } : {},
3461
+ ...pollText !== "" ? { bodyText: pollText } : {},
3462
+ cause: `轮询请求失败:HTTP ${polled.status ?? "未知"},本轮未读到任务状态${previous}`
3463
+ })
3464
+ };
3465
+ }
3421
3466
  const data = await safeJson(polled, pollText);
3422
3467
  const status = readTaskStatus(data);
3423
3468
  if (status !== null) lastStatus = status;
@@ -3516,21 +3561,34 @@ function drawModelIds(entries) {
3516
3561
  * @returns {unknown} 已定义的 agent 工具。
3517
3562
  */
3518
3563
  function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), jobs = createDrawJobs(), pollIntervalMs = DRAW_POLL_INTERVAL_MS, isDisposed = () => false }) {
3519
- /** 总放弃线:从提交算起,跨多次调用累计。 */
3520
- const timeoutMs = Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS));
3564
+ /**
3565
+ * 总放弃线:从提交算起,跨多次调用累计。
3566
+ *
3567
+ * 上下界与 `host-config.resolveSettings` 同源,这里再夹一次是为了让本函数的
3568
+ * 不变式**自足**:`waitMs + margin <= timeoutMs <= DRAW_TIMEOUT_MAX_MS`(见下面
3569
+ * `waitMs` 与工具定义里的 `timeoutMs`)。直接调用本函数(不经配置层)时也成立。
3570
+ */
3571
+ const timeoutMs = Math.min(DRAW_TIMEOUT_MAX_MS, Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS)));
3521
3572
  /**
3522
3573
  * 单次调用愿意等的时间。
3523
3574
  *
3524
- * 只夹**上界**(不该超过总放弃线,超过它的等待没有意义);下界交给
3525
- * `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/下限就会
3526
- * 各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
3575
+ * 上界是 `timeoutMs - margin`,**不是** `timeoutMs`:工具的硬护栏取
3576
+ * `waitMs + margin`,若允许两者相等,护栏就会越过 `DRAW_TIMEOUT_MAX_MS`
3577
+ * (配置允许 `drawWaitMs == drawTimeoutMs`,上限 1_800_000 → 护栏 1_810_000),
3578
+ * 且「等待循环先返回」从结构保证退化成「用户不会把两个值设成相等」的运气。
3579
+ * 下界交给 `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/
3580
+ * 下限就会各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
3527
3581
  */
3528
- const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
3582
+ const waitMs = Math.min(Math.max(1, timeoutMs - DRAW_TOOL_TIMEOUT_MARGIN_MS), Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
3529
3583
  const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
3530
3584
  const abortController = new AbortController();
3531
3585
  const abortIfDisposed = () => {
3532
3586
  if (isDisposed()) abortController.abort();
3533
3587
  };
3588
+ const shouldStop = () => {
3589
+ abortIfDisposed();
3590
+ return isDisposed();
3591
+ };
3534
3592
  /**
3535
3593
  * 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
3536
3594
  *
@@ -3551,7 +3609,8 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3551
3609
  taskId: job.taskId,
3552
3610
  waitMs,
3553
3611
  pollIntervalMs,
3554
- signal: abortController.signal
3612
+ signal: abortController.signal,
3613
+ shouldStop
3555
3614
  });
3556
3615
  if (awaited.kind === "failed") {
3557
3616
  jobs.drop(job.jobId);
@@ -3574,6 +3633,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3574
3633
  }
3575
3634
  const elapsedMs = Date.now() - job.submittedAt;
3576
3635
  if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})可能仍在生成——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
3636
+ const resumeHint = jobs.get(job.jobId) !== null ? `稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图;作业表只在本次挂载内有效,重启 DSH 或重挂插件后要重新提交)` : "本次挂载已撤下(插件被卸载/重载),作业表已清空——这一轮之后**不能**再带 jobId 续查;上游任务可能仍在生成,但结果不保证还能取回,重新出图请去掉 jobId 再调用";
3577
3637
  return {
3578
3638
  source: "modelscope",
3579
3639
  status: "pending",
@@ -3581,7 +3641,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3581
3641
  model: job.model,
3582
3642
  url: "",
3583
3643
  prompt: job.prompt,
3584
- hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图)`
3644
+ hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。${resumeHint}`
3585
3645
  };
3586
3646
  };
3587
3647
  return defineTool({
@@ -3645,7 +3705,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3645
3705
  text: result?.hint || "图片已生成"
3646
3706
  }]
3647
3707
  },
3648
- timeoutMs: waitMs + 1e4,
3708
+ timeoutMs: waitMs + DRAW_TOOL_TIMEOUT_MARGIN_MS,
3649
3709
  async execute(params) {
3650
3710
  if (abortController.signal.aborted || isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
3651
3711
  const apiKey = await resolveApiKey();
@@ -3899,7 +3959,7 @@ async function resolveService(ctx, service, isDisposed) {
3899
3959
  * 读一次开关,决定是否注册出图工具。
3900
3960
  * @param {object} ctx - Host 上下文。
3901
3961
  * @param {object} wiring - `{ settings, configError, tokenStore, inference, providerStore, publisher, logger }`。
3902
- * @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`)。
3962
+ * @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`、`pollIntervalMs`)。
3903
3963
  * @returns {Promise<void>}
3904
3964
  */
3905
3965
  async function registerDrawTool(ctx, wiring, deps = {}) {
@@ -3948,6 +4008,8 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3948
4008
  drawTimeoutMs: settings.drawTimeoutMs,
3949
4009
  drawWaitMs: settings.drawWaitMs
3950
4010
  },
4011
+ ...wiring.drawJobs !== void 0 ? { jobs: wiring.drawJobs } : {},
4012
+ ...deps.pollIntervalMs !== void 0 ? { pollIntervalMs: deps.pollIntervalMs } : {},
3951
4013
  isDisposed
3952
4014
  }));
3953
4015
  setDrawToolMounted(true);
@@ -5008,12 +5070,13 @@ async function buildSnapshotBody(wiring) {
5008
5070
  const allowedList = parseAllowedList(enabledIds);
5009
5071
  const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
5010
5072
  const contextWindow = await providerStore.contextWindow().catch(() => null);
5073
+ const catalogNote = models.ok ? null : `模型目录读不到:${models.error}`;
5011
5074
  return {
5012
5075
  enabled,
5013
5076
  source,
5014
5077
  llmAvailable: publisher.state.llmAvailable === true,
5015
5078
  registered: publisher.state.registered === true,
5016
- error: typeof publisher.state.error === "string" ? publisher.state.error : null,
5079
+ error: typeof publisher.state.error === "string" ? publisher.state.error : catalogNote,
5017
5080
  modelCount: roster.length,
5018
5081
  enabledCount,
5019
5082
  allowed: allowedList.mode,
@@ -6003,25 +6066,73 @@ function registerRoutes(ctx, wiring) {
6003
6066
  //#endregion
6004
6067
  //#region src/host/index.ts
6005
6068
  /**
6006
- * dsh-connect-modelscope-token-plan — Host 半边(thin router)。
6007
- *
6008
- * 读魔搭 API-Inference 的**本地用量**,并把它与官方魔粒余额一起经一条只读 /api
6009
- * 路由喂给 Client 面板。口径分工:推理响应**不携带**额度头(SPIKE.md §结论 1),但
6010
- * 官方另有魔粒余额端点(`GET {siteBase}/openapi/v1/magicubes/balance`,SPIKE.md
6011
- * 文首后记,2026-10-04 实测可用),面板头条读的是它;本文件下的兄弟模块只负责本地
6012
- * 计数那一半(按模型分布 / 本地趋势 / 429 事件流),回答官方余额答不了的问题。
6013
- * 重活都在专注的兄弟模块里,本文件
6014
- * 只保留 Cordis 入口(name/inject/apply)、wiring 装配与卸载副作用:
6015
- *
6016
- * - `host-config.ts` 配置契约 + isAdmitted 信任围栏
6017
- * - `routes.ts` 四条路由的门面(snapshot/models/token/probe)
6018
- * - `ms-auth.ts` 访问令牌存取(静态钥匙,无登录流)
6019
- * - `inference-client.ts` 模型目录(零额度)+ probe 调用(带分诊)
6020
- * - `usage-store.ts` 本地计数:天桶/单模型/事件流(原子写、版本守卫)
6021
- * - `snapshot-aggregate.ts` 快照聚合(软失败)
6022
- * - `state-store.ts` 状态文件原子原语
6069
+ * dsh-connect-modelscope-token-plan — Host 半边(装配入口)。
6070
+ *
6071
+ * 本插件的定位是**额度 + provider + 出图 + 上下文窗口策展**的多功能集成(见
6072
+ * AGENTS.md「定位变更」),不再是「只做额度信息」的那一版。口径分工:推理响应
6073
+ * **不携带**额度头(SPIKE.md §结论 1),但官方另有魔粒余额端点
6074
+ * (`GET {siteBase}/openapi/v1/magicubes/balance`,SPIKE.md 文首后记,2026-10-04
6075
+ * 实测可用),面板头条读的是它;本文件下的兄弟模块只负责本地计数那一半(按模型
6076
+ * 分布 / 本地趋势 / 429 事件流),回答官方余额答不了的问题。
6077
+ *
6078
+ * 重活都在专注的兄弟模块里,本文件只保留 Cordis 入口(name/inject/apply)、
6079
+ * wiring 装配与卸载副作用。兄弟模块按域分组:
6080
+ *
6081
+ * **配置与凭据**
6082
+ * - `host-config.ts` 配置契约 + isAdmitted 信任围栏 + 夹取常量
6083
+ * - `switch-precedence.ts` 开关优先级:面板值 > patch 默认
6084
+ * - `ms-auth.ts` 访问令牌存取(静态钥匙,无登录流;唯一凭据出口)
6085
+ *
6086
+ * **路由(7 条,`routes.ts` 是门面,`routes/` 下各域一份)**
6087
+ * - `routes/snapshot.ts` 面板轮询的唯一只读路由
6088
+ * - `routes/models.ts` 模型目录
6089
+ * - `routes/token.ts` 令牌保存/忘掉/验证
6090
+ * - `routes/probe.ts` 零额度鉴权探针
6091
+ * - `routes/provider.ts` provider 开关/清单/窗口/重置
6092
+ * - `routes/draw.ts` 出图开关/模型/重置
6093
+ * - `routes/usage-prefs.ts` 趋势显示窗口
6094
+ * - `routes/http.ts` CORS / JSON / 错误脱敏的公共辅助
6095
+ * - `routes/paths.ts` 路由路径常量
6096
+ *
6097
+ * **数据层(`state-store.ts` 是共用原语)**
6098
+ * - `state-store.ts` 原子写/版本守卫/读缓存/写队列(四个 store 共用)
6099
+ * - `usage-store.ts` 本地计数:天桶/单模型/事件流
6100
+ * - `provider-store.ts` 开关 + 允许清单 + 窗口覆盖的持久化
6101
+ * - `draw-store.ts` 出图开关 + 模型偏好
6102
+ * - `usage-prefs-store.ts` 趋势显示窗口偏好 + `resolveWindowDays` 夹取
6103
+ * - `inference-client.ts` HTTP 出口:模型目录 + 魔粒余额 + 探针
6104
+ * - `coalesced-fetch.ts` 有界并发缓存(目录/余额/标签共用)
6105
+ * - `context-catalog.ts` 内置上下文窗口策展表(models.dev 交叉真值)
6106
+ *
6107
+ * **Provider 注册**
6108
+ * - `llm-models.ts` 目录 → descriptor 映射(peer-free)
6109
+ * - `llm-adapter.ts` / `llm-adapter-core.ts` 适配器装配(core 为三族共享)
6110
+ * - `provider-publish.ts` 发布状态机:队列/disposed 闸/回滚
6111
+ * - `provider-reconcile.ts` desired-state 收敛器(轮询 + 路由统一触发)
6112
+ * - `publish-core.ts` 三族共享的发布原语
6113
+ *
6114
+ * **观测与纠正**
6115
+ * - `usage-observer.ts` 真实对话 → 本地计数/事件(观察流出口)
6116
+ * - `llm-error-fix.ts` 429 误判纠正(QUOTA → RATE_LIMIT)
6117
+ * - `llm-retry.ts` 重试策略(按 code 决定退避)
6023
6118
  * - `codes.ts` 错误分类单一真源
6024
- * - `usage-observer.ts` 真实对话 → 本地计数/事件(观察流出口,见该文件头)
6119
+ *
6120
+ * **出图工具**
6121
+ * - `draw.ts` 协议纯逻辑半边(peer-free)
6122
+ * - `draw-register.ts` 工具注册(挂载时一次性,不可撤销)
6123
+ * - `draw-runtime.ts` 运行时(两段式:提交 → 轮询)
6124
+ * - `draw-jobs.ts` 作业表(本次挂载内有效)
6125
+ * - `draw-tool-state.ts` 缺席原因 + 本次挂载注册位(内存)
6126
+ *
6127
+ * **聚合与工具**
6128
+ * - `snapshot-aggregate.ts` 快照聚合(软失败,绝不白屏)
6129
+ * - `doctor.ts` 只读盘点工具
6130
+ * - `util.ts` 通用工具(脱敏/错误消息/类型守卫)
6131
+ * - `types.ts` Host 侧类型定义
6132
+ *
6133
+ * 完整的「文件 → 一句话职责 → 关键导出」表在 AGENTS.md 的代码地图里,由
6134
+ * test/docs.test.mjs §14 门禁守着(每个 src/*.ts 都必须登记)。本注释只做入口
6135
+ * 处的分组索引——**新增兄弟模块时两处都要改**,别让这里再落后一轮。
6025
6136
  *
6026
6137
  * @module dsh-connect-modelscope-token-plan
6027
6138
  */
@@ -6062,6 +6173,7 @@ function apply(ctx, config = {}, deps = {}) {
6062
6173
  profile,
6063
6174
  logger: ctx.logger
6064
6175
  });
6176
+ const drawJobs = createDrawJobs();
6065
6177
  const usagePrefsStore = createFileUsagePrefsStore({
6066
6178
  profile,
6067
6179
  logger: ctx.logger
@@ -6098,6 +6210,7 @@ function apply(ctx, config = {}, deps = {}) {
6098
6210
  inference,
6099
6211
  providerStore,
6100
6212
  drawStore,
6213
+ drawJobs,
6101
6214
  usagePrefsStore,
6102
6215
  publisher,
6103
6216
  reconcile,
@@ -6117,6 +6230,7 @@ function apply(ctx, config = {}, deps = {}) {
6117
6230
  return () => {
6118
6231
  clearInterval(timer);
6119
6232
  resetDrawToolNote();
6233
+ drawJobs.dispose();
6120
6234
  publisher.dispose();
6121
6235
  publisher.release();
6122
6236
  for (const off of offs) try {
@@ -1,4 +1,4 @@
1
- import { D as redactSecrets, c as LLM_PROVIDER_ID, i as name, l as buildDescriptors, o as LLM_API_KEY_NAME, s as LLM_DISPLAY_NAME, v as CODE, x as reclassifyStream } from "./host-config.js";
1
+ import { O as redactSecrets, S as reclassifyStream, a as name, c as LLM_DISPLAY_NAME, l as LLM_PROVIDER_ID, s as LLM_API_KEY_NAME, u as buildDescriptors, y as CODE } from "./host-config.js";
2
2
  import { createProvider } from "@earendil-works/pi-ai";
3
3
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
4
4
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "ModelScope Model Access",
4
- "description": "Register ModelScope's language models and draw tool into DSH with one toggle: image-recognition capability and context windows auto-filled, usable the moment it is registered (both default off); the free-quota panel — official Magicube balance, local call counts, trends, rate-limit events — works even without registering."
4
+ "description": "Bring the ModelScope language and image-generation models into DSH as default-off switches, with image recognition and context windows filled in automatically. The card also shows the free Magicube balance, links each model to its ModelScope page, and records local call counts, a daily trend and rate-limit events. All of it needs only an API key, with no account sign-in."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "魔搭模型接入",
4
- "description": "一键把魔搭(ModelScope)的语言模型与生图工具注册进 DSH:自动填入图片识别能力与上下文窗口,注册即可用(均默认关);不注册也能看免费额度面板:官方魔粒余额、本地调用计数、趋势、限流事件。"
4
+ "description": "一键接入魔搭社区的语言、生图模型到 DSH ,自动填入图片识别与上下文窗口(默认关);还可查看免费魔粒余额、跳转到模型信息页,记录本地调用计数、趋势、限流事件。上述功能仅需填入 API key ,不需要登陆账号。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -2,13 +2,13 @@
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.2",
5
+ "version": "0.6.3",
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"
9
9
  },
10
10
  "author": "eghrhegpe",
11
- "description": "ModelScope (modelscope.cn) access bundle for DSH: opt-in registration of ModelScope language models and draw tool as an OpenAI-compatible provider (vision and context windows auto-filled, usable the moment registered), plus a free-quota panel with the official Magicube balance, per-model local call counts, daily trends and 429 event list — usable without registering.",
11
+ "description": "Bring the ModelScope language and image-generation models into DSH as default-off switches, with image recognition and context windows filled in automatically. The card also shows the free Magicube balance, links each model to its ModelScope page, and records local call counts, a daily trend and rate-limit events. All of it needs only an API key, with no account sign-in.",
12
12
  "keywords": [
13
13
  "dsh-plugin",
14
14
  "deepseek-harness",