dsh-multimodal-node 0.0.0-stage → 0.3.1

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/lib/remote.js ADDED
@@ -0,0 +1,3348 @@
1
+ /**
2
+ * The remote port (8186 by default).
3
+ *
4
+ * A separate listener from the gateway, on purpose: the gateway speaks
5
+ * ComfyUI's protocol to other machines' scripts, while this one speaks to
6
+ * people — it needs a login, a session cookie, an HTML console, and a
7
+ * picture-in-picture tunnel to a real ComfyUI canvas. Mixing the two on one
8
+ * port would mean every API caller also has to survive an HTML 401 flow.
9
+ *
10
+ * Auth is password + a 6-digit code shown on the desktop gateway panel. Both
11
+ * are required; a reviewer who only has the password gets nothing.
12
+ *
13
+ * Bridging: opening an instance's canvas takes an exclusive *lease* on it. The
14
+ * pool stops dispatching there while the lease is held (the machine's VRAM is
15
+ * in a person's hands, not the scheduler's), and the lease is released on
16
+ * close, on logout, and by timeout. The canvas is proxied through this port, so
17
+ * the remote browser never needs direct reach to 8188/8189.
18
+ */
19
+ import { createServer, request as httpRequest } from 'node:http';
20
+ import { readdirSync } from 'node:fs';
21
+ import { dirname, isAbsolute, join } from 'node:path';
22
+ import { connect } from 'node:net';
23
+ import { randomBytes } from 'node:crypto';
24
+ import { readCookie, cookieHeader, SESSION_MAX_MS } from './auth.js';
25
+ import { errorMessage, pipeGuarded, readJson, sendJson, closeServer } from './http.js';
26
+ import { imageTargets, promptTargets, setImage, setText, stripNameAnnotation } from './workflows.js';
27
+ import { classifyWorkflow, classifyRun, detectAdult, normalizeTier, scanStrings, screenImage, visibleTo } from './tiers.js';
28
+ import { probeSize } from './imagesize.js';
29
+ import { LOGIN_HTML, CONSOLE_HTML, ADMIN_HTML } from './pages.js';
30
+ import { layoutFor, withLayout } from './viewport.js';
31
+ import { promptTargets as textTargets } from './workflows.js';
32
+ import { assist, assistCandidates, joinPrompt, listKnowledgeBases, loadPromptSkill, searchTags, suggestTags } from './promptassist.js';
33
+ import { booleanInputsOf, collectNodeTitles, headTargets, joinLayers, layersForNode, normalizeNodeHeads, normalizeNodeLayers, normalizeParamKind, normalizePromptNodes, normalizeSwitchValue, parseParamText, promptBoxes } from './library.js';
34
+ import { applySeed, hasSeed, seedForRun } from './seed.js';
35
+ import { firstImageOutput, MAX_STEPS, ChainLibrary } from './chain.js';
36
+
37
+ const COOKIE = 'cgw_remote';
38
+ const BRIDGE_TTL_MS = 30 * 60_000;
39
+ const MAX_UPLOAD_BYTES = 48 * 1024 * 1024;
40
+
41
+ export class RemoteServer {
42
+ /**
43
+ * @param {{settings:()=>any, store:object, auth:import('./auth.js').AuthStore,
44
+ * instances:import('./instances.js').InstanceManager,
45
+ * pool:import('./pool.js').TaskPool, gateway:object,
46
+ * library:import('./library.js').WorkflowLibrary,
47
+ * log:(level:string,message:string)=>void}} deps
48
+ */
49
+ constructor(deps) {
50
+ this.deps = deps;
51
+ this.server = null;
52
+ this.port = null;
53
+ this.host = null;
54
+ this.lastError = null;
55
+ /** token -> { user, instanceId, createdAt, lastSeen } */
56
+ this.bridges = new Map();
57
+ /**
58
+ * Session tokens whose account has lifted its tier ceiling.
59
+ *
60
+ * Deliberately in memory and keyed by token, never persisted:
61
+ *
62
+ * - "退出登录或关闭页面都会恢复关闭" is a promise about lifetime, and anything
63
+ * written to disk would survive a restart, which is the opposite;
64
+ * - per token, not per user, so two tabs (or two people sharing an admin account)
65
+ * cannot silently inherit each other's elevated view;
66
+ * - admins only, checked at the route, because it is an operator convenience for
67
+ * reviewing the library, not a tier change for the account.
68
+ */
69
+ this.adminEyes = new Map();
70
+ }
71
+
72
+ get listening() {
73
+ return this.server !== null && this.server.listening;
74
+ }
75
+
76
+ async start() {
77
+ const config = this.deps.settings().remote ?? {};
78
+ if (config.enabled !== true) return { ok: true, skipped: 'disabled' };
79
+ const host = typeof config.host === 'string' && config.host !== '' ? config.host : '0.0.0.0';
80
+ const port = Number(config.port ?? 8286);
81
+ await this.stop();
82
+ const server = createServer((request, response) => {
83
+ this.handle(request, response).catch((error) => {
84
+ if (!response.headersSent) sendJson(response, 500, { error: errorMessage(error) });
85
+ else response.end();
86
+ });
87
+ });
88
+ server.on('upgrade', (request, socket, head) => this.handleUpgrade(request, socket, head));
89
+ await new Promise((resolve, reject) => {
90
+ const onError = (error) => {
91
+ server.off('listening', onListening);
92
+ reject(error);
93
+ };
94
+ const onListening = () => {
95
+ server.off('error', onError);
96
+ resolve();
97
+ };
98
+ server.once('error', onError);
99
+ server.once('listening', onListening);
100
+ server.listen(port, host);
101
+ });
102
+ server.on('error', (error) => {
103
+ this.lastError = errorMessage(error);
104
+ this.deps.log('error', `remote listen error: ${this.lastError}`);
105
+ });
106
+ this.server = server;
107
+ this.port = port;
108
+ this.host = host;
109
+ // Expire leases: a closed tab must not freeze a GPU forever.
110
+ this.sweeper = setInterval(() => this.sweepLeases(), 15_000);
111
+ if (typeof this.sweeper.unref === 'function') this.sweeper.unref();
112
+ this.lastError = null;
113
+ this.deps.log('info', `remote console listening on ${host}:${port}`);
114
+ return { ok: true, host, port };
115
+ }
116
+
117
+ async stop() {
118
+ if (this.sweeper !== undefined) {
119
+ clearInterval(this.sweeper);
120
+ this.sweeper = undefined;
121
+ }
122
+ for (const token of [...this.bridges.keys()]) this.releaseBridge(token);
123
+ const server = this.server;
124
+ this.server = null;
125
+ if (server === null) return { ok: true };
126
+ // 强制断开 keep-alive 连接,端口才真的释放(见 http.js 的 closeServer)。
127
+ await closeServer(server);
128
+ return { ok: true };
129
+ }
130
+
131
+ /** Drop leases nobody is looking at any more. */
132
+ sweepLeases() {
133
+ const now = Date.now();
134
+ for (const [token, lease] of this.bridges) {
135
+ if (now - lease.lastSeen > BRIDGE_TTL_MS) this.releaseBridge(token);
136
+ }
137
+ for (const task of this.deps.pool.tasks.values()) {
138
+ if (task.state === 'done' || task.state === 'failed' || task.state === 'cancelled') {
139
+ if (task.responseWaitedAt !== undefined) delete task.responseWaitedAt;
140
+ }
141
+ }
142
+ void this.settleOwnRuns().catch(() => {});
143
+ }
144
+
145
+ releaseBridge(token) {
146
+ const lease = this.bridges.get(token);
147
+ if (lease === undefined) return;
148
+ this.bridges.delete(token);
149
+ // Only lift the lock if no other open tab holds the same instance.
150
+ const stillHeld = [...this.bridges.values()].some((other) => other.instanceId === lease.instanceId);
151
+ if (!stillHeld) this.deps.pool.setBridgeLock(lease.instanceId, null);
152
+ this.deps.log('info', `bridge released for ${lease.instanceId}`);
153
+ }
154
+
155
+ currentSession(request) {
156
+ const token = readCookie(request.headers.cookie, COOKIE);
157
+ return this.deps.auth.touch(token);
158
+ }
159
+
160
+ // --- HTTP ------------------------------------------------------------
161
+ async handle(request, response) {
162
+ const url = new URL(request.url ?? '/', 'http://remote.invalid');
163
+ const path = decodeURIComponent(url.pathname);
164
+
165
+ // A bridge tunnel is unauthenticated only at the socket level: the token in
166
+ // the URL *is* the credential, minted behind the login and expiring.
167
+ if (path.startsWith('/bridge/')) {
168
+ return this.handleBridgeHttp(path, url, request, response);
169
+ }
170
+
171
+ if (path === '/health') return sendJson(response, 200, { ok: true });
172
+
173
+ if (path === '/' || path === '/index.html') return this.sendPage(response, 'login', request);
174
+ if (path === '/api/login' && request.method === 'POST') {
175
+ const body = await readJson(request).catch(() => ({}));
176
+ const result = await this.deps.auth.login(body.name, body.password, body.code ?? '', {
177
+ ip: request.socket.remoteAddress ?? '',
178
+ agent: request.headers['user-agent'] ?? '',
179
+ });
180
+ if (result.ok !== true) return sendJson(response, 401, { ok: false, error: result.error });
181
+ response.setHeader('set-cookie', cookieHeader(COOKIE, result.token, Math.floor(SESSION_MAX_MS / 1000)));
182
+ return sendJson(response, 200, {
183
+ ok: true,
184
+ user: { name: result.session.user, role: result.session.role, tier: result.session.tier },
185
+ });
186
+ }
187
+ if (path === '/api/pairing-status') {
188
+ // Tells the login page whether a code is even required, and how long the
189
+ // current one lives — never the code itself.
190
+ const code = await this.deps.auth.currentCode();
191
+ return sendJson(response, 200, { ok: true, secondsLeft: code.secondsLeft, required: code.required !== false });
192
+ }
193
+
194
+ const session = await this.currentSession(request);
195
+ if (session === undefined) {
196
+ if (path.startsWith('/api/')) return sendJson(response, 401, { ok: false, error: '未登录' });
197
+ return this.sendPage(response, 'login', request);
198
+ }
199
+ /**
200
+ * Attach the eye state to the session, so every existing tier check keeps working
201
+ * unchanged. `tierOf()` reads `session.eyes` and returns nsfw when it is on — one
202
+ * place to change instead of touching each route.
203
+ *
204
+ * Read from the PERSISTED session row, not from the in-memory map: the row is what
205
+ * survives a reload, a second tab, and a host restart. The map is now only a fallback
206
+ * for a token written before this field existed.
207
+ */
208
+ session.token = readCookie(request.headers.cookie, COOKIE);
209
+ const persisted = this.deps.auth.sessions().find((entry) => entry.token === session.token);
210
+ const eyeWanted = persisted?.eyes === true || this.adminEyes.get(session.token) === true;
211
+ session.eyes = eyeWanted && session.role === 'admin';
212
+
213
+ if (path === '/app' || path === '/console') return this.sendPage(response, 'console', request);
214
+ if (path === '/admin') {
215
+ // The page itself is not a secret — every control it shows is gated on the
216
+ // server — but sending a viewer to their own console is kinder than letting
217
+ // them land on a page of buttons that all refuse.
218
+ if (session.role !== 'admin') {
219
+ response.writeHead(302, { location: '/app' });
220
+ response.end();
221
+ return undefined;
222
+ }
223
+ return this.sendPage(response, 'admin', request);
224
+ }
225
+ if (path === '/api/logout' && request.method === 'POST') {
226
+ const token = readCookie(request.headers.cookie, COOKIE);
227
+ await this.deps.auth.logout(token);
228
+ // The eye is per session; logging out must also drop the elevated view, or the
229
+ // next person to log in on this browser would inherit it.
230
+ if (token !== null && token !== '') this.adminEyes.delete(token);
231
+ for (const [bridgeToken, lease] of this.bridges) {
232
+ if (lease.user === session.user) this.releaseBridge(bridgeToken);
233
+ }
234
+ response.setHeader('set-cookie', cookieHeader(COOKIE, '', 0));
235
+ return sendJson(response, 200, { ok: true });
236
+ }
237
+ if (path === '/api/me') {
238
+ return sendJson(response, 200, {
239
+ ok: true,
240
+ user: { name: session.user, role: session.role, tier: this.tierOf(session) },
241
+ gateway: { port: this.deps.gateway.port, listening: this.deps.gateway.listening },
242
+ });
243
+ }
244
+
245
+ /**
246
+ * The admin "eye": show the adult half of the library for THIS session.
247
+ *
248
+ * Two things were wrong with the first version and both are fixed here.
249
+ *
250
+ * 1. **It was memory-only.** Reloading the page kept it, but the token was handed
251
+ * out again on the same session... except the map was keyed by token and the page
252
+ * could not read it back after a reload raced the GET, so it looked like "刷新了
253
+ * 就没了". Worse, nothing in the console called it at all. The state now lives on
254
+ * the session record, which is already persisted, so it survives a reload and a
255
+ * host restart until the operator turns it off or logs out.
256
+ *
257
+ * 2. **Nothing consumed it.** The toggle changed `tierOf()`, but the workflow list
258
+ * and the asset list were both already "own data only", so flipping it changed
259
+ * nothing visible — "控制台的资源库和工作流没有关联到成人按钮上". Both routes now
260
+ * consult `eyes` explicitly: on, the adult rows appear (with a badge); off, they
261
+ * are absent.
262
+ *
263
+ * Deliberate limit: this is an ELEVATED VIEW, not an account change. It never edits
264
+ * the account's stored tier, so an SFW viewer cannot be raised by an admin's toggle.
265
+ */
266
+ if (path === '/api/admin/eyes') {
267
+ if (session.role !== 'admin') {
268
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以切换成人内容显示' });
269
+ }
270
+ const token = readCookie(request.headers.cookie, COOKIE);
271
+ if (request.method === 'GET') {
272
+ return sendJson(response, 200, { ok: true, eyes: session.eyes === true });
273
+ }
274
+ const body = await readJson(request).catch(() => ({}));
275
+ const wanted = body?.on === true;
276
+ if (wanted) this.adminEyes.set(token, true);
277
+ else this.adminEyes.delete(token);
278
+ // Persisted as well as cached: the in-memory map alone is why a reload could lose
279
+ // the state. The row is the source of truth; the map is the fast path.
280
+ await this.deps.auth.patchSession(token, { eyes: wanted });
281
+ this.deps.log('info', `admin eyes ${wanted ? 'on' : 'off'} for ${session.user}`);
282
+ return sendJson(response, 200, { ok: true, eyes: wanted });
283
+ }
284
+ if (path === '/api/state') return sendJson(response, 200, this.statePayload(session));
285
+
286
+ if (path === '/api/workflows' && request.method === 'GET') {
287
+ const tier = this.tierOf(session);
288
+ // Pick up a library that changed on disk while the plugin was running.
289
+ // Without this, an import script writes workflows.json and the console
290
+ // keeps serving the list it read at startup — "Restart gateway" only
291
+ // restarts listeners, so the only cure was restarting the whole host.
292
+ await this.deps.library.reloadIfChanged();
293
+ /**
294
+ * The library is SHARED: one list configured by the operator, visible to every
295
+ * account. Ownership is no longer the boundary — the tier is.
296
+ *
297
+ * This reverses an earlier design where each account kept its own workflows.
298
+ * That made "admin builds the workflows, users just run them" impossible: a
299
+ * user saw an empty picker. The library is a rig capability (which graphs this
300
+ * machine can run), not a personal document, so it belongs to the deployment.
301
+ *
302
+ * Tier still filters: an SFW account does not see adult workflows at all —
303
+ * not greyed out, absent, because the name alone is a disclosure.
304
+ */
305
+ const all = this.deps.library.list(tier);
306
+ return sendJson(response, 200, {
307
+ ok: true,
308
+ tier,
309
+ workflows: all,
310
+ // Whether this account may add or change workflows at all. The console uses
311
+ // it to hide the edit affordances rather than letting someone fill a form
312
+ // that the POST will reject.
313
+ canEdit: session.role === 'admin',
314
+ // Still returned, but now only as the *template* a new workflow
315
+ // starts from. The heads actually used come with the workflow.
316
+ defaultHeads: this.deps.settings().promptHeads ?? {},
317
+ });
318
+ }
319
+ if (path === '/api/workflows' && request.method === 'POST') {
320
+ /**
321
+ * Writing is admin-only now.
322
+ *
323
+ * Not an arbitrary restriction: the library is shared, so an edit by one account
324
+ * silently rewrites what every other account runs. That is a deployment-level
325
+ * change and belongs to the operator. Running a shared workflow stays open to
326
+ * everyone — that is the whole point of sharing it.
327
+ */
328
+ if (session.user === '') return sendJson(response, 403, { ok: false, error: '需要登录' });
329
+ if (session.role !== 'admin') {
330
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以新增或修改工作流(工作流库是所有账号共用的)' });
331
+ }
332
+ const body = await readJson(request, 8 * 1024 * 1024).catch(() => null);
333
+ if (body === null) return sendJson(response, 400, { ok: false, error: 'JSON 解析失败' });
334
+ try {
335
+ /**
336
+ * `object_info` is only needed to convert a UI graph. An API-format
337
+ * workflow is already the prompt, and a rename/head edit adds nothing to
338
+ * the graph at all — so a failed probe must not block the save.
339
+ *
340
+ * Before this, every save called objectInfo() and turned into
341
+ * "pa /object_info: fetch failed" whenever an instance was asleep or a
342
+ * GPU box was off. That made the most routine edit (fix the name) depend
343
+ * on ComfyUI being up, which is exactly backwards: the library is the
344
+ * thing you edit *while* the rig is down.
345
+ */
346
+ const tier = this.tierOf(session);
347
+ const incoming = body?.declared === 'nsfw' ? 'nsfw' : null;
348
+ if (incoming === 'nsfw' && tier !== 'nsfw') {
349
+ return sendJson(response, 403, { ok: false, error: '当前账号不能创建成人级工作流' });
350
+ }
351
+ const graph = body?.graph;
352
+ const alreadyApi =
353
+ graph !== null &&
354
+ typeof graph === 'object' &&
355
+ !Array.isArray(graph.nodes) &&
356
+ Object.keys(graph).length > 0 &&
357
+ Object.values(graph).every((node) => node !== null && typeof node === 'object' && typeof node.class_type === 'string');
358
+ const objectInfo = alreadyApi ? undefined : await this.objectInfo();
359
+ const saved = await this.deps.library.put({
360
+ ...body,
361
+ objectInfo,
362
+ owner: session.user,
363
+ // Sent by the console's create form; when omitted the stored
364
+ // operator default seeds a new workflow.
365
+ defaultHeads: this.deps.settings().promptHeads,
366
+ });
367
+ return sendJson(response, 200, { ok: true, workflow: saved });
368
+ } catch (error) {
369
+ return sendJson(response, 400, { ok: false, error: errorMessage(error) });
370
+ }
371
+ }
372
+ if (path.startsWith('/api/workflows/') && request.method === 'GET') {
373
+ const id = path.slice('/api/workflows/'.length);
374
+ // Same reason as the list route: the row may have arrived on disk after
375
+ // startup, and a freshly imported workflow must open without a host restart.
376
+ await this.deps.library.reloadIfChanged();
377
+ const record = this.deps.library.get(id);
378
+ // Shared library: any account may read any workflow it is allowed to see. The
379
+ // tier check below is the only gate, and it answers 403 rather than 404 — the
380
+ // id is not a secret once the row appears in a shared picker.
381
+ if (record === undefined) {
382
+ return sendJson(response, 404, { ok: false, error: 'no such workflow' });
383
+ }
384
+ if (!visibleTo(this.tierOf(session), classifyWorkflow(record).tier)) {
385
+ return sendJson(response, 403, { ok: false, error: '当前账号的等级看不到这个工作流' });
386
+ }
387
+ const described = this.deps.library.describe(record);
388
+ /**
389
+ * Model pickers follow the ROLE, not the eye.
390
+ *
391
+ * This used to key off the session tier, which was fine while the tier was a fact
392
+ * about the account. It stopped being fine when the eye became the switch for an
393
+ * admin: turning adult content OFF dropped the tier to sfw and took the model
394
+ * dropdowns with it, so the workbench lost its checkpoint lists for a reason that
395
+ * has nothing to do with content. Choosing a model file is an operator capability;
396
+ * which half of the library is on screen is a separate question.
397
+ *
398
+ * Filtered HERE, on the payload, not in the page: a viewer that hand-edits a
399
+ * request must not receive model entries either.
400
+ */
401
+ const tier = this.tierOf(session);
402
+ const mayPickModels = session.role === 'admin';
403
+ const allParams = mainParams(record.api, record.paramPicks ?? [], record.paramHidden ?? [], new Set(record.booleanKeys ?? []));
404
+ const params = mayPickModels ? allParams : allParams.filter((row) => row.kind !== 'model');
405
+ /**
406
+ * Fill each model parameter's dropdown.
407
+ *
408
+ * Done here rather than by the page calling /api/models per folder: the detail
409
+ * request is already open, the folder list is small, and a second round trip per
410
+ * model parameter would make the form appear in stages.
411
+ */
412
+ if (mayPickModels) {
413
+ /**
414
+ * Options come from the NODE first, the disk second.
415
+ *
416
+ * A custom node that builds its own list does not list filenames at all:
417
+ * `GroundingDinoModelLoader` offers `GroundingDINO_SwinB (938MB)`, while the
418
+ * file on disk is `groundingdino_swinb_cogcoor.pth`. Offering the disk name
419
+ * produced `value_not_in_list` on submit — the run failed before it started,
420
+ * and the dropdown showed a name the node never accepts.
421
+ *
422
+ * So: when `object_info` declares a concrete option list for this exact
423
+ * node+input, that list IS the answer and the disk has no say. Only when the
424
+ * declaration is a bare type (IMAGE / a free string) does the folder scan
425
+ * supply the choices.
426
+ */
427
+ const info = await this.objectInfo().catch(() => null);
428
+ for (const row of params) {
429
+ if (row.kind !== 'model') continue;
430
+ const declared = comboValuesOf(info, row.classType, row.input);
431
+ if (declared.length > 0) {
432
+ row.options = declared;
433
+ row.optionsFrom = 'node';
434
+ continue;
435
+ }
436
+ if (row.folder === '') {
437
+ row.options = [];
438
+ row.optionsFrom = 'none';
439
+ continue;
440
+ }
441
+ try {
442
+ row.options = await this.modelsInFolder(row.folder);
443
+ row.optionsFrom = 'disk';
444
+ } catch {
445
+ // A folder this build does not expose simply has no choices; the
446
+ // select renders empty rather than failing the whole payload.
447
+ row.options = [];
448
+ row.optionsFrom = 'none';
449
+ }
450
+ }
451
+ }
452
+ /**
453
+ * Tell the console whether each image slot's CURRENT value is usable.
454
+ *
455
+ * A stored filename can point at a file the instance no longer has — the graph was
456
+ * saved elsewhere, or the file was cleaned out of `input/`. Today that only surfaces
457
+ * at submit time as `Invalid image file`, after the person has filled in everything
458
+ * else. Flagging it here lets the panel say "这个值不行,选一张" before anything is typed.
459
+ *
460
+ * `available: null` means the instance could not be asked (asleep), so the console
461
+ * shows no verdict rather than a wrong one. `false` is not an error: the slot still
462
+ * works, the person just has to pick a file first.
463
+ */
464
+ let inputImages = null;
465
+ try {
466
+ const info = await this.objectInfo();
467
+ const decl = info?.LoadImage?.input?.required?.image;
468
+ const list = Array.isArray(decl?.[0]) ? decl[0] : (decl?.[0]?.options ?? null);
469
+ if (Array.isArray(list)) inputImages = new Set(list.map(String));
470
+ } catch {
471
+ inputImages = null;
472
+ }
473
+ const images = (described.images ?? []).map((row) => ({
474
+ ...row,
475
+ available: inputImages === null ? null : inputImages.has(String(row.value ?? '')),
476
+ }));
477
+
478
+ return sendJson(response, 200, {
479
+ ok: true,
480
+ workflow: { ...described, images },
481
+ graph: record.graph,
482
+ // The few inputs a person actually turns: what the sampler is set to,
483
+ // and what the latent is. Everything else stays in the canvas.
484
+ params,
485
+ /**
486
+ * Node titles, so both surfaces can group parameters by node NAME.
487
+ *
488
+ * A grouped list whose heading is only "节点 11" is no better than the flat
489
+ * grid it replaced. Read off the graph here rather than stored on the record:
490
+ * the title is presentation, and a renamed node in the canvas must show up
491
+ * without a save.
492
+ */
493
+ nodeTitles: collectNodeTitles(record.graph, record.api),
494
+ /** Whether model pickers are available at all, so the page can say why. */
495
+ modelsLocked: !mayPickModels,
496
+ /**
497
+ * Whether this graph has a seed input at all.
498
+ *
499
+ * The console hides the whole seed control for a pure upscale, and more
500
+ * importantly the server refuses to invent one: writing `seed` into a graph
501
+ * that has no seed input would submit a prompt the instance rejects.
502
+ */
503
+ hasSeed: hasSeed(record.api ?? {}),
504
+ });
505
+ }
506
+ if (path.startsWith('/api/workflows/') && request.method === 'DELETE') {
507
+ // Deleting from a shared library is an operator action: it removes the workflow
508
+ // for everyone. Ownership is no longer the boundary, so the role is.
509
+ if (session.role !== 'admin') {
510
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以删除工作流(工作流库是所有账号共用的)' });
511
+ }
512
+ const id = path.slice('/api/workflows/'.length);
513
+ const record = this.deps.library.get(id);
514
+ if (record === undefined) {
515
+ return sendJson(response, 404, { ok: false, error: 'no such workflow' });
516
+ }
517
+ try {
518
+ await this.deps.library.remove(id);
519
+ return sendJson(response, 200, { ok: true });
520
+ } catch (error) {
521
+ return sendJson(response, 404, { ok: false, error: errorMessage(error) });
522
+ }
523
+ }
524
+
525
+ if (path === '/api/upload' && request.method === 'POST') {
526
+ return this.handleUpload(request, response, session);
527
+ }
528
+
529
+ /**
530
+ * Model inventory, grouped by the loader a workflow would point at.
531
+ *
532
+ * Read from the instances' own /models/<folder> endpoints rather than from a
533
+ * hardcoded list: whatever is installed is what the operator can actually pick,
534
+ * and a folder the ComfyUI build does not have simply comes back empty. Failures
535
+ * are per-folder so one dead instance cannot blank the whole panel.
536
+ */
537
+ if (path === '/api/models' && request.method === 'GET') {
538
+ /**
539
+ * The model inventory is NSFW-only.
540
+ *
541
+ * A SFW account gets the folder list but no filenames: the shape is public (the
542
+ * page shows folders in the model panel), the contents are the operator's model
543
+ * collection. Answering 403 would break that panel; answering with names would
544
+ * hand over exactly what the model parameters are gated away from.
545
+ */
546
+ const inventory = await this.modelInventory();
547
+ // Keyed off the ROLE, not the eye: choosing a model file is an operator capability,
548
+ // and safe mode is about adult CONTENT, not about hiding the checkpoint list.
549
+ if (session.role === 'admin') return sendJson(response, 200, inventory);
550
+ return sendJson(response, 200, {
551
+ ...inventory,
552
+ folders: (inventory.folders ?? []).map((folder) => ({ ...folder, items: [] })),
553
+ total: 0,
554
+ locked: true,
555
+ error: inventory.ok === false ? inventory.error : '当前账号是全年龄等级,不显示模型文件',
556
+ });
557
+ }
558
+
559
+ /**
560
+ * Test a workflow without queueing anything.
561
+ *
562
+ * Two things are checked, in the order that saves the most time:
563
+ * 1. structural — is the graph something ComfyUI would accept (node classes, links
564
+ * pointing at nodes that exist)?
565
+ * 2. semantic — ask the instance's own /object_info whether every class_type in the
566
+ * graph is installed, and whether the model files it names are present.
567
+ *
568
+ * It deliberately does NOT submit a job. "Test" on a workflow editor means "will this
569
+ * run", and submitting a real job would consume GPU time and write files while the
570
+ * operator is still typing — and a broken graph would fail minutes later in the
571
+ * queue rather than immediately here.
572
+ */
573
+ /**
574
+ * Recognise a pasted parameter list.
575
+ *
576
+ * Runs on the server because the matcher needs the *graph* — a name is only valid if
577
+ * some node in this workflow actually has that input. Doing it in the browser would
578
+ * mean shipping the graph there twice and keeping two matchers in step.
579
+ */
580
+ if (path === '/api/workflows/params/parse' && request.method === 'POST') {
581
+ if (session.role !== 'admin') {
582
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以识别参数' });
583
+ }
584
+ const body = await readJson(request, 1024 * 1024).catch(() => ({}));
585
+ const text = String(body?.text ?? '');
586
+ let api = body?.api ?? null;
587
+ // A workflow id may be given instead of the graph itself.
588
+ if (api === null && typeof body?.workflowId === 'string' && body.workflowId !== '') {
589
+ await this.deps.library.reloadIfChanged();
590
+ const record = this.deps.library.get(body.workflowId);
591
+ api = record?.api ?? null;
592
+ }
593
+ if (api === null || typeof api !== 'object') {
594
+ return sendJson(response, 400, { ok: false, error: '需要先有 workflow JSON 才能识别参数名' });
595
+ }
596
+ const parsed = parseParamText(text, api);
597
+ return sendJson(response, 200, {
598
+ ok: parsed.problems.length === 0,
599
+ params: parsed.params,
600
+ problems: parsed.problems,
601
+ });
602
+ }
603
+
604
+ if (path === '/api/workflows/test' && request.method === 'POST') {
605
+ if (session.role !== 'admin') {
606
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以测试工作流' });
607
+ }
608
+ const body = await readJson(request, 8 * 1024 * 1024).catch(() => null);
609
+ if (body === null) return sendJson(response, 400, { ok: false, error: 'JSON 解析失败' });
610
+ try {
611
+ return sendJson(response, 200, await this.testWorkflow(body));
612
+ } catch (error) {
613
+ return sendJson(response, 200, { ok: false, problems: [errorMessage(error)], checked: 0 });
614
+ }
615
+ }
616
+
617
+ /**
618
+ * The 8187 pool, unfiltered by account.
619
+ *
620
+ * /api/state deliberately returns only the caller's own tasks — the console is
621
+ * per-account. The workbench is the operator's view of the machine, so it asks
622
+ * for this one explicitly, which is why it is a separate route and not a flag
623
+ * on the state payload: the boundary stays one route wide and greppable.
624
+ */
625
+ if (path === '/api/queue' && request.method === 'GET') {
626
+ if (session.role !== 'admin') return sendJson(response, 403, { ok: false, error: '只有管理员能看整机队列' });
627
+ const status = this.deps.gateway.statusPayload();
628
+ return sendJson(response, 200, {
629
+ ok: true,
630
+ dynamic: status.dynamic,
631
+ counters: status.counters,
632
+ estimate: status.estimate,
633
+ gateway: { port: this.deps.gateway.port, listening: this.deps.gateway.listening },
634
+ tasks: status.tasks.map((task) => ({
635
+ ...task,
636
+ user: this.deps.pool.tasks.get(task.id)?.extraData?.remoteUser ?? null,
637
+ })),
638
+ });
639
+ }
640
+
641
+ if (path === '/api/queue' && request.method === 'POST') {
642
+ if (session.role !== 'admin') return sendJson(response, 403, { ok: false, error: '只有管理员能动整机队列' });
643
+ const body = await readJson(request).catch(() => ({}));
644
+ const action = String(body?.action ?? '');
645
+ const id = String(body?.id ?? '');
646
+ if (action === 'cancel') return sendJson(response, 200, { ...this.deps.pool.cancel(id) });
647
+ if (action === 'move') return sendJson(response, 200, { ...this.deps.pool.reorder(id, Number(body?.delta ?? 0)) });
648
+ return sendJson(response, 400, { ok: false, error: `未知队列操作:${action}` });
649
+ }
650
+
651
+ if (path === '/api/generate' && request.method === 'POST') {
652
+ const body = await readJson(request).catch(() => ({}));
653
+ try {
654
+ const result = await this.generate(body, session);
655
+ return sendJson(response, 200, result);
656
+ } catch (error) {
657
+ return sendJson(response, 400, { ok: false, error: errorMessage(error) });
658
+ }
659
+ }
660
+
661
+ if (path === '/api/pool' && request.method === 'POST') {
662
+ const body = await readJson(request).catch(() => ({}));
663
+ const action = String(body.action ?? '');
664
+ if (action === 'cancel') return sendJson(response, 200, { ...this.deps.pool.cancel(String(body.id ?? '')), state: this.statePayload(session) });
665
+ if (action === 'move') {
666
+ const result = this.deps.pool.reorder(String(body.id ?? ''), Number(body.delta ?? 0));
667
+ return sendJson(response, 200, { ...result, state: this.statePayload(session) });
668
+ }
669
+ if (action === 'dynamic') {
670
+ this.deps.pool.toggleDynamic(body.enabled === true);
671
+ return sendJson(response, 200, { ok: true, state: this.statePayload(session) });
672
+ }
673
+ return sendJson(response, 400, { ok: false, error: `unknown action ${action}` });
674
+ }
675
+
676
+ if (path === '/api/bridge' && request.method === 'POST') {
677
+ // Leasing an instance is an administrative act: it takes a GPU out of
678
+ // the shared pool and hands its canvas to one person. A viewer may use
679
+ // the console; only an admin may take a machine away from everybody.
680
+ if (session.role !== 'admin') {
681
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以桥接实例' });
682
+ }
683
+ const body = await readJson(request).catch(() => ({}));
684
+ const instanceId = String(body.instanceId ?? '');
685
+ const runtime = this.deps.instances.get(instanceId);
686
+ if (runtime === undefined) return sendJson(response, 404, { ok: false, error: `没有实例 ${instanceId}` });
687
+ if (body.action === 'release') {
688
+ for (const [token, lease] of this.bridges) {
689
+ if (lease.instanceId === instanceId) this.releaseBridge(token);
690
+ }
691
+ return sendJson(response, 200, { ok: true, state: this.statePayload(session) });
692
+ }
693
+ const token = randomBytes(18).toString('base64url');
694
+ this.bridges.set(token, { user: session.user, instanceId, createdAt: Date.now(), lastSeen: Date.now() });
695
+ // Take the instance out of rotation for the duration: a person editing a
696
+ // canvas and the scheduler both queueing work on one GPU is how VRAM
697
+ // gets exhausted and a run dies mid-sampling.
698
+ this.deps.pool.setBridgeLock(instanceId, { by: session.user, token });
699
+ this.deps.log('info', `bridge opened for ${instanceId} by ${session.user}`);
700
+ return sendJson(response, 200, {
701
+ ok: true,
702
+ url: `/bridge/${token}/`,
703
+ instanceId,
704
+ state: this.statePayload(session),
705
+ });
706
+ }
707
+
708
+ if (path === '/api/history') {
709
+ const max = Math.min(200, Number(url.searchParams.get('max') ?? '40') || 40);
710
+ const tier = this.tierOf(session);
711
+ // The account's own runs, from the durable index — not the merged
712
+ // gateway history, which is everybody's work.
713
+ const own = await this.ownHistory(session, max);
714
+ return sendJson(response, 200, { ok: true, tier, history: own });
715
+ }
716
+ /**
717
+ * Forget one run.
718
+ *
719
+ * Deleting a *history row* and deleting a *picture* are different actions, so
720
+ * they are different routes. This one drops the run record (`runs.json`) and
721
+ * deliberately leaves the asset rows alone: the pictures were really produced,
722
+ * and a library that loses images because someone tidied their history would be
723
+ * worse than the clutter. `/api/assets` owns picture deletion.
724
+ *
725
+ * A run still in the pool is refused rather than half-forgotten: the index is the
726
+ * console's only source of truth, and removing a live record would make the pool's
727
+ * later settle silently recreate it.
728
+ */
729
+ if (path.startsWith('/api/history/') && request.method === 'DELETE') {
730
+ const promptId = decodeURIComponent(path.slice('/api/history/'.length));
731
+ if (promptId === '') return sendJson(response, 400, { ok: false, error: '缺少运行 id' });
732
+ const live = this.deps.pool.tasks.get(promptId);
733
+ if (live !== undefined && (live.state === 'queued' || live.state === 'running')) {
734
+ return sendJson(response, 409, { ok: false, error: '这次运行还在队列里,先取消再删记录' });
735
+ }
736
+ const result = await this.deps.runs.forget(session.user, promptId);
737
+ if (result.ok !== true) return sendJson(response, 404, { ok: false, error: '没有这条记录' });
738
+ return sendJson(response, 200, { ok: true, history: await this.ownHistory(session, 30) });
739
+ }
740
+ if (path === '/api/assist' && request.method === 'POST') {
741
+ const body = await readJson(request, 1024 * 1024).catch(() => ({}));
742
+ const tier = this.tierOf(session);
743
+ const heads = this.deps.settings().promptHeads ?? {};
744
+ try {
745
+ const result = await this.assistTurn(body, session);
746
+ return sendJson(response, 200, { ok: result.ok !== false, ...result, heads });
747
+ } catch (error) {
748
+ return sendJson(response, 400, { ok: false, error: errorMessage(error) });
749
+ }
750
+ }
751
+ /**
752
+ * Live suggestion pass while the person types — model first, dictionary as fallback.
753
+ *
754
+ * Called on every keystroke (debounced in the page). `suggestTags` merges a model
755
+ * pass with the deterministic dictionary scan, so a slow or failed model degrades to
756
+ * the local list instead of an empty panel.
757
+ *
758
+ * Returns rows shaped for the list the console draws:
759
+ * {tag, zh, note, section, role, fromModel}
760
+ * where `note` is the one-line Chinese reading the person chooses by.
761
+ */
762
+ if (path === '/api/tags' && request.method === 'POST') {
763
+ const body = await readJson(request, 64 * 1024).catch(() => ({}));
764
+ const tier = this.tierOf(session);
765
+ const config = this.deps.settings();
766
+ if (config.remote?.assistant === false) {
767
+ return sendJson(response, 200, { ok: false, tags: [], error: '提示词助手已被管理员关闭' });
768
+ }
769
+ const text = String(body?.text ?? '').slice(0, 400);
770
+ if (text.trim() === '') return sendJson(response, 200, { ok: true, tags: [] });
771
+ if (tier !== 'nsfw' && detectAdult(text).hit === true) {
772
+ return sendJson(response, 200, { ok: false, tags: [], error: '当前账号是全年龄等级,这条需求包含成人内容' });
773
+ }
774
+ try {
775
+ const tags = await suggestTags(
776
+ { llm: this.deps.llm, route: this.deps.llmRoute, config, tier },
777
+ { text },
778
+ );
779
+ return sendJson(response, 200, { ok: true, tags });
780
+ } catch (error) {
781
+ return sendJson(response, 200, { ok: false, tags: [], error: errorMessage(error) });
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Work chains: saved step lists plus the runner.
787
+ *
788
+ * Saving and listing are ordinary CRUD. `/api/chains/run` blocks until the chain
789
+ * finishes, because "ran it" and "it finished" must not be conflated: the caller
790
+ * needs the per-step outcome to show which link broke.
791
+ */
792
+ if (path === '/api/chains' && request.method === 'GET') {
793
+ return sendJson(response, 200, { ok: true, chains: this.deps.chains.list(session.user), maxSteps: MAX_STEPS });
794
+ }
795
+ if (path === '/api/chains' && request.method === 'POST') {
796
+ const body = await readJson(request, 1024 * 1024).catch(() => ({}));
797
+ try {
798
+ const saved = await this.deps.chains.add(session.user, body);
799
+ return sendJson(response, 200, { ok: true, chain: saved, chains: this.deps.chains.list(session.user) });
800
+ } catch (error) {
801
+ return sendJson(response, 400, { ok: false, error: errorMessage(error) });
802
+ }
803
+ }
804
+ // `/run` must be matched BEFORE the `startsWith` delete route, or DELETE would
805
+ // swallow it and a run request would be treated as "delete chain 'run'".
806
+ if (path === '/api/chains/run' && request.method === 'POST') {
807
+ const body = await readJson(request, 1024 * 1024).catch(() => ({}));
808
+ try {
809
+ return sendJson(response, 200, await this.runChain(body, session));
810
+ } catch (error) {
811
+ return sendJson(response, 400, { ok: false, error: errorMessage(error) });
812
+ }
813
+ }
814
+ if (path.startsWith('/api/chains/') && request.method === 'DELETE') {
815
+ const id = path.slice('/api/chains/'.length);
816
+ try {
817
+ await this.deps.chains.remove(session.user, id);
818
+ return sendJson(response, 200, { ok: true, chains: this.deps.chains.list(session.user) });
819
+ } catch (error) {
820
+ return sendJson(response, 404, { ok: false, error: errorMessage(error) });
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Saved prompts. Per-account like every other list here, and deliberately outside
826
+ * the workflow library: a favourite is text, reusable on any graph.
827
+ */
828
+ if (path === '/api/prompts' && request.method === 'GET') {
829
+ return sendJson(response, 200, { ok: true, prompts: this.deps.prompts.list(session.user) });
830
+ }
831
+ if (path === '/api/prompts' && request.method === 'POST') {
832
+ const body = await readJson(request, 1024 * 1024).catch(() => ({}));
833
+ try {
834
+ const saved = await this.deps.prompts.add(session.user, body);
835
+ return sendJson(response, 200, { ok: true, prompt: saved, prompts: this.deps.prompts.list(session.user) });
836
+ } catch (error) {
837
+ return sendJson(response, 400, { ok: false, error: errorMessage(error) });
838
+ }
839
+ }
840
+ if (path.startsWith('/api/prompts/') && request.method === 'DELETE') {
841
+ const id = path.slice('/api/prompts/'.length);
842
+ try {
843
+ await this.deps.prompts.remove(session.user, id);
844
+ return sendJson(response, 200, { ok: true, prompts: this.deps.prompts.list(session.user) });
845
+ } catch (error) {
846
+ return sendJson(response, 404, { ok: false, error: errorMessage(error) });
847
+ }
848
+ }
849
+
850
+ if (path === '/api/assets' && request.method === 'GET') {
851
+ const limit = Math.min(300, Number(url.searchParams.get('max') ?? '60') || 60);
852
+ const list = await this.assetsPayload(session, limit);
853
+ return sendJson(response, 200, { ok: true, assets: list, hiddenAdult: list.hiddenAdult ?? 0, eyes: session.eyes === true });
854
+ }
855
+
856
+ /**
857
+ * Delete one picture from this account's library.
858
+ *
859
+ * Addressed by `?prompt_id=…&file=…` rather than a path segment, because a
860
+ * filename can contain characters a path segment cannot carry safely on every
861
+ * client (spaces, `#`, `%`). Both halves are required and both must match one
862
+ * row, so a guessed filename alone is never enough authority.
863
+ *
864
+ * The bytes stay on the ComfyUI instance: this library only ever held index
865
+ * rows, and pretending otherwise would leave the two disagreeing.
866
+ */
867
+ /**
868
+ * Which prompt-engineering skill is in force, and whether it can actually be read.
869
+ *
870
+ * Admin-only, read-only. The point is the `ok` flag: a skill that is configured but
871
+ * missing on disk silently degrades the assistant to "generic English tags", and the
872
+ * operator would only notice by the pictures getting worse. This answers that directly.
873
+ */
874
+ if (path === '/api/skill' && request.method === 'GET') {
875
+ if (session.role !== 'admin') {
876
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以查看技能配置' });
877
+ }
878
+ const config = this.deps.settings().promptSkill ?? {};
879
+ const loaded = await loadPromptSkill(config);
880
+ return sendJson(response, 200, {
881
+ ok: true,
882
+ configured: String(config.name ?? ''),
883
+ dir: String(config.dir ?? ''),
884
+ includeReferences: config.includeReferences !== false,
885
+ loaded: { ok: loaded.ok, name: loaded.name, dir: loaded.dir, files: loaded.files, bytes: loaded.bytes, reason: loaded.reason ?? '' },
886
+ });
887
+ }
888
+
889
+ /**
890
+ * Bring an output picture into the `input/` folder so a LoadImage can use it.
891
+ *
892
+ * Asset-library rows are produced images: they live under `output/`, and `LoadImage`
893
+ * only ever lists `input/`. Picking one used to submit `nsfw_xxx.png` straight into
894
+ * the graph, which the instance rejects as "Invalid image file" — the reported
895
+ * 「资产库引入图片失败」.
896
+ *
897
+ * The instance's own /view gives the bytes back, so this copies them across the same
898
+ * way a browser download would, then re-uploads under a neutral name. Ownership and
899
+ * tier are checked before any bytes move.
900
+ */
901
+ if (path === '/api/assets/use' && request.method === 'POST') {
902
+ const body = await readJson(request, 64 * 1024).catch(() => ({}));
903
+ const promptId = String(body?.promptId ?? '');
904
+ const filename = String(body?.filename ?? '');
905
+ if (promptId === '' || filename === '') {
906
+ return sendJson(response, 400, { ok: false, error: '需要 promptId 与 filename' });
907
+ }
908
+ if (!this.deps.assets.owns(session.user, promptId, filename)) {
909
+ return sendJson(response, 404, { ok: false, error: '资产库里没有这张图' });
910
+ }
911
+ const asset = this.deps.assets.get(session.user, promptId, filename);
912
+ if (asset === undefined) return sendJson(response, 404, { ok: false, error: '资产库里没有这张图' });
913
+ if (this.tierOf(session) !== 'nsfw' && (asset.tier ?? 'sfw') === 'nsfw') {
914
+ return sendJson(response, 403, { ok: false, error: '当前账号的等级看不到这张图' });
915
+ }
916
+ const runtime = asset.endpointId === null ? undefined : this.deps.instances.get(asset.endpointId);
917
+ const target = runtime ?? this.deps.instances.enabled()[0];
918
+ if (target === undefined) return sendJson(response, 503, { ok: false, error: '没有可用的 ComfyUI 实例' });
919
+ const query = new URLSearchParams({
920
+ filename: asset.filename,
921
+ subfolder: asset.subfolder ?? '',
922
+ type: asset.type ?? 'output',
923
+ }).toString();
924
+ let bytes;
925
+ try {
926
+ const fetched = await this.deps.pool.backend(target).request(`/view?${query}`);
927
+ bytes = Buffer.from(await fetched.arrayBuffer());
928
+ } catch (error) {
929
+ return sendJson(response, 502, { ok: false, error: `取图失败:${errorMessage(error)}` });
930
+ }
931
+ if (bytes.length === 0) return sendJson(response, 502, { ok: false, error: '取回来的图是空的' });
932
+ /**
933
+ * Name the copy after the asset, with a prefix that says where it came from.
934
+ *
935
+ * Reusing the output name is tempting but wrong: `input/` and `output/` are
936
+ * separate namespaces, so a same-named input silently shadows nothing and makes
937
+ * the two libraries look identical in a listing.
938
+ */
939
+ const stem = asset.filename.replace(/\.[^.]+$/, '').replace(/[\\/:*?"<>|]/g, '_').slice(-60);
940
+ const uploadName = `asset_${stem}.png`;
941
+ try {
942
+ /**
943
+ * `target` is an InstanceRuntime, not an instance record.
944
+ *
945
+ * `uploadBytesToInput` reads `.baseUrl`, and a runtime keeps that one level
946
+ * down as `.instance.baseUrl`. Passing the runtime directly made the address
947
+ * read as empty — the reported「写入 input 失败:实例地址为空」, which looked like
948
+ * a missing configuration but was the wrong object shape at the call site.
949
+ */
950
+ const uploaded = await uploadBytesToInput(target.instance ?? target, uploadName, bytes);
951
+ return sendJson(response, 200, { ok: true, ...uploaded });
952
+ } catch (error) {
953
+ return sendJson(response, 502, { ok: false, error: `写入 input 失败:${errorMessage(error)}` });
954
+ }
955
+ }
956
+
957
+ if (path === '/api/assets' && request.method === 'DELETE') {
958
+ const promptId = url.searchParams.get('prompt_id') ?? '';
959
+ const filename = url.searchParams.get('file') ?? '';
960
+ if (promptId === '' || filename === '') {
961
+ return sendJson(response, 400, { ok: false, error: '需要 prompt_id 与 file 两个参数' });
962
+ }
963
+ const removed = await this.deps.assets.remove(session.user, promptId, filename);
964
+ if (!removed) return sendJson(response, 404, { ok: false, error: '资产库里没有这张图' });
965
+ const list = await this.assetsPayload(session, 60);
966
+ return sendJson(response, 200, { ok: true, assets: list, hiddenAdult: list.hiddenAdult ?? 0, eyes: session.eyes === true });
967
+ }
968
+
969
+ if (path === '/api/settings' && request.method === 'POST') {
970
+ if (session.role !== 'admin') {
971
+ return sendJson(response, 403, { ok: false, error: '只有管理员可以改设置' });
972
+ }
973
+ const body = await readJson(request, 256 * 1024).catch(() => ({}));
974
+ const patch = {};
975
+ if (body?.promptHeads !== undefined && body.promptHeads !== null) {
976
+ patch.promptHeads = {
977
+ positive: String(body.promptHeads.positive ?? '').slice(0, 4000),
978
+ negative: String(body.promptHeads.negative ?? '').slice(0, 4000),
979
+ };
980
+ }
981
+ if (Object.keys(patch).length === 0) {
982
+ return sendJson(response, 400, { ok: false, error: '没有可改的字段' });
983
+ }
984
+ await this.deps.store.patch(patch);
985
+ return sendJson(response, 200, {
986
+ ok: true,
987
+ defaultHeads: this.deps.settings().promptHeads ?? {},
988
+ });
989
+ }
990
+
991
+ if (path === '/api/knowledge' && request.method === 'GET') {
992
+ const root = this.deps.settings().promptKb?.root ?? '';
993
+ const bases = await listKnowledgeBases(root);
994
+ return sendJson(response, 200, {
995
+ ok: true,
996
+ selected: this.deps.settings().promptKb?.kbName ?? '',
997
+ bases: bases.map((entry) => ({
998
+ name: entry.name,
999
+ documents: entry.documents,
1000
+ description: entry.description.slice(0, 120),
1001
+ })),
1002
+ });
1003
+ }
1004
+
1005
+ if (path === '/api/tags' && request.method === 'GET') {
1006
+ const query = url.searchParams.get('q') ?? '';
1007
+ const tier = this.tierOf(session);
1008
+ return sendJson(response, 200, { ok: true, tags: searchTags(query, tier, 40) });
1009
+ }
1010
+
1011
+ // Media for the history grid. The console is on its own port, so it cannot
1012
+ // use the Harness web server's /comfyui/media; this route finds the file on
1013
+ // whichever instance holds it and streams it through the same origin.
1014
+ if (path === '/media') {
1015
+ const promptId = url.searchParams.get('prompt_id') ?? '';
1016
+ // A file is served only when this account's own index names it. The
1017
+ // filename alone is not authority: output names are guessable
1018
+ // (`ComfyUI_00001_.png`), so an unowned request is a 404, not a stream.
1019
+ const owned = promptId !== '' && this.deps.runs.owns(session.user, promptId);
1020
+ const byName = promptId === '' ? this.ownsFile(session.user, url.searchParams.get('file') ?? '') : null;
1021
+ if (!owned && byName === null) {
1022
+ return sendJson(response, 404, { error: '没有这个文件' });
1023
+ }
1024
+ return this.proxyMedia(url, request, response);
1025
+ }
1026
+
1027
+ sendJson(response, 404, { ok: false, error: `not found: ${path}` });
1028
+ }
1029
+
1030
+ /** The session's content ceiling; a missing field means SFW. */
1031
+ tierOf(session) {
1032
+ /**
1033
+ * The eye means SAFE MODE, not elevation.
1034
+ *
1035
+ * The purpose, in the operator's words: 让 NSFW 账户在公共环境不适合暴露成人信息的
1036
+ * 场景下可以正常使用. So the eye starts OFF (the account behaves normally, adult
1037
+ * content visible to an NSFW account) and turning it ON HIDES the adult half — for
1038
+ * that session only, so a glance over your shoulder is not a config change.
1039
+ *
1040
+ * The earlier version had it backwards (on = show), which is why the button looked
1041
+ * broken: the rig's owner is an NSFW admin, so "on" changed nothing they could see.
1042
+ */
1043
+ if (session?.eyes === true) return 'sfw';
1044
+ return normalizeTier(session?.tier ?? (this.deps.auth.accounts().find((entry) => entry.name === session?.user)?.tier));
1045
+ }
1046
+
1047
+ /**
1048
+ /**
1049
+ * Run one chain: steps in order, each waiting for the previous to finish.
1050
+ *
1051
+ * Deliberately NOT queued as one pool task. A chain is a *sequence* whose later steps
1052
+ * depend on earlier results; handing all steps to the scheduler at once would run them
1053
+ * in parallel and the "feed the previous image" step would have nothing to feed.
1054
+ *
1055
+ * The loop is bounded (MAX_STEPS) and every step gets a hard deadline, because a step
1056
+ * that never finishes must not hold the chain open forever — the run is marked failed
1057
+ * with the step index, which is the only useful thing to report.
1058
+ */
1059
+ async runChain(body, session) {
1060
+ /**
1061
+ * Two ways in: a saved chain by id, or an inline `chain` object.
1062
+ *
1063
+ * Inline exists so a person can try a chain before saving it. It is normalised
1064
+ * through the same `normalizeStep`, so an inline run cannot be a way to bypass the
1065
+ * step validation (a workflow id that does not exist still fails here, not three
1066
+ * steps later).
1067
+ */
1068
+ let chain;
1069
+ const chainId = String(body?.chainId ?? '');
1070
+ if (chainId !== '') {
1071
+ chain = this.deps.chains.get(session.user, chainId);
1072
+ if (chain === undefined) throw new Error('没有这条工作链');
1073
+ } else if (body?.chain !== undefined && Array.isArray(body.chain.steps)) {
1074
+ const rawSteps = body.chain.steps;
1075
+ if (rawSteps.length === 0) throw new Error('工作链至少要有一个环节');
1076
+ if (rawSteps.length > MAX_STEPS) throw new Error(`最多 ${MAX_STEPS} 个环节`);
1077
+ const steps = rawSteps.map((step, index) => ChainLibrary.normalizeStep(step, index));
1078
+ if (steps.some((step) => step.workflowId === '')) throw new Error('每个环节都要选一个工作流');
1079
+ chain = { id: 'inline', name: String(body.chain.name ?? '临时链'), steps };
1080
+ } else {
1081
+ throw new Error('需要 chainId 或 chain.steps');
1082
+ }
1083
+
1084
+ const runId = `chain-${randomBytes(4).toString('hex')}`;
1085
+ const results = [];
1086
+ /** The previous step's output image, or null when the next step did not ask for it. */
1087
+ let previous = null;
1088
+
1089
+ for (let index = 0; index < chain.steps.length; index += 1) {
1090
+ const step = chain.steps[index];
1091
+ const stepLabel = step.label || step.workflowId;
1092
+ try {
1093
+ const request = {
1094
+ workflowId: step.workflowId,
1095
+ negative: step.negative,
1096
+ params: step.params,
1097
+ repeat: step.repeat,
1098
+ /**
1099
+ * Chains get the same seed behaviour as a single run, per step.
1100
+ *
1101
+ * Default random, not fixed: a chain re-run is usually "try that again
1102
+ * differently", and pinning the seed would hand back the same pictures
1103
+ * step after step. A step that names a concrete value still overrides.
1104
+ */
1105
+ seed: step.seed,
1106
+ chainOf: { runId, step: index + 1, total: chain.steps.length },
1107
+ };
1108
+ if (Array.isArray(step.layers) && step.layers.some((value) => value !== '')) {
1109
+ request.layers = step.layers.map((value) => ({ value }));
1110
+ }
1111
+ /**
1112
+ * The ordering contract, in one line: this step's input image IS the previous
1113
+ * step's output. `feedPrevious` is per step because a chain may start with a
1114
+ * text-to-image step (no input) and continue with an upscaler (needs one);
1115
+ * forcing every step to accept an image would silently drop it for the ones
1116
+ * that have no image input.
1117
+ *
1118
+ * `generate()` writes `images[]` into whatever image node the workflow has,
1119
+ * which is exactly the mechanism the console's upload uses — so a chained
1120
+ * image and an uploaded one are indistinguishable downstream.
1121
+ */
1122
+ if (previous !== null && step.feedPrevious) {
1123
+ request.images = [{ nodeId: '', filename: previous }];
1124
+ }
1125
+ const reply = await this.generate(request, session);
1126
+ const promptIds = Array.isArray(reply.promptIds) ? reply.promptIds : [reply.prompt_id];
1127
+
1128
+ // Wait for EVERY run of this step before moving on: with repeat>1 the point
1129
+ // of waiting is that the next step must not start while one of this step's
1130
+ // images is still being written.
1131
+ let lastOutputs = null;
1132
+ for (const promptId of promptIds) {
1133
+ const finished = await this.waitForTask(promptId, 30 * 60_000);
1134
+ if (finished.state !== 'done') {
1135
+ throw new Error(`第 ${index + 1} 步「${stepLabel}」失败:${finished.error ?? finished.state}`);
1136
+ }
1137
+ lastOutputs = finished.outputs ?? null;
1138
+ }
1139
+ const image = firstImageOutput(lastOutputs);
1140
+ results.push({
1141
+ step: index + 1,
1142
+ label: stepLabel,
1143
+ workflowId: step.workflowId,
1144
+ promptIds,
1145
+ state: 'done',
1146
+ image: image?.filename ?? null,
1147
+ });
1148
+ // Carry it forward only if the next step declared that it consumes one.
1149
+ const next = chain.steps[index + 1];
1150
+ previous = image !== null && next !== undefined && next.feedPrevious === true ? image.filename : null;
1151
+ } catch (error) {
1152
+ results.push({
1153
+ step: index + 1,
1154
+ label: stepLabel,
1155
+ workflowId: step.workflowId,
1156
+ promptIds: [],
1157
+ state: 'failed',
1158
+ error: errorMessage(error),
1159
+ });
1160
+ // Stop at the first failure: later steps were designed against this one's
1161
+ // output, so running them would produce confident nonsense.
1162
+ return { ok: false, runId, chain: { id: chain.id, name: chain.name }, steps: results, error: errorMessage(error) };
1163
+ }
1164
+ }
1165
+ return { ok: true, runId, chain: { id: chain.id, name: chain.name }, steps: results };
1166
+ }
1167
+
1168
+ /** Resolve when a pool task leaves queued/running, or when the deadline passes. */
1169
+ async waitForTask(promptId, timeoutMs) {
1170
+ const deadline = Date.now() + timeoutMs;
1171
+ while (Date.now() < deadline) {
1172
+ const task = this.deps.pool.tasks.get(promptId);
1173
+ if (task === undefined) {
1174
+ return { state: 'failed', error: '任务从池里消失了', outputs: null };
1175
+ }
1176
+ if (task.state === 'done' || task.state === 'failed' || task.state === 'cancelled') {
1177
+ return { state: task.state, error: task.error ?? null, outputs: task.outputs ?? null };
1178
+ }
1179
+ await new Promise((resolve) => setTimeout(resolve, 700));
1180
+ }
1181
+ return { state: 'failed', error: '等待超时(30 分钟)', outputs: null };
1182
+ }
1183
+
1184
+ /**
1185
+ * One assistant turn, with the tier enforced on both the input and the model's
1186
+ * answer. A SFW account must not be able to smuggle an adult request through
1187
+ * the assistant and have it pasted into a workflow that then runs.
1188
+ */
1189
+ async assistTurn(body, session) {
1190
+ const tier = this.tierOf(session);
1191
+ const config = this.deps.settings();
1192
+ if (config.remote?.assistant === false) {
1193
+ throw new Error('提示词助手已被管理员关闭');
1194
+ }
1195
+ const message = String(body?.message ?? '');
1196
+ const history = Array.isArray(body?.history) ? body.history : [];
1197
+ if (tier !== 'nsfw') {
1198
+ const hit = detectAdult(message) || scanStrings(history);
1199
+ if (hit.hit === true) {
1200
+ return {
1201
+ ok: false,
1202
+ mode: 'tier',
1203
+ error: `当前账号是全年龄等级,这条需求里的「${hit.matched}」属于成人内容;换成适合的表述,或让管理员提升等级。`,
1204
+ };
1205
+ }
1206
+ }
1207
+ const requested = typeof body?.kb === 'string' ? body.kb.trim() : undefined;
1208
+ const effective =
1209
+ requested === undefined
1210
+ ? config
1211
+ : { ...config, promptKb: { ...(config.promptKb ?? {}), kbName: requested } };
1212
+ const layerNames = this.layerNamesOf(body?.workflowId);
1213
+ const result = await assist(
1214
+ {
1215
+ llm: this.deps.llm,
1216
+ route: this.deps.llmRoute,
1217
+ config: effective,
1218
+ tier,
1219
+ },
1220
+ {
1221
+ message,
1222
+ positive: body?.positive,
1223
+ negative: body?.negative,
1224
+ history,
1225
+ /**
1226
+ * Layer names, resolved on the server from the workflow the console is
1227
+ * showing — not taken from the request. A client that could name its own
1228
+ * layers could ask the model to produce a shape the workflow does not have,
1229
+ * and the assemble step would then write layer text nowhere.
1230
+ */
1231
+ layerNames,
1232
+ },
1233
+ );
1234
+ /**
1235
+ * Ship the answer as pickable rows, not as a blob to paste in.
1236
+ *
1237
+ * The console lists these under the assistant box with a checkbox each and a target
1238
+ * box per row; nothing is written until the person presses 确定. Built here rather
1239
+ * than in the page so the "which layer is which" names come from the stored workflow
1240
+ * and the row notes cannot disagree with what the model actually produced.
1241
+ */
1242
+ return { ...result, candidates: assistCandidates(result, layerNames) };
1243
+ }
1244
+
1245
+ /** Declared layer names for one workflow, or [] when it has none. */
1246
+ layerNamesOf(workflowId) {
1247
+ const id = String(workflowId ?? '');
1248
+ if (id === '') return [];
1249
+ const record = this.deps.library.get(id);
1250
+ if (record === undefined) return [];
1251
+ const layers = Array.isArray(record.layers) ? record.layers : [];
1252
+ return layers.map((layer) => (typeof layer?.name === 'string' ? layer.name : ''));
1253
+ }
1254
+
1255
+ /**
1256
+ * One account's pictures, with the one field the console cannot compute.
1257
+ *
1258
+ * Pixel sizes live only on the instance, so they are probed here — but under a
1259
+ * budget: measuring every row on every browse would turn opening the library into
1260
+ * hundreds of upstream requests. Twelve per call, newest first, means the first
1261
+ * browse fills in quickly and later ones are free because the result is written
1262
+ * back to the index.
1263
+ *
1264
+ * Shared by the list route and the delete route so a caller gets the same shape
1265
+ * from both, and so deleting a picture does not need a second round trip just to
1266
+ * learn what the library looks like afterwards.
1267
+ */
1268
+ async assetsPayload(session, limit = 60) {
1269
+ const owner = session.user;
1270
+ /**
1271
+ * Which pictures this session may see.
1272
+ *
1273
+ * The eye is the only thing that gates the library, and it now actually does:
1274
+ * "控制台的资源库…没有关联到成人按钮上". With the eye off, adult rows are filtered
1275
+ * out; with it on, they appear and say so. `null` tier on an old row means "recorded
1276
+ * before the column existed" — treated as SFW so an upgrade cannot start showing
1277
+ * adult pictures to an account that had the eye off.
1278
+ */
1279
+ // Safe mode: the eye hides the adult half instead of revealing it.
1280
+ const safeMode = session.eyes === true;
1281
+ const everything = this.deps.assets.list(owner, limit);
1282
+ const rows = everything.filter((asset) => !safeMode || (asset.tier ?? 'sfw') !== 'nsfw');
1283
+ const hiddenAdult = everything.length - rows.length;
1284
+ let budget = 12;
1285
+ for (const asset of rows) {
1286
+ if (budget <= 0) break;
1287
+ if (asset.width !== null && asset.height !== null) continue;
1288
+ const runtime = asset.endpointId === null ? undefined : this.deps.instances.get(asset.endpointId);
1289
+ const target = runtime ?? this.deps.instances.enabled()[0];
1290
+ if (target === undefined) break;
1291
+ budget -= 1;
1292
+ const query = new URLSearchParams({ filename: asset.filename, subfolder: asset.subfolder, type: asset.type }).toString();
1293
+ const size = await probeSize(this.deps.pool.backend(target), query);
1294
+ if (size !== null) {
1295
+ asset.width = size.width;
1296
+ asset.height = size.height;
1297
+ void this.deps.assets.setSize(owner, asset.promptId, asset.filename, size);
1298
+ }
1299
+ }
1300
+ const payload = rows.map((asset) => {
1301
+ const source = `/media?file=${encodeURIComponent(asset.filename)}&subfolder=${encodeURIComponent(asset.subfolder)}&type=${encodeURIComponent(asset.type)}&prompt_id=${encodeURIComponent(asset.promptId)}`;
1302
+ return {
1303
+ promptId: asset.promptId,
1304
+ filename: asset.filename,
1305
+ subfolder: asset.subfolder,
1306
+ type: asset.type,
1307
+ name: asset.name,
1308
+ width: asset.width,
1309
+ height: asset.height,
1310
+ /** The seed this exact picture came from; null when the run had none. */
1311
+ seed: typeof asset.seed === 'number' ? asset.seed : null,
1312
+ tier: asset.tier ?? 'sfw',
1313
+ submittedAt: asset.submittedAt,
1314
+ url: source,
1315
+ download: `${source}&download=1`,
1316
+ };
1317
+ });
1318
+ /**
1319
+ * The count of adult rows the eye is currently hiding travels with the list.
1320
+ *
1321
+ * Silently dropping them makes an empty library indistinguishable from a filtered
1322
+ * one, and the operator's first question on seeing a gap was "where did my pictures
1323
+ * go". A number lets the page say "还有 N 张在成人内容里".
1324
+ */
1325
+ if (hiddenAdult > 0) payload.hiddenAdult = hiddenAdult;
1326
+ return payload;
1327
+ }
1328
+
1329
+ /**
1330
+ * One account's own runs, from the durable index and the live pool together.
1331
+ *
1332
+ * The index is what survives logout; the pool is what has a live task object
1333
+ * and therefore the freshest state and file list. Either alone is incomplete:
1334
+ * the index alone would never show progress, the pool alone would forget
1335
+ * everything at a restart.
1336
+ */
1337
+ async ownHistory(session, max) {
1338
+ const merged = {};
1339
+ for (const record of this.deps.runs.list(session.user, max)) {
1340
+ const live = this.deps.pool.tasks.get(record.promptId);
1341
+ if (live !== undefined && live.state === 'done' && live.historyEntry !== undefined && live.historyEntry !== null) {
1342
+ const files = outputsOf(live.historyEntry);
1343
+ if (files.length !== (record.files ?? []).length) {
1344
+ void this.deps.runs.settle(session.user, record.promptId, { files, state: live.state });
1345
+ }
1346
+ merged[record.promptId] = {
1347
+ ...live.historyEntry,
1348
+ meta: {
1349
+ ...(live.historyEntry.meta ?? {}),
1350
+ owner: session.user,
1351
+ run: { name: record.name, endpointId: live.backendId, submittedAt: record.submittedAt },
1352
+ },
1353
+ files,
1354
+ state: live.state,
1355
+ error: live.error ?? null,
1356
+ };
1357
+ continue;
1358
+ }
1359
+ if (live !== undefined) {
1360
+ merged[record.promptId] = {
1361
+ prompt: null,
1362
+ outputs: {},
1363
+ status: { status_str: live.state, completed: false },
1364
+ meta: { owner: session.user, run: { name: record.name, endpointId: live.backendId, submittedAt: record.submittedAt } },
1365
+ files: [],
1366
+ state: live.state,
1367
+ error: live.error ?? null,
1368
+ };
1369
+ continue;
1370
+ }
1371
+ const state = record.state ?? 'done';
1372
+ merged[record.promptId] = {
1373
+ prompt: null,
1374
+ outputs: {},
1375
+ status: { status_str: state, completed: state === 'done' },
1376
+ meta: { owner: session.user, run: { name: record.name, endpointId: record.endpointId ?? null, submittedAt: record.submittedAt } },
1377
+ files: record.files ?? [],
1378
+ state,
1379
+ error: record.error ?? null,
1380
+ };
1381
+ }
1382
+ return merged;
1383
+ }
1384
+
1385
+ /** Does any run this account owns reference this exact filename? */
1386
+ ownsFile(owner, filename) {
1387
+ if (filename === '') return null;
1388
+ for (const record of this.deps.runs.list(owner, 400)) {
1389
+ for (const file of record.files ?? []) {
1390
+ if (file.filename === filename) return file;
1391
+ }
1392
+ }
1393
+ return null;
1394
+ }
1395
+
1396
+ /**
1397
+ * Pull the pool's finished state into the durable index.
1398
+ *
1399
+ * Called on a timer: without it a run finished while the browser was closed
1400
+ * would never record its file list, and the history after re-login would show
1401
+ * the run with no images.
1402
+ */
1403
+ async settleOwnRuns() {
1404
+ for (const task of this.deps.pool.tasks.values()) {
1405
+ const owner = task.extraData?.remoteUser;
1406
+ if (typeof owner !== 'string' || owner === '') continue;
1407
+ if (task.state !== 'done' && task.state !== 'failed' && task.state !== 'cancelled') continue;
1408
+ const record = this.deps.runs.get(owner, task.id);
1409
+ if (record === undefined) continue;
1410
+ const files = task.historyEntry === null || task.historyEntry === undefined ? [] : outputsOf(task.historyEntry);
1411
+ const same = (record.state ?? null) === task.state && (record.files ?? []).length === files.length;
1412
+ if (same) continue;
1413
+ await this.deps.runs.settle(owner, task.id, {
1414
+ state: task.state,
1415
+ files,
1416
+ endpointId: task.backendId ?? record.endpointId ?? null,
1417
+ error: task.error ?? null,
1418
+ });
1419
+ // One asset row per produced file, so the library is a picture list
1420
+ // rather than a run list. Idempotent: a second sweep rewrites the same
1421
+ // rows and keeps any size already measured.
1422
+ if (files.length > 0 && task.state === 'done') {
1423
+ await this.deps.assets.recordRun(owner, { ...record, endpointId: task.backendId ?? record.endpointId ?? null, tier: classifyRun(task) }, files);
1424
+ }
1425
+ }
1426
+ /**
1427
+ * Second pass over the durable index, because the loop above cannot see everything.
1428
+ *
1429
+ * `pool.tasks` lives in memory. A run that finished before a host restart — or whose
1430
+ * task object was dropped — sits in runs.json with its files but was never copied into
1431
+ * the asset library, so the console showed "还没有图片" for images the person had
1432
+ * produced. That is the reported bug: one done run with a file, assets.json empty.
1433
+ *
1434
+ * Idempotent by construction: `recordRun` replaces that run's rows wholesale and keeps
1435
+ * sizes already measured, and `hasRun` short-circuits the common case so a sweep over a
1436
+ * current library writes nothing.
1437
+ */
1438
+ for (const owner of this.deps.runs.owners()) {
1439
+ for (const record of this.deps.runs.list(owner, 400)) {
1440
+ if (record.state !== 'done') continue;
1441
+ const files = Array.isArray(record.files) ? record.files : [];
1442
+ if (files.length === 0) continue;
1443
+ if (this.deps.assets.hasRun(owner, record.promptId, files.length)) continue;
1444
+ await this.deps.assets.recordRun(owner, { ...record, endpointId: record.endpointId ?? null, tier: record.tier ?? classifyRun({ extraData: { workflowName: record.name } }) }, files);
1445
+ }
1446
+ }
1447
+ }
1448
+
1449
+ async objectInfo() {
1450
+ const primary = await this.deps.gateway.primary();
1451
+ if (primary === undefined) throw new Error('没有可用的 ComfyUI 实例');
1452
+ return this.deps.gateway.cachedObjectInfo(primary, '');
1453
+ }
1454
+
1455
+ /**
1456
+ * What is installed on the bound instances, grouped by folder.
1457
+ *
1458
+ * The folder names are ComfyUI's own (`/models/checkpoints`, `/models/loras`, …);
1459
+ * asking each one is the only way to learn what a given build actually serves.
1460
+ * Results are merged across instances because the operator cares that a model
1461
+ * exists somewhere in the rig, not on which card.
1462
+ */
1463
+ /**
1464
+ * Check a graph without running it.
1465
+ *
1466
+ * Returns a list of problems rather than a boolean: "cannot run" without saying which
1467
+ * node or model is useless to the person editing the JSON.
1468
+ */
1469
+ async testWorkflow(body) {
1470
+ const problems = [];
1471
+ const warnings = [];
1472
+ const raw = body?.graph;
1473
+
1474
+ // --- shape ---------------------------------------------------------
1475
+ let api = null;
1476
+ const graph = raw;
1477
+ if (graph === null || typeof graph !== 'object') {
1478
+ return { ok: false, problems: ['JSON 不是一个对象'], warnings: [], checked: 0 };
1479
+ }
1480
+ const isApi =
1481
+ !Array.isArray(graph.nodes) &&
1482
+ Object.keys(graph).length > 0 &&
1483
+ Object.values(graph).every((node) => node !== null && typeof node === 'object' && typeof node.class_type === 'string');
1484
+ if (isApi) {
1485
+ api = graph;
1486
+ } else if (Array.isArray(graph.nodes)) {
1487
+ // A UI graph is legal input for saving, but testing it requires the instance to
1488
+ // convert it — which is exactly what saving does. Say so rather than pretend.
1489
+ warnings.push('这是画布格式(含 nodes 数组)。测试只检查 API 格式;保存时会自动转成 API。');
1490
+ } else {
1491
+ return { ok: false, problems: ['既不是 API 格式(class_type 节点表),也不像画布格式(缺 nodes 数组)'], warnings: [], checked: 0 };
1492
+ }
1493
+
1494
+ if (api === null) {
1495
+ return { ok: warnings.length === 0, problems, warnings, checked: 0 };
1496
+ }
1497
+
1498
+ // Node id sanity: a link to a node that does not exist is the classic silent break.
1499
+ for (const [nodeId, node] of Object.entries(api)) {
1500
+ for (const [input, value] of Object.entries(node?.inputs ?? {})) {
1501
+ if (!Array.isArray(value) || value.length !== 2) continue;
1502
+ const [target, slot] = value;
1503
+ if (typeof target !== 'string') continue;
1504
+ if (api[target] === undefined) {
1505
+ problems.push(`节点 ${nodeId} 的 ${input} 指向不存在的节点 ${target}`);
1506
+ } else if (!Number.isInteger(slot)) {
1507
+ problems.push(`节点 ${nodeId} 的 ${input} 槽位不是整数:${String(slot)}`);
1508
+ }
1509
+ }
1510
+ }
1511
+
1512
+ // --- installed node types and model files ---------------------------
1513
+ let objectInfo = null;
1514
+ try {
1515
+ objectInfo = await this.objectInfo();
1516
+ } catch (error) {
1517
+ warnings.push(`读不到实例的节点清单(${errorMessage(error)}),只能做结构检查`);
1518
+ }
1519
+ if (objectInfo !== null) {
1520
+ const classes = [...new Set(Object.values(api).map((node) => String(node.class_type)))].sort();
1521
+ for (const className of classes) {
1522
+ if (objectInfo[className] === undefined) {
1523
+ problems.push(`实例上没有这个节点类型:${className}`);
1524
+ }
1525
+ }
1526
+ /**
1527
+ * Model files: only check inputs whose name and value look like a model
1528
+ * reference. A missing checkpoint gives the most confusing ComfyUI error of all
1529
+ * ("Value not in list"), so naming the file here is worth the small heuristic.
1530
+ *
1531
+ * The folder depends on the NODE TYPE where the input name alone is ambiguous:
1532
+ * `model_name` is a LoRA on LoraLoader and an upscale model on
1533
+ * UpscaleModelLoader. Checking a valid upscaler against /models/loras reported a
1534
+ * phantom "file not found", which is worse than not checking at all.
1535
+ */
1536
+ const INPUT_FOLDERS = {
1537
+ ckpt_name: 'checkpoints',
1538
+ unet_name: 'diffusion_models',
1539
+ vae_name: 'vae',
1540
+ clip_name: 'clip',
1541
+ clip_name1: 'clip',
1542
+ clip_name2: 'clip',
1543
+ clip_vision_name: 'clip_vision',
1544
+ control_net_name: 'controlnet',
1545
+ ipadapter_file: 'ipadapter',
1546
+ };
1547
+ /** Node types whose `model_name` means a specific folder. */
1548
+ const MODEL_NAME_FOLDER = {
1549
+ LoraLoader: 'loras',
1550
+ LoraLoaderModelOnly: 'loras',
1551
+ UpscaleModelLoader: 'upscale_models',
1552
+ ImageUpscaleWithModel: 'upscale_models',
1553
+ UnetLoaderGGUF: 'unet',
1554
+ };
1555
+ for (const [nodeId, node] of Object.entries(api)) {
1556
+ const classType = String(node?.class_type ?? '');
1557
+ for (const [input, value] of Object.entries(node?.inputs ?? {})) {
1558
+ if (typeof value !== 'string' || value === '') continue;
1559
+ let folder = INPUT_FOLDERS[input];
1560
+ if (folder === undefined && input === 'model_name') folder = MODEL_NAME_FOLDER[classType];
1561
+ if (folder === undefined) continue;
1562
+ try {
1563
+ const list = await this.deps.pool.backend(this.deps.instances.enabled()[0]).json(`/models/${folder}`);
1564
+ if (Array.isArray(list) && !list.map(String).includes(value)) {
1565
+ problems.push(`节点 ${nodeId} 的 ${input} 找不到模型文件:${value}(/models/${folder})`);
1566
+ }
1567
+ } catch {
1568
+ // Best effort: a folder this build does not expose is not a failure.
1569
+ }
1570
+ }
1571
+ }
1572
+ }
1573
+
1574
+ return {
1575
+ ok: problems.length === 0,
1576
+ problems,
1577
+ warnings,
1578
+ checked: Object.keys(api).length,
1579
+ classes: [...new Set(Object.values(api).map((node) => String(node.class_type)))].sort(),
1580
+ /**
1581
+ * What the editor needs to redraw its derived sections without a second route.
1582
+ * "重新识别" and "测试" are the same question to the server — "look at this
1583
+ * graph" — so they share one response rather than two nearly identical ones.
1584
+ */
1585
+ needsText: promptBoxes(
1586
+ promptTargets(api, body?.promptNodes ?? null),
1587
+ body?.nodeHeads ?? null,
1588
+ body?.nodeLayers ?? null,
1589
+ body?.layers ?? null,
1590
+ ).length > 0,
1591
+ params: mainParams(api, body?.paramPicks ?? [], body?.paramHidden ?? [], booleanInputsOf(api, objectInfo)),
1592
+ /** Node titles, so the grouped parameter list has headings worth reading. */
1593
+ nodeTitles: collectNodeTitles(body?.graph, api),
1594
+ texts: promptTargets(api, body?.promptNodes ?? null).map((row) => ({
1595
+ nodeId: row.nodeId,
1596
+ input: row.input,
1597
+ role: row.role,
1598
+ classType: String(api[row.nodeId]?.class_type ?? ''),
1599
+ value: api[row.nodeId]?.inputs?.[row.input] ?? '',
1600
+ })),
1601
+ };
1602
+ }
1603
+
1604
+ /**
1605
+ * Filenames in one model folder, merged across instances.
1606
+ *
1607
+ * Used to fill a model parameter's dropdown. Merged and de-duplicated for the same
1608
+ * reason the inventory is: two GPUs sharing a model directory must not list every file
1609
+ * twice, and a file only on the second card must still be selectable.
1610
+ */
1611
+ /**
1612
+ * The model folders on THIS machine, read from disk.
1613
+ *
1614
+ * Local disk is the authority, not the instance's `/models`. Reasons, all observed here:
1615
+ *
1616
+ * - `/models` only lists folders `folder_paths` registered. `grounding-dino`,
1617
+ * `insightface`, `reactor`, `yolo`, `nsfw_detector` exist on disk and never appear
1618
+ * there, so an API-only list can never offer them.
1619
+ * - The API answers per instance. Two instances sharing one `models` directory produced
1620
+ * the same names twice, and an instance that was asleep produced nothing at all.
1621
+ * - Scanning the directory is one `readdir`; asking an instance is an HTTP round trip
1622
+ * that fails when the rig is down — and the library is exactly what you edit while it is
1623
+ * down.
1624
+ *
1625
+ * The root comes from `modelsRoot` when set, else from the launcher: `<dir of bat>/ComfyUI/models`,
1626
+ * which is how every portable ComfyUI build is laid out. Labels are the folder's own name —
1627
+ * the operator is matching against what is on disk.
1628
+ */
1629
+ async modelFolders() {
1630
+ const seen = new Set();
1631
+ const rows = [];
1632
+ for (const root of this.localModelRoots()) {
1633
+ let entries;
1634
+ try {
1635
+ entries = readdirSync(root, { withFileTypes: true });
1636
+ } catch {
1637
+ continue;
1638
+ }
1639
+ for (const entry of entries) {
1640
+ if (!entry.isDirectory()) continue;
1641
+ const name = entry.name;
1642
+ if (seen.has(name)) continue;
1643
+ /**
1644
+ * Build artefacts are not model folders. ComfyUI keeps `configs` / `datasets`
1645
+ * next to the weights and they hold no loadable model.
1646
+ */
1647
+ if (name === 'configs' || name === 'datasets') continue;
1648
+ seen.add(name);
1649
+ rows.push([name, name]);
1650
+ }
1651
+ }
1652
+ if (rows.length === 0) return MODEL_FOLDERS.map(([id, label]) => [id, label]);
1653
+ return rows.sort((left, right) => left[0].localeCompare(right[0]));
1654
+ }
1655
+
1656
+ /**
1657
+ * Every resolvable local `models` root, de-duplicated, in instance order.
1658
+ *
1659
+ * Read from the SETTINGS, not from `instances.enabled()`. The runtime objects are rebuilt
1660
+ * by `sync()` on a settings change, and a caller that patches settings without syncing
1661
+ * (the tests do; so does any future editor) would otherwise keep serving the previous
1662
+ * `modelsRoot` — the panel would look stale until the next restart.
1663
+ *
1664
+ * Settings are the flat truth here: this only needs a path string, no live process state.
1665
+ */
1666
+ localModelRoots() {
1667
+ const roots = [];
1668
+ const rows = this.deps.settings?.()?.instances ?? [];
1669
+ for (const instance of rows) {
1670
+ if (instance === null || typeof instance !== 'object') continue;
1671
+ if (instance.enabled === false) continue;
1672
+ const explicit = String(instance.modelsRoot ?? '').trim();
1673
+ let root = explicit;
1674
+ if (root === '') {
1675
+ const bat = String(instance.control?.batPath ?? '').trim();
1676
+ if (bat === '') continue;
1677
+ root = join(dirname(bat), 'ComfyUI', 'models');
1678
+ }
1679
+ if (!isAbsolute(root)) continue;
1680
+ if (!roots.includes(root)) roots.push(root);
1681
+ }
1682
+ return roots;
1683
+ }
1684
+
1685
+ /**
1686
+ * Sub-directory names under each instance's local `models` root.
1687
+ *
1688
+ * Kept as the folder-only view; `modelFolders()` is the one the routes use.
1689
+ */
1690
+ localModelFolders() {
1691
+ const names = [];
1692
+ for (const root of this.localModelRoots()) {
1693
+ try {
1694
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
1695
+ if (entry.isDirectory()) names.push(entry.name);
1696
+ }
1697
+ } catch {
1698
+ // No local access to that root: nothing to contribute.
1699
+ }
1700
+ }
1701
+ return names;
1702
+ }
1703
+
1704
+ /**
1705
+ * Files inside one model folder, read from DISK.
1706
+ *
1707
+ * Recursive, and returned as `subdir/name.ext` — the same shape ComfyUI's own `/models`
1708
+ * reports, so a graph that names `subdir/name.safetensors` keeps working and a dropdown
1709
+ * shows the nested file where it actually lives.
1710
+ *
1711
+ * **Only actual weights are listed.** An earlier version said "every extension, because a
1712
+ * filter would hide real files" — and then `grounding-dino/` offered
1713
+ * `GroundingDINO_SwinB.cfg.py`, `bert-base-uncased/` offered `vocab.txt` and
1714
+ * `tokenizer_config.json`, and `configs/` offered a dozen `.yaml` sampling configs. Those
1715
+ * are not models: they are code and configuration that sit NEXT TO the weights, and picking
1716
+ * one fails at load time. A whitelist of weight extensions is the honest rule.
1717
+ */
1718
+ async modelsInFolder(folder) {
1719
+ const name = String(folder ?? '').trim();
1720
+ // A folder name is one path segment: reject anything that could escape the root.
1721
+ if (name === '' || name === '.' || name === '..' || /[\\/]/.test(name)) return [];
1722
+ const found = new Set();
1723
+ for (const root of this.localModelRoots()) {
1724
+ const dir = join(root, name);
1725
+ const walk = (current, prefix, depth) => {
1726
+ // A model tree is shallow; the cap stops a symlink loop from hanging the request.
1727
+ if (depth > 6) return;
1728
+ let entries;
1729
+ try {
1730
+ entries = readdirSync(current, { withFileTypes: true });
1731
+ } catch {
1732
+ return;
1733
+ }
1734
+ for (const entry of entries) {
1735
+ if (entry.isDirectory()) {
1736
+ /**
1737
+ * Skip the auxiliary trees that hold assets rather than weights.
1738
+ *
1739
+ * `bert-base-uncased/` is a tokenizer directory (config.json +
1740
+ * vocab.txt); walking into it produced two "models" that are plainly
1741
+ * configuration.
1742
+ */
1743
+ if (/^(\.git|__pycache__)$/i.test(entry.name)) continue;
1744
+ walk(join(current, entry.name), `${prefix}${entry.name}/`, depth + 1);
1745
+ continue;
1746
+ }
1747
+ if (!entry.isFile()) continue;
1748
+ /**
1749
+ * ComfyUI's own placeholder markers, and anything that is not weights.
1750
+ *
1751
+ * Every model folder ships a zero-byte file named like
1752
+ * `put_esrgan_and_other_upscale_models_here`; it is documentation, not a
1753
+ * model. And a `.py` / `.json` / `.yaml` / `.txt` next to the weights is
1754
+ * code or configuration — `GroundingDINO_SwinB.cfg.py` is the reported case.
1755
+ */
1756
+ if (/^put_.*_here$/i.test(entry.name)) continue;
1757
+ if (!MODEL_FILE_EXT.test(entry.name)) continue;
1758
+ found.add(`${prefix}${entry.name}`);
1759
+ }
1760
+ };
1761
+ walk(dir, '', 0);
1762
+ }
1763
+ return [...found].sort();
1764
+ }
1765
+
1766
+ async modelInventory() {
1767
+ /**
1768
+ * Everything here comes from the LOCAL DISK, not from the instances.
1769
+ *
1770
+ * The instance's `/models` was the old source and it was wrong three ways at once: it
1771
+ * omits folders `folder_paths` never registered (`grounding-dino`, `insightface`,
1772
+ * `reactor`, `yolo`), it answers per instance so a shared models directory came back
1773
+ * duplicated, and it fails outright when the rig is asleep — which is precisely when
1774
+ * someone is in the workbench fixing a graph.
1775
+ *
1776
+ * One `readdir` per folder answers the same question locally and completely.
1777
+ */
1778
+ const FOLDERS = await this.modelFolders();
1779
+ const roots = this.localModelRoots();
1780
+ if (roots.length === 0) {
1781
+ return { ok: false, error: '没有可读的本地模型目录(请在实例里设置 modelsRoot 或启动脚本)', folders: [], instances: [] };
1782
+ }
1783
+ const folders = [];
1784
+ const enabledIds = (this.deps.settings?.()?.instances ?? [])
1785
+ .filter((row) => row !== null && typeof row === 'object' && row.enabled !== false)
1786
+ .map((row) => String(row.id ?? ''))
1787
+ .filter((id) => id !== '')
1788
+ .sort();
1789
+ for (const [name, label] of FOLDERS) {
1790
+ const items = await this.modelsInFolder(name);
1791
+ // An empty section is pure noise: the operator cannot pick anything from it.
1792
+ if (items.length === 0) continue;
1793
+ folders.push({
1794
+ name,
1795
+ label,
1796
+ items,
1797
+ /**
1798
+ * Which instance(s) would load these files. With one shared local `models`
1799
+ * directory every enabled instance can, so this stays a plain list of ids
1800
+ * rather than an attribution guess.
1801
+ */
1802
+ instances: enabledIds,
1803
+ });
1804
+ }
1805
+ folders.sort((left, right) => right.items.length - left.items.length);
1806
+ return {
1807
+ ok: true,
1808
+ source: 'disk',
1809
+ roots,
1810
+ instance: null,
1811
+ instances: enabledIds.map((id) => ({ id, label: id })),
1812
+ failed: [],
1813
+ folders,
1814
+ total: folders.reduce((sum, folder) => sum + folder.items.length, 0),
1815
+ };
1816
+ }
1817
+
1818
+ /**
1819
+ * Stream one output file from whichever instance holds it.
1820
+ *
1821
+ * The pool knows where a run landed; for a filename with no prompt id the
1822
+ * instances are tried in order. Range is forwarded so a phone can scrub a
1823
+ * video preview instead of downloading it whole.
1824
+ */
1825
+ async proxyMedia(url, request, response) {
1826
+ const filename = url.searchParams.get('file') ?? url.searchParams.get('filename') ?? '';
1827
+ if (filename === '') return sendJson(response, 400, { error: 'file is required' });
1828
+ const subfolder = url.searchParams.get('subfolder') ?? '';
1829
+ const type = url.searchParams.get('type') ?? 'output';
1830
+ const query = new URLSearchParams({ filename, subfolder, type }).toString();
1831
+ const method = request.method === 'HEAD' ? 'HEAD' : 'GET';
1832
+ const ordered = [];
1833
+ const promptId = url.searchParams.get('prompt_id');
1834
+ if (promptId !== null && promptId !== '') {
1835
+ const task = this.deps.pool.tasks.get(promptId);
1836
+ const runtime = task?.backendId ? this.deps.instances.get(task.backendId) : undefined;
1837
+ if (runtime !== undefined) ordered.push(runtime);
1838
+ }
1839
+ for (const runtime of this.deps.instances.enabled()) {
1840
+ if (!ordered.includes(runtime)) ordered.push(runtime);
1841
+ }
1842
+ let lastError = '没有实例持有这个文件';
1843
+ for (const runtime of ordered) {
1844
+ try {
1845
+ const stream = await this.deps.pool.backend(runtime).openView(query, method, request.headers.range);
1846
+ const head = {
1847
+ 'content-type': stream.headers.get('content-type') ?? 'application/octet-stream',
1848
+ 'accept-ranges': 'bytes',
1849
+ 'cache-control': 'private, max-age=600',
1850
+ };
1851
+ if (url.searchParams.get('download') === '1') {
1852
+ // A download is the same bytes with a filename, so the file keeps
1853
+ // its name instead of becoming what the browser would invent.
1854
+ head['content-disposition'] = `attachment; filename="${filename.replace(/["\\]/g, '_')}"`;
1855
+ }
1856
+ for (const name of ['content-length', 'content-range']) {
1857
+ const value = stream.headers.get(name);
1858
+ if (value !== null) head[name] = value;
1859
+ }
1860
+ response.writeHead(stream.status, head);
1861
+ if (method === 'HEAD' || stream.body === null) {
1862
+ response.end();
1863
+ return undefined;
1864
+ }
1865
+ // Same reason as the gateway relay: the upstream body carries an abort
1866
+ // timer, and an abandoned `/media` (thumbnail scrolled away, console
1867
+ // closed while saving) must not surface as a host-killing exception.
1868
+ pipeGuarded(stream.body, response);
1869
+ return undefined;
1870
+ } catch (error) {
1871
+ lastError = errorMessage(error);
1872
+ }
1873
+ }
1874
+ return sendJson(response, 404, { error: lastError });
1875
+ }
1876
+
1877
+ /** Everything the console polls: instances, pool, leases. */
1878
+ statePayload(session) {
1879
+ const status = this.deps.gateway.statusPayload();
1880
+ const tier = this.tierOf(session);
1881
+ const defaultHeads = this.deps.settings().promptHeads ?? {};
1882
+ return {
1883
+ ok: true,
1884
+ user: { name: session.user, role: session.role, tier },
1885
+ canBridge: session.role === 'admin',
1886
+ gateway: { port: this.deps.gateway.port, listening: this.deps.gateway.listening },
1887
+ assistant: this.deps.settings().remote?.assistant !== false,
1888
+ /** Which knowledge base the assistant reads, so the page can show it. */
1889
+ promptKb: { kbName: this.deps.settings().promptKb?.kbName ?? '' },
1890
+ /**
1891
+ * Which prompt-engineering skill the assistant follows.
1892
+ *
1893
+ * Reported so the console can say "按技能 X 出词" instead of leaving the operator
1894
+ * to guess whether the rules are actually in play. Cheap: it is the configured
1895
+ * name, not a disk read.
1896
+ */
1897
+ promptSkill: {
1898
+ name: this.deps.settings().promptSkill?.name ?? '',
1899
+ references: this.deps.settings().promptSkill?.includeReferences !== false,
1900
+ },
1901
+ // The template for new workflows, not the heads in force: those travel
1902
+ // with each workflow, because only the workflow knows whether a graph
1903
+ // takes text at all.
1904
+ defaultHeads: { positive: defaultHeads.positive ?? '', negative: defaultHeads.negative ?? '' },
1905
+ dynamic: status.dynamic,
1906
+ counters: status.counters,
1907
+ estimate: status.estimate,
1908
+ // Only this account's own tasks. The pool is shared, but the console is
1909
+ // not: seeing other people's queued work (and its workflow names) is the
1910
+ // same disclosure as seeing their history.
1911
+ tasks: status.tasks.filter((task) => {
1912
+ const live = this.deps.pool.tasks.get(task.id);
1913
+ if (live === undefined) return false;
1914
+ if (live.extraData?.remoteUser !== session.user) return false;
1915
+ return tier === 'nsfw' ? true : visibleTo(tier, classifyRun(live));
1916
+ }),
1917
+ instances: status.instances.map((entry) => ({
1918
+ id: entry.id,
1919
+ label: entry.label,
1920
+ healthy: entry.healthy,
1921
+ enabled: entry.enabled,
1922
+ version: entry.version,
1923
+ devices: entry.devices,
1924
+ speed: entry.speed,
1925
+ maxConcurrent: entry.maxConcurrent,
1926
+ bridgeLock: this.deps.pool.bridgeLockOf(entry.id),
1927
+ })),
1928
+ bridges: [...this.bridges.entries()].map(([token, lease]) => ({
1929
+ token: token.slice(0, 6),
1930
+ instanceId: lease.instanceId,
1931
+ user: lease.user,
1932
+ url: `/bridge/${token}/`,
1933
+ })),
1934
+ };
1935
+ }
1936
+
1937
+ /**
1938
+ * Build the prompt for one workflow, apply the console's edits, and hand it to
1939
+ * the pool. The reply is the gateway prompt id — polling /api/task/<id> follows.
1940
+ */
1941
+ async generate(body, session) {
1942
+ const workflowId = String(body.workflowId ?? '');
1943
+ const tier = this.tierOf(session);
1944
+ // Generating right after an import must work in the same session that sees the
1945
+ // new row; resolve() reads from the in-memory list.
1946
+ await this.deps.library.reloadIfChanged();
1947
+ const record = this.deps.library.get(workflowId);
1948
+ if (record !== undefined && !visibleTo(tier, classifyWorkflow(record).tier)) {
1949
+ throw new Error('当前账号的等级看不到这个工作流');
1950
+ }
1951
+ /**
1952
+ * Gate the PROMPT, not just the workflow.
1953
+ *
1954
+ * A SFW account could reach an adult result two ways before this: by typing an adult
1955
+ * prompt into a shared SFW workflow (the graph is safe, the text is not), or by
1956
+ * putting the words in `layers` rather than `texts`. This checks every string the
1957
+ * request would write into the graph — the two the console sends — and refuses the
1958
+ * run rather than silently sanitising it: a silently cleaned prompt produces a
1959
+ * picture the person did not ask for, which is worse than a clear refusal.
1960
+ */
1961
+ if (tier !== 'nsfw') {
1962
+ const wanted = [];
1963
+ for (const edit of Array.isArray(body.texts) ? body.texts : []) wanted.push(String(edit?.value ?? ''));
1964
+ for (const layer of Array.isArray(body.layers) ? body.layers : []) {
1965
+ wanted.push(String(layer !== null && typeof layer === 'object' ? layer?.value ?? '' : layer ?? ''));
1966
+ }
1967
+ // Per-node layers are a separate array: a SFW account must not be able to put an
1968
+ // adult tag into the negative encoder's 排除项 layer just because the scan only
1969
+ // looked at the legacy field.
1970
+ for (const entry of Array.isArray(body.layered) ? body.layered : []) {
1971
+ for (const value of Array.isArray(entry?.values) ? entry.values : []) {
1972
+ wanted.push(String(value !== null && typeof value === 'object' ? value?.value ?? '' : value ?? ''));
1973
+ }
1974
+ }
1975
+ wanted.push(String(body.prompt ?? ''));
1976
+ const hit = wanted.map((text) => detectAdult(text)).find((row) => row.hit === true);
1977
+ if (hit !== undefined) {
1978
+ throw new Error(`当前账号是全年龄等级,提示词里的「${hit.matched}」属于成人内容,已被拦截。`);
1979
+ }
1980
+ }
1981
+ /**
1982
+ * The operator's declared prompt roles for THIS workflow, or null when inferred.
1983
+ *
1984
+ * Read once here and threaded through every place a role decides which encoder gets
1985
+ * written — layered positive, per-run head override, and the first-positive fallback.
1986
+ * Reading it per call site would let two of them disagree on a graph with several
1987
+ * text nodes, which is exactly the case this declaration exists for.
1988
+ *
1989
+ * The declaration is resolved AFTER `resolved` because it comes off the record:
1990
+ * reading `resolved.record` up here was a temporal-dead-zone throw on every call
1991
+ * ("Cannot access 'resolved' before initialization"), not an undefined read.
1992
+ */
1993
+ const objectInfo = await this.objectInfo();
1994
+ const resolved = this.deps.library.resolve(workflowId, objectInfo);
1995
+ const prompt = resolved.api;
1996
+ /**
1997
+ * Roles, and the per-node prompt surface built from them.
1998
+ *
1999
+ * `promptNodes` says which nodes are positive, which negative and which 不参与;
2000
+ * `nodeHeads` carries each node's own standing head. `boxes` is the SAME list the
2001
+ * console renders, so a box shown and the encoder written cannot be different nodes.
2002
+ */
2003
+ const recordTexts = resolved.record === undefined
2004
+ ? []
2005
+ : promptTargets(prompt, normalizePromptNodes(resolved.record.promptNodes) ?? null);
2006
+ const boxes = promptBoxes(recordTexts, resolved.record?.nodeHeads ?? null);
2007
+ /**
2008
+ * Which inputs this workflow must write as real booleans.
2009
+ *
2010
+ * Persisted on the record at save time (see library.put → booleanKeys). It is read
2011
+ * here, not from a live object_info call, because a run must not depend on the
2012
+ * instance answering a second request — and because the parameter edit loop below has
2013
+ * to know the answer before it writes.
2014
+ */
2015
+ const declaredBooleans = new Set(
2016
+ Array.isArray(resolved.record?.booleanKeys) ? resolved.record.booleanKeys.map(String) : [],
2017
+ );
2018
+ /**
2019
+ * Drop the folder annotation ComfyUI's frontend leaves on filenames.
2020
+ *
2021
+ * A graph saved with a dropdown pick stores `photo.png [output]`; the bracket is a UI
2022
+ * label, not part of the name. Submitted as-is the instance answers "Invalid image
2023
+ * file", and the slot was not even recognised as an image input. Cleaning every string
2024
+ * input costs one pass and fixes both.
2025
+ */
2026
+ for (const node of Object.values(prompt ?? {})) {
2027
+ for (const [key, value] of Object.entries(node.inputs ?? {})) {
2028
+ if (typeof value !== 'string') continue;
2029
+ if (!/\s*\[[^\]]*\]\s*$/.test(value)) continue;
2030
+ node.inputs[key] = stripNameAnnotation(value);
2031
+ }
2032
+ }
2033
+ /**
2034
+ * Fill in required inputs that the stored graph predates.
2035
+ *
2036
+ * A custom node can gain a REQUIRED input at any time — this happened for real:
2037
+ * `LoraLoaderVanilla` added `append_loraname_if_empty`, and every existing workflow
2038
+ * using it started failing with `required_input_missing` even though nothing about
2039
+ * the workflow had changed. The prompt is rejected before it runs, so the operator
2040
+ * sees "生成失败" with no way to fix it from the console: the missing input is not a
2041
+ * parameter anyone can edit, it simply is not in the JSON.
2042
+ *
2043
+ * Only MISSING keys are touched, and only with the node's own declared default. An
2044
+ * input already present keeps its value — this fills gaps, it never overrides.
2045
+ */
2046
+ const filled = [];
2047
+ for (const [nodeId, node] of Object.entries(prompt ?? {})) {
2048
+ const spec = objectInfo?.[node.class_type];
2049
+ if (spec === undefined) continue;
2050
+ node.inputs = node.inputs ?? {};
2051
+ for (const [name, decl] of Object.entries(spec.input?.required ?? {})) {
2052
+ if (name in node.inputs) continue;
2053
+ const meta = Array.isArray(decl) ? decl[1] : undefined;
2054
+ const type = Array.isArray(decl) ? decl[0] : decl?.type;
2055
+ // A link-shaped input (MODEL/CLIP/IMAGE...) cannot be defaulted — wiring it to
2056
+ // a constant would produce a graph the instance rejects for a different reason.
2057
+ if (typeof type === 'string' && /^[A-Z_]+$/.test(type) && !['STRING', 'INT', 'FLOAT', 'BOOLEAN'].includes(type)) continue;
2058
+ if (meta === undefined || !('default' in meta)) continue;
2059
+ node.inputs[name] = meta.default;
2060
+ filled.push(`#${nodeId}.${name}`);
2061
+ }
2062
+ }
2063
+ if (filled.length > 0) {
2064
+ this.deps.log('info', `filled missing defaults: ${filled.join(', ')}`);
2065
+ }
2066
+ /**
2067
+ * Rename stored input keys to the names the INSTANCE currently uses.
2068
+ *
2069
+ * A custom node can rename an input between versions. `TagsSelector` used to take
2070
+ * `civitai_tags_list` and `Selector`; this build takes `tags_list` and `selector`. The
2071
+ * stored workflow keeps the old spellings, so the prompt was rejected with
2072
+ * `required_input_missing: tags_list` while the link sat right there under a name the
2073
+ * instance no longer reads.
2074
+ *
2075
+ * Only when the match is unambiguous: the stored key is absent from the spec, and
2076
+ * exactly ONE spec key equals it case-insensitively or differs only by a recognised
2077
+ * prefix. Two candidates means "not the same input", and guessing would silently wire
2078
+ * the wrong thing.
2079
+ */
2080
+ const renamed = [];
2081
+ for (const [nodeId, node] of Object.entries(prompt ?? {})) {
2082
+ const spec = objectInfo?.[node.class_type];
2083
+ if (spec === undefined) continue;
2084
+ const known = new Set([
2085
+ ...Object.keys(spec.input?.required ?? {}),
2086
+ ...Object.keys(spec.input?.optional ?? {}),
2087
+ ]);
2088
+ const lower = new Map([...known].map((name) => [name.toLowerCase(), name]));
2089
+ for (const stored of Object.keys(node.inputs ?? {})) {
2090
+ if (known.has(stored)) continue;
2091
+ // `civitai_tags_list` → `tags_list`: strip a leading qualifier and retry.
2092
+ const tail = stored.replace(/^[a-z0-9]+_/i, '');
2093
+ const candidates = [
2094
+ lower.get(stored.toLowerCase()),
2095
+ known.has(tail) ? tail : undefined,
2096
+ lower.get(tail.toLowerCase()),
2097
+ ].filter((name) => name !== undefined);
2098
+ const unique = [...new Set(candidates)];
2099
+ if (unique.length !== 1) continue;
2100
+ const target = unique[0];
2101
+ if (target in node.inputs) continue;
2102
+ node.inputs[target] = node.inputs[stored];
2103
+ delete node.inputs[stored];
2104
+ renamed.push(`#${nodeId}.${stored}→${target}`);
2105
+ }
2106
+ }
2107
+ if (renamed.length > 0) {
2108
+ this.deps.log('info', `renamed inputs to match the instance: ${renamed.join(', ')}`);
2109
+ }
2110
+ /**
2111
+ * Repair a model value the node will not accept.
2112
+ *
2113
+ * A custom node can name its choices anything it likes: `GroundingDinoModelLoader`
2114
+ * wants `GroundingDINO_SwinB (938MB)` while the file on disk is
2115
+ * `groundingdino_swinb_cogcoor.pth`. A graph saved with the disk name is rejected with
2116
+ * `value_not_in_list` before anything runs, and nothing in the UI explained why — the
2117
+ * dropdown simply showed a value that was not one of the choices.
2118
+ *
2119
+ * Silently substituting a DIFFERENT model would change what the picture looks like, so
2120
+ * this only fires when the stored value maps onto exactly one declared choice by name
2121
+ * (case-insensitive, ignoring spaces/punctuation/extension). Anything fuzzier is left
2122
+ * alone: a wrong guess here is worse than a visible failure.
2123
+ */
2124
+ const repaired = [];
2125
+ for (const [nodeId, node] of Object.entries(prompt ?? {})) {
2126
+ for (const [input, value] of Object.entries(node.inputs ?? {})) {
2127
+ if (typeof value !== 'string' || value === '') continue;
2128
+ const declared = comboValuesOf(objectInfo, node.class_type, input);
2129
+ if (declared.length === 0 || declared.includes(value)) continue;
2130
+ /**
2131
+ * Normalise away the three things that differ between a disk filename and the
2132
+ * name a node shows: the parenthesised size, the extension, and punctuation.
2133
+ *
2134
+ * Order matters — the size is stripped FIRST. Running the extension strip
2135
+ * first matched the dot inside `(2.56GB)` and left a mangled tail that could
2136
+ * never equal a choice.
2137
+ */
2138
+ const norm = (text) => String(text)
2139
+ .replace(/\([^)]*\)/g, ' ')
2140
+ .replace(/\.[a-z0-9]+(\s|$)/gi, ' ')
2141
+ .toLowerCase()
2142
+ .replace(/[^a-z0-9]/g, '');
2143
+ const key = norm(value);
2144
+ if (key === '') continue;
2145
+ /**
2146
+ * Exact match first, then ONE-SIDED containment.
2147
+ *
2148
+ * `groundingdino_swinb_cogcoor.pth` normalises to `groundingdinoswinbcogcoor`,
2149
+ * while the node's choice is `groundingdinoswinb` — equal prefixes, not equal
2150
+ * strings, because the checkpoint's variant suffix (`cogcoor`) is not part of
2151
+ * the display name. Requiring exact equality meant this case was never fixed
2152
+ * and the workflow kept failing with `value_not_in_list`.
2153
+ *
2154
+ * Containment is only accepted when exactly ONE choice qualifies: two choices
2155
+ * sharing a prefix means the name does not identify the model, and guessing
2156
+ * would silently load the wrong one.
2157
+ */
2158
+ const exact = declared.filter((choice) => norm(choice) === key);
2159
+ const matches = exact.length > 0
2160
+ ? exact
2161
+ /**
2162
+ * Containment is ONE-DIRECTIONAL: the value may be a SUPERSET of a choice,
2163
+ * never a subset.
2164
+ *
2165
+ * The case this repairs is a checkpoint whose real name carries a suffix the
2166
+ * display name omits — `groundingdino_swinb_cogcoor` contains
2167
+ * `groundingdinoswinb`, so the value is longer and the choice is its prefix.
2168
+ * That direction is safe: the extra text identifies a variant, and the node
2169
+ * only knows the family name.
2170
+ *
2171
+ * The other direction is not. `v1` is contained in `v10`, and
2172
+ * `realesrganx4plus` is contained in `realesrganx4plusanime6b`. Allowing it
2173
+ * meant a stale filename silently became a DIFFERENT model — the anime
2174
+ * upscaler instead of the generic one, a v10 checkpoint instead of v1 — with
2175
+ * only a log line. A wrong guess here changes the picture, which is worse
2176
+ * than a visible failure that names the missing file.
2177
+ */
2178
+ : declared.filter((choice) => {
2179
+ const other = norm(choice);
2180
+ return other !== '' && key.includes(other) && other.length >= 4;
2181
+ });
2182
+ if (matches.length !== 1) continue;
2183
+ node.inputs[input] = matches[0];
2184
+ repaired.push(`#${nodeId}.${input}: ${value} → ${matches[0]}`);
2185
+ }
2186
+ }
2187
+ if (repaired.length > 0) {
2188
+ this.deps.log('warn', `repaired model values the node does not accept: ${repaired.join(', ')}`);
2189
+ }
2190
+ /**
2191
+ * Which SLOTS the console must never write per-run text into.
2192
+ *
2193
+ * Keyed by `nodeId::input`, not by node id. Roles are per-slot: on a multi-input node
2194
+ * (`StringFunction|pysssss` has text_a / text_b / text_c) the wired and empty slots are
2195
+ * always 不参与 while text_a is a real box. Keying this set by node id put node 15 into
2196
+ * it permanently, so every request naming `15` was skipped and the operator's text_a
2197
+ * content was silently discarded — the run succeeded, the picture came out, and the
2198
+ * prompt was simply not in it. That is the worst shape a bug can take.
2199
+ */
2200
+ const ignoredSlots = new Set(
2201
+ (recordTexts ?? [])
2202
+ .filter((row) => row.role === 'ignored')
2203
+ .map((row) => `${String(row.nodeId)}::${String(row.input)}`),
2204
+ );
2205
+ /**
2206
+ * A whole node is off limits only when EVERY one of its slots is 不参与.
2207
+ *
2208
+ * Without this, a request that names a node but no input (the legacy shape) would slip
2209
+ * past the slot check on a node the operator fully parked.
2210
+ */
2211
+ const slotsPerNode = new Map();
2212
+ for (const row of recordTexts ?? []) {
2213
+ const id = String(row.nodeId);
2214
+ const entry = slotsPerNode.get(id) ?? { total: 0, ignored: 0 };
2215
+ entry.total += 1;
2216
+ if (row.role === 'ignored') entry.ignored += 1;
2217
+ slotsPerNode.set(id, entry);
2218
+ }
2219
+ const fullyIgnored = (nodeId) => {
2220
+ const entry = slotsPerNode.get(nodeId);
2221
+ return entry !== undefined && entry.total > 0 && entry.ignored === entry.total;
2222
+ };
2223
+ /** The single positive / negative node a role-wide write should land on, or null. */
2224
+ const headOf = (role) => {
2225
+ /**
2226
+ * A LEGACY per-role head still wins when it names a node that has a box.
2227
+ *
2228
+ * Workflows saved before per-node heads existed carry `headNodes: {positive: '9'}`
2229
+ * — "the 正向 box lives on node 9". Ignoring it once the workbench switched to per-node
2230
+ * heads would silently move every layered/overridden write to the FIRST positive,
2231
+ * which on a two-encoder graph is a different node and a different picture. New
2232
+ * records never write this field, so it can only ever be a leftover to honour.
2233
+ */
2234
+ const legacy = String(resolved.record?.headNodes?.[role] ?? '');
2235
+ if (legacy !== '') {
2236
+ const match = boxes.find((row) => row.role === role && String(row.nodeId) === legacy);
2237
+ if (match !== undefined) return { nodeId: match.nodeId, input: match.input };
2238
+ }
2239
+ const box = boxes.find((row) => row.role === role);
2240
+ return box === undefined ? null : { nodeId: box.nodeId, input: box.input };
2241
+ };
2242
+
2243
+ // Main parameters, applied by node+input so a workflow with two samplers only
2244
+ // changes the one the person was looking at.
2245
+ for (const edit of Array.isArray(body.params) ? body.params : []) {
2246
+ const node = prompt[String(edit?.nodeId ?? '')];
2247
+ if (node === undefined || typeof node.inputs !== 'object') continue;
2248
+ const key = String(edit?.input ?? '');
2249
+ if (key === '' || !(key in node.inputs)) continue;
2250
+ /**
2251
+ * Type follows the DECLARATION first, then the current value.
2252
+ *
2253
+ * A switch input in these custom nodes stores the STRING "true" — ComfyUI accepts
2254
+ * `true` for a BOOLEAN input but the graph was saved with the string form, and
2255
+ * testing only `typeof node.inputs[key] === 'boolean'` therefore never fired. The
2256
+ * submitted prompt then carried `"true"` instead of `true`, which is the reported
2257
+ * 「开关参数提交时仍然按照字符串提交」.
2258
+ *
2259
+ * `booleanKeys` is the authority (from object_info, persisted on the record); the
2260
+ * typeof check stays as the second signal for graphs saved before that existed.
2261
+ */
2262
+ const isBoolean = typeof node.inputs[key] === 'boolean'
2263
+ || (declaredBooleans !== null && declaredBooleans.has(`${String(edit?.nodeId ?? '')}::${key}`));
2264
+ if (isBoolean) {
2265
+ node.inputs[key] = normalizeSwitchValue(edit.value);
2266
+ continue;
2267
+ }
2268
+ const value = edit.value;
2269
+ if (typeof value !== 'number' && typeof value !== 'string') continue;
2270
+ node.inputs[key] = value;
2271
+ }
2272
+
2273
+ /**
2274
+ * Per-run prompt text: APPENDED to the encoder, never substituted.
2275
+ *
2276
+ * The contract the operator settled on: the JSON body holds the standing head, and
2277
+ * everything typed at task time goes after it. So `append: true` joins with the
2278
+ * plugin's own comma rule instead of replacing — which also means an empty box is a
2279
+ * no-op rather than a way to blank the head.
2280
+ *
2281
+ * `append: false` (or absent) keeps the older replace semantics for callers that
2282
+ * really do mean "set this encoder to exactly this": scripts, the chain runner, and
2283
+ * the assistant's "覆盖" mode.
2284
+ */
2285
+ const texts = Array.isArray(body.texts) ? body.texts : [];
2286
+ for (const entry of texts) {
2287
+ const nodeId = String(entry.nodeId ?? '');
2288
+ if (!(nodeId in prompt)) continue;
2289
+ /**
2290
+ * Skip a write only when THIS SLOT is 不参与.
2291
+ *
2292
+ * A request that names an input is judged on `nodeId::input`; one that names only a
2293
+ * node (legacy) is judged on whether the whole node was parked. Blanket node-level
2294
+ * skipping silently dropped text_a on any node whose other inputs were wired or
2295
+ * empty — see the note on `ignoredSlots`.
2296
+ */
2297
+ const slotKey = typeof entry.input === 'string' && entry.input !== ''
2298
+ ? `${nodeId}::${entry.input}`
2299
+ : '';
2300
+ if (slotKey !== '' ? ignoredSlots.has(slotKey) : fullyIgnored(nodeId)) continue;
2301
+ if (typeof entry.value !== 'string') continue;
2302
+ if (entry.append === true) {
2303
+ const current = String(prompt[nodeId]?.inputs?.[String(entry.input ?? '')] ?? '');
2304
+ const extra = entry.value.trim();
2305
+ if (extra === '') continue;
2306
+ // Find the text key the entry means; fall back to the first string input,
2307
+ // which is what setText would have written anyway.
2308
+ const key = typeof entry.input === 'string' && entry.input in (prompt[nodeId].inputs ?? {})
2309
+ ? entry.input
2310
+ : Object.keys(prompt[nodeId]?.inputs ?? {}).find((name) => typeof prompt[nodeId].inputs[name] === 'string');
2311
+ if (key === undefined) continue;
2312
+ const base = String(prompt[nodeId].inputs[key] ?? '');
2313
+ prompt[nodeId].inputs[key] = joinPrompt(base, extra);
2314
+ continue;
2315
+ }
2316
+ setText(prompt, nodeId, entry.value, typeof entry.input === 'string' ? entry.input : '');
2317
+ }
2318
+ /**
2319
+ * Layered positive prompt: one box per declared layer, joined in array order by
2320
+ * the server so the console never has to know the separator or the skipping rules.
2321
+ *
2322
+ * `layers` here is the *content* ([{value}, …]), while the names come from the
2323
+ * workflow's stored definition — the request must not be able to rename layers,
2324
+ * only fill them. Filling the positive head encoder is the single place layered
2325
+ * input lands.
2326
+ *
2327
+ * "The positive head encoder" is the first positive BOX — the same list the console
2328
+ * renders — not "the first positive row in the JSON". On a graph with two positive
2329
+ * encoders those are different nodes, and on a graph where the first positive was
2330
+ * marked 不参与 the old rule wrote into a node the operator had removed from the form.
2331
+ */
2332
+ /**
2333
+ * Layered prompt, PER NODE.
2334
+ *
2335
+ * Every text node can carry its own layer list (面部 / 躯干 for the positive,
2336
+ * 排除项 / 画质 for the negative), and each node's layers are joined in array order by
2337
+ * the server so the console never has to know the separator or the skipping rules.
2338
+ *
2339
+ * Two request shapes are accepted:
2340
+ *
2341
+ * - `layered: [{nodeId, value}, …]` — the console's current form: the node says
2342
+ * where the text goes, so a negative encoder can be layered independently.
2343
+ * - `layers: [{value}, …]` — the legacy form, which meant "the positive encoder".
2344
+ * Kept so the chain runner and older consoles keep working; it lands on the first
2345
+ * positive BOX (not the first positive ROW — they differ once a node is 不参与).
2346
+ *
2347
+ * The level count is NOT taken from the request: a node is only written when the
2348
+ * workflow declares layers for it, so a stale console cannot invent a split.
2349
+ */
2350
+ const layered = Array.isArray(body.layered) ? body.layered : [];
2351
+ if (layered.length > 0) {
2352
+ for (const entry of layered) {
2353
+ const nodeId = String(entry?.nodeId ?? '');
2354
+ if (!(nodeId in prompt)) continue;
2355
+ // Layered writes pick the node's box below, so the guard is "the whole node is
2356
+ // parked" — a node with one live slot still has text to receive.
2357
+ if (fullyIgnored(nodeId)) continue;
2358
+ const values = Array.isArray(entry?.values)
2359
+ ? entry.values.map((row) => (row && typeof row === 'object' ? row.value : row))
2360
+ : [];
2361
+ const assembled = joinLayers(values);
2362
+ if (assembled === '') continue;
2363
+ const box = boxes.find((row) => row.nodeId === nodeId);
2364
+ if (box === undefined) continue;
2365
+ prompt[nodeId].inputs[box.input] = assembled;
2366
+ }
2367
+ }
2368
+ if (Array.isArray(body.layers) && body.layers.length > 0) {
2369
+ const assembled = joinLayers(body.layers.map((row) => (row && typeof row === 'object' ? row.value : row)));
2370
+ if (assembled !== '') {
2371
+ const head = headOf('positive');
2372
+ if (head !== null) prompt[head.nodeId].inputs[head.input] = assembled;
2373
+ }
2374
+ }
2375
+ if (body.prompt !== undefined && String(body.prompt) !== '') {
2376
+ // Convenience: the console's single text box fills the positive head encoder
2377
+ // when the user did not address a node explicitly.
2378
+ const head = headOf('positive');
2379
+ if (head !== null && texts.length === 0) prompt[head.nodeId].inputs[head.input] = String(body.prompt);
2380
+ }
2381
+ /**
2382
+ * Heads are the graph's own prompt text.
2383
+ *
2384
+ * They used to be a separate stored prefix, joined in front of the encoder value at
2385
+ * submit time. That made two copies of the same thing: the JSON said one prompt, the
2386
+ * `heads` block said another, and the picture used a third (head + body). Nobody
2387
+ * could tell which one to edit, and a workbench edit to the JSON left the prefix
2388
+ * behind. Now the text box IS the encoder value: what the person types is what runs.
2389
+ *
2390
+ * A request may still name `heads` explicitly — that is the console's per-run
2391
+ * override — and it replaces the whole positive/negative text rather than prefixing
2392
+ * it. Nothing is ever joined.
2393
+ */
2394
+ if (body?.heads !== undefined && body?.heads !== null && body.applyHeads !== false) {
2395
+ const override = {
2396
+ positive: typeof body.heads.positive === 'string' ? body.heads.positive : undefined,
2397
+ negative: typeof body.heads.negative === 'string' ? body.heads.negative : undefined,
2398
+ };
2399
+ /**
2400
+ * One node per role — the FIRST box of that role, i.e. the same node the console
2401
+ * would have shown first. Writing every positive encoder would be wrong precisely
2402
+ * when it matters: a second positive encoder carries its own standing text (a
2403
+ * refiner prompt, a style prefix), and overwriting it with the console's text is a
2404
+ * silent edit of something the person never saw.
2405
+ *
2406
+ * A node marked 不参与 has no box, so it is not a candidate at all — `headOf`
2407
+ * reads the box list, not the raw text rows.
2408
+ */
2409
+ for (const role of ['positive', 'negative']) {
2410
+ const wanted = role === 'negative' ? override.negative : override.positive;
2411
+ const target = headOf(role);
2412
+ if (wanted === undefined || target === null) continue;
2413
+ prompt[target.nodeId].inputs[target.input] = wanted;
2414
+ }
2415
+ }
2416
+ if (tier !== 'nsfw') {
2417
+ const hit = scanStrings(prompt);
2418
+ if (hit.hit === true) {
2419
+ throw new Error(`当前账号是全年龄等级,提示词里的「${hit.matched}」属于成人内容,已阻止提交`);
2420
+ }
2421
+ }
2422
+
2423
+ const images = Array.isArray(body.images) ? body.images : [];
2424
+ for (const entry of images) {
2425
+ const nodeId = String(entry.nodeId ?? '');
2426
+ const filename = String(entry.filename ?? '');
2427
+ const inputName = String(entry.input ?? '');
2428
+ if (filename === '') continue;
2429
+ if (nodeId === '') {
2430
+ /**
2431
+ * No node named: fill EVERY image input the graph has.
2432
+ *
2433
+ * This is the chain case. A chained step cannot know which node holds the
2434
+ * image — that depends on the workflow the operator dragged in — and the
2435
+ * caller is saying "use this image wherever this graph takes one". Silently
2436
+ * skipping it (the old behaviour for an empty id) made a chain look like it
2437
+ * ran while every step was actually text-only.
2438
+ */
2439
+ for (const target of imageTargetsOf(prompt)) setImage(prompt, target.nodeId, filename, target.input);
2440
+ continue;
2441
+ }
2442
+ if (!(nodeId in prompt)) continue;
2443
+ /**
2444
+ * A named `input` wins over the node: one node can hold several image inputs
2445
+ * (a LoadImage plus a mask loader on the same node type, or an inpaint graph with
2446
+ * two), and writing by node id alone would put the chosen photo into every image
2447
+ * slot on it. The console always sends the input name now; a chain or an older
2448
+ * client that does not still falls back to "every image input of that node".
2449
+ */
2450
+ setImage(prompt, nodeId, filename, inputName);
2451
+ }
2452
+ // The sampler reads the negative encoder through its `negative` link, so a
2453
+ // person editing the *text* of that encoder is enough — nothing else to do.
2454
+
2455
+ /**
2456
+ * Stamp the output filename with the content class.
2457
+ *
2458
+ * Every SaveImage gets a prefix of `sfw_` or `nsfw_`, decided once per run:
2459
+ *
2460
+ * - an adult workflow produces `nsfw_…`;
2461
+ * - a run that consumed an `nsfw_` image produces `nsfw_…` even from a safe
2462
+ * workflow, because image-to-image of adult material is still adult material —
2463
+ * a laundered filename would be the whole filter defeated by one round trip;
2464
+ * - everything else produces `sfw_…`.
2465
+ *
2466
+ * The prefix replaces whatever the workflow declared rather than being prepended to
2467
+ * it: two prefixes make the class ambiguous, which defeats the purpose.
2468
+ */
2469
+ const adultWorkflow = record !== undefined && classifyWorkflow(record).tier === 'nsfw';
2470
+ const adultInput = (Array.isArray(body.images) ? body.images : []).some(
2471
+ (entry) => /^nsfw/i.test(String(entry?.filename ?? '')),
2472
+ );
2473
+ const outputPrefix = adultWorkflow || adultInput ? 'nsfw' : 'sfw';
2474
+ for (const [nodeId, node] of Object.entries(prompt)) {
2475
+ if (!/SaveImage$/i.test(String(node?.class_type ?? ''))) continue;
2476
+ const original = String(node.inputs?.filename_prefix ?? '');
2477
+ // Keep the workflow's own naming after the class, so files stay recognisable:
2478
+ // nsfw_单张文生图_人物 → the operator still knows what made it.
2479
+ const stem = original.replace(/^(sfw|nsfw)[_-]*/i, '').replace(/[\\/:*?"<>|]/g, '_').slice(0, 40);
2480
+ node.inputs = { ...node.inputs, filename_prefix: stem === '' ? outputPrefix : `${outputPrefix}_${stem}` };
2481
+ void nodeId;
2482
+ }
2483
+
2484
+ /**
2485
+ * How many runs to queue from this click, 1–8.
2486
+ *
2487
+ * The pool would happily take 50, and that is exactly why the cap is small: these
2488
+ * land on one or two consumer GPUs, and a typo that queues fifty SDXL runs is a
2489
+ * half-day queue nobody can cancel one by one. Eight is "give me a few options",
2490
+ * which is what the feature is for; more is a batch job and belongs in a script.
2491
+ *
2492
+ * Each run is enqueued as its own task, so the pool schedules them independently —
2493
+ * no special multi-run state, and cancel/priority keep working per run.
2494
+ */
2495
+ const repeat = Math.min(8, Math.max(1, Math.floor(Number(body.repeat ?? 1)) || 1));
2496
+ /**
2497
+ * Seeds are resolved per run, inside the loop.
2498
+ *
2499
+ * Drawing one seed outside the loop and copying it would give `repeat` identical
2500
+ * pictures — the batch feature exists precisely to get different ones. A fixed seed
2501
+ * is the opposite intent, so it is read once and applied to every run of this click.
2502
+ *
2503
+ * Two sources, in order of authority:
2504
+ *
2505
+ * 1. `body.seed` — the console's request-level mode (random / fixed + value).
2506
+ * 2. a seed PARAMETER declared on the workflow, whose own `seedMode` says whether
2507
+ * that row is random or fixed. This is what makes the workbench's seed row work
2508
+ * without the console having to know anything about it.
2509
+ *
2510
+ * The graph is probed here rather than earlier because a workflow without any seed
2511
+ * input must not fail and must not grow one: applySeed writes only the inputs that
2512
+ * already exist.
2513
+ */
2514
+ const seedParam = (resolved.record?.paramPicks ?? []).find((pick) => normalizeParamKind(pick, pick.input) === 'seed');
2515
+ const seedSeed = body.seed !== undefined && body.seed !== null
2516
+ ? body.seed
2517
+ : (seedParam === undefined
2518
+ ? undefined
2519
+ : (seedParam.seedMode === 'fixed'
2520
+ ? { mode: 'fixed', value: seedParam.value }
2521
+ : { mode: 'random' }));
2522
+ const wantsSeed = seedSeed !== undefined;
2523
+ const graphHasSeed = hasSeed(prompt);
2524
+ const extraData = {
2525
+ workflowName: resolved.record.name,
2526
+ workflowId: resolved.record.id,
2527
+ remoteUser: session.user,
2528
+ tier: classifyWorkflow(resolved.record).tier,
2529
+ };
2530
+ const replies = [];
2531
+ const seeds = [];
2532
+ for (let index = 0; index < repeat; index += 1) {
2533
+ // A fresh deep copy per run: the pool mutates the prompt it owns
2534
+ // (node errors, injected values), and sharing one object across runs
2535
+ // would let run 2 see run 1's edits.
2536
+ const runPrompt = JSON.parse(JSON.stringify(prompt));
2537
+ let usedSeed = null;
2538
+ if (wantsSeed && graphHasSeed) {
2539
+ const resolvedSeed = seedForRun(seedSeed);
2540
+ applySeed(runPrompt, resolvedSeed.seed);
2541
+ usedSeed = resolvedSeed;
2542
+ seeds.push(resolvedSeed.seed);
2543
+ }
2544
+ replies.push(
2545
+ this.deps.pool.enqueue({
2546
+ prompt: runPrompt,
2547
+ client_id: `remote-${session.user}`,
2548
+ priority: Number(body.priority ?? 0),
2549
+ extra_data: { ...extraData, runIndex: index + 1, runOf: repeat },
2550
+ ...(typeof body.endpoint === 'string' && body.endpoint !== '' ? { endpoint: body.endpoint } : {}),
2551
+ }),
2552
+ );
2553
+ }
2554
+ const reply = replies[0];
2555
+ // Durable record before returning: a browser that closes the tab mid-run
2556
+ // must still find the run after logging back in.
2557
+ //
2558
+ // `seed` travels with the record because it is the one fact about a run that cannot
2559
+ // be recovered afterwards: the picture is on disk, the workflow is unchanged, and the
2560
+ // seed that made that particular image exists nowhere else once this line is gone.
2561
+ // The asset library shows it per picture, which is what makes "reproduce that one"
2562
+ // possible without digging through the console log.
2563
+ for (const [index, entry] of replies.entries()) {
2564
+ await this.deps.runs.submit(session.user, {
2565
+ promptId: entry.prompt_id,
2566
+ name: resolved.record.name,
2567
+ workflowId: resolved.record.id,
2568
+ endpointId: null,
2569
+ state: 'queued',
2570
+ files: [],
2571
+ ...(seeds[index] === undefined ? {} : { seed: seeds[index] }),
2572
+ });
2573
+ }
2574
+ return {
2575
+ ok: true,
2576
+ ...reply,
2577
+ promptIds: replies.map((entry) => entry.prompt_id),
2578
+ repeat,
2579
+ /** Which seed each run actually got, in submission order. */
2580
+ seeds,
2581
+ seedMode: seeds.length === 0 ? null : (seedSeed?.mode === 'fixed' ? 'fixed' : 'random'),
2582
+ workflow: { id: resolved.record.id, name: resolved.record.name },
2583
+ warnings: resolved.warnings,
2584
+ };
2585
+ }
2586
+
2587
+ /** Multipart is not worth a dependency: one file per request, raw body. */
2588
+ async handleUpload(request, response, session) {
2589
+ const filename = String(request.headers['x-filename'] ?? 'upload.png');
2590
+ /**
2591
+ * Sanitise for FILESYSTEM danger only, not for being non-ASCII.
2592
+ *
2593
+ * The old rule `[^\w.\-] → _` turned `测试图片.png` into `____.png`: every Chinese
2594
+ * character became an underscore, so two different photos could collide on the same
2595
+ * name and the console would submit a filename the person never had. ComfyUI itself
2596
+ * stores Chinese and spaced filenames verbatim (verified on this instance), so the
2597
+ * only things that must go are path separators and characters Windows forbids.
2598
+ */
2599
+ const safe = filename
2600
+ .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
2601
+ .replace(/^\.+/, '')
2602
+ .trim()
2603
+ .slice(-80) || 'upload.png';
2604
+ void session;
2605
+ const chunks = [];
2606
+ let total = 0;
2607
+ for await (const chunk of request) {
2608
+ total += chunk.length;
2609
+ if (total > MAX_UPLOAD_BYTES) {
2610
+ return sendJson(response, 413, { ok: false, error: `文件超过 ${Math.round(MAX_UPLOAD_BYTES / 1048576)} MB` });
2611
+ }
2612
+ chunks.push(chunk);
2613
+ }
2614
+ if (chunks.length === 0) return sendJson(response, 400, { ok: false, error: '空文件' });
2615
+ /**
2616
+ * Screen the image before it reaches the instance.
2617
+ *
2618
+ * The rule is one rule: an SFW account may not upload adult images. It is checked
2619
+ * here, at the boundary, so a blocked file never lands in ComfyUI's input directory
2620
+ * where a later run could pick it up by name.
2621
+ *
2622
+ * NSFW accounts skip the call entirely rather than screening and ignoring the answer:
2623
+ * spending a model call to discard its result would slow every upload for nothing.
2624
+ */
2625
+ const tier = this.tierOf(session);
2626
+ const body = Buffer.concat(chunks);
2627
+ if (tier !== 'nsfw') {
2628
+ const mime = String(request.headers['content-type'] ?? 'image/png').split(';')[0].trim() || 'image/png';
2629
+ const verdict = await screenImage(this.deps.llm, this.deps.llmRoute, body, mime);
2630
+ if (verdict.nsfw) {
2631
+ this.deps.log('warn', `upload blocked as adult for ${String(session?.user ?? '')}: ${verdict.reason}`);
2632
+ return sendJson(response, 403, {
2633
+ ok: false,
2634
+ error: `这张图片被判为成人内容,当前账号不能上传:${verdict.reason}`,
2635
+ screened: true,
2636
+ });
2637
+ }
2638
+ }
2639
+ const primary = await this.deps.gateway.primary();
2640
+ if (primary === undefined) return sendJson(response, 503, { ok: false, error: '没有可用的 ComfyUI 实例' });
2641
+ let uploaded;
2642
+ try {
2643
+ uploaded = await uploadBytesToInput(primary.instance, safe, body);
2644
+ } catch (error) {
2645
+ return sendJson(response, 502, { ok: false, error: `实例上传失败: ${errorMessage(error).slice(0, 400)}` });
2646
+ }
2647
+ return sendJson(response, 200, { ok: true, ...uploaded, instanceId: primary.id });
2648
+ }
2649
+
2650
+ // --- bridge ----------------------------------------------------------
2651
+ leaseOf(pathname) {
2652
+ const match = /^\/bridge\/([^/]+)(\/.*)?$/.exec(pathname);
2653
+ if (match === null) return undefined;
2654
+ return { token: match[1], rest: match[2] ?? '/' };
2655
+ }
2656
+
2657
+ handleBridgeHttp(pathname, url, request, response) {
2658
+ const parsed = this.leaseOf(pathname);
2659
+ if (parsed === undefined) return sendJson(response, 404, { error: 'bad bridge path' });
2660
+ const lease = this.bridges.get(parsed.token);
2661
+ if (lease === undefined) return sendJson(response, 404, { error: '桥接已结束,请重新打开' });
2662
+ lease.lastSeen = Date.now();
2663
+ const runtime = this.deps.instances.get(lease.instanceId);
2664
+ if (runtime === undefined) return sendJson(response, 404, { error: '实例已解绑' });
2665
+ const base = new URL(runtime.instance.baseUrl);
2666
+ const targetPath = parsed.rest + url.search;
2667
+ const headers = { ...request.headers };
2668
+ delete headers.host;
2669
+ delete headers.cookie;
2670
+ /**
2671
+ * Strip the cross-origin markers the browser adds, because the bridge is a proxy.
2672
+ *
2673
+ * Measured on this machine: the same asset URL answers 200 with no Origin header and
2674
+ * **403 with one** — ComfyUI's CSRF guard treats any Origin as foreign. Every ES
2675
+ * module fetch and XHR from a bridged page carries one, so three bundles came back
2676
+ * 403 and the SPA could not boot.
2677
+ *
2678
+ * The bridge has already authenticated the caller (session cookie, checked before
2679
+ * this handler runs), so dropping Origin grants the upstream nothing it should not
2680
+ * have: the console is the client now, not a foreign page.
2681
+ */
2682
+ delete headers.origin;
2683
+ delete headers.referer;
2684
+ delete headers['sec-fetch-site'];
2685
+ delete headers['sec-fetch-mode'];
2686
+ delete headers['sec-fetch-dest'];
2687
+ delete headers['sec-fetch-user'];
2688
+ const upstream = httpRequest(
2689
+ { hostname: base.hostname, port: base.port, path: targetPath, method: request.method, headers },
2690
+ (reply) => {
2691
+ const outHeaders = { ...reply.headers };
2692
+ delete outHeaders['set-cookie'];
2693
+ const contentType = String(reply.headers['content-type'] ?? '');
2694
+ /**
2695
+ * JavaScript gets its module specifiers rewritten too.
2696
+ *
2697
+ * `import('/assets/late.js')` is evaluated by the browser's module loader,
2698
+ * not by fetch, so no runtime shim can intercept it: a 404 there leaves the
2699
+ * SPA half-booted. The only place to fix it is the bytes.
2700
+ *
2701
+ * The rewrite is deliberately narrow — a quoted root-absolute path that
2702
+ * looks like a file, is not already prefixed, and is not a protocol-relative
2703
+ * "//host". Anything wider starts corrupting string literals that merely
2704
+ * contain a slash.
2705
+ */
2706
+ if (/javascript|ecmascript/i.test(contentType)) {
2707
+ const chunks = [];
2708
+ reply.on('data', (chunk) => chunks.push(chunk));
2709
+ reply.on('end', () => {
2710
+ const source = Buffer.concat(chunks).toString('utf8');
2711
+ const body = Buffer.from(rewriteModuleSpecifiers(source, `/bridge/${parsed.token}/`), 'utf8');
2712
+ delete outHeaders['transfer-encoding'];
2713
+ delete outHeaders['content-encoding'];
2714
+ outHeaders['content-length'] = String(body.length);
2715
+ response.writeHead(reply.statusCode ?? 200, outHeaders);
2716
+ response.end(body);
2717
+ });
2718
+ reply.on('error', () => response.end());
2719
+ return;
2720
+ }
2721
+ /**
2722
+ * JSON too, for the same reason as JavaScript.
2723
+ *
2724
+ * The web app manifest names its icons with ROOT-ABSOLUTE paths
2725
+ * ("/assets/images/comfy-icon-192.png"). The browser resolves those against
2726
+ * the origin, so it asked the console for the icon and got a 404 — visible
2727
+ * in the report as "comfy-icon-192.png Not Found". No runtime shim can fix
2728
+ * this: the manifest is parsed by the browser, not by page JavaScript.
2729
+ *
2730
+ * Rewritten values are only strings that begin with a single "/", so numbers,
2731
+ * booleans and ordinary text are untouched.
2732
+ */
2733
+ if (/application\/json|\+json/i.test(contentType)) {
2734
+ const chunks = [];
2735
+ reply.on('data', (chunk) => chunks.push(chunk));
2736
+ reply.on('end', () => {
2737
+ const source = Buffer.concat(chunks).toString('utf8');
2738
+ const body = Buffer.from(rewriteJsonPaths(source, `/bridge/${parsed.token}/`), 'utf8');
2739
+ delete outHeaders['transfer-encoding'];
2740
+ delete outHeaders['content-encoding'];
2741
+ outHeaders['content-length'] = String(body.length);
2742
+ response.writeHead(reply.statusCode ?? 200, outHeaders);
2743
+ response.end(body);
2744
+ });
2745
+ reply.on('error', () => response.end());
2746
+ return;
2747
+ }
2748
+ if (contentType.includes('text/html')) {
2749
+ // The canvas is a full SPA: it fetches and opens sockets by path.
2750
+ // A <base> alone is not enough for root-absolute URLs, so the page
2751
+ // also gets a shim that re-prefixes fetch/WebSocket/EventSource.
2752
+ const chunks = [];
2753
+ reply.on('data', (chunk) => chunks.push(chunk));
2754
+ reply.on('end', () => {
2755
+ const html = Buffer.concat(chunks).toString('utf8');
2756
+ const patched = injectBridgeShim(html, `/bridge/${parsed.token}/`);
2757
+ const body = Buffer.from(patched, 'utf8');
2758
+ // Node re-frames the body: the upstream may have used chunked
2759
+ // encoding, and keeping that header while also claiming a length
2760
+ // hands the browser framing bytes instead of HTML.
2761
+ delete outHeaders['transfer-encoding'];
2762
+ delete outHeaders['content-encoding'];
2763
+ outHeaders['content-length'] = String(body.length);
2764
+ response.writeHead(reply.statusCode ?? 200, outHeaders);
2765
+ response.end(body);
2766
+ });
2767
+ reply.on('error', () => response.end());
2768
+ return;
2769
+ }
2770
+ response.writeHead(reply.statusCode ?? 502, outHeaders);
2771
+ // Same class of bug as the media proxies: an http IncomingMessage has
2772
+ // no 'error' listener here, so an upstream reset mid-body (the canvas
2773
+ // WS dying as the tab closes) would surface as an uncaughtException.
2774
+ pipeGuarded(reply, response);
2775
+ },
2776
+ );
2777
+ upstream.on('error', (error) => {
2778
+ if (!response.headersSent) sendJson(response, 502, { error: errorMessage(error) });
2779
+ else response.end();
2780
+ });
2781
+ // The request body is the caller's own upload stream. When the browser
2782
+ // cancels a large upload mid-flight, an unguarded pipe here raises on the
2783
+ // request object — and that has no error handler above it either.
2784
+ request.on('error', () => upstream.destroy());
2785
+ request.pipe(upstream);
2786
+ }
2787
+
2788
+ handleUpgrade(request, socket, head) {
2789
+ const pathname = (request.url ?? '/').split('?')[0];
2790
+ const parsed = this.leaseOf(pathname);
2791
+ if (parsed === undefined) {
2792
+ socket.destroy();
2793
+ return;
2794
+ }
2795
+ const lease = this.bridges.get(parsed.token);
2796
+ if (lease === undefined) {
2797
+ socket.destroy();
2798
+ return;
2799
+ }
2800
+ lease.lastSeen = Date.now();
2801
+ const runtime = this.deps.instances.get(lease.instanceId);
2802
+ if (runtime === undefined) {
2803
+ socket.destroy();
2804
+ return;
2805
+ }
2806
+ const base = new URL(runtime.instance.baseUrl);
2807
+ // WebSocket needs the raw framing preserved, so this is a byte tunnel with
2808
+ // the request line rewritten, not a message-level proxy.
2809
+ const upstream = connect(Number(base.port), base.hostname, () => {
2810
+ const headerLines = [`GET ${parsed.rest} HTTP/1.1`];
2811
+ for (const [name, value] of Object.entries(request.headers)) {
2812
+ const lower = name.toLowerCase();
2813
+ if (lower === 'host') continue;
2814
+ /**
2815
+ * The WebSocket handshake must look same-origin to the instance.
2816
+ *
2817
+ * ComfyUI answers 101 only when Origin matches its own host:port; anything
2818
+ * else is 403 and the socket never opens. The bridged page's Origin is the
2819
+ * CONSOLE, so the canvas could never connect — which is exactly what a
2820
+ * broken bridge looks like from the browser: a shell that loads and then
2821
+ * does nothing.
2822
+ *
2823
+ * Dropping it is safe: the caller's session cookie was already verified
2824
+ * before this handler ran, so the tunnel is authorised at this layer.
2825
+ */
2826
+ if (lower === 'origin' || lower === 'referer') continue;
2827
+ if (Array.isArray(value)) for (const item of value) headerLines.push(`${name}: ${item}`);
2828
+ else headerLines.push(`${name}: ${value}`);
2829
+ }
2830
+ headerLines.push(`host: ${base.host}`);
2831
+ upstream.write(`${headerLines.join('\r\n')}\r\n\r\n`);
2832
+ if (head !== undefined && head.length > 0) upstream.write(head);
2833
+ upstream.pipe(socket);
2834
+ socket.pipe(upstream);
2835
+ });
2836
+ const close = () => {
2837
+ upstream.destroy();
2838
+ socket.destroy();
2839
+ };
2840
+ upstream.on('error', close);
2841
+ socket.on('error', close);
2842
+ socket.on('close', close);
2843
+ }
2844
+
2845
+ sendPage(response, which, request) {
2846
+ const html = which === 'admin' ? ADMIN_HTML : which === 'login' ? LOGIN_HTML : CONSOLE_HTML;
2847
+ const layout = layoutFor(request?.headers?.['user-agent']);
2848
+ response.writeHead(200, {
2849
+ 'content-type': 'text/html; charset=utf-8',
2850
+ 'cache-control': 'no-store',
2851
+ 'referrer-policy': 'same-origin',
2852
+ 'x-content-type-options': 'nosniff',
2853
+ vary: 'User-Agent',
2854
+ });
2855
+ response.end(withLayout(html, layout));
2856
+ }
2857
+ }
2858
+
2859
+ // --- pages ---------------------------------------------------------------
2860
+ //
2861
+ // Both pages are single files with no build step and no external asset: the
2862
+ // only machine guaranteed to reach them is a phone on the same LAN.
2863
+
2864
+ /**
2865
+ * Rewrite a proxied ComfyUI page so its root-absolute requests come back here.
2866
+ *
2867
+ * The frontend loads `./assets/...` (fine with <base>), but also calls
2868
+ * `/api/...`, `/view`, `/ws`, `/object_info` and writes `<img src="/view?...">`.
2869
+ * A prefix on those must be applied in the page, because the browser resolves
2870
+ * them against this origin — which is the console, not the instance.
2871
+ */
2872
+ /**
2873
+ * The handful of inputs worth showing as controls: sampler steps/cfg/sampler/
2874
+ * seed/denoise, and the latent size/batch. Everything else belongs in the canvas.
2875
+ */
2876
+ const PARAM_KEYS = ['steps', 'cfg', 'sampler_name', 'scheduler', 'seed', 'denoise', 'width', 'height', 'batch_size'];
2877
+
2878
+ /**
2879
+ * The model folders an instance can expose, shared with the parameter editor.
2880
+ *
2881
+ * A model parameter names one of these folders and the console lists that folder's
2882
+ * files. Keeping the list in one place is what stops the editor from offering a folder
2883
+ * the model inventory never reads.
2884
+ */
2885
+ /**
2886
+ * The model folders a workflow can reference, as `[id, label]` pairs.
2887
+ *
2888
+ * The LABEL is the folder's own name now, not a Chinese translation. "底模" tells you nothing
2889
+ * when ComfyUI's own UI says `checkpoints` and the file on disk sits in `models/checkpoints` —
2890
+ * the operator's whole job here is matching what the instance actually reports, so the label
2891
+ * and the id are the same string.
2892
+ *
2893
+ * This list used to be 13 hardcoded entries, which is the other half of the "文件夹不全"
2894
+ * report: the instance exposes 39 folders (per /models), and anything outside the hardcoded
2895
+ * 13 could never be chosen — nor was it read by the inventory. `discoverModelFolders()` asks
2896
+ * the instance instead, and this constant is only the fallback for when it cannot.
2897
+ */
2898
+ const MODEL_FOLDERS = [
2899
+ ['checkpoints', 'checkpoints'],
2900
+ ['loras', 'loras'],
2901
+ ['vae', 'vae'],
2902
+ ['upscale_models', 'upscale_models'],
2903
+ ['clip', 'clip'],
2904
+ ['text_encoders', 'text_encoders'],
2905
+ ['clip_vision', 'clip_vision'],
2906
+ ['controlnet', 'controlnet'],
2907
+ ['embeddings', 'embeddings'],
2908
+ ['unet', 'unet'],
2909
+ ['diffusion_models', 'diffusion_models'],
2910
+ ['animatediff_models', 'animatediff_models'],
2911
+ ['ipadapter', 'ipadapter'],
2912
+ ['style_models', 'style_models'],
2913
+ ['gligen', 'gligen'],
2914
+ ['photomaker', 'photomaker'],
2915
+ ['hypernetworks', 'hypernetworks'],
2916
+ ['latent_upscale_models', 'latent_upscale_models'],
2917
+ ['model_patches', 'model_patches'],
2918
+ ['audio_encoders', 'audio_encoders'],
2919
+ ['background_removal', 'background_removal'],
2920
+ ['frame_interpolation', 'frame_interpolation'],
2921
+ ['geometry_estimation', 'geometry_estimation'],
2922
+ ['optical_flow', 'optical_flow'],
2923
+ ['detection', 'detection'],
2924
+ ['sams', 'sams'],
2925
+ ['ultralytics', 'ultralytics'],
2926
+ ['ultralytics_bbox', 'ultralytics_bbox'],
2927
+ ['ultralytics_segm', 'ultralytics_segm'],
2928
+ ['facerestore_models', 'facerestore_models'],
2929
+ ['rmbg', 'rmbg'],
2930
+ ['unet_gguf', 'unet_gguf'],
2931
+ ['clip_gguf', 'clip_gguf'],
2932
+ ['diffusers', 'diffusers'],
2933
+ ['vae_approx', 'vae_approx'],
2934
+ ['LLM', 'LLM'],
2935
+ ];
2936
+
2937
+ export { MODEL_FOLDERS };
2938
+
2939
+ /**
2940
+ * Parameters a person can turn for one workflow.
2941
+ *
2942
+ * Two sources, merged: the well-known sampler/latent keys (derived from the graph, so they
2943
+ * always exist), plus whatever the operator pinned by hand. The pinning exists because
2944
+ * `PARAM_KEYS` is a fixed list and real graphs carry knobs outside it — a ControlNet
2945
+ * strength, a LoRA weight, a `batch_size` on a node whose name is not ours. Without pins,
2946
+ * "add a key parameter" was impossible: the list was whatever the code knew about.
2947
+ *
2948
+ * A pinned entry is `{nodeId, input}`; the value still comes from the graph, so a pin
2949
+ * cannot invent a value the graph does not carry.
2950
+ */
2951
+ /**
2952
+ * The parameters a workflow exposes, merged from the graph and the operator's declarations.
2953
+ *
2954
+ * `hidden` is the third input and it is load-bearing: a row the operator deleted in the
2955
+ * workbench must not come back in the console. It used to, because this function only ever
2956
+ * looked at the graph and the pins — the delete wrote `paramHidden` on the workflow and
2957
+ * nothing read it, so "在编辑工作流删除的关键参数,在控制台页面还会出现". A pin for the
2958
+ * same key wins over the hidden entry, which is how "put it back" still works.
2959
+ */
2960
+ /**
2961
+ * Push bytes into an instance's `input/` folder and report the name a node should use.
2962
+ *
2963
+ * One implementation for both entry points — a browser upload and "use this asset as an
2964
+ * input" — because they must agree on two things that were each wrong once:
2965
+ *
2966
+ * - the name the INSTANCE assigns is authoritative (ComfyUI keeps Chinese filenames
2967
+ * verbatim); returning our own guess points at a file that was never created;
2968
+ * - the folder must travel with it. `/upload/image` answers `{name, subfolder}`, and
2969
+ * `LoadImage` expects `subfolder/name`; dropping the folder makes an existing file read
2970
+ * as "Invalid image file".
2971
+ *
2972
+ * Resolves to `{filename, name, subfolder}` where `filename` is what a node input takes.
2973
+ */
2974
+ function uploadBytesToInput(instance, filename, bytes) {
2975
+ const base = String(instance?.baseUrl ?? '').replace(/\/+$/, '');
2976
+ if (base === '') return Promise.reject(new Error('实例地址为空'));
2977
+ const boundary = `----cgw${randomBytes(6).toString('hex')}`;
2978
+ const head = Buffer.from(
2979
+ `--${boundary}\r\nContent-Disposition: form-data; name="image"; filename="${filename}"\r\n`
2980
+ + 'Content-Type: application/octet-stream\r\n\r\n',
2981
+ 'utf8',
2982
+ );
2983
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
2984
+ const payload = Buffer.concat([head, bytes, tail]);
2985
+ const target = new URL(`${base}/upload/image`);
2986
+ return new Promise((resolve, reject) => {
2987
+ const upstream = httpRequest(
2988
+ {
2989
+ hostname: target.hostname,
2990
+ port: target.port,
2991
+ path: `${target.pathname}?type=input&overwrite=true`,
2992
+ method: 'POST',
2993
+ headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, 'content-length': payload.length },
2994
+ },
2995
+ (reply) => {
2996
+ const parts = [];
2997
+ reply.on('data', (chunk) => parts.push(chunk));
2998
+ reply.on('end', () => {
2999
+ const body = Buffer.concat(parts).toString('utf8');
3000
+ if ((reply.statusCode ?? 0) >= 400) {
3001
+ reject(new Error(`HTTP ${reply.statusCode} ${body.slice(0, 400)}`));
3002
+ return;
3003
+ }
3004
+ try {
3005
+ const parsed = JSON.parse(body);
3006
+ const name = typeof parsed.name === 'string' && parsed.name !== '' ? parsed.name : filename;
3007
+ const subfolder = typeof parsed.subfolder === 'string' ? parsed.subfolder.trim() : '';
3008
+ resolve({ filename: subfolder === '' ? name : `${subfolder}/${name}`, name, subfolder });
3009
+ } catch {
3010
+ resolve({ filename, name: filename, subfolder: '' });
3011
+ }
3012
+ });
3013
+ },
3014
+ );
3015
+ upstream.on('error', reject);
3016
+ upstream.end(payload);
3017
+ });
3018
+ }
3019
+
3020
+ /**
3021
+ * Extensions that hold model WEIGHTS.
3022
+ *
3023
+ * A folder like `grounding-dino/` contains `GroundingDINO_SwinB.cfg.py` next to the `.pth`, and
3024
+ * `bert-base-uncased/` is all `config.json` + `vocab.txt`. Those are code and configuration;
3025
+ * listing them as models invites someone to pick one and fail at load time.
3026
+ *
3027
+ * A whitelist rather than a blacklist: a new auxiliary file type appearing in a model folder is
3028
+ * far more likely than a new weight format nobody has told this list about.
3029
+ */
3030
+ const MODEL_FILE_EXT = /\.(safetensors|ckpt|pt|pth|bin|gguf|ggml|onnx|sft|pkl)$/i;
3031
+
3032
+ /**
3033
+ * The concrete choices a node declares for one input, or [] when it declares none.
3034
+ *
3035
+ * ComfyUI has shipped two shapes for a dropdown:
3036
+ *
3037
+ * old: ["a.png", "b.png"] // the list IS the declaration
3038
+ * new: ["COMBO", { "options": ["a.png", "b.png"] }] // wrapped one level deeper
3039
+ *
3040
+ * Both must be understood: reading only the old shape silently returns [] on a current build,
3041
+ * and then the caller falls back to a disk listing that may use entirely different names —
3042
+ * which is how `groundingdino_swinb_cogcoor.pth` got submitted to a node that only accepts
3043
+ * `GroundingDINO_SwinB (938MB)`.
3044
+ *
3045
+ * A declaration that is a bare type ("IMAGE", "STRING") has no choices and returns [].
3046
+ */
3047
+ function comboValuesOf(objectInfo, classType, inputName) {
3048
+ const spec = objectInfo?.[String(classType ?? '')];
3049
+ if (spec === undefined) return [];
3050
+ const decl = spec.input?.required?.[inputName] ?? spec.input?.optional?.[inputName];
3051
+ if (!Array.isArray(decl) || decl.length === 0) return [];
3052
+ if (Array.isArray(decl[0])) return decl[0].map(String);
3053
+ if (decl[0] === 'COMBO' && decl[1] !== null && typeof decl[1] === 'object' && Array.isArray(decl[1].options)) {
3054
+ return decl[1].options.map(String);
3055
+ }
3056
+ if (decl[0] !== null && typeof decl[0] === 'object' && Array.isArray(decl[0].options)) {
3057
+ return decl[0].options.map(String);
3058
+ }
3059
+ return [];
3060
+ }
3061
+
3062
+ function mainParams(api, picks = [], hidden = [], booleans = null) {
3063
+ const rows = [];
3064
+ const seen = new Set();
3065
+ const declared = Array.isArray(picks) ? picks : [];
3066
+ const byKey = new Map();
3067
+ for (const pick of declared) {
3068
+ const nodeId = String(pick?.nodeId ?? '');
3069
+ const input = String(pick?.input ?? '');
3070
+ if (nodeId === '' || input === '') continue;
3071
+ byKey.set(`${nodeId}::${input}`, pick);
3072
+ }
3073
+ const hiddenKeys = new Set(
3074
+ (Array.isArray(hidden) ? hidden : [])
3075
+ .map((row) => `${String(row?.nodeId ?? '')}::${String(row?.input ?? '')}`)
3076
+ .filter((key) => key !== '::'),
3077
+ );
3078
+ /**
3079
+ * The kind of one row, decided by the instance's declaration first.
3080
+ *
3081
+ * `booleans` is a Set of `nodeId::input` keys that `object_info` reports as BOOLEAN. It is
3082
+ * the authority, and it is what made the switch kind finally work: the stored graph holds
3083
+ * these as the STRING "true" (which ComfyUI accepts for those custom nodes), so a check on
3084
+ * `typeof value === 'boolean'` never fired — every real switch fell through to a text box
3085
+ * and was submitted as a string, which is exactly the reported failure.
3086
+ */
3087
+ const kindOf = (pick, input, value, key) => {
3088
+ const isBool = typeof value === 'boolean' || (booleans !== null && booleans.has(key));
3089
+ return normalizeParamKind(pick, input, isBool);
3090
+ };
3091
+
3092
+ for (const [nodeId, node] of Object.entries(api ?? {})) {
3093
+ const type = String(node?.class_type ?? '');
3094
+ for (const [input, value] of Object.entries(node?.inputs ?? {})) {
3095
+ /**
3096
+ * Derived rows are the well-known sampler keys, plus every boolean input.
3097
+ *
3098
+ * Booleans are included because a switch IS a knob — `tiled_decode` decides
3099
+ * whether an upscale tiles its VAE decode, and it used to be reachable only by
3100
+ * pinning it by hand. The whitelist cannot grow to cover them: their names are
3101
+ * whatever a custom node chose.
3102
+ */
3103
+ if (!PARAM_KEYS.includes(input) && typeof value !== 'boolean' && (booleans === null || !booleans.has(`${nodeId}::${input}`))) continue;
3104
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') continue;
3105
+ const key = `${nodeId}::${input}`;
3106
+ const pick = byKey.get(key);
3107
+ if (hiddenKeys.has(key) && pick === undefined) continue;
3108
+ const kind = kindOf(pick, input, value, key);
3109
+ const declaredValue = pick !== undefined && (typeof pick.value === 'number' || typeof pick.value === 'string' || typeof pick.value === 'boolean')
3110
+ ? pick.value
3111
+ : undefined;
3112
+ rows.push({
3113
+ // A declaration renames a derived row without removing it: the operator's
3114
+ // label beats the raw input key, which is what they wanted to fix.
3115
+ name: String(pick?.name ?? '').trim() || input,
3116
+ nodeId,
3117
+ input,
3118
+ value: kind === 'switch' ? normalizeSwitchValue(declaredValue ?? value) : (declaredValue ?? value),
3119
+ desc: String(pick?.desc ?? ''),
3120
+ // Four kinds: a switch is decided by the value's type, a seed by its input
3121
+ // name, a model by its declaration, everything else is a plain value.
3122
+ kind,
3123
+ folder: String(pick?.folder ?? ''),
3124
+ ...(kind === 'seed' ? { seedMode: pick?.seedMode === 'fixed' ? 'fixed' : 'random' } : {}),
3125
+ classType: type,
3126
+ declared: pick !== undefined,
3127
+ });
3128
+ seen.add(key);
3129
+ }
3130
+ }
3131
+
3132
+ // Declared entries whose input is outside the whitelist above — the reason declaring
3133
+ // exists at all: ControlNet strength, LoRA weight, a second sampler, a boolean flag.
3134
+ for (const [key, pick] of byKey) {
3135
+ if (seen.has(key)) continue;
3136
+ const node = api?.[pick.nodeId];
3137
+ if (node === undefined) continue;
3138
+ const graphValue = node.inputs?.[pick.input];
3139
+ if (typeof graphValue !== 'number' && typeof graphValue !== 'string' && typeof graphValue !== 'boolean') continue;
3140
+ const kind = kindOf(pick, pick.input, graphValue);
3141
+ const pickedValue = typeof pick.value === 'number' || typeof pick.value === 'string' || typeof pick.value === 'boolean'
3142
+ ? pick.value
3143
+ : graphValue;
3144
+ rows.push({
3145
+ name: String(pick.name ?? '').trim() || pick.input,
3146
+ nodeId: String(pick.nodeId),
3147
+ input: String(pick.input),
3148
+ value: kind === 'switch' ? normalizeSwitchValue(pickedValue) : pickedValue,
3149
+ desc: String(pick.desc ?? ''),
3150
+ kind,
3151
+ folder: String(pick.folder ?? ''),
3152
+ ...(kind === 'seed' ? { seedMode: pick.seedMode === 'fixed' ? 'fixed' : 'random' } : {}),
3153
+ classType: String(node.class_type ?? ''),
3154
+ declared: true,
3155
+ });
3156
+ seen.add(key);
3157
+ }
3158
+
3159
+ // Straw-poll order: sampler settings first, then the frame.
3160
+ const rank = (row) => {
3161
+ const order = ['steps', 'cfg', 'sampler_name', 'scheduler', 'seed', 'denoise', 'width', 'height', 'batch_size'];
3162
+ const index = order.indexOf(row.input);
3163
+ return index < 0 ? order.length : index;
3164
+ };
3165
+ return rows.sort((left, right) => rank(left) - rank(right));
3166
+ }
3167
+
3168
+ /**
3169
+ * Image inputs of a graph, found without the instance's object_info.
3170
+ *
3171
+ * `imageTargets()` needs class declarations to know which inputs take an IMAGE, but a
3172
+ * chain step runs through `generate()` where object_info may be unavailable or stale. The
3173
+ * fallback rule is what a person would use reading the JSON: an input whose value looks
3174
+ * like a media filename, or whose name says image/mask/video, on a node that is not the
3175
+ * one producing them.
3176
+ */
3177
+ function imageTargetsOf(prompt) {
3178
+ const rows = [];
3179
+ for (const [nodeId, node] of Object.entries(prompt ?? {})) {
3180
+ const classType = String(node?.class_type ?? '');
3181
+ for (const [input, value] of Object.entries(node?.inputs ?? {})) {
3182
+ if (typeof value !== 'string') continue;
3183
+ const looksLikeFile = /\.(png|jpe?g|webp|bmp|gif|mp4|webm|mp3|wav|flac)$/i.test(value);
3184
+ const namedLikeImage = /^(image|image_path|mask|video|images)$/i.test(input);
3185
+ if (looksLikeFile || (namedLikeImage && value !== '')) rows.push({ nodeId, input, classType });
3186
+ }
3187
+ }
3188
+ return rows;
3189
+ }
3190
+
3191
+ /** Every media reference one history entry produced, flattened. */
3192
+ function outputsOf(entry) {
3193
+ const rows = [];
3194
+ for (const output of Object.values(entry?.outputs ?? {})) {
3195
+ for (const list of Object.values(output ?? {})) {
3196
+ if (!Array.isArray(list)) continue;
3197
+ for (const item of list) {
3198
+ if (item === null || typeof item !== 'object') continue;
3199
+ if (typeof item.filename !== 'string' || item.filename === '') continue;
3200
+ rows.push({ filename: item.filename, subfolder: item.subfolder ?? '', type: item.type ?? 'output' });
3201
+ }
3202
+ }
3203
+ }
3204
+ return rows;
3205
+ }
3206
+
3207
+ /**
3208
+ * Classify a merged-history entry that has no live pool task (older run, or one
3209
+ * started directly on an instance). Its graph is in `prompt[2]`.
3210
+ */
3211
+ function classifyHistoryEntry(entry) {
3212
+ const name = entry?.meta?.run?.name ?? entry?.meta?.workflowName ?? '';
3213
+ if (detectAdult(String(name)).hit) return 'nsfw';
3214
+ const prompt = Array.isArray(entry?.prompt) ? entry.prompt[2] : undefined;
3215
+ return scanStrings(prompt ?? {}).hit ? 'nsfw' : 'sfw';
3216
+ }
3217
+
3218
+ /** A history entry that predates the pool's task record: classify from its graph. */
3219
+ /**
3220
+ * Add the bridge prefix to root-absolute path values inside a JSON response.
3221
+ *
3222
+ * Only JSON *string values* that start with a single "/" are touched, which covers a web
3223
+ * app manifest's icon list without disturbing numbers, booleans, or prose. The rewrite is
3224
+ * done on the raw text with a narrow pattern rather than by parsing and re-serialising:
3225
+ * re-serialising would change key order and whitespace, and ComfyUI's own ETags would then
3226
+ * disagree with the bytes.
3227
+ *
3228
+ * A path is left alone when it is already prefixed, protocol-relative ("//host"), or does
3229
+ * not look like a request path (no character after the slash).
3230
+ */
3231
+ export function rewriteJsonPaths(source, prefix) {
3232
+ return source.replace(/"(\/(?!\/)[^"\\]*)"/g, (whole, path) => {
3233
+ if (path.startsWith(prefix)) return whole;
3234
+ return `"${prefix}${path.slice(1)}"`;
3235
+ });
3236
+ }
3237
+
3238
+ /**
3239
+ * Add the bridge prefix to module specifiers inside a JavaScript response.
3240
+ *
3241
+ * Only two shapes are touched, because anything broader corrupts ordinary strings:
3242
+ * - static `from "/x"` / `import "/x"`
3243
+ * - dynamic `import("/x")`
3244
+ *
3245
+ * A path that is already prefixed, protocol-relative ("//host"), or a bare specifier
3246
+ * ("three") is left alone.
3247
+ */
3248
+ export function rewriteModuleSpecifiers(source, prefix) {
3249
+ const fix = (path) => {
3250
+ if (!path.startsWith('/')) return path;
3251
+ if (path.startsWith('//')) return path;
3252
+ if (path.startsWith(prefix)) return path;
3253
+ return `${prefix}${path.slice(1)}`;
3254
+ };
3255
+ return source
3256
+ // from "/x" and from '/x'
3257
+ .replace(/\bfrom\s*(["'])(\/(?!\/)[^"']*)\1/g, (whole, quote, path) => `from ${quote}${fix(path)}${quote}`)
3258
+ // import "/x" (side-effect import)
3259
+ .replace(/\bimport\s*(["'])(\/(?!\/)[^"']*)\1/g, (whole, quote, path) => `import ${quote}${fix(path)}${quote}`)
3260
+ // import("/x")
3261
+ .replace(/\bimport\s*\(\s*(["'])(\/(?!\/)[^"']*)\1\s*\)/g, (whole, quote, path) => `import(${quote}${fix(path)}${quote})`);
3262
+ }
3263
+
3264
+ export function injectBridgeShim(html, prefix) {
3265
+ const shim = `<script>
3266
+ (function(){
3267
+ var P=${JSON.stringify(prefix)};
3268
+ function fix(u){
3269
+ if(typeof u!=='string'||u==='')return u;
3270
+ if(u.indexOf(P)===0)return u;
3271
+ // data:/blob:/about: are not requests; an absolute http URL is left alone —
3272
+ // the instance's own page has no reason to hold one, and rewriting a foreign
3273
+ // origin would break it rather than help.
3274
+ if(/^(data:|blob:|about:|javascript:|https?:)/i.test(u))return u;
3275
+ if(u.charAt(0)==='/')return P+u.slice(1);
3276
+ return u;
3277
+ }
3278
+ var of=window.fetch;
3279
+ window.fetch=function(input,init){
3280
+ if(typeof input==='string')input=fix(input);
3281
+ else if(input&&input.url){try{var c=new Request(fix(input.url),input);input=c;}catch(e){}}
3282
+ return of.call(this,input,init);
3283
+ };
3284
+ var OW=window.WebSocket;
3285
+ window.WebSocket=function(url,protocols){
3286
+ return protocols===undefined?new OW(fix(url)):new OW(fix(url),protocols);
3287
+ };
3288
+ window.WebSocket.prototype=OW.prototype;
3289
+ try{Object.assign(window.WebSocket,OW);}catch(e){}
3290
+ var OE=window.EventSource;
3291
+ if(OE){window.EventSource=function(url,cfg){return new OE(fix(url),cfg);};window.EventSource.prototype=OE.prototype;}
3292
+ var od=Object.getOwnPropertyDescriptor(HTMLImageElement.prototype,'src');
3293
+ if(od&&od.set){Object.defineProperty(HTMLImageElement.prototype,'src',{set:function(v){return od.set.call(this,fix(v));},get:od.get,configurable:true});}
3294
+ var ov=Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype,'src');
3295
+ if(ov&&ov.set){Object.defineProperty(HTMLVideoElement.prototype,'src',{set:function(v){return ov.set.call(this,fix(v));},get:ov.get,configurable:true});}
3296
+ var open=window.open;
3297
+ window.open=function(url){return open.call(window,typeof url==='string'?fix(url):url);};
3298
+ try{
3299
+ var base=document.createElement('base');
3300
+ base.href=P;
3301
+ (document.head||document.documentElement).prepend(base);
3302
+
3303
+ /**
3304
+ * Patch the asset URLs that are already in the document.
3305
+ *
3306
+ * A <base href> does NOT fix root-absolute URLs: "/assets/app.js" resolves against the
3307
+ * origin and drops the base path entirely, so the browser asked the CONSOLE for
3308
+ * /assets/app.js and got the console's 404 JSON instead of JavaScript. Nothing booted
3309
+ * and the page sat on its logo forever.
3310
+ *
3311
+ * Only root-absolute paths are rewritten. Relative ones already resolve against the
3312
+ * base, and absolute http(s) URLs belong to somebody else.
3313
+ */
3314
+ function fixAttr(selector, attr){
3315
+ try{
3316
+ var nodes=document.querySelectorAll(selector);
3317
+ for(var i=0;i<nodes.length;i+=1){
3318
+ var value=nodes[i].getAttribute(attr);
3319
+ if(value&&value.charAt(0)==='/'&&value.indexOf(P)!==0){
3320
+ nodes[i].setAttribute(attr,P+value.slice(1));
3321
+ }
3322
+ }
3323
+ }catch(e){}
3324
+ }
3325
+ fixAttr('script[src]','src');
3326
+ fixAttr('link[href]','href');
3327
+ fixAttr('img[src]','src');
3328
+ fixAttr('source[src]','src');
3329
+ fixAttr('video[src]','src');
3330
+ fixAttr('audio[src]','src');
3331
+ }catch(e){}
3332
+ })();
3333
+ </script>`;
3334
+ /**
3335
+ * Rewrite root-absolute asset URLs in the markup too, not only at runtime.
3336
+ *
3337
+ * The shim runs after parsing has begun, so `<script type="module" src="/assets/app.js">`
3338
+ * may already have been fetched — and a module script that 404s is not retried. Fixing
3339
+ * the HTML is the difference between "the bundle loads" and "the SPA spins forever".
3340
+ */
3341
+ const rewrite = (markup) => markup.replace(
3342
+ /\b(src|href)="(\/(?!\/)[^"]*)"/gi,
3343
+ (whole, attr, value) => (value.startsWith(prefix) ? whole : `${attr}="${prefix}${value.slice(1)}"`),
3344
+ );
3345
+ const withPrefix = rewrite(shim);
3346
+ if (/<head[^>]*>/i.test(html)) return rewrite(html.replace(/<head([^>]*)>/i, (match) => `${match}${withPrefix}`));
3347
+ return `${withPrefix}${rewrite(html)}`;
3348
+ }