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/README.md +504 -2
- package/client.js +2817 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/index.js +960 -0
- package/lib/apikeys.js +250 -0
- package/lib/assets.js +234 -0
- package/lib/auth.js +315 -0
- package/lib/blender.js +644 -0
- package/lib/chain.js +161 -0
- package/lib/comfy.js +204 -0
- package/lib/config.js +377 -0
- package/lib/gateway.js +974 -0
- package/lib/http.js +119 -0
- package/lib/imagesize.js +92 -0
- package/lib/instances.js +554 -0
- package/lib/library.js +1209 -0
- package/lib/models.js +322 -0
- package/lib/nodeapi.js +491 -0
- package/lib/nodeclient.js +143 -0
- package/lib/pages.js +5788 -0
- package/lib/pool.js +591 -0
- package/lib/promptassist.js +1077 -0
- package/lib/promptlibrary.js +92 -0
- package/lib/remote.js +3348 -0
- package/lib/runindex.js +126 -0
- package/lib/seed.js +118 -0
- package/lib/tagdata.json +1 -0
- package/lib/tiers.js +283 -0
- package/lib/tools.js +230 -0
- package/lib/viewport.js +134 -0
- package/lib/workflows.js +472 -0
- package/lib/ws.js +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
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
|
+
}
|