dsh-connect-modelscope-token-plan 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/README.md +38 -2
- package/client.js +918 -111
- package/cordis.patch.yml +31 -0
- package/lib/{host-config-C4bWNPWU.js → host-config.js} +340 -89
- package/lib/index.js +2681 -410
- package/lib/{llm-adapter-DnaNNORe.js → llm-adapter.js} +21 -12
- package/locale/en.json +2 -1
- package/package.json +13 -5
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { C as obj, D as str, E as redactSecrets, O as verbatim, S as errMsg, T as pluginError, a as resolveSettings, c as LLM_PROVIDER_ID, d as isVisionModel, f as normalizeEntry, g as parseAllowedList, h as enabledCountOf, i as name, m as textToImageModelIds, n as inject, p as rosterWithAvailability, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as imageGenModelIds, v as hasHardQuotaWordingIn, w as optional, x as degrade, y as looksLikeRateLimit } from "./host-config.js";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
|
|
@@ -64,14 +64,70 @@ function isPlausibleToken(value) {
|
|
|
64
64
|
* @param {object|Function|null} [options.credentials] - `ctx.credentials` 服务
|
|
65
65
|
* 或其 resolver;**每次使用时解析**(服务可能在本插件挂载之后才注册,
|
|
66
66
|
* 一次性查找会把错误的 "ephemeral" 断言冻进后续每个轮询)。
|
|
67
|
-
* @param {object} [options.env] -
|
|
67
|
+
* @param {object} [options.env] - 环境源;**默认 `process.env`**。
|
|
68
|
+
* 测试经 `apply()` 的 `deps.env` 注入 `{}` 以隔离宿主环境——本文件时不能假设
|
|
69
|
+
* 「没有传就是要用真环境」以外的语义,但调用方可以(详见 types.ts 的 env 缝)。
|
|
70
|
+
*
|
|
71
|
+
* `| undefined` 不是冗余:`tsconfig` 开了 `exactOptionalPropertyTypes`,
|
|
72
|
+
* 「可选」不允许被显式赋值为 `undefined`,而 `apply()` 是原样转发 `deps.env`
|
|
73
|
+
* (产品侧不传时它就是 `undefined`)。不写这一半,转发点编译不过。
|
|
68
74
|
*/
|
|
69
75
|
function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
70
76
|
/** 无凭据服务的 Host 的内存兜底。 */
|
|
71
77
|
const memory = /* @__PURE__ */ new Map();
|
|
78
|
+
/**
|
|
79
|
+
* 最后一次零额度探针的结论(`TokenStatus.valid` / `checkedAt` 的唯一生产者)。
|
|
80
|
+
*
|
|
81
|
+
* 只活在本进程:重启即回到「未检查过」,那是诚实答案——`valid: null` 的语义本来
|
|
82
|
+
* 就是「没检查过」,不是「检查过但没结论」。
|
|
83
|
+
*
|
|
84
|
+
* **只在令牌换手时清**(`save` / `forget`):上一次的结论只对**那一把**令牌成立。
|
|
85
|
+
* 换令牌后留着旧结论,等于让一个关于别的值的判断继续替新令牌说话——正是
|
|
86
|
+
* `ephemeral` 那条修正反对的「不是判出来的、是碰巧对的」。env 在 DSH 启动时读
|
|
87
|
+
* 一次,进程内不会换手,所以这两个入口是全部。
|
|
88
|
+
*/
|
|
89
|
+
let lastCheck = null;
|
|
72
90
|
const resolveService = () => {
|
|
73
91
|
return (typeof credentials === "function" ? credentials() : credentials) ?? null;
|
|
74
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* 解析当前令牌与来源(实现体)。
|
|
95
|
+
*
|
|
96
|
+
* 提成局部函数而不是只做返回对象上的方法:`state()` 也要走同一条路,而方法里
|
|
97
|
+
* 调 `this.resolve()` 是一个**解构即崩**的 footgun——`const { state } =
|
|
98
|
+
* createTokenStore(...)` 之后 `this` 是 undefined,调用直接 TypeError。当前
|
|
99
|
+
* 调用点都规规矩矩用方法调用所以没炸,但那是运气,不是设计。返回类型显式
|
|
100
|
+
* 注解:这是快照 TokenStatus.source 联合类型的上游,靠推断会把字面量联合
|
|
101
|
+
* 拓宽成 string。
|
|
102
|
+
* @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
|
|
103
|
+
*/
|
|
104
|
+
const resolveToken = async () => {
|
|
105
|
+
try {
|
|
106
|
+
const service = resolveService();
|
|
107
|
+
if (service !== null && typeof service.resolve === "function") {
|
|
108
|
+
const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
|
|
109
|
+
const value = verbatim(resolved?.value, "");
|
|
110
|
+
if (typeof value === "string" && value.trim() !== "") return {
|
|
111
|
+
value,
|
|
112
|
+
source: "credentials"
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
const held = memory.get(TOKEN_REF);
|
|
117
|
+
if (typeof held === "string" && held.trim() !== "") return {
|
|
118
|
+
value: held,
|
|
119
|
+
source: "memory"
|
|
120
|
+
};
|
|
121
|
+
const fromEnv = verbatim(env[TOKEN_REF], "");
|
|
122
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
|
|
123
|
+
value: fromEnv,
|
|
124
|
+
source: "env"
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
value: "",
|
|
128
|
+
source: null
|
|
129
|
+
};
|
|
130
|
+
};
|
|
75
131
|
return {
|
|
76
132
|
/**
|
|
77
133
|
* 把面板输入的令牌存成引用;原样存(不 trim),空白值视为没输入。
|
|
@@ -95,6 +151,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
95
151
|
await service.set(TOKEN_REF, value);
|
|
96
152
|
memory.set(TOKEN_REF, value);
|
|
97
153
|
} else memory.set(TOKEN_REF, value);
|
|
154
|
+
lastCheck = null;
|
|
98
155
|
},
|
|
99
156
|
/**
|
|
100
157
|
* 忘掉面板保存的令牌。env 回退**不动**——清面板引用不能删操作者的 .env。
|
|
@@ -107,46 +164,39 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
107
164
|
*/
|
|
108
165
|
async forget() {
|
|
109
166
|
memory.delete(TOKEN_REF);
|
|
167
|
+
lastCheck = null;
|
|
110
168
|
const service = resolveService();
|
|
111
169
|
if (service !== null && typeof service.unset === "function") await service.unset(TOKEN_REF);
|
|
112
170
|
},
|
|
113
171
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
172
|
+
* 记录一次**零额度探针**的结论(probe 路由是唯一调用方)。
|
|
173
|
+
*
|
|
174
|
+
* 只接已经判出来的结论:`true`(200/400,令牌好)或 `false`(401/403,令牌坏)。
|
|
175
|
+
* 429 / 上游错误 / 配置错误**不调用**——那些回答的是「上游现在忙不忙」或
|
|
176
|
+
* 「这次请求成没成」,不是「这把令牌好不好」,把它们记进 `valid` 就是把限频
|
|
177
|
+
* 说成令牌坏(正是 429 分诊那条修正反对的误判)。不调用即留着上一次的结论,
|
|
178
|
+
* 那是已知的最好答案。
|
|
179
|
+
*/
|
|
180
|
+
noteValidity(valid) {
|
|
181
|
+
lastCheck = {
|
|
182
|
+
valid,
|
|
183
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
/**
|
|
187
|
+
* 解析当前令牌与来源。
|
|
116
188
|
* @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
|
|
117
189
|
*/
|
|
118
190
|
async resolve() {
|
|
119
|
-
|
|
120
|
-
const service = resolveService();
|
|
121
|
-
if (service !== null && typeof service.resolve === "function") {
|
|
122
|
-
const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
|
|
123
|
-
const value = verbatim(resolved?.value, "");
|
|
124
|
-
if (typeof value === "string" && value.trim() !== "") return {
|
|
125
|
-
value,
|
|
126
|
-
source: "credentials"
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
} catch {}
|
|
130
|
-
const held = memory.get(TOKEN_REF);
|
|
131
|
-
if (typeof held === "string" && held.trim() !== "") return {
|
|
132
|
-
value: held,
|
|
133
|
-
source: "memory"
|
|
134
|
-
};
|
|
135
|
-
const fromEnv = verbatim(env[TOKEN_REF], "");
|
|
136
|
-
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
|
|
137
|
-
value: fromEnv,
|
|
138
|
-
source: "env"
|
|
139
|
-
};
|
|
140
|
-
return {
|
|
141
|
-
value: "",
|
|
142
|
-
source: null
|
|
143
|
-
};
|
|
191
|
+
return resolveToken();
|
|
144
192
|
},
|
|
145
193
|
/**
|
|
146
194
|
* 无秘密的状态描述(快照与面板消费)。
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
195
|
+
*
|
|
196
|
+
* `valid` / `checkedAt` 来自 `noteValidity`(零额度探针的结论),**本模块自己
|
|
197
|
+
* 不做有效性断言**——判定规则在 probe 路由(401/403 = 令牌坏、200/400 = 令牌好,
|
|
198
|
+
* 2026-10-04 真机回填;SPIKE.md §结论 5)。没探过就是 `null`,那不是「无效」,
|
|
199
|
+
* 是「没检查过」。
|
|
150
200
|
*
|
|
151
201
|
* `ephemeral` 答的是「**手里这个值**重启会不会丢」,不是「有没有凭据服务」
|
|
152
202
|
* ——wire 的定义是前者。曾经只按「服务缺席」置位,于是两种都说谎:没有服务
|
|
@@ -155,12 +205,12 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
155
205
|
* `memory` 才真的只在本次进程存活期间存在。
|
|
156
206
|
*/
|
|
157
207
|
async state() {
|
|
158
|
-
const { source } = await
|
|
208
|
+
const { source } = await resolveToken();
|
|
159
209
|
return {
|
|
160
210
|
present: source !== null,
|
|
161
211
|
source: source === null ? "none" : source,
|
|
162
|
-
valid: null,
|
|
163
|
-
checkedAt: null,
|
|
212
|
+
valid: lastCheck === null ? null : lastCheck.valid,
|
|
213
|
+
checkedAt: lastCheck === null ? null : lastCheck.checkedAt,
|
|
164
214
|
ephemeral: source === "memory"
|
|
165
215
|
};
|
|
166
216
|
}
|
|
@@ -170,16 +220,16 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
170
220
|
//#endregion
|
|
171
221
|
//#region src/host/state-store.ts
|
|
172
222
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
223
|
+
* 状态文件公共原语。**TL;DR**:本文件只做四件事——`dshHome`/`stateDir`(定位
|
|
224
|
+
* 状态目录)、`profileSegment`(按 profile 分段路径)、{@link createStateReadCache}
|
|
225
|
+
* (统一读缓存)、以及写侧三件套({@link createStateWriteQueue} 写串行化 /
|
|
226
|
+
* {@link guardStateWrite} ADR-006 写守卫 / {@link stateVersionNote} 读侧版本说明)。
|
|
227
|
+
* 其余一切遵守下方「行为约定」。
|
|
176
228
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* peer-free 与四个 store 同纪律:不 import 任何 Host peer,纯 `node:fs`,
|
|
182
|
-
* 离线可测(store.test.mjs 直接注入 dir 构造即可)。
|
|
229
|
+
* 把本插件各 store(usage-store / provider / draw)各自手写的同一段
|
|
230
|
+
* "版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略" 收敛到这里。
|
|
231
|
+
* 家规:peer-free(不 import 任何 Host peer,纯 `node:fs`)、离线可测
|
|
232
|
+
* (store.test.mjs 直接注入 dir 构造即可)。
|
|
183
233
|
*
|
|
184
234
|
* 行为约定(与本插件 store 的约定逐一对齐):
|
|
185
235
|
* - 目录:`$DSH_HOME/state/<name>`——与 Host 自己的目录并列,而不是在
|
|
@@ -187,29 +237,39 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
187
237
|
* - 写:临时文件(0600,owner-only)→ `rename` 原子落位。**失败抛错**,
|
|
188
238
|
* 是否吞错是各 store 的语义(usage-store 面对只读 Home 选择吞、
|
|
189
239
|
* provider 面板开关交给调用方的错误路径),原语不做决定。
|
|
190
|
-
* - 临时名:进程 + 时间戳 + 随机 UUID
|
|
191
|
-
*
|
|
192
|
-
* 两次异步写也会撞名(`writeFile` 截断覆盖后一次 `rename` 静默丢写),
|
|
193
|
-
* 随机后缀让每个 temp 路径唯一,`rename` 原子性借此成立(见 `temporaryOf`)。
|
|
194
|
-
* 此前的写法曾只有进程 + 时间戳,同毫秒并发写会静默丢一条。
|
|
240
|
+
* - 临时名:进程 + 时间戳 + 随机 UUID 后缀。随机后缀让每个 temp 路径唯一,
|
|
241
|
+
* `rename` 原子性借此成立(见 `temporaryOf`)。
|
|
195
242
|
* - 读:缺失、不可读、非 JSON 一律返回 `null`——"损坏即忽略"的方向。是否
|
|
196
243
|
* 缓存、缓存多久由 {@link createStateReadCache} 决定,不是每个 store 各自的
|
|
197
244
|
* 即兴实现。
|
|
198
245
|
*
|
|
246
|
+
* ## 演变记录(历史,非契约)
|
|
247
|
+
* 1. 第一步收敛的是上述「版本载荷 + 临时文件 + 原子落位」;临时名此前只有
|
|
248
|
+
* 进程 + 时间戳,同毫秒并发写会静默丢一条,故加随机后缀。
|
|
249
|
+
* 2. 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全
|
|
250
|
+
* 没有(进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了
|
|
251
|
+
* 两个、漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
|
|
252
|
+
* 3. 第三步收敛的是**写侧三件套**。这三个 store 曾各自手写:provider 有
|
|
253
|
+
* writeChain、usage 有 writeChain、draw **两个都没有**(并发保存丢一次、
|
|
254
|
+
* 磁盘 version 比本构建新时降级覆写、读侧静默读作「没设过」——2026-10-17
|
|
255
|
+
* 实测复现,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条)。
|
|
256
|
+
* 现在三件套各有一份公共实现:串行化是{@link createStateWriteQueue}、写守卫是
|
|
257
|
+
* {@link guardStateWrite}、读侧说明是{@link stateVersionNote};provider /
|
|
258
|
+
* usage / draw 三个 store 都是调用方,不再各写一遍。
|
|
259
|
+
*
|
|
199
260
|
* @module dsh-connect-modelscope-token-plan/state-store
|
|
200
261
|
*/
|
|
201
262
|
/**
|
|
202
|
-
*
|
|
203
|
-
* @returns {string}
|
|
263
|
+
* DSH 主目录:操作者导出了 `DSH_HOME` 就用它,否则回落到 `~/.dsh`。
|
|
264
|
+
* @returns {string} 主目录。
|
|
204
265
|
*/
|
|
205
266
|
function dshHome() {
|
|
206
267
|
return str(process.env.DSH_HOME, join(homedir(), ".dsh"));
|
|
207
268
|
}
|
|
208
269
|
/**
|
|
209
|
-
*
|
|
210
|
-
* @param {string} name -
|
|
211
|
-
*
|
|
212
|
-
* @returns {string} the directory.
|
|
270
|
+
* 本插件存放状态的位置:`$DSH_HOME/state/<name>`。
|
|
271
|
+
* @param {string} name - 插件自己的状态目录名(即 `host-config.ts` 的 `name`)。
|
|
272
|
+
* @returns {string} 目录路径。
|
|
213
273
|
*/
|
|
214
274
|
function stateDir(name) {
|
|
215
275
|
return join(dshHome(), "state", name);
|
|
@@ -229,9 +289,9 @@ function stateDir(name) {
|
|
|
229
289
|
const PROFILE_SEGMENT_MAX = 64;
|
|
230
290
|
const PROFILE_SEGMENT_RE = /^(?!\.)[A-Za-z0-9._-]+$/;
|
|
231
291
|
/**
|
|
232
|
-
*
|
|
233
|
-
* @param {unknown} value -
|
|
234
|
-
* @returns {boolean}
|
|
292
|
+
* 该字符串能否安全地作为**一个**路径段使用?
|
|
293
|
+
* @param {unknown} value - 候选 profile 名。
|
|
294
|
+
* @returns {boolean} 通过 {@link PROFILE_SEGMENT_RE} 即为 true。
|
|
235
295
|
*/
|
|
236
296
|
function isProfileSegment(value) {
|
|
237
297
|
if (typeof value !== "string") return false;
|
|
@@ -263,8 +323,8 @@ function isProfileSegment(value) {
|
|
|
263
323
|
*
|
|
264
324
|
* 取到 = 调用方据此分段;取不到 = **退回当前的全局路径**,行为零漂移。
|
|
265
325
|
*
|
|
266
|
-
* @param {object} [ctx] -
|
|
267
|
-
* @returns {string|null}
|
|
326
|
+
* @param {object} [ctx] - Host 交给 `apply()` 的 Cordis context。
|
|
327
|
+
* @returns {string|null} profile 名,取不到或不安全时为 `null`。
|
|
268
328
|
*/
|
|
269
329
|
function profileSegment(ctx) {
|
|
270
330
|
if (ctx === null || typeof ctx !== "object") return null;
|
|
@@ -510,22 +570,46 @@ async function readStateJson(file) {
|
|
|
510
570
|
}
|
|
511
571
|
}
|
|
512
572
|
/**
|
|
513
|
-
* Read the persisted `version` field of one state file
|
|
514
|
-
* absent, unreadable, non-JSON, or carries no numeric `version`.
|
|
573
|
+
* Read the persisted `version` field of one state file.
|
|
515
574
|
*
|
|
516
575
|
* This is the ADR-006 write-side guard's probe: a writer must know what it is
|
|
517
|
-
* about to overwrite.
|
|
518
|
-
*
|
|
519
|
-
*
|
|
576
|
+
* about to overwrite. Three outcomes, and the difference between the first two
|
|
577
|
+
* is load-bearing:
|
|
578
|
+
*
|
|
579
|
+
* - `null` — ABSENT (ENOENT), non-JSON, or carrying no numeric `version`.
|
|
580
|
+
* Nothing here the guard could protect, so a write belongs: a corrupt file
|
|
581
|
+
* must stay replaceable, or an operator would be stuck with it forever.
|
|
582
|
+
* - a number — the version on disk. One this build does not recognise was
|
|
583
|
+
* written by a NEWER build and must not be clobbered.
|
|
584
|
+
* - **throws** — the path EXISTS but could not be read (EACCES/EBUSY/EIO, or a
|
|
585
|
+
* directory sitting at the path). The guard must read that as "there is
|
|
586
|
+
* something here and I cannot see it", never as "nothing stored": collapsing
|
|
587
|
+
* it into `null` made the anti-clobber guard fail OPEN, so the very
|
|
588
|
+
* downgrade it exists to prevent came through that seam.
|
|
520
589
|
*
|
|
521
590
|
* Kept apart from `readStateJson` on purpose: the guard needs the version even
|
|
522
|
-
* when the rest of the payload is unparseable,
|
|
523
|
-
*
|
|
591
|
+
* when the rest of the payload is unparseable, it must not depend on any store's
|
|
592
|
+
* parse semantics, and it must NOT inherit the read path's deliberate "anything
|
|
593
|
+
* unrecognised reads as not set" permissiveness — that permissiveness is right
|
|
594
|
+
* for a reader and wrong for a writer.
|
|
524
595
|
* @param {string} file - the state file path.
|
|
525
|
-
* @returns {Promise<number|null>} the persisted version, or `null
|
|
596
|
+
* @returns {Promise<number|null>} the persisted version, or `null` when absent/unusable.
|
|
597
|
+
* @throws when the path exists but cannot be read — callers must refuse to write.
|
|
526
598
|
*/
|
|
527
599
|
async function readStateVersion(file) {
|
|
528
|
-
|
|
600
|
+
let text;
|
|
601
|
+
try {
|
|
602
|
+
text = await readFile(file, "utf8");
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (error?.code === "ENOENT") return null;
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
let raw;
|
|
608
|
+
try {
|
|
609
|
+
raw = JSON.parse(text);
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
529
613
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
530
614
|
const version = raw.version;
|
|
531
615
|
return typeof version === "number" ? version : null;
|
|
@@ -543,11 +627,113 @@ async function readStateVersion(file) {
|
|
|
543
627
|
function isKnownStateVersion(version, known) {
|
|
544
628
|
return version === null || known.includes(version);
|
|
545
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* 写串行化队列——store 的「读-改-写必须排队」的那把链。
|
|
632
|
+
*
|
|
633
|
+
* 为什么这不是某个 store 的私事:三个 store(provider / usage / draw)各写过
|
|
634
|
+
* 一份一模一样的 `let writeChain = Promise.resolve(...)`,而 draw 曾经**两份都
|
|
635
|
+
* 没有**——并发 `saveEnabled(true)` + `saveModelId(...)` 各自读到同一个 current,
|
|
636
|
+
* 后写的赢,前一次保存被整个丢掉(2026-10-17 实测:落盘只剩其中一个字段)。
|
|
637
|
+
* provider-store 的注释记着这类漂移「咬过三次」,draw 是第四条。抽到这里,
|
|
638
|
+
* 第四个 store 起只需调用,没有「再手写一遍」这个选项。
|
|
639
|
+
*
|
|
640
|
+
* 语义(与 provider / usage 此前的私有实现逐字一致):
|
|
641
|
+
* - `task` 在上一个任务落幕后才起动,临界区内重新读盘(基线不来自 TTL 缓存);
|
|
642
|
+
* - **调用方拿到的是 `run` 本身**——拒绝照旧传播(provider 的 `save()` 要抛给
|
|
643
|
+
* 路由、usage 的 `recordCall()` 也要抛),因为吞掉失败是调用方的语义决定;
|
|
644
|
+
* - 链自己吞掉失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
|
|
645
|
+
*
|
|
646
|
+
* peer-free,与其余原语同纪律。
|
|
647
|
+
* @returns {{
|
|
648
|
+
* enqueue: <T>(task: () => Promise<T>) => Promise<T>
|
|
649
|
+
* }} 排队执行一次任务。
|
|
650
|
+
*/
|
|
651
|
+
function createStateWriteQueue() {
|
|
652
|
+
let chain = Promise.resolve();
|
|
653
|
+
return { enqueue(task) {
|
|
654
|
+
const run = chain.then(task);
|
|
655
|
+
chain = run.then(() => void 0, () => void 0);
|
|
656
|
+
return run;
|
|
657
|
+
} };
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* ADR-006 写侧守卫:本构建读不懂的版本,不许覆盖。
|
|
661
|
+
*
|
|
662
|
+
* 磁盘上出现一个**本构建不认识的数字版本**,只可能是更新的构建写的;用一个旧
|
|
663
|
+
* 载荷盖掉它,销毁的是我们根本看不见的数据(实测过一次 version 2 → 1 的降级
|
|
664
|
+
* 覆写,110 次历史归零)。所以写入前先问盘,三种答案:
|
|
665
|
+
* - `null`(没有文件 / 没有版本 / 损坏)——没有什么可保护,写是恢复手段,放行;
|
|
666
|
+
* - 已知版本——放行;
|
|
667
|
+
* - 未知数字版本,或文件**在场却读不出来**(EACCES/EBUSY/EIO)——拒写。
|
|
668
|
+
* 后者由 {@link readStateVersion} 抛出来(它只把 ENOENT 读作「没有文件」),
|
|
669
|
+
* 这里接住并拒写:「读不到」不等于「没有东西要保护」。
|
|
670
|
+
*
|
|
671
|
+
* 拒写返回一句**已 degrade 记录**的原因(调用方决定要不要再抛给用户——provider
|
|
672
|
+
* 的 save 抛、draw 的 saveEnabled 抛、usage 的 recordCall 静默 no-op),而不是
|
|
673
|
+
* 静默 no-op: swallow the failure, not the reason。
|
|
674
|
+
*
|
|
675
|
+
* @param {string} file - 目标状态文件路径(消息里只取 basename)。
|
|
676
|
+
* @param {readonly number[]} known - 本构建读得懂的版本集合。
|
|
677
|
+
* @param {{label: string, logger?: {warn?: (m: string) => void}|undefined}} options -
|
|
678
|
+
* `label` 进拒绝消息的前缀(如 `"provider"` / `"draw-state"`)。`logger` 的
|
|
679
|
+
* `| undefined` 不是冗余:tsconfig 开了 `exactOptionalPropertyTypes`,而各 store
|
|
680
|
+
* 解构出来的 `logger` 本身就带着 `undefined`(属性可选),不写这一半编译不过。
|
|
681
|
+
* @returns {Promise<string|null>} 拒绝原因(已记日志),放行时为 `null`。
|
|
682
|
+
*/
|
|
683
|
+
async function guardStateWrite(file, known, options) {
|
|
684
|
+
const base = basename(file);
|
|
685
|
+
let existing;
|
|
686
|
+
try {
|
|
687
|
+
existing = await readStateVersion(file);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
const reason = `${options.label}: refusing to overwrite ${base} (cannot read it: ${redactSecrets(errMsg(error))})`;
|
|
690
|
+
degrade(reason, null, options.logger, null);
|
|
691
|
+
return reason;
|
|
692
|
+
}
|
|
693
|
+
if (!isKnownStateVersion(existing, known)) {
|
|
694
|
+
const reason = `${options.label}: refusing to overwrite ${base} holding version ${existing} (this build knows ${known.join("/")})`;
|
|
695
|
+
degrade(reason, null, options.logger, null);
|
|
696
|
+
return reason;
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* 读侧版本说明:磁盘上那份文件带着本构建读不懂的版本时,给操作者的一句话。
|
|
702
|
+
*
|
|
703
|
+
* 为什么读侧宽容还要说话:写侧守卫挡住了覆盖,读侧按设计把未知版本**宽容地**
|
|
704
|
+
* 读作「没设过」——于是「保存的值被忽略」在面板上呈现成「从未保存过」。对
|
|
705
|
+
* provider 的允许清单,这两者读出的方向是危险的(空清单 = 不过滤 = 提供全部
|
|
706
|
+
* 模型);对 draw 的开关,回退方向是 patch 默认(通常 = 关)。闸与说明是同一条
|
|
707
|
+
* 承诺的两半,缺了哪半都静默。
|
|
708
|
+
*
|
|
709
|
+
* `consequence` 由调用方给:每个 store 知道自己「读作没设过」意味着什么,公共
|
|
710
|
+
* 原语只负责把文件名、版本号、已知版本集合与下一步动作说全。
|
|
711
|
+
*
|
|
712
|
+
* @param {object} options
|
|
713
|
+
* @param {string} options.label - 消息前缀(如 `"provider-state"`)。
|
|
714
|
+
* @param {string} options.file - 状态文件路径(消息里只取 basename)。
|
|
715
|
+
* @param {number} options.version - 盘上的版本号(调用方已判过是未知的)。
|
|
716
|
+
* @param {readonly number[]} options.known - 本构建读得懂的版本集合。
|
|
717
|
+
* @param {string} options.consequence - 「读作没设过」对这个 store 意味着什么。
|
|
718
|
+
* @returns {string} 人读的一句警告。
|
|
719
|
+
*/
|
|
720
|
+
function stateVersionNote(options) {
|
|
721
|
+
return `${options.label}: ${basename(options.file)} holds version ${options.version}, which this build cannot read (this build knows ${options.known.join("/")}); ${options.consequence} Upgrade this build to see the saved values, or delete the file to reset them.`;
|
|
722
|
+
}
|
|
546
723
|
|
|
547
724
|
//#endregion
|
|
548
|
-
//#region src/
|
|
725
|
+
//#region src/shared/codes.ts
|
|
549
726
|
/**
|
|
550
|
-
*
|
|
727
|
+
* 错误分类码表的**单一真源**——Host 与 Client 同读这一份。
|
|
728
|
+
*
|
|
729
|
+
* 为什么从 `host/codes.ts` 搬来:这张表曾经在 Client 侧有一份「刻意副本」
|
|
730
|
+
* (`client/snapshot.ts` 的 `CLIENT_CODE`),理由是「浏览器无法 import Host
|
|
731
|
+
* 模块」。那个理由已经过期——`shared/wire.ts` 的运行时值(如
|
|
732
|
+
* `SNAPSHOT_REQUIRED_KEYS`)早就在双端 import,浏览器 bundle 拿得到 shared/
|
|
733
|
+
* 下的任何模块。两份表必然漂移,而漂移的后果是最需要人看的内部错误没有引导
|
|
734
|
+
* 文案(INTERNAL_ERROR 当初就是这么漏的)。码表住 shared,Host 的分类函数
|
|
735
|
+
* (`classifyStatus` / `classifyRateLimit` / `parseRetryAfterMs`)留在
|
|
736
|
+
* `host/codes.ts`——那些读上游响应,是 Host 独有的行为。
|
|
551
737
|
*
|
|
552
738
|
* 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
|
|
553
739
|
* 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
|
|
@@ -556,7 +742,10 @@ function isKnownStateVersion(version, known) {
|
|
|
556
742
|
*
|
|
557
743
|
* @module dsh-connect-modelscope-token-plan/codes
|
|
558
744
|
*/
|
|
559
|
-
/**
|
|
745
|
+
/**
|
|
746
|
+
* 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约
|
|
747
|
+
* (且以 `CodeValue` 为**类型**契约:缺键是编译错误,不再只靠测试红)。
|
|
748
|
+
*/
|
|
560
749
|
const CODE = Object.freeze({
|
|
561
750
|
CONFIG_ERROR: "config_error",
|
|
562
751
|
NETWORK_ERROR: "network_error",
|
|
@@ -573,19 +762,41 @@ const CODE = Object.freeze({
|
|
|
573
762
|
* 「每个键必须在 CODE 里」),最需要人看的内部错误反而没有引导文案。
|
|
574
763
|
* 正式收进码表,两端从此同一份真源。
|
|
575
764
|
*/
|
|
576
|
-
INTERNAL_ERROR: "internal_error"
|
|
765
|
+
INTERNAL_ERROR: "internal_error",
|
|
766
|
+
/**
|
|
767
|
+
* Client 自产码:Host 响应无法解读成快照(非 JSON / 缺 ok 字段)时,
|
|
768
|
+
* 面板用它走引导文案,而不是把裸英文串("unexpected payload")上屏。
|
|
769
|
+
*
|
|
770
|
+
* 本表是 `GUIDANCE_BY_CODE` 的键集真源——新增 client 码必须同时进
|
|
771
|
+
* 这张表,两侧才同步。
|
|
772
|
+
*/
|
|
773
|
+
PAYLOAD_ERROR: "payload_error"
|
|
577
774
|
});
|
|
775
|
+
|
|
776
|
+
//#endregion
|
|
777
|
+
//#region src/host/codes.ts
|
|
778
|
+
/**
|
|
779
|
+
* Host 侧的 429 分诊分类函数。**码表本身已迁至 `shared/codes.ts`**(双端同读,
|
|
780
|
+
* 见那里的搬迁理由);本文件只剩「读上游响应」的 Host 独有行为,并把 CODE
|
|
781
|
+
* 再导出一次,让 Host 各模块的 `from "./codes.ts"` 导入路径不变。
|
|
782
|
+
*
|
|
783
|
+
* 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
|
|
784
|
+
* 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
|
|
785
|
+
* QUOTA_EXCEEDED 快速失败不重试,RATE_LIMITED 交给调用方退避。分不清时归
|
|
786
|
+
* RATE_LIMITED(退避是两方向里更安全的那个)。
|
|
787
|
+
*
|
|
788
|
+
* @module dsh-connect-modelscope-token-plan/codes
|
|
789
|
+
*/
|
|
578
790
|
/**
|
|
579
791
|
* 把上游 HTTP 状态归类成稳定码。
|
|
580
792
|
*
|
|
581
793
|
* 401/403 → AUTH_ERROR(换令牌能修,绝不自动重试——魔搭没有锁号问题,
|
|
582
794
|
* 但重试一个坏令牌只会刷屏);429 由调用方先走 {@link classifyRateLimit}
|
|
583
|
-
*
|
|
795
|
+
* 分诊,这里只兜底;其余 4xx/5xx → UPSTREAM_ERROR;低于 400 → NETWORK_ERROR。
|
|
584
796
|
*/
|
|
585
797
|
function classifyStatus(status) {
|
|
586
798
|
if (status === 401 || status === 403) return CODE.AUTH_ERROR;
|
|
587
799
|
if (status === 429) return CODE.RATE_LIMITED;
|
|
588
|
-
if (status >= 500) return CODE.UPSTREAM_ERROR;
|
|
589
800
|
if (status >= 400) return CODE.UPSTREAM_ERROR;
|
|
590
801
|
return CODE.NETWORK_ERROR;
|
|
591
802
|
}
|
|
@@ -617,11 +828,7 @@ function classifyStatus(status) {
|
|
|
617
828
|
function classifyRateLimit(message) {
|
|
618
829
|
if (typeof message !== "string" || message.length === 0) return "rate_limit";
|
|
619
830
|
if (looksLikeRateLimit(message)) return "rate_limit";
|
|
620
|
-
return
|
|
621
|
-
}
|
|
622
|
-
/** 文本是否含硬额度措辞(真耗尽,不纠正)——直接问 llm-error-fix 的那张表。 */
|
|
623
|
-
function hasHardQuotaWording(message) {
|
|
624
|
-
return hasHardQuotaWordingIn(message);
|
|
831
|
+
return hasHardQuotaWordingIn(message) ? "quota" : "rate_limit";
|
|
625
832
|
}
|
|
626
833
|
/**
|
|
627
834
|
* 解析 Retry-After(秒或 HTTP 日期)与平台文案里写明的等待时长。
|
|
@@ -684,6 +891,19 @@ function parseRetryAfterMs(headers, message) {
|
|
|
684
891
|
function localDateKey(now) {
|
|
685
892
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
686
893
|
}
|
|
894
|
+
/**
|
|
895
|
+
* 路径在不在(任何 fs 错误都读作「不在」——存在性问的是能不能 `ls` 到它。
|
|
896
|
+
* EACCES 让 `stat` 失败时,真相其实是「在,只是看不见」,而对下面的判据来说
|
|
897
|
+
* 那同样不能被当成「没有记录」)。
|
|
898
|
+
*/
|
|
899
|
+
async function pathExists(file) {
|
|
900
|
+
try {
|
|
901
|
+
await stat(file);
|
|
902
|
+
return true;
|
|
903
|
+
} catch {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
687
907
|
const STATE_VERSION = 1;
|
|
688
908
|
const KNOWN_VERSIONS = [STATE_VERSION];
|
|
689
909
|
/** 解析并校验磁盘载荷;形状不对读作 null(损坏即忽略),并标记异常。 */
|
|
@@ -773,8 +993,10 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
773
993
|
events: []
|
|
774
994
|
};
|
|
775
995
|
let loaded = false;
|
|
776
|
-
/** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。
|
|
777
|
-
|
|
996
|
+
/** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。
|
|
997
|
+
队列实现在 state-store({@link createStateWriteQueue})——provider / draw
|
|
998
|
+
同款,三份私有副本正是 draw-store 曾经三件全缺的入口。 */
|
|
999
|
+
const writeQueue = createStateWriteQueue();
|
|
778
1000
|
/**
|
|
779
1001
|
* 磁盘版本闸 —— 读路径与写路径**各自**调用,不共享副作用。
|
|
780
1002
|
*
|
|
@@ -790,7 +1012,16 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
790
1012
|
* @returns `true` = 磁盘版本比本构建新(写必须拒绝);`false` = 可以写。
|
|
791
1013
|
*/
|
|
792
1014
|
const diskVersionBlocksWrites = async () => {
|
|
793
|
-
|
|
1015
|
+
let onDisk;
|
|
1016
|
+
try {
|
|
1017
|
+
onDisk = await readStateVersion(file);
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
const note = `usage-state: usage.json exists but cannot be read (${redactSecrets(errMsg(error))})`;
|
|
1020
|
+
if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
|
|
1021
|
+
anomaly = note;
|
|
1022
|
+
readOnly = true;
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
794
1025
|
if (onDisk !== null && !isKnownStateVersion(onDisk, KNOWN_VERSIONS)) {
|
|
795
1026
|
const note = `usage-state: on-disk version ${onDisk} is newer than this build knows`;
|
|
796
1027
|
if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
|
|
@@ -820,23 +1051,19 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
820
1051
|
cache.remember(payload);
|
|
821
1052
|
};
|
|
822
1053
|
/** 排队执行一次读-改-写;只读闸置位时整个变更是 no-op。 */
|
|
823
|
-
const enqueue = (mutate) => {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
});
|
|
837
|
-
writeChain = run.catch(() => {});
|
|
838
|
-
return run;
|
|
839
|
-
};
|
|
1054
|
+
const enqueue = (mutate) => writeQueue.enqueue(async () => {
|
|
1055
|
+
if (await diskVersionBlocksWrites()) return;
|
|
1056
|
+
const payload = await readThrough() ?? (loaded ? current : null) ?? {
|
|
1057
|
+
version: STATE_VERSION,
|
|
1058
|
+
days: {},
|
|
1059
|
+
models: {},
|
|
1060
|
+
events: []
|
|
1061
|
+
};
|
|
1062
|
+
mutate(payload);
|
|
1063
|
+
current = payload;
|
|
1064
|
+
loaded = true;
|
|
1065
|
+
await persist(payload);
|
|
1066
|
+
});
|
|
840
1067
|
/** 修剪:只留最近 trendDays 个天桶(按 dateKey 排序,今天不删)。 */
|
|
841
1068
|
const pruneDays = (payload, todayKey) => {
|
|
842
1069
|
const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, trendDays)));
|
|
@@ -891,16 +1118,47 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
891
1118
|
}, ...payload.events].slice(0, maxEvents);
|
|
892
1119
|
});
|
|
893
1120
|
},
|
|
894
|
-
/**
|
|
1121
|
+
/**
|
|
1122
|
+
* 今天(本地时区)的账户级计数。
|
|
1123
|
+
*
|
|
1124
|
+
* 三种答案,三种含义,**不可合并**(wire.ts DailyUsage 的语义基线):
|
|
1125
|
+
* - `0` —— 确实一次都没有(没记录,或今天还没有桶);这是可信的日常值;
|
|
1126
|
+
* - `null` —— **读不到**:文件在,但本构建看不懂它(损坏 / 形状不对 / 更新
|
|
1127
|
+
* 构建写的版本 / 存在却不可读)。面板渲染「—」;
|
|
1128
|
+
* - 数字 —— 今日次数。
|
|
1129
|
+
*
|
|
1130
|
+
* 判据是「文件在不在」而不是「载荷读没读出来」:**文件不存在**才是真实的 0
|
|
1131
|
+
* (这台 Host 从未经本插件调用过,不是数据丢失),而**文件在却读成空**代表
|
|
1132
|
+
* 我们失去了那部分信息——把它报成 0 会让操作者以为今天没调用过,真相是他
|
|
1133
|
+
* 不知道。
|
|
1134
|
+
*
|
|
1135
|
+
* 这是 2026-10-16 补上的一块拼图:`routes.test.mjs` 早有一条
|
|
1136
|
+
* 「本地计数读不到 → null,绝不是 0」的断言,但它只能用一个**会抛错的桩**
|
|
1137
|
+
* 造出那条路径(注释原话:「挂载的真实 store 造不出『读的时候抛』」)——
|
|
1138
|
+
* 真实 store 把所有读失败都吞成了空载荷。于是 null 那份语义在 wire.ts、
|
|
1139
|
+
* `cards.ts`、`snapshot-aggregate.ts` 里写了三遍,却从未在本机真的产出过。
|
|
1140
|
+
*
|
|
1141
|
+
* @returns {{dateKey: string, calls: number|null, tokens: number|null}}
|
|
1142
|
+
*/
|
|
895
1143
|
async daily() {
|
|
896
|
-
const
|
|
1144
|
+
const payload = await cache.read();
|
|
1145
|
+
const dateKey = localDateKey(now());
|
|
1146
|
+
if (payload === null) {
|
|
1147
|
+
const stored = await pathExists(file);
|
|
1148
|
+
return {
|
|
1149
|
+
dateKey,
|
|
1150
|
+
calls: stored ? null : 0,
|
|
1151
|
+
tokens: stored ? null : 0
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
const day = payload.days[dateKey] ?? null;
|
|
897
1155
|
return day === null ? {
|
|
898
|
-
dateKey
|
|
1156
|
+
dateKey,
|
|
899
1157
|
calls: 0,
|
|
900
1158
|
tokens: 0
|
|
901
1159
|
} : {
|
|
902
1160
|
...day,
|
|
903
|
-
dateKey
|
|
1161
|
+
dateKey
|
|
904
1162
|
};
|
|
905
1163
|
},
|
|
906
1164
|
/** 今天的单模型计数(只含有记录的模型,dayCalls/dayTokens 口径,均跨日清零)。 */
|
|
@@ -1005,6 +1263,13 @@ const MAX_CACHE_AGE_MS = 36e5;
|
|
|
1005
1263
|
* flight's answer would be served to the account that just signed in.
|
|
1006
1264
|
* Hanging the counters off the map makes every instance over that map share
|
|
1007
1265
|
* one generation state, so the guard holds no matter how the caller builds it.
|
|
1266
|
+
*
|
|
1267
|
+
* `seq` is the ONE monotonic source both bumps draw from (see
|
|
1268
|
+
* {@link clearCoalescedFetch}): `global` is a high-water mark that only a global
|
|
1269
|
+
* clear moves, and `keys` holds one absolute number per individually-cleared
|
|
1270
|
+
* key. `keys` is therefore bounded by the number of distinct keys cleared since
|
|
1271
|
+
* the last global clear — not swept, because a per-key number must outlive any
|
|
1272
|
+
* in-flight flight born under it, while a later global clear wipes them all.
|
|
1008
1273
|
*/
|
|
1009
1274
|
const GENERATIONS = /* @__PURE__ */ new WeakMap();
|
|
1010
1275
|
/** The (created-on-demand) generation state belonging to one cache map. */
|
|
@@ -1013,6 +1278,7 @@ function generationsOf(cache) {
|
|
|
1013
1278
|
if (state === void 0) {
|
|
1014
1279
|
state = {
|
|
1015
1280
|
global: 0,
|
|
1281
|
+
seq: 0,
|
|
1016
1282
|
keys: /* @__PURE__ */ new Map()
|
|
1017
1283
|
};
|
|
1018
1284
|
GENERATIONS.set(cache, state);
|
|
@@ -1034,14 +1300,15 @@ function generationsOf(cache) {
|
|
|
1034
1300
|
*/
|
|
1035
1301
|
function clearCoalescedFetch(cache, inflight, key) {
|
|
1036
1302
|
const gens = generationsOf(cache);
|
|
1303
|
+
gens.seq += 1;
|
|
1037
1304
|
if (key === void 0) {
|
|
1038
|
-
gens.global
|
|
1305
|
+
gens.global = gens.seq;
|
|
1039
1306
|
cache.clear();
|
|
1040
1307
|
inflight.clear();
|
|
1041
1308
|
gens.keys.clear();
|
|
1042
1309
|
return;
|
|
1043
1310
|
}
|
|
1044
|
-
gens.keys.set(key,
|
|
1311
|
+
gens.keys.set(key, gens.seq);
|
|
1045
1312
|
cache.delete(key);
|
|
1046
1313
|
inflight.delete(key);
|
|
1047
1314
|
}
|
|
@@ -1116,6 +1383,66 @@ function createCoalescedFetch(options = {}) {
|
|
|
1116
1383
|
};
|
|
1117
1384
|
}
|
|
1118
1385
|
|
|
1386
|
+
//#endregion
|
|
1387
|
+
//#region src/host/context-catalog.ts
|
|
1388
|
+
/**
|
|
1389
|
+
* 内置上下文窗口策展表——魔搭目录不声明窗口时的第三方真值来源。
|
|
1390
|
+
*
|
|
1391
|
+
* 魔搭 `/v1/models`(及模型详情端点)只回基础标识字段(id/object/owned_by/
|
|
1392
|
+
* created + Tasks 标签),没有窗口字段;未声明窗口的条目会一路落到
|
|
1393
|
+
* `FALLBACK_CONTEXT_WINDOW`(128k)。这张表在「用户全局档位」之外补第三级
|
|
1394
|
+
* 真值:目录声明 > 用户覆盖 > **策展表** > 兜底。
|
|
1395
|
+
*
|
|
1396
|
+
* 数据来自 models.dev 聚合(github.com/anomalyco/models.dev,MIT;api.json
|
|
1397
|
+
* 构建产物)。魔搭 provider 自身在 api.json 只有 7 个不在当前目录的旧模型,
|
|
1398
|
+
* 所以每条真值取的是**同 id 模型在各厂自家 host 下的主流 limit.context**——
|
|
1399
|
+
* 跨 host 抄写的开量窗口是模型出厂规格,不是各网关的部署配额,取多数而非
|
|
1400
|
+
* 最大值:个别网关把窗口抄大(如 openrouter 抄 1M)不代表魔搭侧真开那么宽。
|
|
1401
|
+
*
|
|
1402
|
+
* 维护纪律:
|
|
1403
|
+
* 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-16 实拉 `/v1/models` 全集
|
|
1404
|
+
* 37 个中筛出 19 个);目录下架的条目应随手删除,表不是历史档案。
|
|
1405
|
+
* 2. 每条注明来源 host 与核对日期;有多个数据源时写主流值并留离群说明。
|
|
1406
|
+
* 3. 覆盖优先级由 `curatedWindowOf` 的调用方(inference-client 打标)保证:
|
|
1407
|
+
* 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
|
|
1408
|
+
* 4. 生图/语音模型(Qwen-Image-Edit、ACE-Step 等)无对话上下文,不收。
|
|
1409
|
+
*/
|
|
1410
|
+
/** id → 标称上下文窗口(tokens)。键必须与魔搭目录 id 逐字一致。 */
|
|
1411
|
+
const CURATED_CONTEXT_WINDOWS = {
|
|
1412
|
+
"deepseek-ai/DeepSeek-V4-Flash-0731": 1048576,
|
|
1413
|
+
"deepseek-ai/DeepSeek-V4-Pro": 1048576,
|
|
1414
|
+
"deepseek-ai/DeepSeek-V4-Pro-0813": 1048576,
|
|
1415
|
+
"deepseek-ai/DeepSeek-V4.1-Flash": 1048576,
|
|
1416
|
+
"meituan-longcat/LongCat-Flash-Lite": 256e3,
|
|
1417
|
+
"MiniMax/MiniMax-M1-80k": 1e6,
|
|
1418
|
+
"MiniMax/MiniMax-M3": 1048576,
|
|
1419
|
+
"nex-agi/Nex-N2.5-mini": 262144,
|
|
1420
|
+
"nex-agi/Nex-N2.5-Pro": 262144,
|
|
1421
|
+
"Qwen/Qwen3.5-122B-A10B": 262144,
|
|
1422
|
+
"Qwen/Qwen3.5-27B": 262144,
|
|
1423
|
+
"Qwen/Qwen3.5-35B-A3B": 262144,
|
|
1424
|
+
"Qwen/Qwen3.5-397B-A17B": 262144,
|
|
1425
|
+
"Qwen/Qwen3.8-27B": 262144,
|
|
1426
|
+
"Qwen/Qwen3.8-Flash-Next": 262144,
|
|
1427
|
+
"stepfun-ai/Step-3.5-Flash": 262144,
|
|
1428
|
+
"stepfun-ai/Step-3.7-Flash": 262144,
|
|
1429
|
+
"ZhipuAI/GLM-4.7-Flash": 2e5,
|
|
1430
|
+
"ZhipuAI/GLM-5.2": 1e6
|
|
1431
|
+
};
|
|
1432
|
+
/**
|
|
1433
|
+
* 查策展窗口:命中返回正数,未命中返回 null(调用方继续走兜底)。
|
|
1434
|
+
*
|
|
1435
|
+
* 只认逐字 id——不做大小写/前后缀模糊匹配,宁可查不到落兜底,也不把
|
|
1436
|
+
* 相近 id 的窗口张冠李戴。值合法性再挡一道(表内只应出现正数)。
|
|
1437
|
+
* @param {string} id - 模型 id(魔搭目录原样拼写)。
|
|
1438
|
+
* @returns {number|null} 标称窗口,未收录为 null。
|
|
1439
|
+
*/
|
|
1440
|
+
function curatedWindowOf(id) {
|
|
1441
|
+
if (typeof id !== "string" || id === "") return null;
|
|
1442
|
+
const value = CURATED_CONTEXT_WINDOWS[id];
|
|
1443
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1119
1446
|
//#endregion
|
|
1120
1447
|
//#region src/host/inference-client.ts
|
|
1121
1448
|
/**
|
|
@@ -1245,10 +1572,29 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1245
1572
|
* cacheSeconds。返回归一后的目录条目(entries,M4 用来建 descriptor)与
|
|
1246
1573
|
* 派生的 id 列表(ids,旧消费方继续可用)——两条路线读同一份条目,不会漂移;
|
|
1247
1574
|
* 形状漂移抛 UPSTREAM_ERROR 并带 detail。
|
|
1575
|
+
*
|
|
1576
|
+
* `contextOverride`(tokens)是面板保存的全局上下文窗口覆盖:非 null 时在
|
|
1577
|
+
* 归一**之前**改写每条原始条目——删掉目录可能携带窗口的四个原始拼写键、写上
|
|
1578
|
+
* `contextWindow`。这是唯一能可靠生效的注入点:`normalizeEntry` 按
|
|
1579
|
+
* `context_length/context_window/contextWindow/max_context_tokens` 顺序读
|
|
1580
|
+
* 首个正数,任何只改归一后字段的窄打标都会被 `...source` 展开里的旧键回读
|
|
1581
|
+
* 污染。删四键再写宽形状键,不依赖键序,目录声明与覆盖不可能同时在场。
|
|
1582
|
+
* descriptor、roster、catalogSignature 都从这份归一条目派生,一处打标全链
|
|
1583
|
+
* 路生效;签名含窗口值,覆盖变化自动触发重新注册。
|
|
1584
|
+
*
|
|
1585
|
+
* 覆盖值**参与缓存键**:coalesced 缓存里存的条目已是打标后的形状,若仍用
|
|
1586
|
+
* 字面量 `"models"` 键,保存覆盖后的一个 TTL 内会读到未打标的旧条目(面板
|
|
1587
|
+
* 显示变了、注册却没变)。带指纹后不同覆盖值各自缓存,互不串味。
|
|
1588
|
+
*
|
|
1589
|
+
* 窗口三级真值链(fetchModels 内单点判定):目录声明 > 用户覆盖 >
|
|
1590
|
+
* **策展表**(context-catalog,models.dev 交叉真值,零网络依赖)> 兜底
|
|
1591
|
+
* 128k。目录声明过的条目任何下层都不碰;策展表只在用户没设覆盖时兜在
|
|
1592
|
+
* 兜底之前——它是「更聪明的 128k」,不是新权威。
|
|
1593
|
+
* @param {number|null} [contextOverride] - 全局窗口覆盖(tokens),null = 跟随目录。
|
|
1248
1594
|
*/
|
|
1249
|
-
async fetchModels() {
|
|
1595
|
+
async fetchModels(contextOverride) {
|
|
1250
1596
|
const url = settings.apiBase + "/models";
|
|
1251
|
-
return await read(
|
|
1597
|
+
return await read(`models:${contextOverride ?? "catalog"}`, async () => {
|
|
1252
1598
|
const response = await fetchWithTimeout(url, { method: "GET" });
|
|
1253
1599
|
if (!response.ok) {
|
|
1254
1600
|
const message = await readErrorMessage(response);
|
|
@@ -1265,10 +1611,34 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1265
1611
|
const entries = raws.map((raw) => {
|
|
1266
1612
|
const id = str(raw.id, "");
|
|
1267
1613
|
const tags = tagsById.get(id) ?? [];
|
|
1268
|
-
|
|
1614
|
+
const source = {
|
|
1269
1615
|
...raw,
|
|
1270
1616
|
tasks: tags.length > 0 ? tags : void 0
|
|
1271
|
-
}
|
|
1617
|
+
};
|
|
1618
|
+
const CONTEXT_KEYS = [
|
|
1619
|
+
"context_length",
|
|
1620
|
+
"context_window",
|
|
1621
|
+
"contextWindow",
|
|
1622
|
+
"max_context_tokens"
|
|
1623
|
+
];
|
|
1624
|
+
if (!CONTEXT_KEYS.some((key) => {
|
|
1625
|
+
const value = source[key];
|
|
1626
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
1627
|
+
})) {
|
|
1628
|
+
if (contextOverride != null) {
|
|
1629
|
+
for (const key of CONTEXT_KEYS) delete source[key];
|
|
1630
|
+
source.contextWindow = contextOverride;
|
|
1631
|
+
source.__contextSource = "override";
|
|
1632
|
+
} else {
|
|
1633
|
+
const curated = curatedWindowOf(id);
|
|
1634
|
+
if (curated !== null) {
|
|
1635
|
+
for (const key of CONTEXT_KEYS) delete source[key];
|
|
1636
|
+
source.contextWindow = curated;
|
|
1637
|
+
source.__contextSource = "curated";
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
return normalizeEntry(source);
|
|
1272
1642
|
}).filter((entry) => entry.id !== "");
|
|
1273
1643
|
return {
|
|
1274
1644
|
entries,
|
|
@@ -1314,8 +1684,9 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1314
1684
|
}, settings.cacheSeconds * 1e3);
|
|
1315
1685
|
},
|
|
1316
1686
|
/**
|
|
1317
|
-
* 一次 probe 调用:故意缺 messages
|
|
1318
|
-
*
|
|
1687
|
+
* 一次 probe 调用:故意缺 messages 的请求(**零额度**鉴权探针,语义已真机
|
|
1688
|
+
* 回填,SPIKE.md)。魔搭**不先校验 messages**——缺 messages 返回 200(空壳)
|
|
1689
|
+
* 而非 400,故判定规则是 401/403 = 令牌坏、200/400 = 令牌好、429 = 限频中。
|
|
1319
1690
|
*
|
|
1320
1691
|
* 失败抛 pluginError;调用方(probe 路由)负责把它记进事件流。
|
|
1321
1692
|
*/
|
|
@@ -1389,16 +1760,32 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1389
1760
|
* Integrity follows `state-store.ts`: a versioned payload, a temp file plus an
|
|
1390
1761
|
* atomic rename (two Host processes can share the directory), owner-only
|
|
1391
1762
|
* modes, and "anything unrecognised reads as not set" — a corrupted or
|
|
1392
|
-
* downgraded file costs one re-toggle, never a crash.
|
|
1763
|
+
* downgraded file costs one re-toggle, never a crash. The write side refuses
|
|
1764
|
+
* to overwrite a version this build cannot read (ADR-006); the read side stays
|
|
1765
|
+
* permissive but REPORTS the disagreement via `versionNote()`, because for the
|
|
1766
|
+
* allow-list "unreadable" and "never saved" would otherwise collapse to the
|
|
1767
|
+
* same answer — and that answer is "offer every model".
|
|
1393
1768
|
*
|
|
1394
1769
|
* The allow-list's empty-vs-absent distinction is the one this module guards:
|
|
1395
1770
|
* an EMPTY list means "no filter, offer every model", while `HIDE_ALL_MODELS`
|
|
1396
1771
|
* (`llm-models.ts`) is the caller-side sentinel for "offer nothing". Neither
|
|
1397
1772
|
* spelling is stored here as `null` — absence of a saved list reads as "no
|
|
1398
|
-
* filter", which is the only safe default for a fresh install.
|
|
1773
|
+
* filter", which is the only safe default for a fresh install. A foreign file
|
|
1774
|
+
* reads the same way, which is precisely why `versionNote()` exists.
|
|
1399
1775
|
*
|
|
1400
1776
|
* @module dsh-connect-modelscope-token-plan/provider-store
|
|
1401
1777
|
*/
|
|
1778
|
+
/**
|
|
1779
|
+
* The "nothing stored" payload. Every absent / unreadable / foreign read
|
|
1780
|
+
* collapses here, so the three call sites that produce it stay in step instead
|
|
1781
|
+
* of each spelling out the same three `null`s.
|
|
1782
|
+
*/
|
|
1783
|
+
const EMPTY_PAYLOAD$1 = {
|
|
1784
|
+
enabled: null,
|
|
1785
|
+
enabledIds: null,
|
|
1786
|
+
contextWindow: null,
|
|
1787
|
+
version: null
|
|
1788
|
+
};
|
|
1402
1789
|
/** Shape version, bumped when the persisted form changes incompatibly. */
|
|
1403
1790
|
const PROVIDER_VERSION = 1;
|
|
1404
1791
|
/**
|
|
@@ -1406,12 +1793,36 @@ const PROVIDER_VERSION = 1;
|
|
|
1406
1793
|
* historical ones. Bumping {@link PROVIDER_VERSION} means adding the new number
|
|
1407
1794
|
* here too — otherwise this build would refuse its own newest files.
|
|
1408
1795
|
*
|
|
1409
|
-
* This is
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1796
|
+
* This list is asked in THREE places:
|
|
1797
|
+
* - the write side ({@link writePayload}, ADR-006): an on-disk version not in
|
|
1798
|
+
* this list was written by a NEWER build and must not be clobbered. That
|
|
1799
|
+
* caller asks `isKnownStateVersion(existing, ...)` rather than
|
|
1800
|
+
* {@link isKnownProviderVersion} on purpose — it must also admit `null`
|
|
1801
|
+
* ("no file at all"), which is the one situation where a write belongs.
|
|
1802
|
+
* - the READ side ({@link parseAt}) and the report ({@link versionNote} /
|
|
1803
|
+
* {@link describeProviderPayload}), which ask {@link isKnownProviderVersion}.
|
|
1804
|
+
* Keeping the read side and the report on one predicate is what makes them
|
|
1805
|
+
* unable to disagree about what a foreign version means.
|
|
1412
1806
|
*/
|
|
1413
1807
|
const KNOWN_PROVIDER_VERSIONS = [1];
|
|
1414
1808
|
/**
|
|
1809
|
+
* Whether this build can read a file declaring `version`.
|
|
1810
|
+
*
|
|
1811
|
+
* THE single answer to that question. Note it is deliberately NOT
|
|
1812
|
+
* `version === PROVIDER_VERSION`: the whitelist includes shapes this build can
|
|
1813
|
+
* read but no longer writes, so comparing against the write version would make a
|
|
1814
|
+
* version bump unreadable-of-its-own-files. The original bug behind this function
|
|
1815
|
+
* was exactly that split — `parseAt` compared against the write version while the
|
|
1816
|
+
* report asked the whitelist, so a bumped build read its own v1 files as "not
|
|
1817
|
+
* set" while its report said "nothing to say": the silent collapse the read side
|
|
1818
|
+
* exists to kill, resurrected by the upgrade procedure.
|
|
1819
|
+
* @param {unknown} version - the value read off disk (may not be a number at all).
|
|
1820
|
+
* @returns {boolean}
|
|
1821
|
+
*/
|
|
1822
|
+
function isKnownProviderVersion(version) {
|
|
1823
|
+
return typeof version === "number" && KNOWN_PROVIDER_VERSIONS.includes(version);
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1415
1826
|
* The directory this plugin's state lives in — per-profile when the Host names
|
|
1416
1827
|
* one, shared otherwise. Unlike the THROTTLE, which is deliberately shared
|
|
1417
1828
|
* across profiles, this answers "does THIS profile want the provider registered"
|
|
@@ -1431,6 +1842,18 @@ function normalizeEnabled(raw) {
|
|
|
1431
1842
|
return typeof raw === "boolean" ? raw : null;
|
|
1432
1843
|
}
|
|
1433
1844
|
/**
|
|
1845
|
+
* Normalize a context-window value: a positive finite number, or `null`
|
|
1846
|
+
* ("follow the catalog"). Anything else — strings, zero, negative, NaN,
|
|
1847
|
+
* Infinity — reads as no override, mirroring how {@link normalizeEnabled}
|
|
1848
|
+
* answers "nothing usable" instead of inventing a default.
|
|
1849
|
+
* @param {unknown} raw - the persisted or posted value.
|
|
1850
|
+
* @returns {number|null} the window in tokens, or `null` when nothing usable.
|
|
1851
|
+
*/
|
|
1852
|
+
function normalizeContextWindow(raw) {
|
|
1853
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return null;
|
|
1854
|
+
return raw;
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1434
1857
|
* Normalize a persisted allow-list: an array of non-empty strings, de-duplicated
|
|
1435
1858
|
* and order-preserved. Anything else reads as "no saved list".
|
|
1436
1859
|
*
|
|
@@ -1454,6 +1877,33 @@ function normalizeEnabledIds(raw) {
|
|
|
1454
1877
|
return out;
|
|
1455
1878
|
}
|
|
1456
1879
|
/**
|
|
1880
|
+
* Resolve the allow-list to publish/display, from the persisted value and the
|
|
1881
|
+
* list this process last published.
|
|
1882
|
+
*
|
|
1883
|
+
* THE single answer to "which list wins", so that every consumer asks it the
|
|
1884
|
+
* same way: a READABLE persisted list wins — including an EMPTY one, because
|
|
1885
|
+
* this module's contract defines `[]` as "no filter, offer every model" — and
|
|
1886
|
+
* the in-memory list is consulted only when the persisted side could not be read
|
|
1887
|
+
* at all (the caller's `.catch` turns a rejection into `null`).
|
|
1888
|
+
*
|
|
1889
|
+
* The three call sites used to spell this out separately, and two of them had
|
|
1890
|
+
* drifted: `snapshot-aggregate.ts` and `routes/provider.ts` honoured an empty
|
|
1891
|
+
* persisted list, while `index.ts` treated it as "no answer" and republished the
|
|
1892
|
+
* remembered list. So after the operator deleted `provider.json` (the reset
|
|
1893
|
+
* `versionNote` itself recommends) the panel reported `allowed: "all"` — its
|
|
1894
|
+
* `enabledCount` counting every model — while the poller kept registering the
|
|
1895
|
+
* OLD restriction: the panel and the Host disagreeing about what is served,
|
|
1896
|
+
* which is precisely what the publish queue and the idle guards exist to
|
|
1897
|
+
* prevent. One copy now, asked by all three.
|
|
1898
|
+
* @param {unknown} stored - the persisted list, or `null` when unreadable.
|
|
1899
|
+
* @param {unknown} remembered - `publisher.state.enabledIds` (last published).
|
|
1900
|
+
* @returns {string[]} the list to use.
|
|
1901
|
+
*/
|
|
1902
|
+
function resolveEnabledIds(stored, remembered) {
|
|
1903
|
+
if (Array.isArray(stored)) return stored;
|
|
1904
|
+
return Array.isArray(remembered) ? remembered : [];
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1457
1907
|
* The file-backed provider switch + allow-list.
|
|
1458
1908
|
* @param {object} [options]
|
|
1459
1909
|
* @param {string} [options.dir] - override the state directory (tests).
|
|
@@ -1477,40 +1927,92 @@ function createFileProviderStore(options = {}) {
|
|
|
1477
1927
|
* read. An unknown NUMERIC version means a NEWER build wrote it; clobbering
|
|
1478
1928
|
* it destroys data we cannot even see. So the write is refused with a
|
|
1479
1929
|
* `degrade` signal, not a silent no-op — swallow the failure, not the reason.
|
|
1930
|
+
* The guard itself lives in `state-store.ts` ({@link guardStateWrite}) — it is
|
|
1931
|
+
* the same promise draw-store makes, and two copies of "what may be
|
|
1932
|
+
* overwritten" is one copy too many.
|
|
1480
1933
|
* @param {object} body - the JSON body to persist.
|
|
1481
1934
|
* @returns {Promise<string|null>} the refusal reason when the write was
|
|
1482
1935
|
* refused (already `degrade`-logged), or `null` when it landed — the caller
|
|
1483
1936
|
* decides whether to surface the refusal to a user.
|
|
1484
1937
|
*/
|
|
1485
1938
|
const writePayload = async (body) => {
|
|
1486
|
-
const
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
}
|
|
1492
|
-
const temporary = temporaryOf(stateDir$1, "provider.json");
|
|
1939
|
+
const refusal = await guardStateWrite(filePath, KNOWN_PROVIDER_VERSIONS, {
|
|
1940
|
+
label: "provider",
|
|
1941
|
+
logger
|
|
1942
|
+
});
|
|
1943
|
+
if (refusal !== null) return refusal;
|
|
1493
1944
|
await ensureStateDir(stateDir$1);
|
|
1494
|
-
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary });
|
|
1945
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "provider.json") });
|
|
1495
1946
|
return null;
|
|
1496
1947
|
};
|
|
1497
1948
|
const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
|
|
1498
|
-
/**
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1949
|
+
/**
|
|
1950
|
+
* Read ONE payload off the disk, shape-checked.
|
|
1951
|
+
*
|
|
1952
|
+
* "Can this build read it" is asked through {@link isKnownProviderVersion} —
|
|
1953
|
+
* the same predicate the report and the write guard use. Comparing against
|
|
1954
|
+
* {@link PROVIDER_VERSION} instead would say "cannot read" about the OLDEST
|
|
1955
|
+
* shapes this build still knows, which is exactly wrong when it is bumped:
|
|
1956
|
+
* the header's upgrade rule adds the new number to {@link KNOWN_PROVIDER_VERSIONS},
|
|
1957
|
+
* so a v1 file must stay readable while v2 is written. Getting that wrong
|
|
1958
|
+
* makes this reader disagree with {@link versionNote} — reads "not set" while
|
|
1959
|
+
* the note says "nothing to say", which is the silent collapse this module
|
|
1960
|
+
* exists to kill. That split once existed in production code for one release.
|
|
1961
|
+
*
|
|
1962
|
+
* A version this build cannot read reads as EMPTY, but REMEMBERS which version
|
|
1963
|
+
* it was. The ADR-006 guard already refuses to overwrite such a file, so the
|
|
1964
|
+
* read side has to be able to say "there is an answer in there, it just is
|
|
1965
|
+
* not one we can see" — collapsing it to "never saved" is what made a saved
|
|
1966
|
+
* allow-list look like a deliberate "offer every model". See {@link versionNote}.
|
|
1967
|
+
*/
|
|
1968
|
+
const parseAt = async (file) => {
|
|
1969
|
+
const source = obj(await readStateJson(file));
|
|
1970
|
+
const version = typeof source.version === "number" ? source.version : null;
|
|
1971
|
+
if (!isKnownProviderVersion(version)) return {
|
|
1503
1972
|
enabled: null,
|
|
1504
|
-
enabledIds: null
|
|
1973
|
+
enabledIds: null,
|
|
1974
|
+
contextWindow: null,
|
|
1975
|
+
version
|
|
1505
1976
|
};
|
|
1506
1977
|
return {
|
|
1507
1978
|
enabled: normalizeEnabled(source.enabled),
|
|
1508
|
-
enabledIds: normalizeEnabledIds(source.enabledIds)
|
|
1979
|
+
enabledIds: normalizeEnabledIds(source.enabledIds),
|
|
1980
|
+
contextWindow: normalizeContextWindow(source.contextWindow),
|
|
1981
|
+
version
|
|
1509
1982
|
};
|
|
1510
1983
|
};
|
|
1984
|
+
/** Read the profile's own file, shape-checked. */
|
|
1985
|
+
const parsePayload = async () => parseAt(filePath);
|
|
1986
|
+
/** Read one payload off an explicit path (the legacy shared file). */
|
|
1987
|
+
const parsePayloadFrom = async (file) => parseAt(file);
|
|
1988
|
+
const writeQueue = createStateWriteQueue();
|
|
1989
|
+
const cache = createStateReadCache(async () => {
|
|
1990
|
+
const own = await parsePayload();
|
|
1991
|
+
if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
|
|
1992
|
+
if (own.version !== null) return own;
|
|
1993
|
+
const legacy = await parsePayloadFrom(legacyFile);
|
|
1994
|
+
if (legacy.enabled === null && legacy.enabledIds === null) return own;
|
|
1995
|
+
const inherited = {
|
|
1996
|
+
version: 1,
|
|
1997
|
+
...legacy.enabled === null ? {} : { enabled: legacy.enabled },
|
|
1998
|
+
...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
|
|
1999
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2000
|
+
};
|
|
2001
|
+
await writeQueue.enqueue(() => writePayload(inherited).then(() => void 0, () => void 0));
|
|
2002
|
+
return legacy;
|
|
2003
|
+
}, { ttlMs });
|
|
2004
|
+
const read = () => cache.read();
|
|
2005
|
+
/**
|
|
2006
|
+
* The payload the callers actually read. The short-TTL cache's `read()` is
|
|
2007
|
+
* typed `Promise<T | null>` because it doubles as "never read" signalling for
|
|
2008
|
+
* the §23 legacy adoption; a provider payload is always an object, so the
|
|
2009
|
+
* `null` branch collapses here, in one place, rather than at every caller.
|
|
2010
|
+
* @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
|
|
2011
|
+
*/
|
|
2012
|
+
const readPayload = async () => await read() ?? EMPTY_PAYLOAD$1;
|
|
1511
2013
|
/**
|
|
1512
|
-
* Read one payload, then hand it to
|
|
1513
|
-
* the switch and the list from clobbering each other.
|
|
2014
|
+
* Read one payload, then hand it to {@link writePayload} — the read-modify-
|
|
2015
|
+
* write that keeps the switch and the list from clobbering each other.
|
|
1514
2016
|
*
|
|
1515
2017
|
* Three-state keys, and they are SYMMETRIC between `enabled` and `enabledIds`:
|
|
1516
2018
|
* a key the caller omits (`undefined`) keeps whatever is on disk, an explicit
|
|
@@ -1519,28 +2021,30 @@ function createFileProviderStore(options = {}) {
|
|
|
1519
2021
|
* value is stored as given. An earlier `mergeEnabled` flag applied the
|
|
1520
2022
|
* "keep what is on disk" rule to `enabled` unconditionally, which silently
|
|
1521
2023
|
* discarded the very value a switch save was asked to store.
|
|
1522
|
-
* @param {object} patch - `{ enabled?, enabledIds? }` to persist.
|
|
1523
|
-
* @returns {Promise<string|null>} the write refusal reason, or `null`.
|
|
1524
|
-
*/
|
|
1525
|
-
/**
|
|
1526
|
-
* 读-改-写**整段排队**:两个并发patch 不能互相覆盖。
|
|
1527
|
-
*
|
|
1528
|
-
* 这个 store 以前完全没有写串行化(对比 `usage-store.ts` 的 `writeChain`),于是
|
|
1529
|
-
* 面板开关POST 与 roster POST —— 两个独立请求,外加 `index.ts` 轮询同时在调
|
|
1530
|
-
* `enabledIds()` —— 会各自读到同一个 `current`,后写的赢。实测:先存清单,再并发
|
|
1531
|
-
* `save(true)` + `saveEnabledIds([...])`,其中一次保存被**整个丢掉**(用户勾了
|
|
1532
|
-
* 模型又开了开关,两次点击只活下来一次)。
|
|
1533
2024
|
*
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
* `
|
|
2025
|
+
* 读-改-写**整段排队**:两个并发 patch 不能互相覆盖。这个 store 以前完全没有
|
|
2026
|
+
* 写串行化(对比 `usage-store.ts` 的 `writeChain`),于是面板开关 POST 与
|
|
2027
|
+
* roster POST —— 两个独立请求,外加 `index.ts` 轮询同时在调 `enabledIds()` ——
|
|
2028
|
+
* 会各自读到同一个 `current`,后写的赢。实测:先存清单,再并发 `save(true)` +
|
|
2029
|
+
* `saveEnabledIds([...])`,其中一次保存被**整个丢掉**(用户勾了模型又开了开关,
|
|
2030
|
+
* 两次点击只活下来一次)。文件头说「两个字段必须作为一个载荷读写,否则开关会
|
|
2031
|
+
* 覆盖清单」——设计意图是对的,但那只防住了「**不同字段**互相覆盖」;缺了串行化,
|
|
2032
|
+
* **同字段**与「两次都基于同一快照」的互相覆盖照样发生。链的形状与
|
|
2033
|
+
* `usage-store` 一致,且必须把 `writePayload` 的版本拒绝语义一起搬进临界区
|
|
2034
|
+
* (否则拒绝判断会落在锁外)。
|
|
1538
2035
|
*
|
|
1539
2036
|
* 链吞掉自己的失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
|
|
2037
|
+
*
|
|
2038
|
+
* 写成功的同时更新读缓存,调用方不再各自回读一遍:缓存里永远是刚写下去的那份,
|
|
2039
|
+
* 而 `patchPayload` 的 `current` 来自磁盘(`parsePayload`,不走缓存),所以合并
|
|
2040
|
+
* 结果不会带进过期值。此前的写法是写完再 `readPayload()` 读回另一半——那条回读
|
|
2041
|
+
* 走的是 TTL 缓存,拿到的是**写入前**的值,并发时会把一个较新的缓存项顶成旧值。
|
|
2042
|
+
*
|
|
2043
|
+
* @param {object} patch - `{ enabled?, enabledIds?, contextWindow? }` to persist.
|
|
2044
|
+
* @returns {Promise<string|null>} the write refusal reason, or `null`.
|
|
1540
2045
|
*/
|
|
1541
|
-
let writeChain = Promise.resolve(null);
|
|
1542
2046
|
const patchPayload = (patch) => {
|
|
1543
|
-
|
|
2047
|
+
return writeQueue.enqueue(async () => {
|
|
1544
2048
|
const current = await parsePayload();
|
|
1545
2049
|
/** `undefined` keeps the stored key; `null` clears it (key absent). */
|
|
1546
2050
|
const body = { version: 1 };
|
|
@@ -1548,50 +2052,18 @@ function createFileProviderStore(options = {}) {
|
|
|
1548
2052
|
if (enabled !== null) body.enabled = enabled;
|
|
1549
2053
|
const enabledIds = patch.enabledIds === void 0 ? current.enabledIds : patch.enabledIds;
|
|
1550
2054
|
if (enabledIds !== null) body.enabledIds = enabledIds;
|
|
2055
|
+
const contextWindow = patch.contextWindow === void 0 ? current.contextWindow : patch.contextWindow;
|
|
2056
|
+
if (contextWindow !== null) body.contextWindow = contextWindow;
|
|
1551
2057
|
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1552
|
-
|
|
2058
|
+
const refusal = await writePayload(body);
|
|
2059
|
+
if (refusal === null) cache.remember({
|
|
2060
|
+
enabled,
|
|
2061
|
+
enabledIds,
|
|
2062
|
+
contextWindow,
|
|
2063
|
+
version: 1
|
|
2064
|
+
});
|
|
2065
|
+
return refusal;
|
|
1553
2066
|
});
|
|
1554
|
-
writeChain = run.catch(() => null);
|
|
1555
|
-
return run;
|
|
1556
|
-
};
|
|
1557
|
-
const cache = createStateReadCache(async () => {
|
|
1558
|
-
const own = await parsePayload();
|
|
1559
|
-
if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
|
|
1560
|
-
const legacy = await parsePayloadFrom(legacyFile);
|
|
1561
|
-
if (legacy.enabled === null && legacy.enabledIds === null) return own;
|
|
1562
|
-
const inherited = {
|
|
1563
|
-
version: 1,
|
|
1564
|
-
...legacy.enabled === null ? {} : { enabled: legacy.enabled },
|
|
1565
|
-
...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
|
|
1566
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1567
|
-
};
|
|
1568
|
-
await writeChain.then(() => writePayload(inherited).then(() => void 0, () => void 0), () => writePayload(inherited).then(() => void 0, () => void 0));
|
|
1569
|
-
return legacy;
|
|
1570
|
-
}, { ttlMs });
|
|
1571
|
-
const read = () => cache.read();
|
|
1572
|
-
/**
|
|
1573
|
-
* The payload the callers actually read. The short-TTL cache's `read()` is
|
|
1574
|
-
* typed `Promise<T | null>` because it doubles as "never read" signalling for
|
|
1575
|
-
* the §23 legacy adoption; a provider payload is always an object, so the
|
|
1576
|
-
* `null` branch collapses here, in one place, rather than at every caller.
|
|
1577
|
-
* @returns {Promise<ProviderPayload>} the payload; `{enabled:null,enabledIds:null}` when nothing is stored.
|
|
1578
|
-
*/
|
|
1579
|
-
const readPayload = async () => await read() ?? {
|
|
1580
|
-
enabled: null,
|
|
1581
|
-
enabledIds: null
|
|
1582
|
-
};
|
|
1583
|
-
/** Read one payload off an explicit path (the legacy shared file). */
|
|
1584
|
-
const parsePayloadFrom = async (file) => {
|
|
1585
|
-
const raw = await readStateJson(file);
|
|
1586
|
-
const source = obj(raw);
|
|
1587
|
-
if (source.version !== 1) return {
|
|
1588
|
-
enabled: null,
|
|
1589
|
-
enabledIds: null
|
|
1590
|
-
};
|
|
1591
|
-
return {
|
|
1592
|
-
enabled: normalizeEnabled(source.enabled),
|
|
1593
|
-
enabledIds: normalizeEnabledIds(source.enabledIds)
|
|
1594
|
-
};
|
|
1595
2067
|
};
|
|
1596
2068
|
return {
|
|
1597
2069
|
/**
|
|
@@ -1610,6 +2082,16 @@ function createFileProviderStore(options = {}) {
|
|
|
1610
2082
|
return (await readPayload()).enabledIds ?? [];
|
|
1611
2083
|
},
|
|
1612
2084
|
/**
|
|
2085
|
+
* The saved context-window override (tokens), or `null` = follow the
|
|
2086
|
+
* catalog. `null` is a real answer here, not absence: the override is
|
|
2087
|
+
* optional by design, and the caller (snapshot / stamping) decides what
|
|
2088
|
+
* "follow the catalog" means.
|
|
2089
|
+
* @returns {Promise<number|null>}
|
|
2090
|
+
*/
|
|
2091
|
+
async contextWindow() {
|
|
2092
|
+
return (await readPayload()).contextWindow;
|
|
2093
|
+
},
|
|
2094
|
+
/**
|
|
1613
2095
|
* Whether the panel has ever saved a value here.
|
|
1614
2096
|
* @returns {Promise<boolean>}
|
|
1615
2097
|
*/
|
|
@@ -1618,6 +2100,33 @@ function createFileProviderStore(options = {}) {
|
|
|
1618
2100
|
return payload.enabled !== null || payload.enabledIds !== null;
|
|
1619
2101
|
},
|
|
1620
2102
|
/**
|
|
2103
|
+
* A human-readable note when the file holds a version this build cannot
|
|
2104
|
+
* read, or `null` when there is nothing to say.
|
|
2105
|
+
*
|
|
2106
|
+
* The READ side is permissive on purpose: a foreign file reads as "not set",
|
|
2107
|
+
* so the panel keeps working instead of failing the whole snapshot. The cost
|
|
2108
|
+
* is that it cannot tell "never saved" from "saved but unreadable" — and
|
|
2109
|
+
* for the allow-list those two read differently in the dangerous direction
|
|
2110
|
+
* (an empty list means "offer every model"). Writes are already protected by
|
|
2111
|
+
* the ADR-006 guard in {@link writePayload}; this note is the read side's
|
|
2112
|
+
* half of the same promise. The snapshot aggregate pushes it into
|
|
2113
|
+
* `shapeWarnings`, and `doctor.ts` reports the same file, so the operator
|
|
2114
|
+
* sees the disagreement instead of a switch that looks merely unset.
|
|
2115
|
+
*
|
|
2116
|
+
* @returns {Promise<string|null>}
|
|
2117
|
+
*/
|
|
2118
|
+
async versionNote() {
|
|
2119
|
+
const version = (await readPayload()).version;
|
|
2120
|
+
if (version === null || isKnownProviderVersion(version)) return null;
|
|
2121
|
+
return stateVersionNote({
|
|
2122
|
+
label: "provider-state",
|
|
2123
|
+
file: filePath,
|
|
2124
|
+
version,
|
|
2125
|
+
known: KNOWN_PROVIDER_VERSIONS,
|
|
2126
|
+
consequence: "the saved switch and allow-list are being answered as \"not set\", so the allow-list reads as \"no filter, offer every model\" while the switch falls back to the config default."
|
|
2127
|
+
});
|
|
2128
|
+
},
|
|
2129
|
+
/**
|
|
1621
2130
|
* Persist a switch value. The write is atomic (temp file + rename) so a
|
|
1622
2131
|
* concurrent reader never sees a partial payload, and it PRESERVES the
|
|
1623
2132
|
* saved allow-list.
|
|
@@ -1629,10 +2138,6 @@ function createFileProviderStore(options = {}) {
|
|
|
1629
2138
|
if (enabled === null) throw new TypeError("provider switch expects a boolean");
|
|
1630
2139
|
const refusal = await patchPayload({ enabled });
|
|
1631
2140
|
if (refusal !== null) throw new Error(refusal);
|
|
1632
|
-
cache.remember({
|
|
1633
|
-
enabled,
|
|
1634
|
-
enabledIds: (await readPayload()).enabledIds ?? []
|
|
1635
|
-
});
|
|
1636
2141
|
},
|
|
1637
2142
|
/**
|
|
1638
2143
|
* Persist the model allow-list, preserving the saved switch.
|
|
@@ -1644,75 +2149,1221 @@ function createFileProviderStore(options = {}) {
|
|
|
1644
2149
|
if (list === null) throw new TypeError("provider allow-list expects an array of strings");
|
|
1645
2150
|
const refusal = await patchPayload({ enabledIds: list });
|
|
1646
2151
|
if (refusal !== null) throw new Error(refusal);
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
2152
|
+
},
|
|
2153
|
+
/**
|
|
2154
|
+
* Persist the context-window override, preserving the saved switch and
|
|
2155
|
+
* allow-list. `null` clears the override back to "follow the catalog".
|
|
2156
|
+
* @param {number|null} value - the window in tokens, or `null` to clear.
|
|
2157
|
+
* @returns {Promise<void>}
|
|
2158
|
+
*/
|
|
2159
|
+
async saveContextWindow(value) {
|
|
2160
|
+
const window = normalizeContextWindow(value);
|
|
2161
|
+
if (value !== null && window === null) throw new TypeError("provider context window expects a positive finite number");
|
|
2162
|
+
const refusal = await patchPayload({ contextWindow: window });
|
|
2163
|
+
if (refusal !== null) throw new Error(refusal);
|
|
1651
2164
|
},
|
|
1652
2165
|
/**
|
|
1653
2166
|
* Forget the panel-saved values: the config default and "no filter" rule
|
|
1654
|
-
* again.
|
|
2167
|
+
* again. The context override is forgotten WITH them — the reset button
|
|
2168
|
+
* means "back to the way this plugin ships", and the override is panel
|
|
2169
|
+
* state in exactly the same sense.
|
|
1655
2170
|
* @returns {Promise<void>}
|
|
1656
2171
|
*/
|
|
1657
2172
|
async forget() {
|
|
1658
2173
|
const refusal = await patchPayload({
|
|
1659
2174
|
enabled: null,
|
|
1660
|
-
enabledIds: null
|
|
2175
|
+
enabledIds: null,
|
|
2176
|
+
contextWindow: null
|
|
1661
2177
|
});
|
|
1662
2178
|
if (refusal !== null) throw new Error(refusal);
|
|
1663
|
-
cache.remember({
|
|
1664
|
-
enabled: null,
|
|
1665
|
-
enabledIds: null
|
|
1666
|
-
});
|
|
1667
2179
|
}
|
|
1668
2180
|
};
|
|
1669
2181
|
}
|
|
1670
2182
|
|
|
1671
2183
|
//#endregion
|
|
1672
|
-
//#region src/host/
|
|
2184
|
+
//#region src/host/draw-store.ts
|
|
1673
2185
|
/**
|
|
1674
|
-
*
|
|
1675
|
-
* every opt-in switch.
|
|
2186
|
+
* 出图工具的开关与模型偏好——本插件**自己的**状态文件,与 provider 那条开关分开。
|
|
1676
2187
|
*
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
2188
|
+
* 为什么不是第二个字段塞进 `provider.json`:两者回答的是不同问题。
|
|
2189
|
+
* `provider.json` 说「这个 profile 要不要把魔搭作为 **chat provider** 提供」;
|
|
2190
|
+
* 本文件说「要不要给 agent 一个**出图工具**」。用户完全可能只要其一(用工具出图但
|
|
2191
|
+
* 不污染模型选择器,或反之)。合成一个 payload 会让「关掉出图」连带改掉 provider
|
|
2192
|
+
* 清单的写入时序——provider-store 的注释已说明为什么它的两个字段必须同生共死,
|
|
2193
|
+
* 而那组理由**不适用于**这两个开关(它们之间没有 atomicity 要求)。
|
|
1679
2194
|
*
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
2195
|
+
* 落点与 `provider-store.ts` 一致:`$DSH_HOME/state/<plugin>/draw.json`(无 profile
|
|
2196
|
+
* 时)或 `$DSH_HOME/state/<profile>/<plugin>/draw.json`。profile 分段的意思是
|
|
2197
|
+
* 「这个 profile 要不要暴露出图工具」,两个 profile 不能互相覆盖。
|
|
1683
2198
|
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
* fallback), which is exactly the shape this module does NOT serve: that one has
|
|
1687
|
-
* no config default to adjudicate against, so it is the caller's plain read.
|
|
2199
|
+
* 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
|
|
2200
|
+
* 否则 `cordis.patch.yml` 的 `drawEnabled` 默认值说了算。
|
|
1688
2201
|
*
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
2202
|
+
* 完整性三件套(与 provider / usage 同源,全部上收在 `state-store.ts`,本文件只做
|
|
2203
|
+
* 调用方——2026-10-17 收敛:此前这里**三件全缺**,并发 `saveEnabled` +
|
|
2204
|
+
* `saveModelId` 会丢一次保存、磁盘 version 比本构建新时降级覆写、读侧静默读作
|
|
2205
|
+
* 「没设过」,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条):
|
|
2206
|
+
* - **写串行化**({@link createStateWriteQueue}):读-改-写整体排队,临界区内
|
|
2207
|
+
* 从磁盘重读基线(不用 TTL 缓存—— provider §8d 的回归用例),链吞自己的
|
|
2208
|
+
* 失败继续走;
|
|
2209
|
+
* - **写守卫**({@link guardStateWrite},ADR-006):盘上版本本构建读不懂时
|
|
2210
|
+
* 拒写并给出理由,绝不降级覆写;「文件在场却读不出来」同样拒写(读不到 ≠
|
|
2211
|
+
* 没有东西要保护);
|
|
2212
|
+
* - **读侧说明**({@link stateVersionNote}):未知版本宽容读作「没设过」,但
|
|
2213
|
+
* {@link createFileDrawStore} 的 `versionNote()` 把「保存的开关与模型偏好
|
|
2214
|
+
* 被忽略」说出口,快照聚合把它转进 `shapeWarnings`——不说,面板就把「保存的
|
|
2215
|
+
* 值被忽略」呈现成「从未保存过」。
|
|
1694
2216
|
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
* 离线门禁内。
|
|
2217
|
+
* 与 provider-store 的一处**有意差异**:没有 legacy(pre-profile 共享目录)继承。
|
|
2218
|
+
* draw.json 随出图工具一起诞生,晚于按 profile 分段,市面上不存在共享布局里的
|
|
2219
|
+
* 旧 draw.json;provider.json 则需要(§23 之前它一直在共享目录)。
|
|
1699
2220
|
*
|
|
1700
|
-
*
|
|
2221
|
+
* doctor 覆盖:`describeDrawPayload` 给只读速览,`doctor.ts` 据此把 draw.json
|
|
2222
|
+
* 的「损坏 / 未知版本」报进症状表——写侧守卫挡覆盖,doctor 报静默降级。
|
|
2223
|
+
*
|
|
2224
|
+
* @module dsh-connect-modelscope-token-plan/draw-store
|
|
1701
2225
|
*/
|
|
2226
|
+
/** 本 store 落盘 payload 的版本。 */
|
|
2227
|
+
const DRAW_STORE_VERSION = 1;
|
|
2228
|
+
/** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
|
|
2229
|
+
const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
|
|
2230
|
+
const EMPTY_PAYLOAD = {
|
|
2231
|
+
enabled: null,
|
|
2232
|
+
modelId: null,
|
|
2233
|
+
version: null
|
|
2234
|
+
};
|
|
1702
2235
|
/**
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1706
|
-
* @
|
|
1707
|
-
*
|
|
2236
|
+
* 本 build 读不读得懂这个版本。
|
|
2237
|
+
*
|
|
2238
|
+
* 与 provider-store 的 {@link isKnownProviderVersion} 同形:**白名单**判据,
|
|
2239
|
+
* 不与 {@link DRAW_STORE_VERSION} 比较——升级后旧号仍要可读。读侧(parseAt)、
|
|
2240
|
+
* 报告侧(versionNote / describeDrawPayload)问的都是这一个函数,三者不可能就
|
|
2241
|
+
* 「什么是未知版本」说两套话。写侧问的是 `state-store` 的
|
|
2242
|
+
* `isKnownStateVersion`(它额外放行 `null` = 没有文件,那是唯一该写的情形)。
|
|
2243
|
+
* @param {unknown} version - 盘上读到的值(可能根本不是数字)。
|
|
2244
|
+
* @returns {boolean}
|
|
1708
2245
|
*/
|
|
1709
|
-
function
|
|
1710
|
-
return
|
|
2246
|
+
function isKnownDrawVersion(version) {
|
|
2247
|
+
return typeof version === "number" && KNOWN_DRAW_VERSIONS.includes(version);
|
|
1711
2248
|
}
|
|
1712
2249
|
/**
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
2250
|
+
* 归一一个保存的模型偏好:非空字符串,或 `null`(= 自动挑——具体挑谁由
|
|
2251
|
+
* `draw-runtime.ts` 的 `pickDrawModel` 优先级决定,不是简单取目录第一个)。
|
|
2252
|
+
*
|
|
2253
|
+
* 不校验「这个 id 是否真存在」——目录会变,存一个暂时不在目录里的 id 是合法的
|
|
2254
|
+
* (用户可能先配好、等模型上线)。调用侧解析不出来时退回自动。
|
|
2255
|
+
* @param {unknown} value - 原始值。
|
|
2256
|
+
* @returns {string|null} 归一后的 id 或 `null`。
|
|
2257
|
+
*/
|
|
2258
|
+
function normalizeDrawModelId(value) {
|
|
2259
|
+
if (typeof value !== "string") return null;
|
|
2260
|
+
const trimmed = value.trim();
|
|
2261
|
+
return trimmed === "" ? null : trimmed;
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* 归一保存的开关值:只有真正的 boolean 才算数,其余读作「没设过」。
|
|
2265
|
+
* @param {unknown} value - 原始值。
|
|
2266
|
+
* @returns {boolean|null} 归一后的开关。
|
|
2267
|
+
*/
|
|
2268
|
+
function normalizeDrawEnabled(value) {
|
|
2269
|
+
return typeof value === "boolean" ? value : null;
|
|
2270
|
+
}
|
|
2271
|
+
/** 本 store 的目录(与 provider-store 同一套分段规则)。 */
|
|
2272
|
+
function drawStoreDir(profile, dir) {
|
|
2273
|
+
if (typeof dir === "string" && dir !== "") return dir;
|
|
2274
|
+
return profile ? profileStateDir(name, profile) : stateDir(name);
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* 文件支撑的出图开关 + 模型偏好。
|
|
2278
|
+
* @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
|
|
2279
|
+
* @returns {object} store 句柄。
|
|
2280
|
+
*/
|
|
2281
|
+
function createFileDrawStore(options = {}) {
|
|
2282
|
+
const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
|
|
2283
|
+
const stateDir = drawStoreDir(profile, dir);
|
|
2284
|
+
const filePath = join(stateDir, "draw.json");
|
|
2285
|
+
/**
|
|
2286
|
+
* 写一份补丁并落盘(内部用;对调用方暴露的是 saveEnabled / saveModelId / reset)。
|
|
2287
|
+
*
|
|
2288
|
+
* ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
|
|
2289
|
+
* 返回拒绝理由(已 degrade 记录),不落笔。守卫与随后的写入在同一个临界区里
|
|
2290
|
+
* (由 patchPayload 的链保证),判据与执行之间不隔任何 await。
|
|
2291
|
+
* @param {object} body - 要持久化的 JSON 体。
|
|
2292
|
+
* @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
|
|
2293
|
+
*/
|
|
2294
|
+
const writePayload = async (body) => {
|
|
2295
|
+
const refusal = await guardStateWrite(filePath, KNOWN_DRAW_VERSIONS, {
|
|
2296
|
+
label: "draw-state",
|
|
2297
|
+
logger
|
|
2298
|
+
});
|
|
2299
|
+
if (refusal !== null) return refusal;
|
|
2300
|
+
await ensureStateDir(stateDir);
|
|
2301
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "draw.json") });
|
|
2302
|
+
return null;
|
|
2303
|
+
};
|
|
2304
|
+
/** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue}与文件头)。 */
|
|
2305
|
+
const writeQueue = createStateWriteQueue();
|
|
2306
|
+
/**
|
|
2307
|
+
* 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号——读侧宽容、
|
|
2308
|
+
* versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是 ADR-006)。
|
|
2309
|
+
*/
|
|
2310
|
+
const parsePayload = async () => {
|
|
2311
|
+
const source = obj(await readStateJson(filePath));
|
|
2312
|
+
const version = typeof source.version === "number" ? source.version : null;
|
|
2313
|
+
if (!isKnownDrawVersion(version)) return {
|
|
2314
|
+
enabled: null,
|
|
2315
|
+
modelId: null,
|
|
2316
|
+
version
|
|
2317
|
+
};
|
|
2318
|
+
return {
|
|
2319
|
+
enabled: normalizeDrawEnabled(source.enabled),
|
|
2320
|
+
modelId: normalizeDrawModelId(source.modelId),
|
|
2321
|
+
version
|
|
2322
|
+
};
|
|
2323
|
+
};
|
|
2324
|
+
const cache = createStateReadCache(parsePayload, { ttlMs });
|
|
2325
|
+
/**
|
|
2326
|
+
* 读当前 payload。
|
|
2327
|
+
* @returns {Promise<DrawPayload>}
|
|
2328
|
+
*/
|
|
2329
|
+
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
|
|
2330
|
+
/**
|
|
2331
|
+
* 读-改-写一份补丁,**整段排队**。
|
|
2332
|
+
*
|
|
2333
|
+
* 基线每次从磁盘重读(`parsePayload` 直读,不走 TTL 缓存):两个 Host 进程
|
|
2334
|
+
* 共享一个状态目录,而缓存里可能是写入前的值——provider §8d 的回归用例
|
|
2335
|
+
* (两个写入者共享一个目录,回读把较新的缓存顶成旧值)在 draw 上同样成立。
|
|
2336
|
+
* 写成功即 `cache.remember`,调用方不必各自回读一遍。
|
|
2337
|
+
*
|
|
2338
|
+
* 失败语义与 provider-store 对齐:拒绝原因与 IO 失败都**向上抛**(save 抛、
|
|
2339
|
+
* 路由 catch 后 `respondError` 呈报),链自己吞掉失败继续走后面的保存。
|
|
2340
|
+
*
|
|
2341
|
+
* @param {{enabled?: boolean|null, modelId?: string|null}} patch - 要改的字段;
|
|
2342
|
+
* `undefined` = 保留盘上的值,`null` = 清除(回到「没设过」)。
|
|
2343
|
+
* @returns {Promise<void>}
|
|
2344
|
+
*/
|
|
2345
|
+
const patchPayload = (patch) => writeQueue.enqueue(async () => {
|
|
2346
|
+
const current = await parsePayload();
|
|
2347
|
+
const enabled = patch.enabled === void 0 ? current.enabled : patch.enabled;
|
|
2348
|
+
const modelId = patch.modelId === void 0 ? current.modelId : patch.modelId;
|
|
2349
|
+
const body = { version: 1 };
|
|
2350
|
+
if (enabled !== null) body.enabled = enabled;
|
|
2351
|
+
if (modelId !== null) body.modelId = modelId;
|
|
2352
|
+
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2353
|
+
const refusal = await writePayload(body);
|
|
2354
|
+
if (refusal !== null) throw new Error(refusal);
|
|
2355
|
+
cache.remember({
|
|
2356
|
+
enabled,
|
|
2357
|
+
modelId,
|
|
2358
|
+
version: 1
|
|
2359
|
+
});
|
|
2360
|
+
});
|
|
2361
|
+
return {
|
|
2362
|
+
/** 面板保存的出图开关;`null` = 回退 patch 默认值。 */
|
|
2363
|
+
async enabled() {
|
|
2364
|
+
return (await readPayload()).enabled;
|
|
2365
|
+
},
|
|
2366
|
+
/** 面板保存的模型偏好;`null` = 自动。 */
|
|
2367
|
+
async modelId() {
|
|
2368
|
+
return (await readPayload()).modelId;
|
|
2369
|
+
},
|
|
2370
|
+
/** 这个 profile 是否被显式配置过。 */
|
|
2371
|
+
async isConfigured() {
|
|
2372
|
+
const payload = await readPayload();
|
|
2373
|
+
return payload.enabled !== null || payload.modelId !== null;
|
|
2374
|
+
},
|
|
2375
|
+
/**
|
|
2376
|
+
* 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
|
|
2377
|
+
*
|
|
2378
|
+
* 读侧宽容(未知版本读作「没设过」)的另一半:快照聚合把它转进
|
|
2379
|
+
* `shapeWarnings`,doctor 报同一份文件。没有它,「保存的开关与模型偏好
|
|
2380
|
+
* 被忽略」在面板上就是「从未保存过」——而回退方向是 patch 默认(通常=关)。
|
|
2381
|
+
* @returns {Promise<string|null>}
|
|
2382
|
+
*/
|
|
2383
|
+
async versionNote() {
|
|
2384
|
+
const version = (await readPayload()).version;
|
|
2385
|
+
if (version === null || isKnownDrawVersion(version)) return null;
|
|
2386
|
+
return stateVersionNote({
|
|
2387
|
+
label: "draw-state",
|
|
2388
|
+
file: filePath,
|
|
2389
|
+
version,
|
|
2390
|
+
known: KNOWN_DRAW_VERSIONS,
|
|
2391
|
+
consequence: "the saved draw switch and model preference are being answered as \"not set\", so the switch falls back to the config default and the model back to automatic selection."
|
|
2392
|
+
});
|
|
2393
|
+
},
|
|
2394
|
+
/** 保存开关。 */
|
|
2395
|
+
async saveEnabled(enabled) {
|
|
2396
|
+
if (typeof enabled !== "boolean") throw new TypeError("draw switch expects a boolean");
|
|
2397
|
+
await patchPayload({ enabled });
|
|
2398
|
+
},
|
|
2399
|
+
/** 保存模型偏好(`null` = 清掉,回到自动)。 */
|
|
2400
|
+
async saveModelId(modelId) {
|
|
2401
|
+
if (modelId !== null && (typeof modelId !== "string" || modelId.trim() === "")) throw new TypeError("draw model expects a non-empty string or null");
|
|
2402
|
+
await patchPayload({ modelId });
|
|
2403
|
+
},
|
|
2404
|
+
/** 清掉两个字段(回到「没配过」)。 */
|
|
2405
|
+
async reset() {
|
|
2406
|
+
await patchPayload({
|
|
2407
|
+
enabled: null,
|
|
2408
|
+
modelId: null
|
|
2409
|
+
});
|
|
2410
|
+
},
|
|
2411
|
+
/** 落盘路径(诊断与测试用)。 */
|
|
2412
|
+
filePath
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
//#endregion
|
|
2417
|
+
//#region src/host/draw.ts
|
|
2418
|
+
/**
|
|
2419
|
+
* 魔搭出图(`/v1/images/generations`)的**纯逻辑半边**——协议形状的解析与判定。
|
|
2420
|
+
*
|
|
2421
|
+
* 为什么是这个形状:魔搭的生图是**异步两段式**(提交得 `task_id`,轮询才出图),
|
|
2422
|
+
* 与姊妹插件 `dsh-connect-sensenova-token-plan/src/host/draw.ts` 的**同步单发**
|
|
2423
|
+
* (`drawOnce` 一次 fetch 即 `parseDrawResponse` 取 `data[0].url`)形状不兼容,
|
|
2424
|
+
* **不能照抄**。可复用的只有外壳(endpoint 拼接、冷却门、失败文案),核心必须新写。
|
|
2425
|
+
* 协议依据全部来自 `docs/SPIKE-image-gen.md` 的真机实测,非文档转述。
|
|
2426
|
+
*
|
|
2427
|
+
* 本模块**只做纯函数**:endpoint 构造、请求体构造、响应解析、状态判定、失败分诊。
|
|
2428
|
+
* 它**不发起任何请求、不碰凭据、不注册工具**——那半边(fetch 注入、Host 工具注册)
|
|
2429
|
+
* 与 `llm-adapter.ts` 一样 peer-dependent,测试覆盖同样是零(docs/ROADMAP.md Backlog)。
|
|
2430
|
+
* 刻意 peer-free:纯函数部分能进离线 `npm test` 门禁,`test/draw.test.mjs` 钉住形状。
|
|
2431
|
+
*
|
|
2432
|
+
* 三条承重结论(全部实测,推翻了几条广泛流传的错误写法):
|
|
2433
|
+
*
|
|
2434
|
+
* 1. **轮询必须带 `X-ModelScope-Task-Type: image_generation`**,否则上游一律返回
|
|
2435
|
+
* `task not found` + `task_status: FAILED`——**那是假失败**,任务实际正常。
|
|
2436
|
+
* 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
|
|
2437
|
+
* 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
|
|
2438
|
+
* `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
|
|
2439
|
+
* 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
|
|
2440
|
+
* 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
|
|
2441
|
+
* `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
|
|
2442
|
+
*
|
|
2443
|
+
* 关于两个小工具(`str` / `redactSecrets`):它们曾以**本地私有副本**的形式活在
|
|
2444
|
+
* 本文件里,理由是「避免为 3 行代码引 util 的依赖面」。那条理由不成立——util.ts
|
|
2445
|
+
* 自己只有 type-only import(peer-free),同包的 `draw-runtime.ts` 早就 import 它;
|
|
2446
|
+
* 而私有副本已经漂移:脱敏正则少了 `sk-` 与密码键值对两组、`ms-` 的 charset 与
|
|
2447
|
+
* util 版不一致(`[A-Za-z0-9-]` vs `[0-9a-f-]`)——脱敏闸漏一条就是凭据外泄
|
|
2448
|
+
* (红线 1)。现在一律用 util 的单一副本。
|
|
2449
|
+
*
|
|
2450
|
+
* @module dsh-connect-modelscope-token-plan/draw
|
|
2451
|
+
*/
|
|
2452
|
+
/**
|
|
2453
|
+
* 出图请求的截止时间(墙钟毫秒)。
|
|
2454
|
+
*
|
|
2455
|
+
* **15 分钟**:实测同一模型的耗时在 61s–537s 之间大幅波动(免费池排队),
|
|
2456
|
+
* 且**超时放弃不等于任务死亡**——上游任务会继续跑完(记录之后还会被回收,
|
|
2457
|
+
* 见 docs/SPIKE-image-gen.md §坑 5)。宁可多等,不要扔掉一张已在生成的图。
|
|
2458
|
+
*/
|
|
2459
|
+
const DRAW_TIMEOUT_MS = 9e5;
|
|
2460
|
+
/**
|
|
2461
|
+
* 两次轮询之间的间隔(毫秒)。
|
|
2462
|
+
*
|
|
2463
|
+
* 取 15s 的**真正理由**是「够用且不浪费」:实测最慢一次 537s,15s 间隔意味着
|
|
2464
|
+
* 最多几十次查询,代价可忽略;而更密(社区示例的 5s)没有观测收益。
|
|
2465
|
+
*
|
|
2466
|
+
* ⚠️ 早先曾写「更密的轮询拖慢任务」,**该归因已推翻**——后续对照显示同一模型、
|
|
2467
|
+
* 同样 15s 轮询的耗时是 61s → 261s → 537s 单调恶化,是**上游排队时长在波动**。
|
|
2468
|
+
* 那次构成「证据」的 5s vs 30s 对照,控制变量没做,不足以支撑因果。
|
|
2469
|
+
* 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
|
|
2470
|
+
*/
|
|
2471
|
+
const DRAW_POLL_INTERVAL_MS = 15e3;
|
|
2472
|
+
/**
|
|
2473
|
+
* 一次**工具调用**里愿意等的时间(毫秒)。
|
|
2474
|
+
*
|
|
2475
|
+
* 出图工具是给 agent 用的,一次调用阻塞 = 一整轮对话被占住。15 分钟的总预算
|
|
2476
|
+
* ({@link DRAW_TIMEOUT_MS})是「这张图值不值得继续等」的上限,不是「一次调用
|
|
2477
|
+
* 该不该堵住 agent」的上限——两者同一个数,就是把上游排队的代价转嫁成对话的
|
|
2478
|
+
* 停顿。
|
|
2479
|
+
*
|
|
2480
|
+
* 取 90s:实测耗时 61s–537s 大幅波动,90s 能一次拿完最快那一档(61s),而
|
|
2481
|
+
* 更慢的本来就必须分段查(见 `draw-runtime.ts` 的两段式工具)。再往上加只是
|
|
2482
|
+
* 把「等得久」换成「卡得久」,并不改变「慢的图要分几次查」这个事实。
|
|
2483
|
+
*/
|
|
2484
|
+
const DRAW_WAIT_BUDGET_MS = 9e4;
|
|
2485
|
+
/**
|
|
2486
|
+
* 轮询必须携带的头名与取值。
|
|
2487
|
+
*
|
|
2488
|
+
* 漏掉它 → 上游返回 `task not found` + `FAILED`,而任务其实在正常跑。这是本协议
|
|
2489
|
+
* 最贵的单点知识,也是第一份外部资料缺失的那一块。
|
|
2490
|
+
*/
|
|
2491
|
+
const TASK_TYPE_HEADER = "X-ModelScope-Task-Type";
|
|
2492
|
+
const TASK_TYPE_IMAGE_GENERATION = "image_generation";
|
|
2493
|
+
/**
|
|
2494
|
+
* 一次出图请求由几个 HTTP 请求组成:一次提交 + N 次轮询。
|
|
2495
|
+
* 只作为文档性常量,供工具描述与测试引用。
|
|
2496
|
+
*/
|
|
2497
|
+
const DRAW_PHASE = Object.freeze({
|
|
2498
|
+
submit: "submit",
|
|
2499
|
+
poll: "poll"
|
|
2500
|
+
});
|
|
2501
|
+
/**
|
|
2502
|
+
* 读一个响应的 `task_status`,取不到返回 `null`。
|
|
2503
|
+
*
|
|
2504
|
+
* 刻意**不猜**:字段缺失或类型不对都返回 `null`,由 `isTerminalStatus` 把它当作
|
|
2505
|
+
* 「非终态」继续等——这样一次形状漂移最多变成多轮轮询,不会变成误报失败。
|
|
2506
|
+
* @param {unknown} data - 已解析的 JSON。
|
|
2507
|
+
* @returns {DrawTaskStatus|null} 规范化状态,取不到为 `null`。
|
|
2508
|
+
*/
|
|
2509
|
+
function readTaskStatus(data) {
|
|
2510
|
+
if (data === null || typeof data !== "object") return null;
|
|
2511
|
+
const raw = data.task_status;
|
|
2512
|
+
if (typeof raw !== "string") return null;
|
|
2513
|
+
const upper = raw.trim().toUpperCase();
|
|
2514
|
+
if (upper === "PENDING" || upper === "RUNNING" || upper === "SUCCEED" || upper === "FAILED") return upper;
|
|
2515
|
+
return null;
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* 一个状态是不是终态。
|
|
2519
|
+
*
|
|
2520
|
+
* **只有** `SUCCEED` / `FAILED` 是终态;`null`(读不到)与一切未知值都**不是**。
|
|
2521
|
+
* 「未知值继续等」是刻意的:把上游新增的状态误判成失败会丢掉一张已经能出的图,
|
|
2522
|
+
* 而误判成进行中最多多等几轮(受 `DRAW_POLL_DEADLINE_MS` 兜底)。
|
|
2523
|
+
* @param {DrawTaskStatus|null} status - 规范化状态。
|
|
2524
|
+
* @returns {boolean} 是否终态。
|
|
2525
|
+
*/
|
|
2526
|
+
function isTerminalStatus(status) {
|
|
2527
|
+
return status === "SUCCEED" || status === "FAILED";
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* 从提交响应里取 `task_id`。
|
|
2531
|
+
*
|
|
2532
|
+
* 提交响应里那个 `task_status: "SUCCEED"` **一律忽略**(见模块头第 2 条:它与终态
|
|
2533
|
+
* 无关)。本函数只认 `task_id`,所以一个「提交即失败」的上游只要给了 id 就仍会被
|
|
2534
|
+
* 轮询,而不给 id 会被当作提交失败——这两种都符合实测。
|
|
2535
|
+
* @param {unknown} data - 已解析的提交响应。
|
|
2536
|
+
* @returns {string|null} 任务 id,取不到为 `null`。
|
|
2537
|
+
*/
|
|
2538
|
+
function readTaskId(data) {
|
|
2539
|
+
if (data === null || typeof data !== "object") return null;
|
|
2540
|
+
const raw = data.task_id;
|
|
2541
|
+
return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* 从终态响应里取出成品图的 URL 列表。
|
|
2545
|
+
*
|
|
2546
|
+
* 实测形状是顶层 `output_images: string[]`。**刻意不读** `data[0].url`(OpenAI
|
|
2547
|
+
* 同步形状)与 `output.image_url`(外部资料误传的形状)——两者在魔搭上都不存在。
|
|
2548
|
+
* @param {unknown} data - 已解析的轮询响应。
|
|
2549
|
+
* @returns {string[]} 可用的图片 URL(保持上游顺序,已去空)。
|
|
2550
|
+
*/
|
|
2551
|
+
function readOutputImages(data) {
|
|
2552
|
+
if (data === null || typeof data !== "object") return [];
|
|
2553
|
+
const raw = data.output_images;
|
|
2554
|
+
if (!Array.isArray(raw)) return [];
|
|
2555
|
+
const out = [];
|
|
2556
|
+
for (const item of raw) if (typeof item === "string" && item.trim() !== "") out.push(item.trim());
|
|
2557
|
+
return out;
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* 构造出图请求体。
|
|
2561
|
+
*
|
|
2562
|
+
* 只发上游**确认消费**的字段:`model` / `prompt` / `image_url` / `size` / `steps` /
|
|
2563
|
+
* `guidance` / `seed` / `negative_prompt` 均被实测接受(SPIKE 实测 #9);`loras`
|
|
2564
|
+
* 也被确认消费(无效 LoRA id 在提交阶段直接 500,实测 #18)。**不发** `ref_image`
|
|
2565
|
+
* ——那是别家平台的写法,魔搭上不存在(SPIKE §对照)。
|
|
2566
|
+
*
|
|
2567
|
+
* `image_url` **一律发数组**,哪怕只有一张:官方示例的形状是数组(多图编辑要用),
|
|
2568
|
+
* 实测单张以数组发也通(#17)、以裸字符串发亦通(#8)。取数组是因为它的超集更贴近
|
|
2569
|
+
* 上游本意,也省掉一个分支。
|
|
2570
|
+
*
|
|
2571
|
+
* `n` 刻意**不作为请求字段**:实测目录里的出图模型都是单出模型,`n` 的支持度未
|
|
2572
|
+
* 逐项验证,传一个上游不认的参数比不传更危险。调用方要几张就发几次。
|
|
2573
|
+
* @param {object} options - 请求参数。
|
|
2574
|
+
* @param {string} options.model - 模型 id。
|
|
2575
|
+
* @param {string} options.prompt - 提示词。
|
|
2576
|
+
* @param {string|string[]} [options.imageUrl] - 输入图 URL,**字符串或数组均可**,
|
|
2577
|
+
* 内部一律归一为数组(官方形状;多图编辑要用数组)。
|
|
2578
|
+
* @param {string} [options.size] - `"宽x高"`,如 `"1024x1024"`(64 对齐)。
|
|
2579
|
+
* @param {number} [options.steps] - 采样步数。
|
|
2580
|
+
* @param {number} [options.guidance] - 引导系数。
|
|
2581
|
+
* @param {number} [options.seed] - 随机种子。
|
|
2582
|
+
* @param {string} [options.negativePrompt] - 负向提示词。
|
|
2583
|
+
* @param {string|Record<string, number>} [options.loras] - LoRA 配置:单个 repo id,
|
|
2584
|
+
* 或 `{id: weight}` 映射(官方:最多 6 个,权重和须为 1.0)。
|
|
2585
|
+
* @returns {object} 请求体。
|
|
2586
|
+
*/
|
|
2587
|
+
function buildDrawBody(options) {
|
|
2588
|
+
const body = {
|
|
2589
|
+
model: str(options.model, ""),
|
|
2590
|
+
prompt: str(options.prompt, "")
|
|
2591
|
+
};
|
|
2592
|
+
const imageUrl = normalizeImageUrlList(options.imageUrl);
|
|
2593
|
+
if (imageUrl.length > 0) body.image_url = imageUrl;
|
|
2594
|
+
const size = str(options.size, "");
|
|
2595
|
+
if (size !== "") body.size = size;
|
|
2596
|
+
if (Number.isFinite(options.steps)) body.steps = options.steps;
|
|
2597
|
+
if (Number.isFinite(options.guidance)) body.guidance = options.guidance;
|
|
2598
|
+
if (Number.isFinite(options.seed)) body.seed = options.seed;
|
|
2599
|
+
const negativePrompt = str(options.negativePrompt, "");
|
|
2600
|
+
if (negativePrompt !== "") body.negative_prompt = negativePrompt;
|
|
2601
|
+
const loras = options.loras;
|
|
2602
|
+
if (typeof loras === "string" && loras.trim() !== "") body.loras = loras.trim();
|
|
2603
|
+
else if (loras !== null && typeof loras === "object") {
|
|
2604
|
+
const entries = Object.entries(loras).filter(([id, weight]) => id.trim() !== "" && Number.isFinite(weight));
|
|
2605
|
+
if (entries.length > 0) body.loras = Object.fromEntries(entries);
|
|
2606
|
+
}
|
|
2607
|
+
return body;
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* 把「一张或多张输入图」归一成 URL 数组。
|
|
2611
|
+
*
|
|
2612
|
+
* 单张给字符串上游也吃(实测 #8),但官方示例与多图编辑都用数组(#17 实测通),
|
|
2613
|
+
* 故一律归一为数组。逐项过滤空串,**保持调用方给定的顺序**——多图编辑里顺序即语义
|
|
2614
|
+
* (官方示例:「图一中的狗,去追图二中的飞盘」)。
|
|
2615
|
+
* @param {unknown} value - 单个 URL、URL 数组,或缺省。
|
|
2616
|
+
* @returns {string[]} 归一后的 URL 数组(可为空)。
|
|
2617
|
+
*/
|
|
2618
|
+
function normalizeImageUrlList(value) {
|
|
2619
|
+
const raw = Array.isArray(value) ? value : [value];
|
|
2620
|
+
const out = [];
|
|
2621
|
+
for (const item of raw) if (typeof item === "string" && item.trim() !== "") out.push(item.trim());
|
|
2622
|
+
return out;
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* 从任意配置里读出「可用作输入图」的 URL。
|
|
2626
|
+
*
|
|
2627
|
+
* 承接 agent 工具的 `image_url` 参数。**只接受 http(s) 与 data URL**——其它形态
|
|
2628
|
+
* (本地路径)上游取不到,转发过去只会换回一个原因不可知的 `FAILED`。实测尚未
|
|
2629
|
+
* 验证 data URL 是否被上游接受(SPIKE 待办),但**转发它不会更糟**,而拦掉它会让
|
|
2630
|
+
* 本地图片彻底不可用;故放行并在档案里留待验证。
|
|
2631
|
+
* @param {unknown} value - 原始参数。
|
|
2632
|
+
* @returns {string|null} 规范化后的 URL,不可用时为 `null`。
|
|
2633
|
+
*/
|
|
2634
|
+
function normalizeImageUrlInput(value) {
|
|
2635
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
2636
|
+
if (raw === "") return null;
|
|
2637
|
+
if (/^https?:\/\//i.test(raw)) return raw;
|
|
2638
|
+
if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(raw)) return raw;
|
|
2639
|
+
return null;
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* 把一次失败转成 agent 与面板都能读的一句话。
|
|
2643
|
+
*
|
|
2644
|
+
* **关于魔搭 `FAILED` 的诚实边界**:实测唯一的失败样本是
|
|
2645
|
+
* `{"errors":{"code":0,"message":""}, "task_status":"FAILED"}`——**原因不可知**。
|
|
2646
|
+
* 所以这里只报「可确定的形态事实」(HTTP 码 / 轮询未出终态 / 明确的原因文本),
|
|
2647
|
+
* **不从空错误里编造原因**,并在原因确实拿不到时明说「上游未给出原因」。这是本仓库
|
|
2648
|
+
* README「诚实声明」的要求。
|
|
2649
|
+
*
|
|
2650
|
+
* 凭据永不入消息:body 文本可能回显 `ms-` 令牌,过 `redactSecrets` 后再截断。
|
|
2651
|
+
* @param {object} failure - 失败信息。
|
|
2652
|
+
* @param {number} [failure.status] - HTTP 状态码(提交/轮询的传输层失败)。
|
|
2653
|
+
* @param {string} [failure.bodyText] - 上游 body 原文。
|
|
2654
|
+
* @param {string} [failure.taskStatus] - 轮询读到的状态。
|
|
2655
|
+
* @param {string} [failure.cause] - 已知原因(超时 / 没有可用图片 / 拿不到 task_id 等)。
|
|
2656
|
+
* @returns {string} 一行说明。
|
|
2657
|
+
*/
|
|
2658
|
+
function describeDrawFailure(failure) {
|
|
2659
|
+
const text = redactSecrets(typeof failure.bodyText === "string" ? failure.bodyText : "").slice(0, 300);
|
|
2660
|
+
const status = Number.isFinite(failure.status) ? Number(failure.status) : 0;
|
|
2661
|
+
const tail = text === "" ? "" : `; body: ${text}`;
|
|
2662
|
+
if (status === 401 || status === 403) return `出图失败:HTTP ${status} —— 访问令牌无效、无权访问该模型,或账号未完成邮箱验证/实名(后者是实测遇到过的真实拦截)。请在面板确认令牌与账号状态${tail}`;
|
|
2663
|
+
if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
|
|
2664
|
+
if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
|
|
2665
|
+
if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
|
|
2666
|
+
const cause = str(failure.cause, "");
|
|
2667
|
+
if (cause !== "") return `出图失败:${cause}${tail}`;
|
|
2668
|
+
const taskStatus = str(failure.taskStatus, "");
|
|
2669
|
+
if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
|
|
2670
|
+
if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
|
|
2671
|
+
return `出图失败:上游未给出可诊断的失败信息${tail}`;
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* 冷却门:一次失败后的一段时间内拒绝再试。
|
|
2675
|
+
*
|
|
2676
|
+
* 借 sensenova 那份的形状(它也从 `dsh-draw-router` 抄来):出图慢且贵,一次失败
|
|
2677
|
+
* 后立刻重试既容易撞限流又浪费等待窗口。纯闭包,无依赖,可离线测。
|
|
2678
|
+
* @param {number} cooldownMs - 冷却窗口(毫秒)。
|
|
2679
|
+
* @returns {{fail: () => void, remainingMs: () => number, clear: () => void}} 冷却门。
|
|
2680
|
+
*/
|
|
2681
|
+
function createDrawCooldown(cooldownMs) {
|
|
2682
|
+
const window = Math.max(0, Math.floor(cooldownMs));
|
|
2683
|
+
let until = 0;
|
|
2684
|
+
return {
|
|
2685
|
+
/** 记一次失败,从此刻起进入冷却。 */
|
|
2686
|
+
fail() {
|
|
2687
|
+
until = Date.now() + window;
|
|
2688
|
+
},
|
|
2689
|
+
/** 剩余冷却毫秒;0 表示可以发起。 */
|
|
2690
|
+
remainingMs() {
|
|
2691
|
+
return Math.max(0, until - Date.now());
|
|
2692
|
+
},
|
|
2693
|
+
/** 清掉冷却(例如面板里手动排障后)。 */
|
|
2694
|
+
clear() {
|
|
2695
|
+
until = 0;
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
//#endregion
|
|
2701
|
+
//#region src/host/draw-jobs.ts
|
|
2702
|
+
/**
|
|
2703
|
+
* 建一张出图作业表。
|
|
2704
|
+
* @param {DrawJobsOptions} [options] - 容量上限。
|
|
2705
|
+
* @returns {DrawJobs} 作业表。
|
|
2706
|
+
*/
|
|
2707
|
+
function createDrawJobs(options = {}) {
|
|
2708
|
+
const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
|
|
2709
|
+
/** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
|
|
2710
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
2711
|
+
const evictIfNeeded = () => {
|
|
2712
|
+
while (jobs.size > maxJobs) {
|
|
2713
|
+
const oldest = jobs.keys().next();
|
|
2714
|
+
if (oldest.done === true) return;
|
|
2715
|
+
jobs.delete(oldest.value);
|
|
2716
|
+
}
|
|
2717
|
+
};
|
|
2718
|
+
return {
|
|
2719
|
+
create({ taskId, model, prompt, now }) {
|
|
2720
|
+
const jobId = taskId;
|
|
2721
|
+
const job = {
|
|
2722
|
+
jobId,
|
|
2723
|
+
taskId,
|
|
2724
|
+
model,
|
|
2725
|
+
prompt,
|
|
2726
|
+
submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
|
|
2727
|
+
polls: 0
|
|
2728
|
+
};
|
|
2729
|
+
const previous = jobs.get(jobId);
|
|
2730
|
+
if (previous !== void 0) job.polls = previous.polls;
|
|
2731
|
+
jobs.set(jobId, job);
|
|
2732
|
+
evictIfNeeded();
|
|
2733
|
+
return { ...job };
|
|
2734
|
+
},
|
|
2735
|
+
get(jobId) {
|
|
2736
|
+
const found = jobs.get(jobId);
|
|
2737
|
+
return found === void 0 ? null : { ...found };
|
|
2738
|
+
},
|
|
2739
|
+
notePolls(jobId, polls) {
|
|
2740
|
+
const found = jobs.get(jobId);
|
|
2741
|
+
if (found === void 0) return;
|
|
2742
|
+
found.polls += Math.max(0, Math.floor(polls));
|
|
2743
|
+
},
|
|
2744
|
+
drop(jobId) {
|
|
2745
|
+
jobs.delete(jobId);
|
|
2746
|
+
},
|
|
2747
|
+
dispose() {
|
|
2748
|
+
jobs.clear();
|
|
2749
|
+
},
|
|
2750
|
+
size() {
|
|
2751
|
+
return jobs.size;
|
|
2752
|
+
}
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
//#endregion
|
|
2757
|
+
//#region src/host/draw-runtime.ts
|
|
2758
|
+
/**
|
|
2759
|
+
* 出图的 **peer-dependent 半边**:真正发起请求、驱动异步两段式、注册成 agent 工具。
|
|
2760
|
+
*
|
|
2761
|
+
* 与 `llm-adapter.ts` 同一个处境——import Host 发行的 peer(`@deepseek-ai/dsh-tools`
|
|
2762
|
+
* 的 `defineTool` 由调用方惰性注入),**不能由离线单测套件导入**。纯逻辑全部住在
|
|
2763
|
+
* `draw.ts`(已由 `test/draw.test.mjs` 覆盖 39 组),本文件只做「接线 + I/O」:
|
|
2764
|
+
* 轮询循环、fetch、工具定义。
|
|
2765
|
+
*
|
|
2766
|
+
* **覆盖边界**(2026-10-16 复核):`runDrawOnce` / `pickDrawModel` / `drawModelIds` /
|
|
2767
|
+
* `splitImageUrls` / `buildGenerationsEndpoint` / `buildTasksEndpoint` /
|
|
2768
|
+
* `DRAW_TOOL_NAME` 由 `test/draw-runtime.test.mjs` 钉住;**没被覆盖的是
|
|
2769
|
+
* `defineDrawTool` 落到 peer registry 的那一段**(真 `defineTool` 形状与注册结果)
|
|
2770
|
+
* ——只改那一段不会让任何门禁变红。本文件此前整段自称「无覆盖」,该陈述在补上
|
|
2771
|
+
* 这个套件之后已经过时。
|
|
2772
|
+
*
|
|
2773
|
+
* 与姊妹插件的关键差异(形状不兼容,不能照抄):
|
|
2774
|
+
* `sensenova` 的出图是**同步单发**——一次 `fetch` 拿 `data[0].url` 就结束。魔搭是
|
|
2775
|
+
* **异步两段式**:提交拿 `task_id`,再轮询到终态取 `output_images[0]`。所以这里多
|
|
2776
|
+
* 一个 `pollTask` 循环,且它必须带 `X-ModelScope-Task-Type`(漏了 = 假失败)。
|
|
2777
|
+
*
|
|
2778
|
+
* 三条承重纪律:
|
|
2779
|
+
* 1. 提交响应的 `task_status` **一律忽略**(实测恒为 SUCCEED,连最终 FAILED 的那次
|
|
2780
|
+
* 也是),成功与否只看轮询终态。`readTaskId` 已把这条钉在纯逻辑层。
|
|
2781
|
+
* 2. 轮询间隔 15s(`DRAW_POLL_INTERVAL_MS`),**不是**社区常见的 5s——实测更密的
|
|
2782
|
+
* 轮询只与更慢相关。
|
|
2783
|
+
* 3. 失败原因实测可能**不可知**(`errors.code:0` + 空 message)。`describeDrawFailure`
|
|
2784
|
+
* 因此明说「未给出原因」,本文件也**不从空错误里编造**。
|
|
2785
|
+
*
|
|
2786
|
+
* @module dsh-connect-modelscope-token-plan/draw-runtime
|
|
2787
|
+
*/
|
|
2788
|
+
/** agent 工具名。带插件前缀,避免与 `dsh-draw-router` 的 `draw_image` 撞名。 */
|
|
2789
|
+
const DRAW_TOOL_NAME = "modelscope_draw_image";
|
|
2790
|
+
/**
|
|
2791
|
+
* 已知能**纯文生图**的目录内模型 id(2026-10-16 真机复核)。
|
|
2792
|
+
*
|
|
2793
|
+
* 为什么需要手工策展:api-inference 目录里至今**没有**平台声明的 `text-to-image`
|
|
2794
|
+
* 条目,只有 2 条 `image-to-image`——而两者行为**不同**:
|
|
2795
|
+
* `Qwen/Qwen-Image-Edit` 不带 `image_url` 纯文生图可用(本轮实测:提交成功、两次
|
|
2796
|
+
* 15min 排队超时后于 9.7min 出图);`MusePublic/Qwen-Image-Edit` 纯文生图被上游
|
|
2797
|
+
* 直接拒绝(提交阶段 HTTP 4xx:`image_url is required for model: …`)。
|
|
2798
|
+
* 能力枚举把两者都归为 `image-to-image`,区分不了这个差异,所以这里用实测知识兜底:
|
|
2799
|
+
* 目录里没有 `text-to-image` 可挑时,默认偏向这个已知能用的 id,而不是「目录第一个」
|
|
2800
|
+
* (真实目录顺序 `MusePublic/…` 在前,照旧逻辑纯文生图必挂)。
|
|
2801
|
+
*/
|
|
2802
|
+
const KNOWN_T2I_MODEL_ID = "Qwen/Qwen-Image-Edit";
|
|
2803
|
+
/** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
|
|
2804
|
+
const DRAW_FAILURE_COOLDOWN_MS = 6e4;
|
|
2805
|
+
/**
|
|
2806
|
+
* 把 OpenAI 兼容 base 拼成出图端点。
|
|
2807
|
+
*
|
|
2808
|
+
* `…/v1` → `…/v1/images/generations`;已带该后缀则原样;更深路径回卷到 `/v1`。
|
|
2809
|
+
* 与 sensenova 的同名函数同源(那是受控复制:endpoint 拼接与上游是谁无关)。
|
|
2810
|
+
* @param {string} apiBase - 配置里的 apiBase。
|
|
2811
|
+
* @returns {string} 完整端点。
|
|
2812
|
+
*/
|
|
2813
|
+
function buildGenerationsEndpoint(apiBase) {
|
|
2814
|
+
const trimmed = str(apiBase, "").replace(/\/+$/, "");
|
|
2815
|
+
if (/\/images\/generations$/.test(trimmed)) return trimmed;
|
|
2816
|
+
if (/\/v1$/.test(trimmed)) return `${trimmed}/images/generations`;
|
|
2817
|
+
if (/\/v1\//.test(trimmed)) return trimmed.replace(/\/v1\/.*$/, "/v1/images/generations");
|
|
2818
|
+
return `${trimmed}/v1/images/generations`;
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* 拼轮询端点。task_id 上游是 UUID(实测失败态出现过短 id),一律走 path。
|
|
2822
|
+
* @param {string} apiBase - 配置里的 apiBase。
|
|
2823
|
+
* @param {string} taskId - 任务 id。
|
|
2824
|
+
* @returns {string} 完整端点。
|
|
2825
|
+
*/
|
|
2826
|
+
function buildTasksEndpoint(apiBase, taskId) {
|
|
2827
|
+
return `${buildGenerationsEndpoint(apiBase).replace(/\/images\/generations$/, "")}/tasks/${encodeURIComponent(taskId)}`;
|
|
2828
|
+
}
|
|
2829
|
+
/**
|
|
2830
|
+
* 一个响应体到底是不是「终态」——读不到就当还没好,继续等。
|
|
2831
|
+
*
|
|
2832
|
+
* 与 `isTerminalStatus` 的分工:那个管状态语义,这个管「响应本身能不能读」。
|
|
2833
|
+
* 上游偶发返回非 JSON(CDN 错误页)时,当作暂时性抖动继续等,而不是判失败——
|
|
2834
|
+
* 实测过一个 task 在正常轮询下始终 SUCCEED,没有理由把一次解析失败升级成终局。
|
|
2835
|
+
* @param {unknown} data - 已解析的 JSON。
|
|
2836
|
+
* @returns {boolean} 是否终态。
|
|
2837
|
+
*/
|
|
2838
|
+
function isTerminalPayload(data) {
|
|
2839
|
+
return isTerminalStatus(readTaskStatus(data));
|
|
2840
|
+
}
|
|
2841
|
+
/**
|
|
2842
|
+
* **第一段**:提交一次出图,只要 `task_id`。
|
|
2843
|
+
*
|
|
2844
|
+
* 提交必须带 `X-ModelScope-Async-Mode`(实测可省但官方示例都带,带上无害)。
|
|
2845
|
+
* 响应里的 `task_status` **一律忽略**——实测恒为 `SUCCEED`,连最终 `FAILED` 的那次
|
|
2846
|
+
* 也是,所以成功与否只看轮询终态(这条由 `readTaskId` 钉在纯逻辑层)。
|
|
2847
|
+
* @param {DrawSubmitInput} input - 提交参数。
|
|
2848
|
+
* @returns {Promise<{taskId: string}>} 上游任务 id(= 续查用的 jobId)。
|
|
2849
|
+
* @throws {Error} 提交失败——消息已过 `describeDrawFailure`。
|
|
2850
|
+
*/
|
|
2851
|
+
async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl, size, steps, guidance, seed, negativePrompt }) {
|
|
2852
|
+
const headers = {
|
|
2853
|
+
Authorization: `Bearer ${apiKey}`,
|
|
2854
|
+
"Content-Type": "application/json"
|
|
2855
|
+
};
|
|
2856
|
+
const body = buildDrawBody({
|
|
2857
|
+
model,
|
|
2858
|
+
prompt,
|
|
2859
|
+
...imageUrl !== void 0 ? { imageUrl } : {},
|
|
2860
|
+
...size !== void 0 ? { size } : {},
|
|
2861
|
+
...steps !== void 0 ? { steps } : {},
|
|
2862
|
+
...guidance !== void 0 ? { guidance } : {},
|
|
2863
|
+
...seed !== void 0 ? { seed } : {},
|
|
2864
|
+
...negativePrompt !== void 0 ? { negativePrompt } : {}
|
|
2865
|
+
});
|
|
2866
|
+
let submitted;
|
|
2867
|
+
try {
|
|
2868
|
+
submitted = await fetchImpl(buildGenerationsEndpoint(apiBase), {
|
|
2869
|
+
method: "POST",
|
|
2870
|
+
headers: {
|
|
2871
|
+
...headers,
|
|
2872
|
+
"X-ModelScope-Async-Mode": "true"
|
|
2873
|
+
},
|
|
2874
|
+
body: JSON.stringify(body)
|
|
2875
|
+
});
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
throw new Error(describeDrawFailure({ cause: `提交请求失败:${error instanceof Error ? error.message : String(error)}` }));
|
|
2878
|
+
}
|
|
2879
|
+
const submitText = await safeText(submitted);
|
|
2880
|
+
if (!submitted.ok) throw new Error(describeDrawFailure({
|
|
2881
|
+
status: submitted.status,
|
|
2882
|
+
bodyText: submitText
|
|
2883
|
+
}));
|
|
2884
|
+
const taskId = readTaskId(await safeJson(submitted, submitText));
|
|
2885
|
+
if (taskId === null) throw new Error(describeDrawFailure({
|
|
2886
|
+
bodyText: submitText,
|
|
2887
|
+
cause: "提交响应里没有 task_id"
|
|
2888
|
+
}));
|
|
2889
|
+
return { taskId };
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* **第二段**:在给定预算内轮询一个任务,直到终态或预算耗尽。
|
|
2893
|
+
*
|
|
2894
|
+
* 与 `runDrawOnce` 的差别只有一处:预算耗尽时**返回 `pending` 而不是抛错**——
|
|
2895
|
+
* 抛错是「这件事做不成了」,`pending` 是「还没成,可以再查」。同一段循环因此
|
|
2896
|
+
* 同时服务于两种调用形状(一次性跑完 / 分段续查),不写两份。
|
|
2897
|
+
*
|
|
2898
|
+
* 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败);传输层抖动与非
|
|
2899
|
+
* JSON 响应体都**不升级为终局**,继续等下一轮。
|
|
2900
|
+
* @param {object} options - 等待参数。
|
|
2901
|
+
* @param {DrawFetch} options.fetchImpl - 注入的 fetch。
|
|
2902
|
+
* @param {string} options.apiBase - apiBase。
|
|
2903
|
+
* @param {string} options.apiKey - 访问令牌。
|
|
2904
|
+
* @param {string} options.taskId - 上游任务 id。
|
|
2905
|
+
* @param {number} [options.waitMs] - 本次调用的等待预算。
|
|
2906
|
+
* @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
|
|
2907
|
+
* @returns {Promise<DrawAwait>} 三种结局之一。
|
|
2908
|
+
*/
|
|
2909
|
+
async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS }) {
|
|
2910
|
+
const headers = {
|
|
2911
|
+
Authorization: `Bearer ${apiKey}`,
|
|
2912
|
+
"Content-Type": "application/json"
|
|
2913
|
+
};
|
|
2914
|
+
const deadline = Date.now() + Math.max(1, Math.floor(waitMs));
|
|
2915
|
+
const interval = Math.max(1, Math.floor(pollIntervalMs));
|
|
2916
|
+
let polls = 0;
|
|
2917
|
+
let lastStatus;
|
|
2918
|
+
for (;;) {
|
|
2919
|
+
if (Date.now() >= deadline) return {
|
|
2920
|
+
kind: "pending",
|
|
2921
|
+
polls
|
|
2922
|
+
};
|
|
2923
|
+
await sleep(interval);
|
|
2924
|
+
polls++;
|
|
2925
|
+
let polled;
|
|
2926
|
+
try {
|
|
2927
|
+
polled = await fetchImpl(buildTasksEndpoint(apiBase, taskId), {
|
|
2928
|
+
method: "GET",
|
|
2929
|
+
headers: {
|
|
2930
|
+
...headers,
|
|
2931
|
+
[TASK_TYPE_HEADER]: TASK_TYPE_IMAGE_GENERATION
|
|
2932
|
+
}
|
|
2933
|
+
});
|
|
2934
|
+
} catch {
|
|
2935
|
+
continue;
|
|
2936
|
+
}
|
|
2937
|
+
const pollText = await safeText(polled);
|
|
2938
|
+
if (!polled.ok) return {
|
|
2939
|
+
kind: "failed",
|
|
2940
|
+
message: describeDrawFailure({
|
|
2941
|
+
...polled.status !== void 0 ? { status: polled.status } : {},
|
|
2942
|
+
...pollText !== "" ? { bodyText: pollText } : {},
|
|
2943
|
+
...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
|
|
2944
|
+
})
|
|
2945
|
+
};
|
|
2946
|
+
const data = await safeJson(polled, pollText);
|
|
2947
|
+
const status = readTaskStatus(data);
|
|
2948
|
+
if (status !== null) lastStatus = status;
|
|
2949
|
+
if (!isTerminalPayload(data)) continue;
|
|
2950
|
+
if (status === "FAILED") return {
|
|
2951
|
+
kind: "failed",
|
|
2952
|
+
message: describeDrawFailure({
|
|
2953
|
+
taskStatus: "FAILED",
|
|
2954
|
+
bodyText: pollText
|
|
2955
|
+
})
|
|
2956
|
+
};
|
|
2957
|
+
const images = readOutputImages(data);
|
|
2958
|
+
if (images.length === 0) return {
|
|
2959
|
+
kind: "failed",
|
|
2960
|
+
message: describeDrawFailure({
|
|
2961
|
+
taskStatus: "SUCCEED",
|
|
2962
|
+
bodyText: pollText,
|
|
2963
|
+
cause: "任务报 SUCCEED 但没有可用图片"
|
|
2964
|
+
})
|
|
2965
|
+
};
|
|
2966
|
+
return {
|
|
2967
|
+
kind: "done",
|
|
2968
|
+
images,
|
|
2969
|
+
polls
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* 从目录里挑一个出图模型。
|
|
2975
|
+
*
|
|
2976
|
+
* 优先级(高 → 低):
|
|
2977
|
+
* 1. **工具显式请求**——即使不在目录里也放行(手工覆盖,上游会自己报错);
|
|
2978
|
+
* 2. **首选模型**(面板保存的模型偏好,或配置 `drawModelId`)——用户点了谁就是
|
|
2979
|
+
* 谁,同样不校验在不在目录(用户可能先配好、等模型上线)。此前这个偏好只在
|
|
2980
|
+
* `pickDrawModel` 返回 null 时才生效,目录非空时等于死配置——面板显示的
|
|
2981
|
+
* preferredModel 与实际挑中的模型会说两套话;
|
|
2982
|
+
* 3. 目录里**平台声明**为 `text-to-image` 的条目(`textToImageModelIds`,将来目录
|
|
2983
|
+
* 补上真文生图模型时自动优先生效);
|
|
2984
|
+
* 4. **已知能纯文生图**的模型(`KNOWN_T2I_MODEL_ID`)——今天目录的坑位,见该
|
|
2985
|
+
* 常量的注释:真实目录里它是第二个,照旧取「第一个」会挑中纯文生图必挂的
|
|
2986
|
+
* `MusePublic/…`;
|
|
2987
|
+
* 5. 目录里第一个出图模型(`isImageGenModel` 的严格方向:只认明确声明,缺字段
|
|
2988
|
+
* 不算)。
|
|
2989
|
+
*
|
|
2990
|
+
* 目录为空且既无显式请求也无首选模型 → `null`,由调用方给出可操作的错误,
|
|
2991
|
+
* 而不是发一个注定失败的请求。
|
|
2992
|
+
* @param {unknown} entries - 归一目录条目。
|
|
2993
|
+
* @param {string} [requested] - 工具调用里显式给的 model。
|
|
2994
|
+
* @param {string} [preferred] - 首选模型 id(面板偏好或配置 `drawModelId`)。
|
|
2995
|
+
* @returns {string|null} 选中的 id。
|
|
2996
|
+
*/
|
|
2997
|
+
function pickDrawModel(entries, requested, preferred) {
|
|
2998
|
+
const want = str(requested, "");
|
|
2999
|
+
if (want !== "") return want;
|
|
3000
|
+
const preferredId = str(preferred, "");
|
|
3001
|
+
if (preferredId !== "") return preferredId;
|
|
3002
|
+
const ids = imageGenModelIds(entries);
|
|
3003
|
+
if (ids.length === 0) return null;
|
|
3004
|
+
const t2i = textToImageModelIds(entries);
|
|
3005
|
+
if (t2i.length > 0) return t2i[0] ?? null;
|
|
3006
|
+
if (ids.includes("Qwen/Qwen-Image-Edit")) return KNOWN_T2I_MODEL_ID;
|
|
3007
|
+
return ids[0] ?? null;
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* 目录里可用于出图的模型 id(工具结果里回显,方便 agent 与用户看到实际候选)。
|
|
3011
|
+
* @param {unknown} entries - 归一目录条目。
|
|
3012
|
+
* @returns {string[]} id 列表。
|
|
3013
|
+
*/
|
|
3014
|
+
function drawModelIds(entries) {
|
|
3015
|
+
return imageGenModelIds(entries);
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* 装配 agent 工具定义——**两段式**,一次调用不再阻塞整轮对话。
|
|
3019
|
+
*
|
|
3020
|
+
* ## 为什么是两段式
|
|
3021
|
+
*
|
|
3022
|
+
* 上游出图要排队,实测 61s–537s(免费池)。旧形状是「一次调用 await 到终态」,
|
|
3023
|
+
* 于是 `drawTimeoutMs`(15 分钟)既是「这张图值不值得等」的上限,也成了「一轮
|
|
3024
|
+
* 对话被卡多久」的上限——把上游排队的代价直接转嫁成对话停顿。
|
|
3025
|
+
*
|
|
3026
|
+
* 现在一次调用只做两件事之一:
|
|
3027
|
+
* - **不带 `jobId`** → 提交任务,然后只在 `waitMs`(默认 {@link DRAW_WAIT_BUDGET_MS})
|
|
3028
|
+
* 内等;等到就返回 `done`,没等到就返回 `pending` + `jobId`;
|
|
3029
|
+
* - **带 `jobId`** → 只查那个任务,同样最多等 `waitMs`。
|
|
3030
|
+
*
|
|
3031
|
+
* `drawTimeoutMs` 因此回到它本来的语义:**从提交算起的总放弃线**(跨调用累计),
|
|
3032
|
+
* 越过它才报「放弃」。
|
|
3033
|
+
*
|
|
3034
|
+
* ## 两个刻意的判定
|
|
3035
|
+
*
|
|
3036
|
+
* - `pending` **不是失败**:不抛错、不进冷却门。它只表示「这次调用愿意等的时间里
|
|
3037
|
+
* 没等到」,进冷却就会把续查这条路堵死——那正是分段查询的目的所在。
|
|
3038
|
+
* - 只有**真失败**(提交失败 / 上游 `FAILED` / 越过总放弃线)才 `cooldown.fail()`。
|
|
3039
|
+
*
|
|
3040
|
+
* 工具仍只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
|
|
3041
|
+
* @param {object} options - 装配参数。
|
|
3042
|
+
* @param {(definition: object) => unknown} options.defineTool - peer 的工厂。
|
|
3043
|
+
* @param {DrawFetch} options.fetchImpl - 注入的 fetch。
|
|
3044
|
+
* @param {() => Promise<string>} options.resolveApiKey - 每次调用现读令牌。
|
|
3045
|
+
* @param {() => unknown|Promise<unknown>} [options.getEntries] - 目录条目读取器。
|
|
3046
|
+
* @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs, drawWaitMs }`。
|
|
3047
|
+
* @param {ReturnType<typeof createDrawCooldown>} [options.cooldown] - 冷却门。
|
|
3048
|
+
* @param {DrawJobs} [options.jobs] - 作业表(可注入,便于测试)。
|
|
3049
|
+
* @param {() => boolean} [options.isDisposed] - 卸载闸。
|
|
3050
|
+
* @returns {unknown} 已定义的 agent 工具。
|
|
3051
|
+
*/
|
|
3052
|
+
function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), jobs = createDrawJobs(), pollIntervalMs = DRAW_POLL_INTERVAL_MS, isDisposed = () => false }) {
|
|
3053
|
+
/** 总放弃线:从提交算起,跨多次调用累计。 */
|
|
3054
|
+
const timeoutMs = Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS));
|
|
3055
|
+
/**
|
|
3056
|
+
* 单次调用愿意等的时间。
|
|
3057
|
+
*
|
|
3058
|
+
* 只夹**上界**(不该超过总放弃线,超过它的等待没有意义);下界交给
|
|
3059
|
+
* `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/下限就会
|
|
3060
|
+
* 各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
|
|
3061
|
+
*/
|
|
3062
|
+
const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
|
|
3063
|
+
const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
|
|
3064
|
+
/**
|
|
3065
|
+
* 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
|
|
3066
|
+
*
|
|
3067
|
+
* 两段共用这一段——「刚提交的」与「续查的」在等待这件事上没有区别,唯一的区别
|
|
3068
|
+
* 是作业表里那条记录从哪来。
|
|
3069
|
+
* @param {string} apiKey - 访问令牌。
|
|
3070
|
+
* @param {DrawJob} job - 作业(含提交时刻与累计轮询数)。
|
|
3071
|
+
* @param {string[]} available - 目录里的出图模型(续查时为空,不必再拉目录)。
|
|
3072
|
+
* @returns {Promise<DrawToolResult>} 工具结果。
|
|
3073
|
+
* @throws {Error} 真失败(上游 FAILED / 轮询 HTTP 失败)或越过总放弃线。
|
|
3074
|
+
*/
|
|
3075
|
+
const waitAndReport = async (apiKey, job, available) => {
|
|
3076
|
+
const awaited = await awaitDrawTask({
|
|
3077
|
+
fetchImpl,
|
|
3078
|
+
apiBase,
|
|
3079
|
+
apiKey,
|
|
3080
|
+
taskId: job.taskId,
|
|
3081
|
+
waitMs,
|
|
3082
|
+
pollIntervalMs
|
|
3083
|
+
});
|
|
3084
|
+
if (awaited.kind === "failed") {
|
|
3085
|
+
jobs.drop(job.jobId);
|
|
3086
|
+
throw new Error(awaited.message);
|
|
3087
|
+
}
|
|
3088
|
+
jobs.notePolls(job.jobId, awaited.polls);
|
|
3089
|
+
const polls = job.polls + awaited.polls;
|
|
3090
|
+
if (awaited.kind === "done") {
|
|
3091
|
+
jobs.drop(job.jobId);
|
|
3092
|
+
const urls = awaited.images.join("\n");
|
|
3093
|
+
return {
|
|
3094
|
+
source: "modelscope",
|
|
3095
|
+
status: "done",
|
|
3096
|
+
jobId: job.jobId,
|
|
3097
|
+
model: job.model,
|
|
3098
|
+
url: awaited.images[0] ?? "",
|
|
3099
|
+
prompt: job.prompt,
|
|
3100
|
+
hint: `图片已生成(上游异步任务,${polls} 次轮询,task ${job.taskId})\n模型: ${job.model}\n${urls}` + (available.length > 0 ? `\n可用出图模型: ${available.join(", ")}` : "")
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
const elapsedMs = Date.now() - job.submittedAt;
|
|
3104
|
+
if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})**可能仍在生成**——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
|
|
3105
|
+
return {
|
|
3106
|
+
source: "modelscope",
|
|
3107
|
+
status: "pending",
|
|
3108
|
+
jobId: job.jobId,
|
|
3109
|
+
model: job.model,
|
|
3110
|
+
url: "",
|
|
3111
|
+
prompt: job.prompt,
|
|
3112
|
+
hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图)`
|
|
3113
|
+
};
|
|
3114
|
+
};
|
|
3115
|
+
return defineTool({
|
|
3116
|
+
name: DRAW_TOOL_NAME,
|
|
3117
|
+
description: "Generate or edit an image with the ModelScope (魔搭) API-Inference token. Text-to-image by default; pass `imageUrl` to edit an existing image (image-to-image), or an array of URLs for multi-image editing. Omit `model` to use the catalogue's image model. TWO-PHASE: upstream generation queues, so latency varies from ~1 minute to 15+ minutes. Call without `jobId` to submit and wait briefly; if the result is `status: pending`, call again with the returned `jobId` to keep polling. A pending result is NOT a failure — the image is still being generated. Available image model ids are reported in the result.",
|
|
3118
|
+
parameters: {
|
|
3119
|
+
prompt: {
|
|
3120
|
+
type: "string",
|
|
3121
|
+
required: true,
|
|
3122
|
+
description: "Image prompt; describe the target image (for edits, describe the intended change). Ignored when resuming with `jobId`."
|
|
3123
|
+
},
|
|
3124
|
+
jobId: {
|
|
3125
|
+
type: "string",
|
|
3126
|
+
description: "Resume an earlier task: pass the `jobId` returned by a previous call to keep polling it (no new image is submitted)"
|
|
3127
|
+
},
|
|
3128
|
+
model: {
|
|
3129
|
+
type: "string",
|
|
3130
|
+
description: "ModelScope image model id; defaults to the catalogue's image model"
|
|
3131
|
+
},
|
|
3132
|
+
imageUrl: {
|
|
3133
|
+
type: "string",
|
|
3134
|
+
description: "Input image URL for editing (public http(s) or data:image/...;base64). For multi-image editing pass several URLs separated by newlines — order is significant."
|
|
3135
|
+
},
|
|
3136
|
+
size: {
|
|
3137
|
+
type: "string",
|
|
3138
|
+
description: "Image size as WIDTHxHEIGHT, e.g. 1024x1024 (64-aligned)"
|
|
3139
|
+
},
|
|
3140
|
+
steps: {
|
|
3141
|
+
type: "number",
|
|
3142
|
+
description: "Sampling steps (platform range 1-100)"
|
|
3143
|
+
},
|
|
3144
|
+
guidance: {
|
|
3145
|
+
type: "number",
|
|
3146
|
+
description: "Prompt guidance scale (platform range 1.5-20)"
|
|
3147
|
+
},
|
|
3148
|
+
seed: {
|
|
3149
|
+
type: "number",
|
|
3150
|
+
description: "Random seed"
|
|
3151
|
+
},
|
|
3152
|
+
negativePrompt: {
|
|
3153
|
+
type: "string",
|
|
3154
|
+
description: "Negative prompt"
|
|
3155
|
+
}
|
|
3156
|
+
},
|
|
3157
|
+
output: {
|
|
3158
|
+
schema: {
|
|
3159
|
+
type: "object",
|
|
3160
|
+
additionalProperties: false,
|
|
3161
|
+
properties: {
|
|
3162
|
+
source: { type: "string" },
|
|
3163
|
+
status: { type: "string" },
|
|
3164
|
+
jobId: { type: "string" },
|
|
3165
|
+
model: { type: "string" },
|
|
3166
|
+
url: { type: "string" },
|
|
3167
|
+
prompt: { type: "string" },
|
|
3168
|
+
hint: { type: "string" }
|
|
3169
|
+
}
|
|
3170
|
+
},
|
|
3171
|
+
render: (_args, result) => [{
|
|
3172
|
+
type: "text",
|
|
3173
|
+
text: result?.hint || "图片已生成"
|
|
3174
|
+
}]
|
|
3175
|
+
},
|
|
3176
|
+
timeoutMs: waitMs + 1e4,
|
|
3177
|
+
async execute(params) {
|
|
3178
|
+
if (isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
|
|
3179
|
+
const apiKey = await resolveApiKey();
|
|
3180
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
|
|
3181
|
+
const resumeId = str(params?.jobId, "");
|
|
3182
|
+
if (resumeId !== "") {
|
|
3183
|
+
const job = jobs.get(resumeId);
|
|
3184
|
+
if (job === null) throw new Error(`jobId ${resumeId} 不在本次会话的作业表里(插件重启过,或该 id 不是本插件发出的):原任务可能仍在生成,但它的结果不保证还能取回(上游终态记录会被回收),请重新出图`);
|
|
3185
|
+
try {
|
|
3186
|
+
return await waitAndReport(apiKey, job, []);
|
|
3187
|
+
} catch (error) {
|
|
3188
|
+
cooldown.fail();
|
|
3189
|
+
throw error;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
const prompt = str(params?.prompt, "");
|
|
3193
|
+
if (prompt === "") throw new Error("prompt is required");
|
|
3194
|
+
const remaining = cooldown.remainingMs();
|
|
3195
|
+
if (remaining > 0) throw new Error(`draw cooldown: 上一次出图失败,${Math.ceil(remaining / 1e3)}s 后再试`);
|
|
3196
|
+
let picked = [];
|
|
3197
|
+
try {
|
|
3198
|
+
picked = await getEntries?.() ?? [];
|
|
3199
|
+
} catch {
|
|
3200
|
+
picked = [];
|
|
3201
|
+
}
|
|
3202
|
+
const entries = Array.isArray(picked) ? picked : [];
|
|
3203
|
+
const configuredPreferred = str(settings?.drawModelId, "");
|
|
3204
|
+
const panelPreferred = typeof getPreferredModelId === "function" ? await getPreferredModelId().catch(() => null) : null;
|
|
3205
|
+
const preferred = typeof panelPreferred === "string" && panelPreferred.trim() !== "" ? panelPreferred : configuredPreferred;
|
|
3206
|
+
const model = pickDrawModel(entries, str(params?.model, ""), preferred);
|
|
3207
|
+
if (model === null) throw new Error("目录里没有出图模型(image-to-image / text-to-image 条目为空):确认令牌已配置、面板已至少轮询一次,且套餐含该模型");
|
|
3208
|
+
const rawImages = splitImageUrls(params?.imageUrl);
|
|
3209
|
+
const images = [];
|
|
3210
|
+
for (const raw of rawImages) {
|
|
3211
|
+
const normalized = normalizeImageUrlInput(raw);
|
|
3212
|
+
if (normalized === null) throw new Error(`imageUrl 不可用:${raw.slice(0, 80)} —— 只接受公网 http(s) 链接或 data:image/...;base64 内联数据(本地文件上游取不到,请先上传)`);
|
|
3213
|
+
images.push(normalized);
|
|
3214
|
+
}
|
|
3215
|
+
try {
|
|
3216
|
+
const size = str(params?.size, "");
|
|
3217
|
+
const steps = toPositiveInt(params?.steps);
|
|
3218
|
+
const guidance = toPositiveNumber(params?.guidance);
|
|
3219
|
+
const seed = toNonNegativeInt(params?.seed);
|
|
3220
|
+
const negativePrompt = str(params?.negativePrompt, "");
|
|
3221
|
+
const { taskId } = await submitDrawTask({
|
|
3222
|
+
fetchImpl,
|
|
3223
|
+
apiBase,
|
|
3224
|
+
apiKey,
|
|
3225
|
+
model,
|
|
3226
|
+
prompt,
|
|
3227
|
+
...images.length > 0 ? { imageUrl: images } : {},
|
|
3228
|
+
...size !== "" ? { size } : {},
|
|
3229
|
+
...steps !== void 0 ? { steps } : {},
|
|
3230
|
+
...guidance !== void 0 ? { guidance } : {},
|
|
3231
|
+
...seed !== void 0 ? { seed } : {},
|
|
3232
|
+
...negativePrompt !== "" ? { negativePrompt } : {}
|
|
3233
|
+
});
|
|
3234
|
+
const job = jobs.create({
|
|
3235
|
+
taskId,
|
|
3236
|
+
model,
|
|
3237
|
+
prompt
|
|
3238
|
+
});
|
|
3239
|
+
return await waitAndReport(apiKey, job, drawModelIds(entries));
|
|
3240
|
+
} catch (error) {
|
|
3241
|
+
cooldown.fail();
|
|
3242
|
+
throw error;
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* 把工具参数里的输入图拆成列表。
|
|
3249
|
+
*
|
|
3250
|
+
* 官方支持多图(数组形状),而 agent 工具的参数类型是标量,所以约定**用换行分隔**
|
|
3251
|
+
* 多张。同时容忍调用方直接传数组(内部调用与测试会这么用)。
|
|
3252
|
+
* @param {unknown} value - 原始参数。
|
|
3253
|
+
* @returns {string[]} 拆好的 URL(未归一,调用方逐项过闸)。
|
|
3254
|
+
*/
|
|
3255
|
+
function splitImageUrls(value) {
|
|
3256
|
+
if (Array.isArray(value)) return value.map((v) => String(v)).filter((v) => v.trim() !== "");
|
|
3257
|
+
const raw = str(value, "");
|
|
3258
|
+
if (raw === "") return [];
|
|
3259
|
+
return raw.split(/\r?\n/).map((v) => v.trim()).filter((v) => v !== "");
|
|
3260
|
+
}
|
|
3261
|
+
/** 读正整数,非法返回 undefined(不下发无意义参数)。 */
|
|
3262
|
+
function toPositiveInt(value) {
|
|
3263
|
+
const n = Number(value);
|
|
3264
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
|
|
3265
|
+
}
|
|
3266
|
+
/** 读非负整数(seed 可为 0)。 */
|
|
3267
|
+
function toNonNegativeInt(value) {
|
|
3268
|
+
const n = Number(value);
|
|
3269
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : void 0;
|
|
3270
|
+
}
|
|
3271
|
+
/** 读正浮点(guidance 可为小数)。 */
|
|
3272
|
+
function toPositiveNumber(value) {
|
|
3273
|
+
const n = Number(value);
|
|
3274
|
+
return Number.isFinite(n) && n > 0 ? n : void 0;
|
|
3275
|
+
}
|
|
3276
|
+
/** 读 body 文本,读不到给空串(错误分诊不能因为读 body 失败而失败)。 */
|
|
3277
|
+
async function safeText(response) {
|
|
3278
|
+
try {
|
|
3279
|
+
return await response.text();
|
|
3280
|
+
} catch {
|
|
3281
|
+
return "";
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
/** 解析 JSON;读不到就返回 `null`,由调用方按「非终态/无 id」处理。 */
|
|
3285
|
+
async function safeJson(response, text) {
|
|
3286
|
+
try {
|
|
3287
|
+
return await response.json();
|
|
3288
|
+
} catch {
|
|
3289
|
+
try {
|
|
3290
|
+
return text === "" ? null : JSON.parse(text);
|
|
3291
|
+
} catch {
|
|
3292
|
+
return null;
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
/** 可注入的 sleep(测试可覆写;这里只提供默认实现)。 */
|
|
3297
|
+
function sleep(ms) {
|
|
3298
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
//#endregion
|
|
3302
|
+
//#region src/host/draw-tool-state.ts
|
|
3303
|
+
/** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
|
|
3304
|
+
let note = null;
|
|
3305
|
+
/**
|
|
3306
|
+
* 记下缺席原因(`null` 清空)。
|
|
3307
|
+
* @param {DrawToolAbsentReason|null} reason - 原因码。
|
|
3308
|
+
* @returns {void}
|
|
3309
|
+
*/
|
|
3310
|
+
function setDrawToolNote(reason) {
|
|
3311
|
+
note = reason;
|
|
3312
|
+
}
|
|
3313
|
+
/**
|
|
3314
|
+
* 读当前缺席原因。
|
|
3315
|
+
* @returns {DrawToolAbsentReason|null} 原因码。
|
|
3316
|
+
*/
|
|
3317
|
+
function drawToolNote() {
|
|
3318
|
+
return note;
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
|
|
3322
|
+
* @returns {void}
|
|
3323
|
+
*/
|
|
3324
|
+
function resetDrawToolNote() {
|
|
3325
|
+
note = null;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
//#endregion
|
|
3329
|
+
//#region src/host/switch-precedence.ts
|
|
3330
|
+
/**
|
|
3331
|
+
* 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
|
|
3332
|
+
*
|
|
3333
|
+
* 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
|
|
3334
|
+
* `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
|
|
3335
|
+
* 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
|
|
3336
|
+
* 方言,照抄形状的新调用方会被它标成异类。
|
|
3337
|
+
*
|
|
3338
|
+
* 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
|
|
3339
|
+
* 调用方直接读即可。peer-free,纯函数。
|
|
3340
|
+
*
|
|
3341
|
+
* @module dsh-connect-modelscope-token-plan/switch-precedence
|
|
3342
|
+
*/
|
|
3343
|
+
/**
|
|
3344
|
+
* Resolve the effective boolean switch: a panel-saved value always wins,
|
|
3345
|
+
* otherwise the config default rules.
|
|
3346
|
+
* @param {boolean|null} panel - the panel-saved value (`null` = never saved).
|
|
3347
|
+
* @param {boolean} config - the patch-declared default.
|
|
3348
|
+
* @returns {boolean} the effective switch.
|
|
3349
|
+
*/
|
|
3350
|
+
function resolveSwitchEnabled(panel, config) {
|
|
3351
|
+
return (panel ?? config) === true;
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* Resolve the effective string preference (e.g. a draw-model id): a
|
|
3355
|
+
* panel-saved value wins, otherwise the config default rules.
|
|
3356
|
+
* @param {string|null} panel - the panel-saved value (`null` = never saved).
|
|
3357
|
+
* @param {string|undefined} config - the patch-declared default.
|
|
3358
|
+
* @returns {string|undefined} the effective preference.
|
|
3359
|
+
*/
|
|
3360
|
+
function resolveSwitchValue(panel, config) {
|
|
3361
|
+
return panel ?? config;
|
|
3362
|
+
}
|
|
3363
|
+
/**
|
|
3364
|
+
* Where the effective value came from — the panel when it saved one, the
|
|
3365
|
+
* config otherwise. Rides with every resolved answer so the panel can name
|
|
3366
|
+
* the side in charge.
|
|
1716
3367
|
* @param {boolean|string|null} panel - the panel-saved value.
|
|
1717
3368
|
* @returns {"panel"|"config"}
|
|
1718
3369
|
*/
|
|
@@ -1720,6 +3371,116 @@ function switchSource(panel) {
|
|
|
1720
3371
|
return panel === null ? "config" : "panel";
|
|
1721
3372
|
}
|
|
1722
3373
|
|
|
3374
|
+
//#endregion
|
|
3375
|
+
//#region src/host/draw-register.ts
|
|
3376
|
+
/**
|
|
3377
|
+
* 出图工具的挂载副作用:读开关 → 解析目录 → 注册 agent 工具。
|
|
3378
|
+
*
|
|
3379
|
+
* 与 `provider-publish.ts` 的分工:那个管「魔搭作为 chat provider 的注册」,本文件
|
|
3380
|
+
* 管「给 agent 一个出图工具」。两者是**独立的开关**——回答的是不同问题,合并没有
|
|
3381
|
+
* 好处(用户可能想用工具出图但不想把魔搭塞进模型选择器,反之亦然)。
|
|
3382
|
+
*
|
|
3383
|
+
* 承重约束:**peer 的 tools registry 没有 unregister**。所以
|
|
3384
|
+
* - 注册只在挂载时尝试**一次**,不在任何轮询/路由里重复调用;
|
|
3385
|
+
* - 开关在挂载后翻转,要到下次 Host 启动才生效(面板文案必须说这句,否则用户
|
|
3386
|
+
* 会以为开关坏了);
|
|
3387
|
+
* - 目录变化**不需要**重注册:工具在**调用时**才读目录(`getEntries` 是个函数),
|
|
3388
|
+
* 所以一次目录刷新会自然被下一次出图看到。
|
|
3389
|
+
*
|
|
3390
|
+
* 失败一律降级为「工具缺席」,绝不炸掉插件:tools 服务缺失、peer 加载失败、
|
|
3391
|
+
* registry 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。本文件不吞
|
|
3392
|
+
* 错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
|
|
3393
|
+
*
|
|
3394
|
+
* peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入。
|
|
3395
|
+
*
|
|
3396
|
+
* **本文件目前没有测试覆盖**——`registerDrawTool` 在 `test/` 里零引用
|
|
3397
|
+
* (docs/ROADMAP.md Backlog 记着这条缺口)。但**这不是「不能」**:同一处境的
|
|
3398
|
+
* `llm-adapter.ts` / `draw-runtime.ts` 已经用 `test/helpers/peer-stub-loader.mjs`
|
|
3399
|
+
* 证明这类模块能在离线套件里原样加载执行。此前的措辞把 `llm-adapter.ts` 当作
|
|
3400
|
+
* 「同样无法导入」的例证拉来作证,那条类比在补上那个 loader 之后已经失效。
|
|
3401
|
+
*
|
|
3402
|
+
* @module dsh-connect-modelscope-token-plan/draw-register
|
|
3403
|
+
*/
|
|
3404
|
+
/**
|
|
3405
|
+
* 解析一个可选服务,允许它稍后才注册。
|
|
3406
|
+
*
|
|
3407
|
+
* 承 sensenova 同名函数的纪律:**重试的是读,不是注册**——工具不能注销,重复注册
|
|
3408
|
+
* 比缺席更糟。这里只做有限次立即读取,不做长退避(服务要么已注册,要么这次挂载
|
|
3409
|
+
* 就没有)。
|
|
3410
|
+
* @param {object} ctx - Host 上下文。
|
|
3411
|
+
* @param {string} service - 服务名。
|
|
3412
|
+
* @param {() => boolean} isDisposed - 卸载闸。
|
|
3413
|
+
* @returns {Promise<object|null>} 服务实例或 `null`。
|
|
3414
|
+
*/
|
|
3415
|
+
async function resolveService(ctx, service, isDisposed) {
|
|
3416
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3417
|
+
if (isDisposed()) return null;
|
|
3418
|
+
const found = ctx.get?.(service);
|
|
3419
|
+
if (found !== null && found !== void 0) return found;
|
|
3420
|
+
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
3421
|
+
}
|
|
3422
|
+
return null;
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* 读一次开关,决定是否注册出图工具。
|
|
3426
|
+
* @param {object} ctx - Host 上下文。
|
|
3427
|
+
* @param {object} wiring - `{ settings, configError, tokenStore, inference, providerStore, publisher, logger }`。
|
|
3428
|
+
* @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`)。
|
|
3429
|
+
* @returns {Promise<void>}
|
|
3430
|
+
*/
|
|
3431
|
+
async function registerDrawTool(ctx, wiring, deps = {}) {
|
|
3432
|
+
const { settings, configError, tokenStore, inference, drawStore, publisher, logger } = wiring;
|
|
3433
|
+
const loggerFor = logger ?? ctx.logger;
|
|
3434
|
+
setDrawToolNote(null);
|
|
3435
|
+
try {
|
|
3436
|
+
if (configError !== null) return;
|
|
3437
|
+
const panelValue = drawStore ? await drawStore.enabled().catch(() => null) : null;
|
|
3438
|
+
if (resolveSwitchEnabled(panelValue, settings.drawEnabled === true) !== true) return;
|
|
3439
|
+
const isDisposed = () => publisher?.isDisposed?.() === true;
|
|
3440
|
+
const tools = await resolveService(ctx, "tools", isDisposed);
|
|
3441
|
+
if (tools === null || typeof tools.register !== "function") {
|
|
3442
|
+
setDrawToolNote("no-tools-service");
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
let defineTool = null;
|
|
3446
|
+
try {
|
|
3447
|
+
const mod = await Promise.resolve(deps.loadToolsModule ? deps.loadToolsModule() : import("@deepseek-ai/dsh-tools"));
|
|
3448
|
+
defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
|
|
3449
|
+
} catch (error) {
|
|
3450
|
+
setDrawToolNote("peer-load-failed");
|
|
3451
|
+
loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets(errMsg(error))}) — the tool is absent`);
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
if (typeof defineTool !== "function") {
|
|
3455
|
+
setDrawToolNote("peer-load-failed");
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
const drawFetch = deps.drawFetch ?? ((url, options) => fetch(url, options));
|
|
3459
|
+
tools.register(defineDrawTool({
|
|
3460
|
+
defineTool,
|
|
3461
|
+
fetchImpl: drawFetch,
|
|
3462
|
+
resolveApiKey: async () => (await tokenStore.resolve()).value,
|
|
3463
|
+
getEntries: async () => {
|
|
3464
|
+
return (await inference.fetchModels().catch(() => null))?.entries ?? [];
|
|
3465
|
+
},
|
|
3466
|
+
getPreferredModelId: async () => {
|
|
3467
|
+
const panelValue = drawStore ? await drawStore.modelId().catch(() => null) : null;
|
|
3468
|
+
return resolveSwitchValue(panelValue, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
|
|
3469
|
+
},
|
|
3470
|
+
settings: {
|
|
3471
|
+
apiBase: settings.apiBase,
|
|
3472
|
+
drawModelId: settings.drawModelId,
|
|
3473
|
+
drawTimeoutMs: settings.drawTimeoutMs,
|
|
3474
|
+
drawWaitMs: settings.drawWaitMs
|
|
3475
|
+
},
|
|
3476
|
+
isDisposed
|
|
3477
|
+
}));
|
|
3478
|
+
} catch (error) {
|
|
3479
|
+
setDrawToolNote("registry-refused");
|
|
3480
|
+
loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
|
|
1723
3484
|
//#endregion
|
|
1724
3485
|
//#region src/host/publish-core.ts
|
|
1725
3486
|
/**
|
|
@@ -1857,6 +3618,15 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
|
|
|
1857
3618
|
*
|
|
1858
3619
|
* The module is loaded once and the factory is read off it once, so a Host
|
|
1859
3620
|
* whose peers resolve slowly pays that cost one time, not per publish.
|
|
3621
|
+
*
|
|
3622
|
+
* Only the SUCCESS is memoized. The memoized value used to be the promise
|
|
3623
|
+
* itself, which pinned a rejection in the slot for the life of the process:
|
|
3624
|
+
* after one failed `import()` every later publish rethrew the same error and
|
|
3625
|
+
* the provider could never register again — even after the operator fixed the
|
|
3626
|
+
* missing peer, which is exactly the remedy this file's own
|
|
3627
|
+
* `describeBuildFailure` prints for `ERR_MODULE_NOT_FOUND`. A recoverable
|
|
3628
|
+
* install problem cost a Host restart. Clearing the slot on failure keeps
|
|
3629
|
+
* "loaded once" for the happy path and gives the next publish a real retry.
|
|
1860
3630
|
* @param {() => Promise<object>} loadModule - resolves the adapter module.
|
|
1861
3631
|
* @param {string} exportName - the factory export to read off the module.
|
|
1862
3632
|
* @returns {() => Promise<Function>} the memoized factory resolver.
|
|
@@ -1864,7 +3634,10 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
|
|
|
1864
3634
|
function createAdapterFactoryResolver(loadModule, exportName) {
|
|
1865
3635
|
let adapterFactoryPromise;
|
|
1866
3636
|
return async () => {
|
|
1867
|
-
if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName])
|
|
3637
|
+
if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]).catch((error) => {
|
|
3638
|
+
adapterFactoryPromise = void 0;
|
|
3639
|
+
throw error;
|
|
3640
|
+
});
|
|
1868
3641
|
return adapterFactoryPromise;
|
|
1869
3642
|
};
|
|
1870
3643
|
}
|
|
@@ -1917,6 +3690,13 @@ function warnBuildFailure(logger, label, described) {
|
|
|
1917
3690
|
* fact about the Host — not about the provider — so it is one copy: a Host
|
|
1918
3691
|
* with no `llm` service gets no registration and a stated reason, never a
|
|
1919
3692
|
* half-built one.
|
|
3693
|
+
*
|
|
3694
|
+
* The absence routes through {@link unregister}, not a hand-rolled
|
|
3695
|
+
* `release(); registered = false`: `state.built` must go down with it. A
|
|
3696
|
+
* stale `built` survives into the NEXT publish as its rollback target, so a
|
|
3697
|
+
* later failed publish would re-register an adapter whose release has already
|
|
3698
|
+
* been called — a rollback path running against a dead pair, which is the
|
|
3699
|
+
* worst possible moment to discover the divergence.
|
|
1920
3700
|
* @param {object} job
|
|
1921
3701
|
* @param {object} job.state - the publisher state.
|
|
1922
3702
|
* @param {(service: string) => object|null} job.getLlm - the service resolver.
|
|
@@ -1927,9 +3707,11 @@ function resolveRegistrationService({ state, getLlm, release }) {
|
|
|
1927
3707
|
const llm = getLlm("llm");
|
|
1928
3708
|
state.llmAvailable = llm !== null && typeof llm.registerAdapter === "function";
|
|
1929
3709
|
if (!state.llmAvailable) {
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
3710
|
+
unregister({
|
|
3711
|
+
state,
|
|
3712
|
+
release,
|
|
3713
|
+
error: NO_LLM_SERVICE_ERROR
|
|
3714
|
+
});
|
|
1933
3715
|
return null;
|
|
1934
3716
|
}
|
|
1935
3717
|
return llm;
|
|
@@ -1993,15 +3775,13 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
|
|
|
1993
3775
|
state.built = null;
|
|
1994
3776
|
onRollback?.();
|
|
1995
3777
|
state.error = redactSecrets(errMsg(error));
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
state
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
}
|
|
2004
|
-
else state.registered = false;
|
|
3778
|
+
restorePreviousPair({
|
|
3779
|
+
llm,
|
|
3780
|
+
previousBuilt,
|
|
3781
|
+
state,
|
|
3782
|
+
registerPair,
|
|
3783
|
+
release
|
|
3784
|
+
});
|
|
2005
3785
|
return {
|
|
2006
3786
|
ok: false,
|
|
2007
3787
|
error
|
|
@@ -2014,6 +3794,30 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
|
|
|
2014
3794
|
return { ok: true };
|
|
2015
3795
|
}
|
|
2016
3796
|
/**
|
|
3797
|
+
* 注册失败后恢复先前在服务的 provider 对(如果有)。
|
|
3798
|
+
*
|
|
3799
|
+
* 从 {@link swapRegistration} 提取,把 catch 块里的嵌套从 4 层降到 2 层——
|
|
3800
|
+
* 原实现里 `registerPair(llm, previousBuilt, ...)` 住在 try 内、try 住在
|
|
3801
|
+
* catch 内,读起来要数四层缩进才能确认它在做什么。行为完全不变:先摘掉
|
|
3802
|
+
* 新对(`release()` 已在调用方做过),再尝试挂回旧对;旧对也注册不上就
|
|
3803
|
+
* 诚实地标 `registered: false`。
|
|
3804
|
+
*/
|
|
3805
|
+
function restorePreviousPair({ llm, previousBuilt, state, registerPair, release }) {
|
|
3806
|
+
if (previousBuilt === null) {
|
|
3807
|
+
state.registered = false;
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3810
|
+
try {
|
|
3811
|
+
registerPair(llm, previousBuilt, state);
|
|
3812
|
+
state.built = previousBuilt;
|
|
3813
|
+
state.registered = true;
|
|
3814
|
+
} catch {
|
|
3815
|
+
release();
|
|
3816
|
+
state.built = null;
|
|
3817
|
+
state.registered = false;
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
/**
|
|
2017
3821
|
* Emit the adapter-update event, tolerating a Host that has no `emit` and a
|
|
2018
3822
|
* Host whose `emit` refuses.
|
|
2019
3823
|
*
|
|
@@ -2049,9 +3853,20 @@ function emitAdaptersUpdated(emit) {
|
|
|
2049
3853
|
* - 单点 `registerPair`(带 factory-await + shape 检查)被 publish 与 rollback
|
|
2050
3854
|
* 两条路共用;失败时**恢复旧对**,坏 publish 不会把已在服务的模型也拉下来。
|
|
2051
3855
|
*
|
|
2052
|
-
*
|
|
2053
|
-
*
|
|
2054
|
-
*
|
|
3856
|
+
* 结构性纪律(本文件新立的):一次 publish 拆成 **gather(可挂起)**与
|
|
3857
|
+
* **commit(不可挂起)**两半,commit 是**非 async 函数**——「disposed 复查与
|
|
3858
|
+
* registerAdapter 之间没有 await」由此前的注释论证升级为语言级保证。相位
|
|
3859
|
+
* (idle/serving/failing/broken/disposed)从契约字段推导,不双写。
|
|
3860
|
+
*
|
|
3861
|
+
* 与 sensenova 版的关系:**结构同源、代码不等价**——去注释后代码行重叠 77%
|
|
3862
|
+
* (2026-10-16 实测;复核:`node tools/dev/cross-repo-drift.mjs`)。所以差异**不止**
|
|
3863
|
+
* 三个标签:除工厂导出名 `"createModelScopeAdapter"`、`registerPair` 用的
|
|
3864
|
+
* `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label `"ModelScope"`
|
|
3865
|
+
* 之外,vision 判定这里取自 `llm-models.ts`(sensenova 侧走 `parsers.ts` 的
|
|
3866
|
+
* `identifyVisionModel`),usage 观察层的接线位置也不同。
|
|
3867
|
+
*
|
|
3868
|
+
* 可以跨仓库**对着读**的是三条承重语义(队列 / disposed 闸 / 单点 registerPair,
|
|
3869
|
+
* 它们是 `publish-core.ts` 的骨头,三处 91% 同源);具体代码不可照搬。
|
|
2055
3870
|
*
|
|
2056
3871
|
* peer-free:不 import 任何运行时 peer。适配器工厂由调用方注入
|
|
2057
3872
|
* (`loadAdapterModule`,默认 `import("./llm-adapter.ts")`),所以离线套件可以换
|
|
@@ -2063,6 +3878,29 @@ function emitAdaptersUpdated(emit) {
|
|
|
2063
3878
|
* @module dsh-connect-modelscope-token-plan/provider-publish
|
|
2064
3879
|
*/
|
|
2065
3880
|
/**
|
|
3881
|
+
* 从 publisher 的契约状态**推导**相位——单源,不双写。
|
|
3882
|
+
*
|
|
3883
|
+
* `registered` / `error` 是 `publish-core.ts` 的共享契约字段(`swapRegistration`
|
|
3884
|
+
* 与 `unregister` 直接写它们)。并行维护一个 `phase` 字段就是双写,双写必然
|
|
3885
|
+
* 漂移且方向静默;推导让相位永远不可能与那两个字段矛盾。
|
|
3886
|
+
*
|
|
3887
|
+
* 相位与两道空转守卫的对应(旧版注释里的条件,现在是有名字的状态):
|
|
3888
|
+
* - `idle` = 没注册且无错:开关关着的稳态 → 关侧空转守卫可跳过;
|
|
3889
|
+
* - `serving` = 已注册且无错:offer 已 live → 开侧空转守卫可跳过;
|
|
3890
|
+
* - `failing` = 已注册但有错:回滚到了旧对、但新 offer 没落地 → **必须继续
|
|
3891
|
+
* 重试**(跳过就是静默丢弃用户刚保存的清单);
|
|
3892
|
+
* - `broken` = 没注册且有错:上次发布失败且什么都没在服务 → 继续重试自愈;
|
|
3893
|
+
* - `disposed` = 插件已 dispose:任何 publish 都是 no-op。
|
|
3894
|
+
* @param {{registered: boolean, error: string|null}} state - publisher 状态。
|
|
3895
|
+
* @param {boolean} disposed - publish 队列的 disposed 闸状态。
|
|
3896
|
+
* @returns {PublisherPhase} 相位。
|
|
3897
|
+
*/
|
|
3898
|
+
function publisherPhaseOf(state, disposed) {
|
|
3899
|
+
if (disposed) return "disposed";
|
|
3900
|
+
if (state.registered) return state.error === null ? "serving" : "failing";
|
|
3901
|
+
return state.error === null ? "idle" : "broken";
|
|
3902
|
+
}
|
|
3903
|
+
/**
|
|
2066
3904
|
* provider 发布器。
|
|
2067
3905
|
*
|
|
2068
3906
|
* 持有活注册状态(`state`);publish 队列、`disposed` 闸与单点 `registerPair` 是
|
|
@@ -2086,7 +3924,7 @@ function createProviderPublisher(deps = {}) {
|
|
|
2086
3924
|
const { settings, panelSwitch, loadAdapterModule, getLlm, resolveApiKey, usage, emit, logger } = deps;
|
|
2087
3925
|
const effectiveSettings = settings ?? {};
|
|
2088
3926
|
const effectivePanelSwitch = panelSwitch ?? (async () => null);
|
|
2089
|
-
const effectiveLoadAdapterModule = loadAdapterModule ?? (() => import("./llm-adapter
|
|
3927
|
+
const effectiveLoadAdapterModule = loadAdapterModule ?? (() => import("./llm-adapter.js"));
|
|
2090
3928
|
const effectiveGetLlm = getLlm ?? (() => null);
|
|
2091
3929
|
const effectiveResolveApiKey = resolveApiKey ?? (async () => "");
|
|
2092
3930
|
const effectiveLogger = logger ?? { warn: () => {} };
|
|
@@ -2134,20 +3972,46 @@ function createProviderPublisher(deps = {}) {
|
|
|
2134
3972
|
displayName: LLM_DISPLAY_NAME
|
|
2135
3973
|
});
|
|
2136
3974
|
/**
|
|
2137
|
-
*
|
|
3975
|
+
* 空转守卫的公共判据:目录 offer 与额度耗尽集合是否都没变。
|
|
2138
3976
|
*
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
* @
|
|
2143
|
-
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
2144
|
-
* @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
|
|
2145
|
-
* @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
|
|
3977
|
+
* 两处空转守卫(开关关着 / 开关开着)共用这个签名比对——公式与轮询同源
|
|
3978
|
+
* (由 {@link syncSignaturesAfterPublish} 内部调用导出的 {@link catalogSignature}
|
|
3979
|
+
* 与 {@link quotaSignatureOf}),不会漂移。提取成函数避免两处各写一遍。
|
|
3980
|
+
* @returns {boolean} true = offer 没变,可以跳过这次 publish。
|
|
2146
3981
|
*/
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
3982
|
+
const offerUnchanged = () => state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds);
|
|
3983
|
+
/**
|
|
3984
|
+
* 发布相位——两道空转守卫的判断条件从此有名字(推导见 {@link publisherPhaseOf})。
|
|
3985
|
+
* @param {boolean} disposed - 当前是否已 dispose。
|
|
3986
|
+
* @returns {PublisherPhase}
|
|
3987
|
+
*/
|
|
3988
|
+
const phaseOf = (disposed) => publisherPhaseOf(state, disposed);
|
|
3989
|
+
/**
|
|
3990
|
+
* gather 阶段:一次 publish 的**可挂起**半边。
|
|
3991
|
+
*
|
|
3992
|
+
* 读面板开关、解析 llm 服务、解析适配器工厂、构建适配器——四个 await 全住
|
|
3993
|
+
* 在这里。产出两种计划:`done`(跳过 / 注销 / 构建失败,都已执行完)或
|
|
3994
|
+
* `commit`(适配器构建好了,只剩同步提交)。
|
|
3995
|
+
*
|
|
3996
|
+
* 领域字段(entries / enabledIds / unavailableIds)在查询开关**之前**写入,
|
|
3997
|
+
* 开关关分支刻意不回滚它们——这个不对称是承重的,不是疏忽:`routes/provider.ts`
|
|
3998
|
+
* 与 roster 保存会在开关重新打开时正好从这些字段重新 publish,所以用户的
|
|
3999
|
+
* 允许清单编辑与最后一次拉到的目录**必须**熬过关闭期。把 `previous*` 还回去
|
|
4000
|
+
* 会静默丢掉两者——开关关闭期间做的保存,会在开关重新打开时消失。
|
|
4001
|
+
* `swapRegistration` 的 `onRollback` 会还它们,但只对注册真的抛了的路径——
|
|
4002
|
+
* 那种失败必须让先前在服务的对继续服务,这是与「开关关着」不同的承诺。
|
|
4003
|
+
* @param {unknown} entries - 归一目录条目。
|
|
4004
|
+
* @param {unknown} enabledIds - 允许清单(空 = 全部)。
|
|
4005
|
+
* @param {string[]} unavailableModelIds - 额度耗尽的模型 id。
|
|
4006
|
+
* @returns {Promise<PublishPlan>} 执行计划。
|
|
4007
|
+
*/
|
|
4008
|
+
const gatherPublish = async (entries, enabledIds, unavailableModelIds) => {
|
|
4009
|
+
if (queue.isDisposed()) return {
|
|
4010
|
+
kind: "done",
|
|
4011
|
+
result: {
|
|
4012
|
+
ok: false,
|
|
4013
|
+
skipped: true
|
|
4014
|
+
}
|
|
2151
4015
|
};
|
|
2152
4016
|
const previousBuilt = state.built;
|
|
2153
4017
|
const previousEntries = state.entries;
|
|
@@ -2158,16 +4022,22 @@ function createProviderPublisher(deps = {}) {
|
|
|
2158
4022
|
state.unavailableIds = Array.isArray(unavailableModelIds) ? unavailableModelIds : [];
|
|
2159
4023
|
const panelValue = await effectivePanelSwitch().catch(() => null);
|
|
2160
4024
|
if (!resolveSwitchEnabled(panelValue, effectiveSettings.registerProvider === true)) {
|
|
2161
|
-
if (
|
|
2162
|
-
|
|
2163
|
-
|
|
4025
|
+
if (phaseOf(false) === "idle" && offerUnchanged()) return {
|
|
4026
|
+
kind: "done",
|
|
4027
|
+
result: {
|
|
4028
|
+
ok: true,
|
|
4029
|
+
skipped: true
|
|
4030
|
+
}
|
|
2164
4031
|
};
|
|
2165
4032
|
const result = unregister({
|
|
2166
4033
|
state,
|
|
2167
4034
|
release
|
|
2168
4035
|
});
|
|
2169
4036
|
syncSignaturesAfterPublish(state);
|
|
2170
|
-
return
|
|
4037
|
+
return {
|
|
4038
|
+
kind: "done",
|
|
4039
|
+
result
|
|
4040
|
+
};
|
|
2171
4041
|
}
|
|
2172
4042
|
const llm = resolveRegistrationService({
|
|
2173
4043
|
state,
|
|
@@ -2175,13 +4045,15 @@ function createProviderPublisher(deps = {}) {
|
|
|
2175
4045
|
release
|
|
2176
4046
|
});
|
|
2177
4047
|
if (llm === null) return {
|
|
2178
|
-
|
|
2179
|
-
|
|
4048
|
+
kind: "done",
|
|
4049
|
+
result: {
|
|
4050
|
+
ok: false,
|
|
4051
|
+
error: state.error
|
|
4052
|
+
}
|
|
2180
4053
|
};
|
|
2181
|
-
let createModelScopeAdapter;
|
|
2182
4054
|
let built;
|
|
2183
4055
|
try {
|
|
2184
|
-
createModelScopeAdapter = await resolveAdapterFactory();
|
|
4056
|
+
const createModelScopeAdapter = await resolveAdapterFactory();
|
|
2185
4057
|
if (createModelScopeAdapter === void 0) throw new Error(BAD_FACTORY_SHAPE_ERROR);
|
|
2186
4058
|
built = await createModelScopeAdapter({
|
|
2187
4059
|
entries: state.entries,
|
|
@@ -2198,36 +4070,98 @@ function createProviderPublisher(deps = {}) {
|
|
|
2198
4070
|
state.error = described.note;
|
|
2199
4071
|
warnBuildFailure(effectiveLogger, "ModelScope", described);
|
|
2200
4072
|
return {
|
|
2201
|
-
|
|
2202
|
-
|
|
4073
|
+
kind: "done",
|
|
4074
|
+
result: {
|
|
4075
|
+
ok: false,
|
|
4076
|
+
error: described.error
|
|
4077
|
+
}
|
|
2203
4078
|
};
|
|
2204
4079
|
}
|
|
2205
|
-
if (
|
|
2206
|
-
|
|
2207
|
-
|
|
4080
|
+
if (phaseOf(false) === "serving" && offerUnchanged()) return {
|
|
4081
|
+
kind: "done",
|
|
4082
|
+
result: {
|
|
4083
|
+
ok: true,
|
|
4084
|
+
skipped: true
|
|
4085
|
+
}
|
|
4086
|
+
};
|
|
4087
|
+
return {
|
|
4088
|
+
kind: "commit",
|
|
4089
|
+
llm,
|
|
4090
|
+
built,
|
|
4091
|
+
previousBuilt,
|
|
4092
|
+
previousEntries,
|
|
4093
|
+
previousEnabledIds,
|
|
4094
|
+
previousUnavailable
|
|
2208
4095
|
};
|
|
4096
|
+
};
|
|
4097
|
+
/**
|
|
4098
|
+
* commit 阶段:一次 publish 的**不可挂起**半边。
|
|
4099
|
+
*
|
|
4100
|
+
* **这个函数刻意不是 `async`**——语言禁止在非 async 函数里写 `await`,所以
|
|
4101
|
+
* 从下面的 disposed 复查到 `swapRegistration` 内部的 `registerAdapter` 之间
|
|
4102
|
+
* **不可能**插入挂起点。旧版靠一句「这个位置严密:从这里到 registerAdapter
|
|
4103
|
+
* 之间没有任何 await」的注释论证这件事;现在由函数签名本身保证——想在这里
|
|
4104
|
+
* 加 await,编译期就过不去。临界区的正确性由结构承载,不由注释承载。
|
|
4105
|
+
*
|
|
4106
|
+
* 为什么这道复查必须有(gather 入口那次只覆盖到第一个 await,而注册发生在
|
|
4107
|
+
* **最后一次** await 之后):teardown 落在这段窗口里时,入口闸门不会重跑,
|
|
4108
|
+
* 于是 registerAdapter 把适配器注册进一个**已经把本插件撤下的 Host**,而
|
|
4109
|
+
* teardown 早就跑完、release 永不调用——泄漏的对持有 tokenStore 与整个
|
|
4110
|
+
* Cordis ctx 的闭包,形成僵尸 provider:picker 里那个 provider 还在、还能
|
|
4111
|
+
* 路由,而面板路由已注销,用户没有界面能关掉它。
|
|
4112
|
+
*
|
|
4113
|
+
* 为什么 peer 的 fiber 兜底救不了:`registerAdapter` 用的是 **llm 服务自己的**
|
|
4114
|
+
* `ctx.effect`(peer 源码里 `.bind(this)` 到 llm 服务),插件卸载不会 dispose
|
|
4115
|
+
* 它,宿主注册表永久持有该 adapter。
|
|
4116
|
+
*
|
|
4117
|
+
* 若 teardown 发生在本次检查**之后**,它执行 release() 时能看到新写入的
|
|
4118
|
+
* releaseAdapter 并摘掉——两种时序都正确。检查刻意留在本文件(publisher)而
|
|
4119
|
+
* **不**放进 `swapRegistration`:那是 publish-core 的共享骨头,不该知道
|
|
4120
|
+
* disposed 的概念。
|
|
4121
|
+
* @param {Extract<PublishPlan, {kind: "commit"}>} plan - gather 产出的提交计划。
|
|
4122
|
+
* @returns {{ok: boolean, skipped?: boolean, error?: unknown}}
|
|
4123
|
+
*/
|
|
4124
|
+
const commitPublish = (plan) => {
|
|
2209
4125
|
if (queue.isDisposed()) return {
|
|
2210
4126
|
ok: false,
|
|
2211
4127
|
skipped: true
|
|
2212
4128
|
};
|
|
4129
|
+
const requestedSignature = catalogSignature(state.entries, state.enabledIds);
|
|
2213
4130
|
const result = swapRegistration({
|
|
2214
|
-
llm,
|
|
2215
|
-
built,
|
|
2216
|
-
previousBuilt,
|
|
4131
|
+
llm: plan.llm,
|
|
4132
|
+
built: plan.built,
|
|
4133
|
+
previousBuilt: plan.previousBuilt,
|
|
2217
4134
|
state,
|
|
2218
4135
|
release,
|
|
2219
4136
|
registerPair,
|
|
2220
4137
|
emit,
|
|
2221
4138
|
onRollback: () => {
|
|
2222
|
-
state.entries = previousEntries;
|
|
2223
|
-
state.enabledIds = previousEnabledIds;
|
|
2224
|
-
state.unavailableIds = previousUnavailable;
|
|
4139
|
+
state.entries = plan.previousEntries;
|
|
4140
|
+
state.enabledIds = plan.previousEnabledIds;
|
|
4141
|
+
state.unavailableIds = plan.previousUnavailable;
|
|
2225
4142
|
}
|
|
2226
4143
|
});
|
|
2227
4144
|
syncSignaturesAfterPublish(state);
|
|
4145
|
+
if (result.ok === false && state.registered === true && state.signature === requestedSignature) state.error = null;
|
|
2228
4146
|
return result;
|
|
2229
4147
|
};
|
|
2230
4148
|
/**
|
|
4149
|
+
* 为一个目录/允许清单快照(重)构建并注册 provider。
|
|
4150
|
+
*
|
|
4151
|
+
* 形状 = gather(可挂起)+ commit(不可挂起):全部 await 住在前者,后者是
|
|
4152
|
+
* 非 async 函数。重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles
|
|
4153
|
+
* 快照,只有新的注册才能改变提供的模型列表。注册失败时**恢复上一对**,所以
|
|
4154
|
+
* 坏 publish 永远不能把已在服务的模型拉下来。
|
|
4155
|
+
* @param {object[]} entries - 归一目录条目。
|
|
4156
|
+
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
4157
|
+
* @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
|
|
4158
|
+
* @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
|
|
4159
|
+
*/
|
|
4160
|
+
const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
|
|
4161
|
+
const plan = await gatherPublish(entries, enabledIds, unavailableModelIds);
|
|
4162
|
+
return plan.kind === "done" ? plan.result : commitPublish(plan);
|
|
4163
|
+
};
|
|
4164
|
+
/**
|
|
2231
4165
|
* publish,排到所有其他在途 publish 之后。
|
|
2232
4166
|
*
|
|
2233
4167
|
* 包装的存在是为了没有任何调用方要记得队列:挂载 seed、目录轮询、provider 开关、
|
|
@@ -2301,11 +4235,134 @@ function syncSignaturesAfterPublish(state) {
|
|
|
2301
4235
|
state.quotaSignature = quotaSignatureOf(unavailable);
|
|
2302
4236
|
}
|
|
2303
4237
|
|
|
4238
|
+
//#endregion
|
|
4239
|
+
//#region src/host/provider-reconcile.ts
|
|
4240
|
+
/**
|
|
4241
|
+
* provider 注册的 **desired-state reconciler**——把两个各自为政的驱动收编成
|
|
4242
|
+
* 一个控制器。
|
|
4243
|
+
*
|
|
4244
|
+
* ## 它取代什么
|
|
4245
|
+
*
|
|
4246
|
+
* 改之前,往同一个注册态里写的有两个 driver:挂载 seed + 每 `pollSeconds`
|
|
4247
|
+
* 一次的目录轮询(`index.ts` 的 `runPoll`),以及四条 provider 路由的
|
|
4248
|
+
* 「保存后立即 publish」。两者没有共同的状态词汇表,于是 `runPoll` 不得不把
|
|
4249
|
+
* 窗口覆盖**读两遍**(fetch 前一次、fetch 后一次),「期间被改就跳过本轮」——
|
|
4250
|
+
* 那是没有 desired-state 控制器的典型补丁:丢一次更新、再等一个轮询周期。
|
|
4251
|
+
*
|
|
4252
|
+
* ## 模型
|
|
4253
|
+
*
|
|
4254
|
+
* - **desired state**(操作者想要什么)= `providerStore` 里的开关 + 允许
|
|
4255
|
+
* 清单 + 窗口覆盖。它只有一个家,轮询和路由都只读它、不各写一份;
|
|
4256
|
+
* - **reconcile 一遍** = 读 desired → 带窗口覆盖拉目录 → 复查 desired
|
|
4257
|
+
* (fetch 期间被改就重跑,不等下一轮)→ 把「当前目录 + 当前清单」交给
|
|
4258
|
+
* publisher(它自己重读开关,single source 不变);
|
|
4259
|
+
* - **触发方全部降级为「标记 dirty」**:挂载、轮询定时器、四条写路由。
|
|
4260
|
+
* 谁都不用记得「别人是不是也在 publish」——队列性质由本模块的串行链保证。
|
|
4261
|
+
*
|
|
4262
|
+
* ## 两个刻意的行为改变(相对旧 runPoll)
|
|
4263
|
+
*
|
|
4264
|
+
* 1. **fetch 失败不再发布空目录**。旧 `readEntries` 把 fetch 失败降级成
|
|
4265
|
+
* `entries: []` 再 publish——一次瞬时的目录抖动会把已服务的模型全部撤下
|
|
4266
|
+
* (破坏,不是重试)。这里失败只记原因、保留当前 offer,下一轮自然重试;
|
|
4267
|
+
* 轮询侧本来就该如此(旧 runPoll 正是这么做的),路由侧现在与它一致。
|
|
4268
|
+
* 2. **fetch 期间 desired 被改 → 重跑,不是跳过**。旧代码「跳过本轮、等
|
|
4269
|
+
* pollSeconds」;这里原地收敛,保存路由的 await 返回时新值一定已落地。
|
|
4270
|
+
*
|
|
4271
|
+
* peer-free:不 import 任何 Host peer。
|
|
4272
|
+
*
|
|
4273
|
+
* @module dsh-connect-modelscope-token-plan/provider-reconcile
|
|
4274
|
+
*/
|
|
4275
|
+
/**
|
|
4276
|
+
* 创建 provider reconciler。
|
|
4277
|
+
*
|
|
4278
|
+
* 串行链 + dirty 标志:每次触发(轮询/路由/挂载)都排在链尾,链上每一环先看
|
|
4279
|
+
* dirty 再决定要不要真跑——在途那一轮会替后来者消费 dirty,所以 N 次连续触发
|
|
4280
|
+
* 最多收敛成两遍 reconcile,不会排 N 遍。调用方 await 返回的 promise,即
|
|
4281
|
+
* 「我的触发已被消费」。
|
|
4282
|
+
* @param {ProviderReconcilerDeps} deps - wiring 子集。
|
|
4283
|
+
* @returns {{reconcile: () => Promise<void>}} 触发一次收敛。
|
|
4284
|
+
*/
|
|
4285
|
+
function createProviderReconciler(deps) {
|
|
4286
|
+
const { providerStore, inference, publisher, logger } = deps;
|
|
4287
|
+
/**
|
|
4288
|
+
* 读一次 desired。三路都 `.catch(() => null)`:读不到按「未设置」降级,
|
|
4289
|
+
* 与开关/清单/覆盖各自的读侧纪律一致——一次读失败不该把收敛打掉。
|
|
4290
|
+
* @returns {Promise<Desired>}
|
|
4291
|
+
*/
|
|
4292
|
+
const readDesired = async () => {
|
|
4293
|
+
const [enabled, enabledIds, contextWindow] = await Promise.all([
|
|
4294
|
+
providerStore.enabled().catch(() => null),
|
|
4295
|
+
providerStore.enabledIds().catch(() => null),
|
|
4296
|
+
providerStore.contextWindow().catch(() => null)
|
|
4297
|
+
]);
|
|
4298
|
+
return {
|
|
4299
|
+
enabled,
|
|
4300
|
+
enabledIds,
|
|
4301
|
+
contextWindow
|
|
4302
|
+
};
|
|
4303
|
+
};
|
|
4304
|
+
/** desired 的稳定键:用于「fetch 期间被改了吗」的比对。 */
|
|
4305
|
+
const desiredKey = (desired) => JSON.stringify([
|
|
4306
|
+
desired.enabled,
|
|
4307
|
+
desired.enabledIds,
|
|
4308
|
+
desired.contextWindow
|
|
4309
|
+
]);
|
|
4310
|
+
/**
|
|
4311
|
+
* 收敛一遍:读 desired → 拉目录 → 复查 desired → publish。
|
|
4312
|
+
* @returns {Promise<void>}
|
|
4313
|
+
*/
|
|
4314
|
+
const runOnce = async () => {
|
|
4315
|
+
const before = await readDesired();
|
|
4316
|
+
let catalog;
|
|
4317
|
+
try {
|
|
4318
|
+
catalog = await inference.fetchModels(before.contextWindow);
|
|
4319
|
+
} catch (error) {
|
|
4320
|
+
logger?.warn?.(`${name}: catalog fetch failed (${redactSecrets(errMsg(error))}) — the current offer stays registered; the next pass retries`);
|
|
4321
|
+
return;
|
|
4322
|
+
}
|
|
4323
|
+
const after = await readDesired();
|
|
4324
|
+
if (desiredKey(after) !== desiredKey(before)) {
|
|
4325
|
+
dirty = true;
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
4328
|
+
const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
4329
|
+
const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
|
|
4330
|
+
try {
|
|
4331
|
+
await publisher.publish(entries, enabledIds, []);
|
|
4332
|
+
} catch (error) {
|
|
4333
|
+
logger?.warn?.(`${name}: publish failed (${redactSecrets(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
|
|
4334
|
+
}
|
|
4335
|
+
};
|
|
4336
|
+
/** 串行链:每次触发排到链尾;在途那一轮替后来者消费 dirty。 */
|
|
4337
|
+
let chain = Promise.resolve();
|
|
4338
|
+
/** 有新触发待消费。闭包状态,不暴露——触发方只看得见 {@link reconcile}。 */
|
|
4339
|
+
let dirty = false;
|
|
4340
|
+
/**
|
|
4341
|
+
* 触发一次收敛。幂等且可重入:在途时只置 dirty,由在途那一轮替本调用消费
|
|
4342
|
+
* (N 次连续触发最多收敛成两遍 reconcile,不会排 N 遍)。
|
|
4343
|
+
* @returns {Promise<void>} 本触发已被消费(注册态已反映 desired)后 resolve。
|
|
4344
|
+
*/
|
|
4345
|
+
const reconcile = () => {
|
|
4346
|
+
dirty = true;
|
|
4347
|
+
return chain = chain.then(async () => {
|
|
4348
|
+
let guard = 0;
|
|
4349
|
+
while (dirty) {
|
|
4350
|
+
dirty = false;
|
|
4351
|
+
await runOnce();
|
|
4352
|
+
if (++guard > 100) break;
|
|
4353
|
+
}
|
|
4354
|
+
});
|
|
4355
|
+
};
|
|
4356
|
+
return { reconcile };
|
|
4357
|
+
}
|
|
4358
|
+
|
|
2304
4359
|
//#endregion
|
|
2305
4360
|
//#region src/host/routes/paths.ts
|
|
2306
4361
|
/**
|
|
2307
4362
|
* 路由字面量。浏览器 bundle(src/client/const.ts)无法从 host-config 导入,
|
|
2308
|
-
* 两边各写一份字面量——由 test/
|
|
4363
|
+
* 两边各写一份字面量——由 test/panel.test.mjs 的 §6/§6b 钉住两端解析出的完整
|
|
4364
|
+
* 路径集合双向相等,改名时先红再改。(原注释指向 test/config.test.mjs,但那套
|
|
4365
|
+
* 钉的是 CONFIG_DEFAULTS ↔ cordis.patch.yml,不含路由字面量。)
|
|
2309
4366
|
* @module dsh-connect-modelscope-token-plan/routes/paths
|
|
2310
4367
|
*/
|
|
2311
4368
|
const SNAPSHOT_PATH = "/api/dsh-connect-modelscope-token-plan/snapshot";
|
|
@@ -2314,6 +4371,7 @@ const TOKEN_PATH = "/api/dsh-connect-modelscope-token-plan/token";
|
|
|
2314
4371
|
const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
|
|
2315
4372
|
const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
|
|
2316
4373
|
const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
|
|
4374
|
+
const DRAW_PATH = "/api/dsh-connect-modelscope-token-plan/draw";
|
|
2317
4375
|
|
|
2318
4376
|
//#endregion
|
|
2319
4377
|
//#region src/host/snapshot-aggregate.ts
|
|
@@ -2357,61 +4415,47 @@ async function soft(promise) {
|
|
|
2357
4415
|
};
|
|
2358
4416
|
}
|
|
2359
4417
|
}
|
|
2360
|
-
/**
|
|
2361
|
-
* Provider 状态块降级形状:读不到开关/注册状态/目录时,面板仍然得到一个完整
|
|
2362
|
-
* 可渲染的块(开关关、roster 空、error 说明原因),绝不让整条快照失败。
|
|
2363
|
-
*/
|
|
2364
|
-
function providerDegraded(error) {
|
|
2365
|
-
return {
|
|
2366
|
-
enabled: false,
|
|
2367
|
-
source: "config",
|
|
2368
|
-
llmAvailable: false,
|
|
2369
|
-
registered: false,
|
|
2370
|
-
error,
|
|
2371
|
-
modelCount: 0,
|
|
2372
|
-
enabledCount: 0,
|
|
2373
|
-
allowed: "all",
|
|
2374
|
-
enabledIds: [],
|
|
2375
|
-
roster: []
|
|
2376
|
-
};
|
|
2377
|
-
}
|
|
2378
4418
|
/** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
|
|
2379
4419
|
async function buildSnapshotBody(wiring) {
|
|
2380
|
-
const { settings, tokenStore, usageStore, inference, providerStore, publisher } = wiring;
|
|
2381
|
-
const [tokenState, daily, perModel, trend, events] = await Promise.all([
|
|
4420
|
+
const { settings, tokenStore, usageStore, inference, providerStore, drawStore, publisher } = wiring;
|
|
4421
|
+
const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
|
|
2382
4422
|
soft(tokenStore.state()),
|
|
2383
4423
|
soft(usageStore.daily()),
|
|
2384
4424
|
soft(usageStore.perModelToday()),
|
|
2385
4425
|
soft(usageStore.trend(settings.trendDays)),
|
|
2386
|
-
soft(usageStore.events())
|
|
4426
|
+
soft(usageStore.events()),
|
|
4427
|
+
soft(providerStore.versionNote())
|
|
2387
4428
|
]);
|
|
2388
4429
|
const shapeWarnings = [];
|
|
2389
4430
|
const usageAnomaly = usageStore.anomaly();
|
|
2390
4431
|
if (usageAnomaly !== null) shapeWarnings.push(usageAnomaly);
|
|
2391
|
-
for (const path of [
|
|
2392
|
-
tokenState,
|
|
2393
|
-
daily,
|
|
2394
|
-
perModel,
|
|
2395
|
-
trend,
|
|
2396
|
-
events
|
|
2397
|
-
]) if (!path.ok) shapeWarnings.push(
|
|
2398
|
-
const
|
|
2399
|
-
const
|
|
2400
|
-
const models = await soft(inference.fetchModels());
|
|
4432
|
+
for (const [label, path] of [
|
|
4433
|
+
["token", tokenState],
|
|
4434
|
+
["daily", daily],
|
|
4435
|
+
["per-model", perModel],
|
|
4436
|
+
["trend", trend],
|
|
4437
|
+
["events", events]
|
|
4438
|
+
]) if (!path.ok) shapeWarnings.push(`${label} source failed (${path.code}): ${path.error}`);
|
|
4439
|
+
const tokenKnownAbsent = tokenState.ok && tokenState.value.present === false;
|
|
4440
|
+
const contextOverride = await providerStore.contextWindow().catch(() => null);
|
|
4441
|
+
const [balance, models] = await Promise.all([soft(inference.fetchBalance()), soft(inference.fetchModels(contextOverride))]);
|
|
2401
4442
|
const modelIds = models.ok ? models.value.ids : [];
|
|
2402
4443
|
const modelEntries = models.ok ? models.value.entries : [];
|
|
2403
4444
|
if (!models.ok && models.code !== "network_error") shapeWarnings.push(`models: ${models.error}`);
|
|
2404
|
-
if (
|
|
4445
|
+
if (!tokenKnownAbsent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
|
|
4446
|
+
if (providerNote.ok && providerNote.value !== null) shapeWarnings.push(providerNote.value);
|
|
4447
|
+
const drawNote = await soft((async () => drawStore.versionNote())());
|
|
4448
|
+
if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
|
|
2405
4449
|
const provider = await soft((async () => {
|
|
2406
4450
|
const panel = await providerStore.enabled().catch(() => null);
|
|
2407
4451
|
const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
|
|
2408
4452
|
const source = switchSource(panel);
|
|
2409
4453
|
const roster = rosterWithAvailability(modelEntries, []);
|
|
2410
4454
|
const stored = await providerStore.enabledIds().catch(() => null);
|
|
2411
|
-
const enabledIds =
|
|
2412
|
-
const
|
|
2413
|
-
const
|
|
2414
|
-
const
|
|
4455
|
+
const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
|
|
4456
|
+
const allowedList = parseAllowedList(enabledIds);
|
|
4457
|
+
const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
|
|
4458
|
+
const contextWindow = await providerStore.contextWindow().catch(() => null);
|
|
2415
4459
|
return {
|
|
2416
4460
|
enabled,
|
|
2417
4461
|
source,
|
|
@@ -2420,12 +4464,31 @@ async function buildSnapshotBody(wiring) {
|
|
|
2420
4464
|
error: typeof publisher.state.error === "string" ? publisher.state.error : null,
|
|
2421
4465
|
modelCount: roster.length,
|
|
2422
4466
|
enabledCount,
|
|
2423
|
-
allowed,
|
|
4467
|
+
allowed: allowedList.mode,
|
|
2424
4468
|
enabledIds,
|
|
4469
|
+
contextWindow,
|
|
2425
4470
|
roster
|
|
2426
4471
|
};
|
|
2427
4472
|
})());
|
|
2428
|
-
const
|
|
4473
|
+
const draw = await soft((async () => {
|
|
4474
|
+
const panel = await drawStore.enabled().catch(() => null);
|
|
4475
|
+
const panelModel = await drawStore.modelId().catch(() => null);
|
|
4476
|
+
const candidateIds = imageGenModelIds(modelEntries);
|
|
4477
|
+
return {
|
|
4478
|
+
enabled: resolveSwitchEnabled(panel, settings.drawEnabled),
|
|
4479
|
+
source: switchSource(panel),
|
|
4480
|
+
candidateIds,
|
|
4481
|
+
candidateCount: candidateIds.length,
|
|
4482
|
+
preferredModel: resolveSwitchValue(panelModel, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null,
|
|
4483
|
+
preferredSource: switchSource(panelModel),
|
|
4484
|
+
absentReason: drawToolNote(),
|
|
4485
|
+
mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolNote() !== null,
|
|
4486
|
+
toolName: "modelscope_draw_image"
|
|
4487
|
+
};
|
|
4488
|
+
})());
|
|
4489
|
+
if (!provider.ok) shapeWarnings.push(`provider source failed (${provider.code}): ${provider.error}`);
|
|
4490
|
+
if (!draw.ok) shapeWarnings.push(`draw source failed (${draw.code}): ${draw.error}`);
|
|
4491
|
+
const usedLocal = daily.ok ? daily.value.calls : null;
|
|
2429
4492
|
return {
|
|
2430
4493
|
ok: true,
|
|
2431
4494
|
name,
|
|
@@ -2433,12 +4496,16 @@ async function buildSnapshotBody(wiring) {
|
|
|
2433
4496
|
now: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2434
4497
|
pollSeconds: settings.pollSeconds,
|
|
2435
4498
|
cacheSeconds: settings.cacheSeconds,
|
|
2436
|
-
token: tokenState.ok ?
|
|
4499
|
+
token: tokenState.ok ? {
|
|
4500
|
+
...tokenState.value,
|
|
4501
|
+
readError: null
|
|
4502
|
+
} : {
|
|
2437
4503
|
present: false,
|
|
2438
4504
|
source: "none",
|
|
2439
4505
|
valid: null,
|
|
2440
4506
|
checkedAt: null,
|
|
2441
|
-
ephemeral: true
|
|
4507
|
+
ephemeral: true,
|
|
4508
|
+
readError: tokenState.error
|
|
2442
4509
|
},
|
|
2443
4510
|
balance: balance.ok ? {
|
|
2444
4511
|
available: balance.value.available,
|
|
@@ -2451,7 +4518,7 @@ async function buildSnapshotBody(wiring) {
|
|
|
2451
4518
|
total: null,
|
|
2452
4519
|
frozen: null,
|
|
2453
4520
|
fetchedAt: null,
|
|
2454
|
-
error:
|
|
4521
|
+
error: tokenKnownAbsent ? null : balance.error
|
|
2455
4522
|
},
|
|
2456
4523
|
quota: {
|
|
2457
4524
|
daily: { usedLocal },
|
|
@@ -2469,7 +4536,8 @@ async function buildSnapshotBody(wiring) {
|
|
|
2469
4536
|
sample: modelIds.slice(0, 20),
|
|
2470
4537
|
error: models.ok ? null : models.error
|
|
2471
4538
|
},
|
|
2472
|
-
provider
|
|
4539
|
+
...provider.ok ? { provider: provider.value } : {},
|
|
4540
|
+
...draw.ok ? { draw: draw.value } : {},
|
|
2473
4541
|
shapeWarnings,
|
|
2474
4542
|
quotaError: null
|
|
2475
4543
|
};
|
|
@@ -2570,6 +4638,22 @@ function redactedError(error) {
|
|
|
2570
4638
|
const text = redactSecrets(errMsg(error));
|
|
2571
4639
|
return text.trim() === "" ? "request failed" : text;
|
|
2572
4640
|
}
|
|
4641
|
+
/**
|
|
4642
|
+
* 写盘/操作失败的统一响应:HTTP 恒 200、成败看 body(与快照路由同一语义,
|
|
4643
|
+
* 一次写失败不许顺着渲染树炸穿)。**用户显式操作的失败必须上屏**,所以
|
|
4644
|
+
* 调用方不吞、这里只负责脱敏后的标准形状。provider 开关/清单/reset 与
|
|
4645
|
+
* token save/forget 五处 catch 曾各写一遍这个 writeJson——集中成一份,
|
|
4646
|
+
* 措辞与形状不会漂移。
|
|
4647
|
+
* @param {object} response - webServer 的响应对象。
|
|
4648
|
+
* @param {unknown} error - catch 到的任意值(未必是 Error)。
|
|
4649
|
+
* @returns {void}
|
|
4650
|
+
*/
|
|
4651
|
+
function respondError(response, error) {
|
|
4652
|
+
writeJson(response, 200, {
|
|
4653
|
+
ok: false,
|
|
4654
|
+
error: redactedError(error)
|
|
4655
|
+
}, { "cache-control": "no-store" });
|
|
4656
|
+
}
|
|
2573
4657
|
/** 读并校验 JSON body,失败时写 400 并返回 null(调用方的停手信号)。 */
|
|
2574
4658
|
async function readJsonBodyOr400(request, response, limit = MAX_JSON_BODY_BYTES) {
|
|
2575
4659
|
const body = await readJsonBody(request, limit);
|
|
@@ -2702,7 +4786,14 @@ function registerModelsRoute(ctx, wiring) {
|
|
|
2702
4786
|
* 简化(SPIKE.md §结论 3)。
|
|
2703
4787
|
* @module dsh-connect-modelscope-token-plan/routes/token
|
|
2704
4788
|
*/
|
|
2705
|
-
/**
|
|
4789
|
+
/**
|
|
4790
|
+
* 注册令牌路由(POST /token 保存,POST /token/forget 忘掉)。
|
|
4791
|
+
*
|
|
4792
|
+
* 返回类型是本族唯一的一个**数组**:token 注册两条路由(save + forget),其余
|
|
4793
|
+
* 路由族各只注册一条、返回单个 off()。标注在**这里**而不是在 routes.ts 里对
|
|
4794
|
+
* 调用结果做 `as Array<() => void>` 断言——断言放在消费端等于把「这两条返回的是
|
|
4795
|
+
* 注销回调而不是 handler」这个约定藏起来,标注在产地才是声明。
|
|
4796
|
+
*/
|
|
2706
4797
|
function registerTokenRoute(ctx, wiring) {
|
|
2707
4798
|
const { settings, tokenStore } = wiring;
|
|
2708
4799
|
const save = withOrigin(async (request, response) => {
|
|
@@ -2727,10 +4818,7 @@ function registerTokenRoute(ctx, wiring) {
|
|
|
2727
4818
|
...await tokenStore.state()
|
|
2728
4819
|
}, { "cache-control": "no-store" });
|
|
2729
4820
|
} catch (error) {
|
|
2730
|
-
|
|
2731
|
-
ok: false,
|
|
2732
|
-
error: redactedError(error)
|
|
2733
|
-
}, { "cache-control": "no-store" });
|
|
4821
|
+
respondError(response, error);
|
|
2734
4822
|
}
|
|
2735
4823
|
}, settings.allowedHosts);
|
|
2736
4824
|
const forget = withOrigin(async (request, response) => {
|
|
@@ -2745,10 +4833,7 @@ function registerTokenRoute(ctx, wiring) {
|
|
|
2745
4833
|
...await tokenStore.state()
|
|
2746
4834
|
}, { "cache-control": "no-store" });
|
|
2747
4835
|
} catch (error) {
|
|
2748
|
-
|
|
2749
|
-
ok: false,
|
|
2750
|
-
error: redactedError(error)
|
|
2751
|
-
}, { "cache-control": "no-store" });
|
|
4836
|
+
respondError(response, error);
|
|
2752
4837
|
}
|
|
2753
4838
|
}, settings.allowedHosts);
|
|
2754
4839
|
return [ctx.webServer.register({
|
|
@@ -2807,13 +4892,15 @@ function registerProbeRoute(ctx, wiring) {
|
|
|
2807
4892
|
}
|
|
2808
4893
|
try {
|
|
2809
4894
|
const result = await inference.probe({ modelId });
|
|
4895
|
+
wiring.tokenStore.noteValidity(true);
|
|
2810
4896
|
writeJson(response, 200, {
|
|
2811
4897
|
...result,
|
|
2812
|
-
tokenState: await wiring.tokenStore.state()
|
|
4898
|
+
tokenState: await wiring.tokenStore.state().catch(() => null)
|
|
2813
4899
|
}, { "cache-control": "no-store" });
|
|
2814
4900
|
} catch (error) {
|
|
2815
4901
|
const code = error.code;
|
|
2816
4902
|
const message = redactedError(error);
|
|
4903
|
+
if (code === CODE.AUTH_ERROR) wiring.tokenStore.noteValidity(false);
|
|
2817
4904
|
const eventKind = code === CODE.QUOTA_EXCEEDED ? "quota" : code === CODE.RATE_LIMITED ? "rate_limit" : "error";
|
|
2818
4905
|
await usageStore.recordEvent({
|
|
2819
4906
|
modelId,
|
|
@@ -2837,13 +4924,15 @@ function registerProbeRoute(ctx, wiring) {
|
|
|
2837
4924
|
//#endregion
|
|
2838
4925
|
//#region src/host/routes/provider.ts
|
|
2839
4926
|
/**
|
|
2840
|
-
* provider
|
|
4927
|
+
* provider 注册路由:开关读/写、允许清单保存、上下文窗口覆盖、回退默认。
|
|
2841
4928
|
*
|
|
2842
|
-
*
|
|
2843
|
-
* - GET `PROVIDER_PATH`
|
|
2844
|
-
* - POST `PROVIDER_PATH`
|
|
2845
|
-
* - POST `PROVIDER_PATH/roster`
|
|
2846
|
-
* - POST `PROVIDER_PATH/
|
|
4929
|
+
* 四件事(§9):
|
|
4930
|
+
* - GET `PROVIDER_PATH` 读当前开关 + 目录 + 允许清单 + 注册状态(无副作用);
|
|
4931
|
+
* - POST `PROVIDER_PATH` body `{enabled?: boolean}` → 保存面板开关并重注册;
|
|
4932
|
+
* - POST `PROVIDER_PATH/roster` body `{enabledIds?: string[]}` → 保存允许清单并重注册;
|
|
4933
|
+
* - POST `PROVIDER_PATH/context` body `{contextWindow: number|null}` → 保存全局上下文
|
|
4934
|
+
* 窗口覆盖并重注册(null = 跟随目录);
|
|
4935
|
+
* - POST `PROVIDER_PATH/reset` 忘掉面板开关、允许清单与窗口覆盖,回到 patch 默认。
|
|
2847
4936
|
*
|
|
2848
4937
|
* `withOrigin` 围栏与 `refuseMethod` 照现有路由;写开关/清单失败**必须传播**
|
|
2849
4938
|
* (`ok:false` + error),不静默吞——这是用户的显式操作,面板必须看见它没生效。
|
|
@@ -2853,9 +4942,16 @@ function registerProbeRoute(ctx, wiring) {
|
|
|
2853
4942
|
*/
|
|
2854
4943
|
/**
|
|
2855
4944
|
* 读目录条目,读不到按空目录降级(GET 不能因目录失败把整个面板状态打掉)。
|
|
4945
|
+
*
|
|
4946
|
+
* 读目录前先取**当前窗口覆盖**传给 `fetchModels`:条目在归一前打上覆盖标记,
|
|
4947
|
+
* descriptor / roster / catalogSignature 都从这份条目派生。每个写路由(开关、
|
|
4948
|
+
* 清单、窗口、reset)都经这里取条目再 publish,所以任何一次重注册都带着最新
|
|
4949
|
+
* 覆盖——否则先存窗口、再存清单会把未打标的目录重新注册上去,覆盖静默失效到
|
|
4950
|
+
* 下一轮轮询。
|
|
2856
4951
|
*/
|
|
2857
|
-
async function readEntries(inference) {
|
|
2858
|
-
const
|
|
4952
|
+
async function readEntries$1(inference, providerStore) {
|
|
4953
|
+
const contextWindow = await optional(providerStore?.contextWindow?.(), null);
|
|
4954
|
+
const catalog = await inference.fetchModels(contextWindow).catch((error) => {
|
|
2859
4955
|
return {
|
|
2860
4956
|
entries: [],
|
|
2861
4957
|
__error: error
|
|
@@ -2865,25 +4961,20 @@ async function readEntries(inference) {
|
|
|
2865
4961
|
}
|
|
2866
4962
|
/**
|
|
2867
4963
|
* 读「当前」允许清单:优先持久化的,否则 publisher 最近 publish 的那份。
|
|
4964
|
+
*
|
|
4965
|
+
* 判据是**共用**的 resolveEnabledIds(provider-store)——本文件与面板聚合、
|
|
4966
|
+
* index.ts 的轮询必须对「空清单」给同一个答案,否则三个界面各说一套。
|
|
2868
4967
|
*/
|
|
2869
4968
|
async function readEnabledIds(store, published) {
|
|
2870
|
-
|
|
2871
|
-
if (Array.isArray(stored)) return stored;
|
|
2872
|
-
return Array.isArray(published) ? published : [];
|
|
4969
|
+
return resolveEnabledIds(await optional(store.enabledIds?.(), null), published);
|
|
2873
4970
|
}
|
|
2874
4971
|
/**
|
|
2875
|
-
* `allowed`
|
|
2876
|
-
* {@link
|
|
2877
|
-
*
|
|
2878
|
-
*
|
|
2879
|
-
* `"
|
|
2880
|
-
* `isModelEnabled` 口径一致。
|
|
2881
|
-
* @param {string[]} enabledIds - 允许清单。
|
|
2882
|
-
* @returns {"all"|"none"|"list"}
|
|
4972
|
+
* `allowed` 的分类与 `enabledCount` 的计数:都从 `shared/allow-list.ts` 的
|
|
4973
|
+
* {@link parseAllowedList} 联合上取(fix D:快照与 provider 路由以前各写一套
|
|
4974
|
+
* 分类,会漂移;旧版 `allowedOf` 只认「恰好哨兵」为 `"none"`,
|
|
4975
|
+
* `["__hide_all__","other"]` 会被错判成 `"list"`——统一到联合后,含哨兵即
|
|
4976
|
+
* `"none"`,与 `filterByEnabled` / `isModelEnabled` 同一份解释)。
|
|
2883
4977
|
*/
|
|
2884
|
-
function allowedOf(enabledIds) {
|
|
2885
|
-
return resolveAllowedList(enabledIds);
|
|
2886
|
-
}
|
|
2887
4978
|
/**
|
|
2888
4979
|
* 注册 provider 路由(三条路径)。wiring 子集:settings / providerStore /
|
|
2889
4980
|
* publisher / inference / logger。
|
|
@@ -2892,7 +4983,7 @@ function allowedOf(enabledIds) {
|
|
|
2892
4983
|
* @returns {Function} 依次调用三个 `off()` 的注销回调。
|
|
2893
4984
|
*/
|
|
2894
4985
|
function registerProviderRoute(ctx, wiring) {
|
|
2895
|
-
const { settings, providerStore, publisher, inference, logger } = wiring;
|
|
4986
|
+
const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
|
|
2896
4987
|
/**
|
|
2897
4988
|
* 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
|
|
2898
4989
|
* 状态是什么」说两套话。
|
|
@@ -2902,11 +4993,11 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
2902
4993
|
const snapshot = async (extra = {}) => {
|
|
2903
4994
|
const panelSwitch = await optional(providerStore.enabled());
|
|
2904
4995
|
const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
|
|
2905
|
-
const
|
|
4996
|
+
const contextWindow = await optional(providerStore.contextWindow?.(), null);
|
|
4997
|
+
const entries = await readEntries$1(inference, providerStore);
|
|
2906
4998
|
const roster = rosterWithAvailability(entries, []);
|
|
2907
|
-
const
|
|
4999
|
+
const allowedList = parseAllowedList(enabledIds);
|
|
2908
5000
|
const modelCount = roster.length;
|
|
2909
|
-
const allowSet = new Set(enabledIds);
|
|
2910
5001
|
return {
|
|
2911
5002
|
ok: true,
|
|
2912
5003
|
enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
|
|
@@ -2915,9 +5006,10 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
2915
5006
|
registered: publisher.state.registered === true,
|
|
2916
5007
|
error: typeof publisher.state.error === "string" ? publisher.state.error : null,
|
|
2917
5008
|
modelCount,
|
|
2918
|
-
enabledCount:
|
|
2919
|
-
allowed,
|
|
5009
|
+
enabledCount: enabledCountOf(allowedList, roster.map((row) => row.id)),
|
|
5010
|
+
allowed: allowedList.mode,
|
|
2920
5011
|
enabledIds,
|
|
5012
|
+
contextWindow,
|
|
2921
5013
|
roster,
|
|
2922
5014
|
...extra
|
|
2923
5015
|
};
|
|
@@ -2947,13 +5039,9 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
2947
5039
|
}
|
|
2948
5040
|
try {
|
|
2949
5041
|
await providerStore.save(body.enabled);
|
|
2950
|
-
|
|
2951
|
-
await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
|
|
5042
|
+
await reconcile();
|
|
2952
5043
|
} catch (error) {
|
|
2953
|
-
|
|
2954
|
-
ok: false,
|
|
2955
|
-
error: redactedError(error)
|
|
2956
|
-
}, { "cache-control": "no-store" });
|
|
5044
|
+
respondError(response, error);
|
|
2957
5045
|
return;
|
|
2958
5046
|
}
|
|
2959
5047
|
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
@@ -2979,15 +5067,38 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
2979
5067
|
const ids = body.enabledIds.filter((id) => typeof id === "string" && id !== "");
|
|
2980
5068
|
try {
|
|
2981
5069
|
await providerStore.saveEnabledIds?.(ids);
|
|
2982
|
-
|
|
2983
|
-
await publisher.publish(entries, ids, []);
|
|
5070
|
+
await reconcile();
|
|
2984
5071
|
} catch (error) {
|
|
2985
|
-
|
|
5072
|
+
respondError(response, error);
|
|
5073
|
+
return;
|
|
5074
|
+
}
|
|
5075
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5076
|
+
}, settings.allowedHosts)
|
|
5077
|
+
}));
|
|
5078
|
+
offs.push(ctx.webServer.register({
|
|
5079
|
+
kind: "exact",
|
|
5080
|
+
path: `${PROVIDER_PATH}/context`,
|
|
5081
|
+
handler: withOrigin(async (request, response) => {
|
|
5082
|
+
if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
|
|
5083
|
+
refuseMethod(response);
|
|
5084
|
+
return;
|
|
5085
|
+
}
|
|
5086
|
+
const body = await readJsonBodyOr400(request, response);
|
|
5087
|
+
if (body === null) return;
|
|
5088
|
+
if (body.contextWindow !== null && (typeof body.contextWindow !== "number" || !Number.isFinite(body.contextWindow) || body.contextWindow <= 0)) {
|
|
5089
|
+
writeJson(response, 400, {
|
|
2986
5090
|
ok: false,
|
|
2987
|
-
error:
|
|
5091
|
+
error: "expected { contextWindow: number | null }"
|
|
2988
5092
|
}, { "cache-control": "no-store" });
|
|
2989
5093
|
return;
|
|
2990
5094
|
}
|
|
5095
|
+
try {
|
|
5096
|
+
await providerStore.saveContextWindow?.(body.contextWindow);
|
|
5097
|
+
await reconcile();
|
|
5098
|
+
} catch (error) {
|
|
5099
|
+
respondError(response, error);
|
|
5100
|
+
return;
|
|
5101
|
+
}
|
|
2991
5102
|
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
2992
5103
|
}, settings.allowedHosts)
|
|
2993
5104
|
}));
|
|
@@ -3006,15 +5117,165 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
3006
5117
|
try {
|
|
3007
5118
|
await providerStore.forget();
|
|
3008
5119
|
await providerStore.saveEnabledIds?.([]);
|
|
3009
|
-
|
|
3010
|
-
await publisher.publish(entries, [], []);
|
|
5120
|
+
await reconcile();
|
|
3011
5121
|
} catch (error) {
|
|
3012
|
-
|
|
5122
|
+
respondError(response, error);
|
|
5123
|
+
return;
|
|
5124
|
+
}
|
|
5125
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5126
|
+
}, settings.allowedHosts)
|
|
5127
|
+
}));
|
|
5128
|
+
return () => {
|
|
5129
|
+
for (const off of offs) try {
|
|
5130
|
+
off();
|
|
5131
|
+
} catch (error) {
|
|
5132
|
+
logger?.warn?.(`provider route unregister failed: ${redactedError(error)}`);
|
|
5133
|
+
}
|
|
5134
|
+
};
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
//#endregion
|
|
5138
|
+
//#region src/host/routes/draw.ts
|
|
5139
|
+
/**
|
|
5140
|
+
* 出图工具路由:开关读/写、模型偏好保存、回到默认。
|
|
5141
|
+
*
|
|
5142
|
+
* 三条路径:
|
|
5143
|
+
* - GET `DRAW_PATH` 读开关 + 出图候选模型 + 工具挂载状态(无副作用);
|
|
5144
|
+
* - POST `DRAW_PATH` body `{enabled?: boolean, modelId?: string|null}` → 保存;
|
|
5145
|
+
* - POST `DRAW_PATH/reset` 忘掉面板值,回到 patch 默认。
|
|
5146
|
+
*
|
|
5147
|
+
* **与 provider 路由刻意分开**:provider 管「魔搭作为 chat provider」,本路由管
|
|
5148
|
+
* 「给 agent 一个出图工具」。两者的开关、状态文件、候选集合都不同,合并会让
|
|
5149
|
+
* 「关掉出图」波及 provider 的注册时序(详见 draw-store.ts 的头注)。
|
|
5150
|
+
*
|
|
5151
|
+
* 一处**与 provider 路由不同**的地方必须说清:provider 开关翻转会**立即**重注册
|
|
5152
|
+
* (publish 是活的重建),而出图工具的开关翻转**要等下次 Host 启动**——peer 的
|
|
5153
|
+
* tools registry 没有 unregister,工具一旦注册就摘不下来。所以本路由保存成功后
|
|
5154
|
+
* 不改挂载状态,只在响应里给出 `mountPending`,让面板能说清这件事,而不是让用户
|
|
5155
|
+
* 以为开关坏了。
|
|
5156
|
+
*
|
|
5157
|
+
* peer-free:不 import 任何 Host peer。
|
|
5158
|
+
* @module dsh-connect-modelscope-token-plan/routes/draw
|
|
5159
|
+
*/
|
|
5160
|
+
/**
|
|
5161
|
+
* 读目录条目,读不到按空目录降级。
|
|
5162
|
+
*
|
|
5163
|
+
* 与 provider 路由同一条纪律:GET 不能因为一次目录失败就把整个面板状态打掉——
|
|
5164
|
+
* 出图候选为空只影响「能不能挑模型」,不影响开关的显示。
|
|
5165
|
+
* @param {object} inference - inference client。
|
|
5166
|
+
* @returns {Promise<unknown[]>} 归一目录条目。
|
|
5167
|
+
*/
|
|
5168
|
+
async function readEntries(inference) {
|
|
5169
|
+
const catalog = await inference.fetchModels().catch(() => ({ entries: [] }));
|
|
5170
|
+
return Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
5171
|
+
}
|
|
5172
|
+
/**
|
|
5173
|
+
* 注册出图路由(两条路径)。
|
|
5174
|
+
* @param ctx - host 根上下文(只用 `ctx.webServer`)。
|
|
5175
|
+
* @param {DrawRouteWiring} wiring - 本路由读到的子集。
|
|
5176
|
+
* @returns {Function} 注销回调。
|
|
5177
|
+
*/
|
|
5178
|
+
function registerDrawRoute(ctx, wiring) {
|
|
5179
|
+
const { settings, drawStore, inference, logger } = wiring;
|
|
5180
|
+
/**
|
|
5181
|
+
* 组装响应。GET 与写成功后的响应共用,两个方向不可能对「当前状态」说两套话。
|
|
5182
|
+
* @param {object} [extra] - 追加/覆盖字段。
|
|
5183
|
+
* @returns {Promise<object>} 响应体。
|
|
5184
|
+
*/
|
|
5185
|
+
const snapshot = async (extra = {}) => {
|
|
5186
|
+
const panelSwitch = await optional(drawStore.enabled());
|
|
5187
|
+
const panelModelId = await optional(drawStore.modelId());
|
|
5188
|
+
const entries = await readEntries(inference);
|
|
5189
|
+
const candidateIds = imageGenModelIds(entries);
|
|
5190
|
+
const effectiveModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
|
|
5191
|
+
return {
|
|
5192
|
+
ok: true,
|
|
5193
|
+
enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
|
|
5194
|
+
source: switchSource(panelSwitch),
|
|
5195
|
+
candidateIds,
|
|
5196
|
+
candidateCount: candidateIds.length,
|
|
5197
|
+
preferredModel: effectiveModelId,
|
|
5198
|
+
preferredSource: switchSource(panelModelId),
|
|
5199
|
+
absentReason: drawToolNote(),
|
|
5200
|
+
/**
|
|
5201
|
+
* 开关的保存**不会**改变本次挂载的工具。
|
|
5202
|
+
*
|
|
5203
|
+
* 工具无法注销(peer 的 tools registry 没有 unregister),所以开关翻转只在
|
|
5204
|
+
* 下次 Host 启动时生效。面板据此显示一句说明——否则用户会以为开关坏了。
|
|
5205
|
+
* 判据:开关为开且本次挂载尚未注册成功(或缺席原因未知)。
|
|
5206
|
+
*/
|
|
5207
|
+
mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true && drawToolNote() !== null,
|
|
5208
|
+
toolName: "modelscope_draw_image",
|
|
5209
|
+
...extra
|
|
5210
|
+
};
|
|
5211
|
+
};
|
|
5212
|
+
const offs = [];
|
|
5213
|
+
offs.push(ctx.webServer.register({
|
|
5214
|
+
kind: "exact",
|
|
5215
|
+
path: DRAW_PATH,
|
|
5216
|
+
handler: withOrigin(async (request, response) => {
|
|
5217
|
+
const method = request.method === void 0 ? "GET" : request.method;
|
|
5218
|
+
if (method === "GET") {
|
|
5219
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5220
|
+
return;
|
|
5221
|
+
}
|
|
5222
|
+
if (method !== "POST") {
|
|
5223
|
+
refuseMethod(response);
|
|
5224
|
+
return;
|
|
5225
|
+
}
|
|
5226
|
+
const body = await readJsonBodyOr400(request, response);
|
|
5227
|
+
if (body === null) return;
|
|
5228
|
+
const hasEnabled = typeof body.enabled === "boolean";
|
|
5229
|
+
const hasModel = body.modelId === null || typeof body.modelId === "string";
|
|
5230
|
+
if (!hasEnabled && !hasModel) {
|
|
5231
|
+
writeJson(response, 400, {
|
|
3013
5232
|
ok: false,
|
|
3014
|
-
error:
|
|
5233
|
+
error: "expected { enabled?: boolean, modelId?: string|null }"
|
|
5234
|
+
}, { "cache-control": "no-store" });
|
|
5235
|
+
return;
|
|
5236
|
+
}
|
|
5237
|
+
if (body.enabled !== void 0 && !hasEnabled) {
|
|
5238
|
+
writeJson(response, 400, {
|
|
5239
|
+
ok: false,
|
|
5240
|
+
error: "enabled must be a boolean"
|
|
5241
|
+
}, { "cache-control": "no-store" });
|
|
5242
|
+
return;
|
|
5243
|
+
}
|
|
5244
|
+
if (body.modelId !== void 0 && !hasModel) {
|
|
5245
|
+
writeJson(response, 400, {
|
|
5246
|
+
ok: false,
|
|
5247
|
+
error: "modelId must be a string or null"
|
|
3015
5248
|
}, { "cache-control": "no-store" });
|
|
3016
5249
|
return;
|
|
3017
5250
|
}
|
|
5251
|
+
try {
|
|
5252
|
+
if (hasEnabled) await drawStore.saveEnabled(body.enabled);
|
|
5253
|
+
if (body.modelId !== void 0) await drawStore.saveModelId(body.modelId);
|
|
5254
|
+
} catch (error) {
|
|
5255
|
+
respondError(response, error);
|
|
5256
|
+
return;
|
|
5257
|
+
}
|
|
5258
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5259
|
+
}, settings.allowedHosts)
|
|
5260
|
+
}));
|
|
5261
|
+
offs.push(ctx.webServer.register({
|
|
5262
|
+
kind: "exact",
|
|
5263
|
+
path: `${DRAW_PATH}/reset`,
|
|
5264
|
+
handler: withOrigin(async (request, response) => {
|
|
5265
|
+
if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
|
|
5266
|
+
refuseMethod(response);
|
|
5267
|
+
return;
|
|
5268
|
+
}
|
|
5269
|
+
await readJsonBody(request, MAX_JSON_BODY_BYTES).catch(() => ({
|
|
5270
|
+
ok: false,
|
|
5271
|
+
error: "ignored"
|
|
5272
|
+
}));
|
|
5273
|
+
try {
|
|
5274
|
+
await drawStore.reset();
|
|
5275
|
+
} catch (error) {
|
|
5276
|
+
respondError(response, error);
|
|
5277
|
+
return;
|
|
5278
|
+
}
|
|
3018
5279
|
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
3019
5280
|
}, settings.allowedHosts)
|
|
3020
5281
|
}));
|
|
@@ -3022,7 +5283,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
3022
5283
|
for (const off of offs) try {
|
|
3023
5284
|
off();
|
|
3024
5285
|
} catch (error) {
|
|
3025
|
-
logger?.warn?.(`
|
|
5286
|
+
logger?.warn?.(`draw route unregister failed: ${redactedError(error)}`);
|
|
3026
5287
|
}
|
|
3027
5288
|
};
|
|
3028
5289
|
}
|
|
@@ -3042,7 +5303,8 @@ function registerRoutes(ctx, wiring) {
|
|
|
3042
5303
|
registerModelsRoute(ctx, wiring),
|
|
3043
5304
|
...registerTokenRoute(ctx, wiring),
|
|
3044
5305
|
registerProbeRoute(ctx, wiring),
|
|
3045
|
-
registerProviderRoute(ctx, wiring)
|
|
5306
|
+
registerProviderRoute(ctx, wiring),
|
|
5307
|
+
registerDrawRoute(ctx, wiring)
|
|
3046
5308
|
];
|
|
3047
5309
|
}
|
|
3048
5310
|
|
|
@@ -3051,8 +5313,12 @@ function registerRoutes(ctx, wiring) {
|
|
|
3051
5313
|
/**
|
|
3052
5314
|
* dsh-connect-modelscope-token-plan — Host 半边(thin router)。
|
|
3053
5315
|
*
|
|
3054
|
-
* 读魔搭 API-Inference
|
|
3055
|
-
*
|
|
5316
|
+
* 读魔搭 API-Inference 的**本地用量**,并把它与官方魔粒余额一起经一条只读 /api
|
|
5317
|
+
* 路由喂给 Client 面板。口径分工:推理响应**不携带**额度头(SPIKE.md §结论 1),但
|
|
5318
|
+
* 官方另有魔粒余额端点(`GET {siteBase}/openapi/v1/magicubes/balance`,SPIKE.md
|
|
5319
|
+
* 文首后记,2026-10-04 实测可用),面板头条读的是它;本文件下的兄弟模块只负责本地
|
|
5320
|
+
* 计数那一半(按模型分布 / 本地趋势 / 429 事件流),回答官方余额答不了的问题。
|
|
5321
|
+
* 重活都在专注的兄弟模块里,本文件
|
|
3056
5322
|
* 只保留 Cordis 入口(name/inject/apply)、wiring 装配与卸载副作用:
|
|
3057
5323
|
*
|
|
3058
5324
|
* - `host-config.ts` 配置契约 + isAdmitted 信任围栏
|
|
@@ -3072,11 +5338,16 @@ function registerRoutes(ctx, wiring) {
|
|
|
3072
5338
|
* @param ctx - Host 根上下文。
|
|
3073
5339
|
* @param config - 行的原始 patch 配置(无 schema,未校验;坏配置以
|
|
3074
5340
|
* configError 经快照呈现,而不是在挂载时炸掉整台插件)。
|
|
3075
|
-
* @param deps -
|
|
5341
|
+
* @param deps - 测试缝(`HostDeps`:fetchImpl / getLlm / emit / credentials / env
|
|
5342
|
+
* 与出图那两条)。真实 Loader 不传;**每个缝不传时都必须落回 ctx 或全局默认**,
|
|
5343
|
+
* 产品行为零漂移。声明与接线由 `test/deps-wiring.test.mjs` 双向扫描守着。
|
|
3076
5344
|
*/
|
|
3077
5345
|
function apply(ctx, config = {}, deps = {}) {
|
|
3078
5346
|
const { settings, configError } = resolveSettings(config);
|
|
3079
|
-
const tokenStore = createTokenStore({
|
|
5347
|
+
const tokenStore = createTokenStore({
|
|
5348
|
+
credentials: deps.credentials !== void 0 ? deps.credentials : () => ctx.get("credentials") ?? null,
|
|
5349
|
+
env: deps.env
|
|
5350
|
+
});
|
|
3080
5351
|
const profile = profileSegment(ctx);
|
|
3081
5352
|
const usageStore = createFileUsageStore({
|
|
3082
5353
|
name,
|
|
@@ -3095,10 +5366,14 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
3095
5366
|
profile,
|
|
3096
5367
|
logger: ctx.logger
|
|
3097
5368
|
});
|
|
5369
|
+
const drawStore = createFileDrawStore({
|
|
5370
|
+
profile,
|
|
5371
|
+
logger: ctx.logger
|
|
5372
|
+
});
|
|
3098
5373
|
const publisher = createProviderPublisher({
|
|
3099
5374
|
settings,
|
|
3100
5375
|
panelSwitch: () => providerStore.enabled(),
|
|
3101
|
-
getLlm: (service) => ctx.get?.(service) ?? null,
|
|
5376
|
+
getLlm: deps.getLlm ?? ((service) => ctx.get?.(service) ?? null),
|
|
3102
5377
|
resolveApiKey: async () => (await tokenStore.resolve()).value,
|
|
3103
5378
|
usage: {
|
|
3104
5379
|
recordCall: (input) => usageStore.recordCall({
|
|
@@ -3107,25 +5382,17 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
3107
5382
|
}).catch(() => {}),
|
|
3108
5383
|
recordEvent: (input) => usageStore.recordEvent(input).catch(() => {})
|
|
3109
5384
|
},
|
|
3110
|
-
emit: (event) => ctx.emit?.(event),
|
|
5385
|
+
emit: deps.emit ?? ((event) => ctx.emit?.(event)),
|
|
5386
|
+
...deps.loadAdapterModule ? { loadAdapterModule: deps.loadAdapterModule } : {},
|
|
5387
|
+
logger: ctx.logger
|
|
5388
|
+
});
|
|
5389
|
+
const { reconcile } = createProviderReconciler({
|
|
5390
|
+
settings,
|
|
5391
|
+
providerStore,
|
|
5392
|
+
inference,
|
|
5393
|
+
publisher,
|
|
3111
5394
|
logger: ctx.logger
|
|
3112
5395
|
});
|
|
3113
|
-
/**
|
|
3114
|
-
* 一次目录轮询:拉目录 + 读落盘允许清单,再 publish 当前 offer。
|
|
3115
|
-
*
|
|
3116
|
-
* 允许清单必须走 `providerStore.enabledIds()`——清单是**落盘**的(provider-store
|
|
3117
|
-
* 按 profile 分段持久化),重启后内存里的 `publisher.state.enabledIds` 早已清空,
|
|
3118
|
-
* 若从它读,用户勾选的清单会随重启静默丢失(见 fix A)。
|
|
3119
|
-
* @returns {Promise<void>}
|
|
3120
|
-
*/
|
|
3121
|
-
const runPoll = async () => {
|
|
3122
|
-
try {
|
|
3123
|
-
const catalog = await inference.fetchModels();
|
|
3124
|
-
const stored = await providerStore.enabledIds().catch(() => null);
|
|
3125
|
-
const enabledIds = Array.isArray(stored) && stored.length > 0 ? stored : Array.isArray(publisher.state.enabledIds) ? publisher.state.enabledIds : [];
|
|
3126
|
-
await publisher.publish(catalog.entries, enabledIds, []);
|
|
3127
|
-
} catch {}
|
|
3128
|
-
};
|
|
3129
5396
|
const wiring = {
|
|
3130
5397
|
settings,
|
|
3131
5398
|
configError,
|
|
@@ -3133,17 +5400,21 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
3133
5400
|
usageStore,
|
|
3134
5401
|
inference,
|
|
3135
5402
|
providerStore,
|
|
5403
|
+
drawStore,
|
|
3136
5404
|
publisher,
|
|
5405
|
+
reconcile,
|
|
3137
5406
|
logger: ctx.logger
|
|
3138
5407
|
};
|
|
3139
5408
|
const offs = registerRoutes(ctx, wiring);
|
|
3140
5409
|
ctx.effect(() => {
|
|
3141
|
-
|
|
5410
|
+
reconcile();
|
|
5411
|
+
registerDrawTool(ctx, wiring, deps);
|
|
3142
5412
|
const timer = setInterval(() => {
|
|
3143
|
-
|
|
5413
|
+
reconcile();
|
|
3144
5414
|
}, settings.pollSeconds * 1e3);
|
|
3145
5415
|
return () => {
|
|
3146
5416
|
clearInterval(timer);
|
|
5417
|
+
resetDrawToolNote();
|
|
3147
5418
|
publisher.dispose();
|
|
3148
5419
|
publisher.release();
|
|
3149
5420
|
for (const off of offs) try {
|