dsh-multimodal-node 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/library.js ADDED
@@ -0,0 +1,1209 @@
1
+ /**
2
+ * The gateway's own workflow library.
3
+ *
4
+ * ComfyUI 0.3.77 does not expose a user workflow directory on every install
5
+ * (`/userdata?dir=workflows` answers 404 until the frontend saves something),
6
+ * so the remote console cannot rely on reading the instance's files. The
7
+ * library therefore lives here, in the plugin data directory, in one JSON file:
8
+ *
9
+ * { id, name, kind, graph, api, targets, updatedAt, nodeCount }
10
+ *
11
+ * `graph` is what the person edited (UI graph or API map), `api` is the converted
12
+ * prompt cached at save time and re-converted on demand if the instance learned
13
+ * new nodes. Keeping both means an edit is cheap and a broken conversion is
14
+ * visible immediately instead of at generation time.
15
+ */
16
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { randomBytes } from 'node:crypto';
19
+ import { graphToApi, imageTargets, promptTargets } from './workflows.js';
20
+ import { classifyWorkflow, visibleTo } from './tiers.js';
21
+
22
+ export class WorkflowLibrary {
23
+ constructor(dataDir) {
24
+ this.file = join(dataDir, 'workflows.json');
25
+ this.items = [];
26
+ /** `mtimeMs:size` of the file this.items was read from; see reloadIfChanged. */
27
+ this.stamp = undefined;
28
+ }
29
+
30
+ async init() {
31
+ await mkdir(join(this.file, '..'), { recursive: true });
32
+ try {
33
+ const raw = await readFile(this.file, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ this.items = Array.isArray(parsed) ? parsed : [];
36
+ } catch {
37
+ this.items = [];
38
+ await this.save();
39
+ }
40
+ // Record what was read, so reloadIfChanged() can tell "same file" from "a
41
+ // different file" without re-parsing on every render.
42
+ try {
43
+ const info = await stat(this.file);
44
+ this.stamp = `${info.mtimeMs}:${info.size}`;
45
+ } catch {
46
+ this.stamp = 'missing';
47
+ }
48
+ }
49
+
50
+ async save() {
51
+ const temp = `${this.file}.tmp`;
52
+ await writeFile(temp, `${JSON.stringify(this.items, null, 2)}\n`, 'utf8');
53
+ await rename(temp, this.file);
54
+ // Our own write must not look like an external change on the next read.
55
+ try {
56
+ const info = await stat(this.file);
57
+ this.stamp = `${info.mtimeMs}:${info.size}`;
58
+ } catch {
59
+ this.stamp = undefined;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The shared library as one tier sees it.
65
+ *
66
+ * No owner filter: every account browses the same list. What a workflow's `owner` still
67
+ * records is who created it (useful when auditing an import), not who may see it.
68
+ *
69
+ * A workflow the account may not see is omitted entirely rather than shown disabled —
70
+ * a greyed-out row still leaks the name, and "NSFW_xxx" as a title is the disclosure.
71
+ */
72
+ list(tier = 'sfw') {
73
+ return this.items
74
+ .filter((item) => visibleTo(tier, classifyWorkflow(item).tier))
75
+ .map((item) => ({
76
+ id: item.id,
77
+ name: item.name,
78
+ kind: item.kind ?? 'graph',
79
+ tier: classifyWorkflow(item).tier,
80
+ category: item.category ?? 't2i',
81
+ nodeCount: item.nodeCount ?? null,
82
+ updatedAt: item.updatedAt ?? null,
83
+ /** Provenance only; it does not gate anything any more. */
84
+ owner: item.owner ?? null,
85
+ }));
86
+ }
87
+
88
+ /** The library as the panel sees it: everything, with its classification. */
89
+ listAll() {
90
+ return this.items.map((item) => ({
91
+ id: item.id,
92
+ name: item.name,
93
+ kind: item.kind ?? 'graph',
94
+ tier: classifyWorkflow(item).tier,
95
+ reason: classifyWorkflow(item).reason,
96
+ nodeCount: item.nodeCount ?? null,
97
+ updatedAt: item.updatedAt ?? null,
98
+ }));
99
+ }
100
+
101
+ get(id) {
102
+ return this.items.find((item) => item.id === id);
103
+ }
104
+
105
+ /**
106
+ * Create or update one workflow.
107
+ * @param {{id?:string, name:string, graph:object, objectInfo?:object}} input
108
+ */
109
+ async put(input) {
110
+ const name = String(input.name ?? '').trim();
111
+ if (name === '') throw new Error('工作流需要名字');
112
+ const graph = input.graph;
113
+ if (graph === null || typeof graph !== 'object') throw new Error('工作流内容必须是 JSON 对象');
114
+ /**
115
+ * An API-format workflow is already the prompt; it must not be dragged through
116
+ * the UI→API converter.
117
+ *
118
+ * The converter needs `objectInfo` (the instance's node table), which a bulk
119
+ * import does not have. Feeding a graph that is *already* `{id: {class_type,
120
+ * inputs}}` to it with `objectInfo` absent used to leave `api = null`, and every
121
+ * downstream consequence followed from that: targets were never computed (so
122
+ * the console showed no prompt boxes on a text-to-image workflow) and
123
+ * nodeCount was null (so the picker could not say how big it is).
124
+ *
125
+ * Detection mirrors graphToApi's own test, so both agree on what "already API"
126
+ * means.
127
+ */
128
+ const looksApi =
129
+ !Array.isArray(graph.nodes) &&
130
+ Object.keys(graph).length > 0 &&
131
+ Object.values(graph).every((node) => node !== null && typeof node === 'object' && typeof node.class_type === 'string');
132
+ const conversion = looksApi
133
+ ? { prompt: graph, warnings: [] }
134
+ : input.objectInfo === undefined
135
+ ? { prompt: null, warnings: [] }
136
+ : graphToApi(graph, input.objectInfo);
137
+ if (conversion.prompt === null && input.objectInfo !== undefined && conversion.error !== undefined) {
138
+ throw new Error(`工作流无法转换:${conversion.error}`);
139
+ }
140
+ const api = conversion.prompt;
141
+ // Declared FIRST: everything below — targets, owner, heads, promptNodes — reads it,
142
+ // and a `const` referenced before its declaration line is a temporal-dead-zone throw
143
+ // ("Cannot access 'existing' before initialization"), not an undefined read.
144
+ const existing = input.id === undefined ? undefined : this.get(input.id);
145
+ const targets = api === null ? {} : {
146
+ texts: promptTargets(api, input.promptNodes ?? existing?.promptNodes ?? null),
147
+ images: imageTargets(api, input.objectInfo ?? {}),
148
+ };
149
+ // Ownership is what makes the remote console per-account: a workflow
150
+ // created from another person's session belongs to them and is invisible
151
+ // here. An update keeps the original owner even when someone else with
152
+ // permission edits it.
153
+ const owner = existing?.owner ?? (typeof input.owner === 'string' && input.owner !== '' ? input.owner : null);
154
+ /**
155
+ * Prompt heads are gone as a separate concept.
156
+ *
157
+ * The positive and negative prompt IS the encoder text in `api`, so there is no
158
+ * second place to store a prefix. The field is still accepted (an older client, or
159
+ * a script, may send it) but it is deliberately never applied at submit time —
160
+ * see the heads block in remote.js. Storing it at all would recreate the two-copy
161
+ * problem: JSON says one prompt, this says another, the picture uses whichever.
162
+ */
163
+ const heads = null;
164
+ /**
165
+ * Positive-prompt layers. Same "null means unchanged" rule as heads: a workflow
166
+ * that never had layers keeps none, and sending `[]` is how a person removes them.
167
+ *
168
+ * Kept as the FALLBACK for workflows written before per-node layers existed; see
169
+ * nodeLayers below.
170
+ */
171
+ const layers = normalizeLayers(input.layers) ?? existing?.layers ?? null;
172
+ /**
173
+ * Per-node prompt layers: {nodeId: [{name}, …]}.
174
+ *
175
+ * Each text node has its own list — a negative encoder wants 排除项 / 画质, not the
176
+ * positive's 面部 / 躯干. `{}` clears every per-node definition (and falls back to the
177
+ * workflow-level list above), which is how a person undoes the whole split.
178
+ */
179
+ const nodeLayers = normalizeNodeLayers(input.nodeLayers) ?? existing?.nodeLayers ?? null;
180
+ /**
181
+ * Extra parameters the operator pinned for this workflow.
182
+ *
183
+ * The derived list is a fixed whitelist (steps/cfg/seed/…); real graphs carry knobs
184
+ * outside it (ControlNet strength, LoRA weight, a second sampler). A pin names one
185
+ * node input and the console then offers it like any other parameter. Values still
186
+ * come from the graph, so a pin can never invent a value.
187
+ *
188
+ * Same null-means-unchanged rule as heads and layers, so `[]` is how a person
189
+ * removes the last pin.
190
+ */
191
+ const paramPicks = normalizeParamPicks(input.paramPicks) ?? existing?.paramPicks ?? null;
192
+ /**
193
+ * Derived parameters the operator un-associated.
194
+ *
195
+ * A stored list, unlike the derived rows themselves, because the removal has to
196
+ * survive a reopen: without it the row comes back on the next render and the delete
197
+ * button looks broken. It only affects what the FORM offers — the graph keeps the
198
+ * input, so the run still uses the body's value. Same null-means-unchanged rule.
199
+ */
200
+ const paramHidden = normalizeParamHidden(input.paramHidden) ?? existing?.paramHidden ?? null;
201
+ /**
202
+ * Which encoder is the positive one and which is the negative one, when the operator
203
+ * says so.
204
+ *
205
+ * Role used to be inferred: trace the sampler's `positive` / `negative` links, else
206
+ * look for "neg" in the name. That works for the two-encoder graphs everybody starts
207
+ * with and fails for the graphs that actually need it — a双 CLIP graph feeding one
208
+ * sampler, an SDXL pair, an extra refiner encoder — where several text nodes are all
209
+ * "positive" by wiring and the console then showed the same role twice. The operator
210
+ * knows which slot is which; this is where that answer is stored.
211
+ *
212
+ * Only the role is stored, never text: values stay in the graph.
213
+ */
214
+ const promptNodes = normalizePromptNodes(input.promptNodes) ?? existing?.promptNodes ?? null;
215
+ /**
216
+ * Which text node carries each head — see normalizeHeadNodes.
217
+ *
218
+ * Stored next to promptNodes and validated against it: the head declaration may only
219
+ * name a node the operator already assigned that role, so "节点 7 是正向" and "正向
220
+ * 表头在节点 7" cannot drift apart.
221
+ */
222
+ const headNodes = normalizeHeadNodes(input.headNodes, promptNodes) ?? existing?.headNodes ?? null;
223
+ /**
224
+ * Per-node heads — the shape the workbench writes now.
225
+ *
226
+ * `normalizeNodeHeads` returns null for "not provided", so `?? existing` keeps the
227
+ * stored map on an unrelated save; sending `{}` is how a person clears all of them.
228
+ * The map only carries nodes the operator overrode; everything else reads the graph.
229
+ */
230
+ const nodeHeads = normalizeNodeHeads(input.nodeHeads) ?? existing?.nodeHeads ?? null;
231
+ const record = {
232
+ id: existing?.id ?? `wf-${randomBytes(4).toString('hex')}`,
233
+ name,
234
+ owner,
235
+ declared: input.declared === 'nsfw' ? 'nsfw' : undefined,
236
+ /**
237
+ * What kind of workflow this is, as the operator classified it.
238
+ *
239
+ * Separate from the tier: the tier is a visibility ceiling, the category is
240
+ * what the thing makes (image / video / audio, from text or from an image).
241
+ * It is declared, not guessed — a graph's node types do not reliably say
242
+ * whether the operator means text-to-image or image-to-image.
243
+ */
244
+ category: normalizeCategory(input.category) ?? existing?.category ?? 't2i',
245
+ heads,
246
+ layers,
247
+ nodeLayers,
248
+ paramPicks,
249
+ paramHidden,
250
+ /**
251
+ * Which `nodeId::input` keys the instance declares BOOLEAN.
252
+ *
253
+ * Persisted because the run path has no node table: the console builds its form
254
+ * from the stored record, and a switch that is not known to be a switch renders as
255
+ * a text box and submits a string — the reported「开关参数提交时仍然按照字符串提交」.
256
+ *
257
+ * Recomputed on every save that brings an `objectInfo`; kept from the previous
258
+ * record otherwise, so an edit made offline does not erase it.
259
+ */
260
+ booleanKeys: (() => {
261
+ const fresh = booleanInputsOf(api, input.objectInfo);
262
+ if (fresh.size > 0) return [...fresh].sort();
263
+ const previous = existing?.booleanKeys;
264
+ return Array.isArray(previous) ? previous : [];
265
+ })(),
266
+ promptNodes,
267
+ headNodes,
268
+ nodeHeads,
269
+ kind: Array.isArray(graph.nodes) ? 'graph' : 'api',
270
+ graph,
271
+ api,
272
+ targets,
273
+ warnings: conversion.warnings ?? [],
274
+ nodeCount: api === null ? null : Object.keys(api).length,
275
+ updatedAt: new Date().toISOString(),
276
+ };
277
+ if (existing === undefined) {
278
+ this.items.push(record);
279
+ } else {
280
+ this.items[this.items.indexOf(existing)] = record;
281
+ }
282
+ await this.save();
283
+ return this.describe(record);
284
+ }
285
+
286
+ /**
287
+ * Adopt workflows that predate ownership.
288
+ *
289
+ * A library seeded before the console became per-account has rows with no
290
+ * owner. Leaving them ownerless would make them invisible to everyone (the
291
+ * per-account filter would drop them), which is a data loss in effect, so they
292
+ * are assigned to the machine's owner on first start.
293
+ */
294
+ async claimOwnerless(owner) {
295
+ let claimed = 0;
296
+ for (const item of this.items) {
297
+ if (item.owner === null || item.owner === undefined) {
298
+ item.owner = owner;
299
+ claimed += 1;
300
+ }
301
+ }
302
+ if (claimed > 0) await this.save();
303
+ return claimed;
304
+ }
305
+
306
+ /** Drop every workflow one account created (called when the account goes). */
307
+ async dropOwner(owner) {
308
+ const before = this.items.length;
309
+ this.items = this.items.filter((item) => item.owner !== owner);
310
+ if (this.items.length !== before) await this.save();
311
+ return { ok: true, removed: before - this.items.length };
312
+ }
313
+
314
+ async remove(id) {
315
+ const before = this.items.length;
316
+ this.items = this.items.filter((item) => item.id !== id);
317
+ if (this.items.length === before) throw new Error(`没有工作流 ${id}`);
318
+ await this.save();
319
+ return { ok: true };
320
+ }
321
+
322
+ /**
323
+ * Re-read workflows.json from disk.
324
+ *
325
+ * The library is loaded once at startup, so anything that writes the file while
326
+ * the plugin is running (an import script, a bulk edit, a hand fix) is invisible
327
+ * to the console until the whole host restarts — and "Restart gateway" only
328
+ * restarts the listeners, it does not re-read state. That gap is what made ten
329
+ * freshly imported workflows show up on disk and nowhere in the UI.
330
+ *
331
+ * On-demand rather than a file watcher: the console already calls list() on every
332
+ * render, a stat() is far cheaper than an fs.watch registration per file, and it
333
+ * cannot miss a change that happened before the watcher attached.
334
+ */
335
+ async reloadIfChanged() {
336
+ let stamp;
337
+ try {
338
+ const info = await stat(this.file);
339
+ stamp = `${info.mtimeMs}:${info.size}`;
340
+ } catch {
341
+ // A missing file means an empty library, not an error to surface.
342
+ if (this.stamp !== 'missing') {
343
+ this.items = [];
344
+ this.stamp = 'missing';
345
+ return { reloaded: true, rows: 0 };
346
+ }
347
+ return { reloaded: false, rows: 0 };
348
+ }
349
+ if (this.stamp === stamp) return { reloaded: false, rows: this.items.length };
350
+ await this.init();
351
+ this.stamp = stamp;
352
+ return { reloaded: true, rows: this.items.length };
353
+ }
354
+
355
+ /**
356
+ * Build the runnable prompt for one workflow, applying the console's text and
357
+ * image edits, and re-converting when the cached API form is missing.
358
+ */
359
+ resolve(id, objectInfo) {
360
+ const record = this.get(id);
361
+ if (record === undefined) throw new Error(`没有工作流 ${id}`);
362
+ let api = record.api;
363
+ const warnings = [...(record.warnings ?? [])];
364
+ if (api === null || api === undefined) {
365
+ const converted = graphToApi(record.graph, objectInfo ?? {});
366
+ if (converted.prompt === null) throw new Error(converted.error ?? '工作流无法转换成 API 格式');
367
+ api = converted.prompt;
368
+ warnings.push(...converted.warnings);
369
+ }
370
+ return { record, api: structuredClone(api), warnings };
371
+ }
372
+
373
+ describe(record) {
374
+ const api = record.api;
375
+ // Recomputed on read rather than trusted from the saved record: target
376
+ // detection is pure and cheap, and a saved `targets` block would keep an
377
+ // older, wrong classification alive after a fix.
378
+ const classification = classifyWorkflow(record);
379
+ /**
380
+ * Roles stay as declared; the head declaration is reported separately.
381
+ *
382
+ * They answer different questions and must not be merged: `promptNodes` says "which
383
+ * nodes are positive / negative", `headNodes` says "which ONE of them the console's
384
+ * head box belongs to". Collapsing the second into the first would relabel the other
385
+ * positives as negatives on a two-encoder graph.
386
+ */
387
+ const promptNodes = normalizePromptNodes(record.promptNodes) ?? null;
388
+ const texts = api === null || api === undefined
389
+ ? (record.targets?.texts ?? [])
390
+ : promptTargets(api, promptNodes);
391
+ /**
392
+ * One entry per text node the console will actually show a box for.
393
+ *
394
+ * This is the console's whole prompt surface: a node the operator marked 不参与 is
395
+ * absent, and when every node is marked that way the list is empty — the console then
396
+ * renders nothing, which is the requested behaviour and cannot be expressed by
397
+ * `needsText` alone.
398
+ */
399
+ const boxes = promptBoxes(texts, record.nodeHeads ?? null, record.nodeLayers ?? null, record.layers ?? null);
400
+ return {
401
+ id: record.id,
402
+ name: record.name,
403
+ kind: record.kind,
404
+ owner: record.owner ?? null,
405
+ /**
406
+ * Whether this graph has anything to type into. A pure upscale, a
407
+ * face swap or an interpolation has no text node, so the console must
408
+ * hide its prompt section rather than show two empty boxes nobody can
409
+ * meaningfully fill. Marking every text node 不参与 is the same answer:
410
+ * no box, because a box nobody reads is worse than no box.
411
+ */
412
+ needsText: boxes.length > 0,
413
+ /**
414
+ * Per-node prompt surface: [{nodeId, input, role, classType, head, value, layers}].
415
+ *
416
+ * `layers` on each box is THAT node's own layering, empty when it has none — the
417
+ * console draws one layer input per entry under that box, so a negative encoder
418
+ * can have 排除项 / 画质 while the positive has 面部 / 躯干.
419
+ */
420
+ promptBoxes: boxes,
421
+ /** What it makes: t2i / i2i / t2v / i2v / t2a. */
422
+ category: record.category ?? 't2i',
423
+ heads: record.heads ?? null,
424
+ /**
425
+ * The workflow-level layer list, kept as the legacy fallback.
426
+ *
427
+ * Per-node layers live on each `promptBoxes` entry; this is what a workflow
428
+ * written before that change still carries, and `layersForNode` applies it to the
429
+ * node that has no entry of its own.
430
+ */
431
+ layers: Array.isArray(record.layers) ? record.layers : [],
432
+ /** Per-node layer definitions: {nodeId: [{name}]}. Null = none. */
433
+ nodeLayers: normalizeNodeLayers(record.nodeLayers) ?? null,
434
+ /** Extra parameters this workflow exposes beyond the derived whitelist. */
435
+ paramPicks: Array.isArray(record.paramPicks) ? record.paramPicks : [],
436
+ paramHidden: Array.isArray(record.paramHidden) ? record.paramHidden : [],
437
+ /** Operator-declared positive/negative encoder ids, or null when inferred. */
438
+ promptNodes,
439
+ /** Per-node head overrides: {nodeId: {input, value}}. Null = none. */
440
+ nodeHeads: normalizeNodeHeads(record.nodeHeads) ?? null,
441
+ /**
442
+ * Which text node carries each head, or null when it is "the first one with that
443
+ * role". The legacy per-role view; the console reads `promptBoxes` instead.
444
+ */
445
+ headNodes: normalizeHeadNodes(record.headNodes, promptNodes) ?? null,
446
+ /** Node titles, so the parameter list can be grouped by node NAME, not just id. */
447
+ nodeTitles: collectNodeTitles(record.graph, api),
448
+ /**
449
+ * Which node each head box addresses, resolved (legacy per-role view).
450
+ *
451
+ * The console sends per-run text with `{nodeId, input}` taken from here, so the
452
+ * box shown and the encoder written are the same node by construction — the
453
+ * declaration cannot be honoured by one and ignored by the other.
454
+ */
455
+ headTargets: headTargets(texts, record.headNodes ?? null),
456
+ tier: classification.tier,
457
+ tierReason: classification.reason,
458
+ nodeCount: record.nodeCount,
459
+ updatedAt: record.updatedAt,
460
+ warnings: record.warnings ?? [],
461
+ texts,
462
+ /**
463
+ * Image slots, RECOMPUTED on every read — never trusted from the record.
464
+ *
465
+ * This line used to be `record.targets?.images ?? []`, a snapshot taken when the
466
+ * workflow was saved. That made every improvement to the detector invisible on
467
+ * existing workflows: 去除衣物 kept reporting zero image inputs long after the
468
+ * annotation-stripping fix, because its stored snapshot was still the empty list
469
+ * computed by the old code. Detection is pure and cheap; recomputing is what makes
470
+ * a fix reach the workflows already in the library.
471
+ *
472
+ * `record.images` (an operator override) is honoured first if present, so an
473
+ * explicit declaration still wins over inference.
474
+ */
475
+ images: Array.isArray(record.images)
476
+ ? record.images
477
+ : (api === null || api === undefined ? (record.targets?.images ?? []) : imageTargets(api, record.objectInfo ?? {})),
478
+ nodes: api === null || api === undefined ? [] : Object.entries(api).map(([nodeId, node]) => ({
479
+ nodeId,
480
+ classType: node.class_type,
481
+ inputs: Object.keys(node.inputs ?? {}),
482
+ })),
483
+ };
484
+ }
485
+ }
486
+
487
+ /** The five categories a workflow can belong to. */
488
+ export const CATEGORIES = [
489
+ { id: 't2i', label: '文生图' },
490
+ { id: 'i2i', label: '图生图' },
491
+ { id: 't2v', label: '文生视频' },
492
+ { id: 'i2v', label: '图生视频' },
493
+ { id: 't2a', label: '文生音频' },
494
+ ];
495
+
496
+ const CATEGORY_IDS = new Set(CATEGORIES.map((entry) => entry.id));
497
+
498
+ /** Accept a category id, or null when the caller said nothing usable. */
499
+ export function normalizeCategory(value) {
500
+ const id = String(value ?? '').trim();
501
+ return CATEGORY_IDS.has(id) ? id : null;
502
+ }
503
+
504
+ /**
505
+ * One key parameter, as the operator declares it.
506
+ *
507
+ * Four fields, fixed, everywhere: the editor, the console, the API and storage all speak
508
+ * this shape. A parameter is a *named handle* on a node input, not the input itself —
509
+ * `nodeId` + `input` locate it, `name` is what the person sees, `value` is the default the
510
+ * console starts from, and `desc` explains what turning it does.
511
+ */
512
+ export const MAX_PARAM_PICKS = 40;
513
+
514
+ /** The canonical field list, exported so the UI and the parser cannot drift apart. */
515
+ export const PARAM_FIELDS = ['name', 'nodeId', 'value', 'desc'];
516
+
517
+ /**
518
+ * Which inputs on this graph are BOOLEAN, according to the instance's own node table.
519
+ *
520
+ * This is the authority for "is this a switch", and it had to come from somewhere: the stored
521
+ * graph holds `"true"` as a STRING (ComfyUI itself accepts that for these custom nodes), so
522
+ * deciding by `typeof value === 'boolean'` never fired and every real switch rendered as a
523
+ * plain text value. `object_info` says `["BOOLEAN", {...}]` for the same input, and that is
524
+ * the fact that actually governs what the instance will accept.
525
+ *
526
+ * Returns a Set of `nodeId::input` keys. An unknown class contributes nothing, so a graph
527
+ * referencing a node type this instance lacks stays fallible rather than guessed at.
528
+ */
529
+ export function booleanInputsOf(api, objectInfo) {
530
+ const out = new Set();
531
+ if (objectInfo === null || typeof objectInfo !== 'object') return out;
532
+ for (const [nodeId, node] of Object.entries(api ?? {})) {
533
+ const spec = objectInfo[node?.class_type];
534
+ if (spec === undefined) continue;
535
+ for (const bucket of [spec.input?.required, spec.input?.optional]) {
536
+ if (bucket === null || typeof bucket !== 'object') continue;
537
+ for (const [input, decl] of Object.entries(bucket)) {
538
+ const type = Array.isArray(decl) ? decl[0] : decl?.type;
539
+ if (String(type).toUpperCase() !== 'BOOLEAN') continue;
540
+ out.add(`${nodeId}::${input}`);
541
+ }
542
+ }
543
+ }
544
+ return out;
545
+ }
546
+
547
+ /**
548
+ * Accept a list of declared parameters, de-duplicated by node+input.
549
+ *
550
+ * `null` means "unchanged"; `[]` means "clear them". The older stored shape was just
551
+ * `{nodeId, input}`, so an entry without `name` is accepted and named after its input —
552
+ * otherwise every workflow written before this change would lose its parameters.
553
+ */
554
+
555
+ /**
556
+ * Accept a list of declared parameters, de-duplicated by node+input.
557
+ *
558
+ * `null` means "unchanged"; `[]` means "clear them". The older stored shape was just
559
+ * `{nodeId, input}`, so an entry without `name` is accepted and named after its input —
560
+ * otherwise every workflow written before this change would lose its parameters.
561
+ */
562
+ /**
563
+ * Resolve one parameter row's kind, keeping the four-way distinction.
564
+ *
565
+ * Seed wins over everything: it is decided by the input name, and a seed the operator
566
+ * mislabelled as a model would render a file picker for a number. Then an explicit
567
+ * declaration — "model", "seed" or "switch" — then plain value.
568
+ *
569
+ * `switch` is the fourth kind, and it is not a number with two values: ComfyUI validates a
570
+ * BOOLEAN input against the JSON type, so writing "true" (a string) or 1 instead of `true`
571
+ * gets the prompt rejected after it has already been queued. The console therefore renders
572
+ * it as a true/false picker and the server writes a real boolean.
573
+ */
574
+ export function normalizeParamKind(entry, input, isBoolean = false) {
575
+ const name = String(input ?? entry?.input ?? '');
576
+ if (/^(?:seed|noise_seed|rand_seed|random_seed)$/i.test(name)) return 'seed';
577
+ const declared = String(entry?.kind ?? '');
578
+ if (declared === 'model') return 'model';
579
+ if (declared === 'seed') return 'seed';
580
+ if (declared === 'switch') return 'switch';
581
+ /**
582
+ * A BOOLEAN input is a switch whether or not anyone declared it.
583
+ *
584
+ * Placed after the explicit declarations so a deliberate label still wins, but before the
585
+ * `value` fallback: the instance will only accept `true`/`false` here, so offering a text
586
+ * box can only ever produce a rejected prompt. `isBoolean` comes from `object_info`, not
587
+ * from the stored value's JS type — the stored value is a string for these nodes.
588
+ */
589
+ if (isBoolean === true) return 'switch';
590
+ return 'value';
591
+ }
592
+
593
+ /**
594
+ * A switch value, as a real boolean.
595
+ *
596
+ * The console sends a `<select>`'s value, which is the string "true" or "false"; a script
597
+ * may send 1/0 or a bare boolean. All four become `true` / `false` here, once, so nothing
598
+ * downstream has to guess. Anything unrecognised falls back to false rather than to a
599
+ * half-truth: "not clearly on" is off.
600
+ */
601
+ export function normalizeSwitchValue(value) {
602
+ if (value === true || value === 1) return true;
603
+ if (value === false || value === 0) return false;
604
+ const text = String(value ?? '').trim().toLowerCase();
605
+ if (text === 'true' || text === '1' || text === 'yes' || text === 'on') return true;
606
+ return false;
607
+ }
608
+
609
+ /**
610
+ * Node titles for one graph, keyed by node id.
611
+ *
612
+ * Parameter rows are grouped by node in both the editor and the console, and "节点 11" says
613
+ * far less than "节点 11 · 放大模型 (4x-UltraSharp)". The title lives in the UI graph as
614
+ * `node._meta.title`, and survivals of it are also read out of an API-format import so one
615
+ * map serves the whole form.
616
+ *
617
+ * Deliberately NOT stored inside `api`: `_meta` on a submitted prompt is tolerated by
618
+ * ComfyUI, but it is still an extra key travelling to every instance, and the title is a
619
+ * property of the FORM.
620
+ */
621
+ export function collectNodeTitles(graph, api = null) {
622
+ const titles = {};
623
+ for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
624
+ if (node === null || typeof node !== 'object') continue;
625
+ const id = String(node.id ?? '');
626
+ const title = typeof node?._meta?.title === 'string' ? node._meta.title.trim() : '';
627
+ if (id !== '' && title !== '') titles[id] = title.slice(0, 80);
628
+ }
629
+ for (const [id, node] of Object.entries(api ?? {})) {
630
+ if (titles[id] !== undefined) continue;
631
+ const title = typeof node?._meta?.title === 'string' ? node._meta.title.trim() : '';
632
+ if (title !== '') titles[id] = title.slice(0, 80);
633
+ }
634
+ return titles;
635
+ }
636
+
637
+ /**
638
+ * Un-associated derived parameters, as [{nodeId, input}].
639
+ *
640
+ * Deliberately narrow: no name, no value, no description. This list means exactly one
641
+ * thing — "do not offer this input in the form" — and anything more would invite storing a
642
+ * second copy of the value next to the one in the graph. `null` means unchanged, `[]` means
643
+ * "show everything again", which is how a person undoes a removal wholesale.
644
+ */
645
+ /**
646
+ * Operator-declared prompt roles: {positive: [nodeId], negative: [nodeId]}.
647
+ *
648
+ * Arrays rather than single ids because a real graph can have two positive encoders (SDXL's
649
+ * clip_l / clip_g pair, or a base + refiner pair). Duplicates collapse, blanks are dropped,
650
+ * and a node may not be in both lists — a node that is both positive and negative is a
651
+ * mistake in the form, not a state the graph can express.
652
+ */
653
+ export function normalizePromptNodes(value) {
654
+ if (value === null || value === undefined) return null;
655
+ if (typeof value !== 'object' || Array.isArray(value)) return null;
656
+ /**
657
+ * Entries are node ids or SLOT keys (`15::text_a`).
658
+ *
659
+ * A node may hold several string inputs, and each one needs its own role: keying only by
660
+ * node forced both of 3lora人物's StringFunction inputs to share a single answer, which is
661
+ * why the editor listed the node twice and neither row could be set independently.
662
+ */
663
+ const read = (rows) => {
664
+ if (!Array.isArray(rows)) return [];
665
+ const seen = new Set();
666
+ const out = [];
667
+ for (const row of rows) {
668
+ const id = String(row ?? '').trim();
669
+ if (id === '' || seen.has(id)) continue;
670
+ seen.add(id);
671
+ out.push(id);
672
+ }
673
+ return out;
674
+ };
675
+ const positive = read(value.positive);
676
+ const negative = read(value.negative).filter((id) => !positive.includes(id));
677
+ /**
678
+ * `ignored` names slots the operator parked ON PURPOSE.
679
+ *
680
+ * Without it the only way to express "off" was "absent from both lists" — and absence is
681
+ * indistinguishable from "never classified", so any slot holding text fell back to its
682
+ * inferred role and came back on after a reload. That made 不参与 one-way: you could switch
683
+ * a slot on but never off. A slot listed here stays 不参与 no matter what text it holds.
684
+ *
685
+ * Kept last in precedence: an explicit positive/negative wins, so a stale `ignored` entry
686
+ * left behind by an earlier save cannot fight a newer decision.
687
+ */
688
+ const ignored = read(value.ignored).filter((id) => !positive.includes(id) && !negative.includes(id));
689
+ /**
690
+ * `{positive: [], negative: []}` is a REAL answer, not "unchanged".
691
+ *
692
+ * It means "every text node is 不参与" — the operator wants a workflow that takes no
693
+ * prompt at all, and the console must then show no text box. Collapsing this to `null`
694
+ * (the old behaviour) made that state inexpressible: the form could never tell
695
+ * "nothing is assigned" from "nothing was ever said", so every node fell back to its
696
+ * inferred role and the boxes came back.
697
+ *
698
+ * The caller distinguishes the two: `null` = "not provided, keep whatever is stored",
699
+ * an object (even with empty arrays) = "this is the assignment".
700
+ */
701
+ return ignored.length === 0 ? { positive, negative } : { positive, negative, ignored };
702
+ }
703
+
704
+ /**
705
+ * Per-node prompt heads, as { "nodeId::input": text }.
706
+ *
707
+ * Why per SLOT and not per node: a node can hold several string inputs. `StringFunction|pysssss`
708
+ * has text_a / text_b / text_c, and a graph like 3lora人物 feeds two of them — so keying by
709
+ * nodeId alone made the second input OVERWRITE the first. The workbench showed one head box,
710
+ * the remaining input had no table of its own, and "两个字符串分两个表" was impossible.
711
+ *
712
+ * Keys are `nodeId::input`. An old record keyed by bare `nodeId` is still honoured: the
713
+ * `{input, value}` payload already carried the input name, so it reads back unambiguously and
714
+ * is rewritten under the slot key on the next save.
715
+ *
716
+ * This map only records where a slot's head STARTS, for the slots the operator changed away
717
+ * from the graph's own text. The authoritative value is still the graph.
718
+ *
719
+ * `null`/`undefined` = unchanged; `{}` = clear every override.
720
+ */
721
+ export function normalizeNodeHeads(value) {
722
+ if (value === null || value === undefined) return null;
723
+ if (typeof value !== 'object' || Array.isArray(value)) return null;
724
+ const out = {};
725
+ for (const [key, text] of Object.entries(value)) {
726
+ const rawKey = String(key ?? '').trim();
727
+ if (rawKey === '') continue;
728
+ if (text === null || text === undefined) continue;
729
+ if (typeof text !== 'object') continue;
730
+ /**
731
+ * Read the payload first: it names the input, which is what makes a legacy bare
732
+ * nodeId key recoverable. A key that already carries `::` is trusted as-is.
733
+ */
734
+ const payloadInput = String(text.input ?? '').trim();
735
+ const split = rawKey.indexOf('::');
736
+ const keyNode = split < 0 ? rawKey : rawKey.slice(0, split);
737
+ const keyInput = split < 0 ? '' : rawKey.slice(split + 2).trim();
738
+ const input = keyInput !== '' ? keyInput : payloadInput;
739
+ if (keyNode === '' || input === '') continue;
740
+ const body = typeof text.value === 'string' ? text.value : '';
741
+ out[`${keyNode}::${input}`] = { input, value: body.slice(0, 8000) };
742
+ }
743
+ return out;
744
+ }
745
+
746
+ /** The head text declared for one slot, or undefined when the graph's own text is in force. */
747
+ export function headFor(nodeId, input, nodeHeads) {
748
+ if (nodeHeads === null || nodeHeads === undefined) return undefined;
749
+ const slot = `${String(nodeId)}::${String(input)}`;
750
+ const direct = nodeHeads[slot];
751
+ if (direct !== undefined && typeof direct === 'object') return direct;
752
+ /**
753
+ * A record saved before this change keyed the map by bare nodeId. Only accept it when its
754
+ * own `input` matches the slot being asked about — otherwise the same entry would be
755
+ * claimed by every input on that node, which is the bug this function exists to prevent.
756
+ */
757
+ const legacy = nodeHeads[String(nodeId)];
758
+ if (legacy !== undefined && typeof legacy === 'object' && String(legacy.input ?? '') === String(input)) return legacy;
759
+ return undefined;
760
+ }
761
+
762
+ /**
763
+ * Which text node each head belongs to — the legacy, per-role shape.
764
+ *
765
+ * Kept because older records and the chain runner still speak it, and because a role with
766
+ * several candidates still needs "which one is THE positive". New work should use per-node
767
+ * heads (normalizeNodeHeads): that is what the workbench writes now.
768
+ */
769
+ export function normalizeHeadNodes(value, promptNodes = null) {
770
+ const declared = normalizePromptNodes(promptNodes);
771
+ const allowed = (role) => new Set((declared?.[role] ?? []).map(String));
772
+ const read = (raw, role) => {
773
+ const id = String(raw ?? '').trim();
774
+ if (id === '') return '';
775
+ const permitted = allowed(role);
776
+ // An empty declaration means "inferred": anything goes, because the operator has not
777
+ // said which nodes are which and this is the only place to say where the head is.
778
+ if (permitted.size === 0) return id;
779
+ return permitted.has(id) ? id : '';
780
+ };
781
+ if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
782
+ return null;
783
+ }
784
+ const positive = read(value.positive, 'positive');
785
+ const negative = read(value.negative, 'negative');
786
+ if (positive === '' && negative === '') return null;
787
+ // Only the roles that were actually named. An empty string here would read as "declared
788
+ // to be node ''", which is not a node — dropping the key is what makes "automatic" the
789
+ // unambiguous default for the other role.
790
+ return {
791
+ ...(positive === '' ? {} : { positive }),
792
+ ...(negative === '' ? {} : { negative }),
793
+ };
794
+ }
795
+
796
+ /**
797
+ * The console's prompt surface: one box per text node the operator did NOT mark 不参与.
798
+ *
799
+ * This is the function the console and the run both read, so the box shown and the node
800
+ * written cannot drift apart. Three things it settles, in one place:
801
+ *
802
+ * 1. **which nodes get a box** — every text row whose role is positive or negative, in
803
+ * graph order. A row whose role is `ignored` gets none, and if every row is ignored the
804
+ * list is empty: the console then shows no text box at all, which is the requested
805
+ * "都选不参与就不显示文本框".
806
+ * 2. **what each box's standing text is** — per-node heads win (the workbench's inline
807
+ * editor), otherwise the node's own encoder text. Never a role-wide "first positive":
808
+ * on a two-positive graph that showed one node's text while the run wrote another's.
809
+ * 3. **where a per-run append lands** — the same node the box came from, with the input
810
+ * key carried along.
811
+ *
812
+ * Returns `[{nodeId, input, role, classType, head, value}]`, where `head` is the read-only
813
+ * standing text and `value` the whole encoder string (they differ only when an operator
814
+ * overrode the head).
815
+ */
816
+ export function promptBoxes(texts = [], nodeHeads = null, nodeLayers = null, workflowLayers = null) {
817
+ const rows = Array.isArray(texts) ? texts : [];
818
+ const heads = normalizeNodeHeads(nodeHeads) ?? {};
819
+ const boxes = [];
820
+ for (const row of rows) {
821
+ const role = row?.role === 'negative' ? 'negative' : (row?.role === 'ignored' ? 'ignored' : 'positive');
822
+ if (role === 'ignored') continue;
823
+ const nodeId = String(row.nodeId);
824
+ const input = String(row.input);
825
+ const graphText = typeof row.value === 'string' ? row.value : '';
826
+ // A head belongs to a SLOT, not a node: a node with two strings gets two heads.
827
+ const override = headFor(nodeId, input, heads);
828
+ const head = override !== undefined ? override.value : graphText;
829
+ boxes.push({
830
+ nodeId,
831
+ input,
832
+ role,
833
+ classType: String(row.classType ?? ''),
834
+ head,
835
+ value: head,
836
+ /**
837
+ * This node's own layers, even when empty.
838
+ *
839
+ * Every box carries the list — not just the positive one — because the console
840
+ * renders one layer box per entry underneath THAT box. A negative encoder with
841
+ * its own 排除项 / 画质 split is the case this exists for.
842
+ */
843
+ layers: layersForNode(nodeId, nodeLayers, workflowLayers),
844
+ });
845
+ }
846
+ return boxes;
847
+ }
848
+
849
+ /**
850
+ * Does this workflow take any prompt text at all?
851
+ *
852
+ * `false` when the graph has no text node, and also when the operator marked every one of
853
+ * them 不参与 — the console hides its whole prompt section in both cases, because a box that
854
+ * nobody reads is worse than no box.
855
+ */
856
+ export function hasPromptBoxes(texts = []) {
857
+ return promptBoxes(texts, null).length > 0;
858
+ }
859
+
860
+ /**
861
+ * Flatten a head-node declaration into the lookup `promptTargets` accepts.
862
+ *
863
+ * `promptTargets` already takes `{positive: [ids], negative: [ids]}`. A head declaration
864
+ * answers the same question with one id per role, and it must WIN over the broader roles —
865
+ * that is the whole point: on a two-positive graph the roles say "both are positive", and
866
+ * only the head declaration says which one the console types into.
867
+ *
868
+ * NOTE: the workbench no longer writes `headNodes` (heads are per node now). This stays for
869
+ * old records and for the chain runner, which still speaks roles.
870
+ */
871
+ export function headRoles(promptNodes = null, headNodes = null) {
872
+ const declared = normalizePromptNodes(promptNodes);
873
+ const heads = normalizeHeadNodes(headNodes, declared);
874
+ if (heads === null) return declared;
875
+ /**
876
+ * A role the declaration does not name keeps its full declared list.
877
+ *
878
+ * `null` must not sneak in here: the shape `promptTargets` accepts is arrays of ids, and
879
+ * a `[null]` would be compared against a node id, never match, and silently relabel the
880
+ * negative encoder as positive.
881
+ */
882
+ const narrowed = (role) => {
883
+ const chosen = String(heads?.[role] ?? '') !== '';
884
+ return chosen ? [String(heads[role])] : (declared?.[role] ?? []);
885
+ };
886
+ return { positive: narrowed('positive'), negative: narrowed('negative') };
887
+ }
888
+
889
+ /**
890
+ * The node ONE head box addresses, per role — legacy companion to `headRoles`.
891
+ *
892
+ * New code should use `promptBoxes`, which is per node. This one still answers "which single
893
+ * node is THE positive", which is what a per-run `heads` override needs.
894
+ */
895
+ export function headTargets(texts = [], headNodes = null) {
896
+ const rows = Array.isArray(texts) ? texts : [];
897
+ const declared = headNodes !== null && typeof headNodes === 'object' ? headNodes : {};
898
+ const pick = (role) => {
899
+ const wanted = String(declared?.[role] ?? '');
900
+ const ofRole = rows.filter((row) => (role === 'negative' ? row.role === 'negative' : row.role === 'positive'));
901
+ const exact = wanted === '' ? undefined : ofRole.find((row) => String(row.nodeId) === wanted);
902
+ const chosen = exact ?? ofRole[0];
903
+ if (chosen === undefined) return null;
904
+ return { nodeId: String(chosen.nodeId), input: String(chosen.input) };
905
+ };
906
+ return { positive: pick('positive'), negative: pick('negative') };
907
+ }
908
+
909
+ export function normalizeParamHidden(value) {
910
+ if (value === null || value === undefined) return null;
911
+ if (!Array.isArray(value)) return null;
912
+ const seen = new Set();
913
+ const rows = [];
914
+ for (const entry of value) {
915
+ if (entry === null || typeof entry !== 'object') continue;
916
+ const nodeId = String(entry.nodeId ?? '').trim();
917
+ const input = String(entry.input ?? '').trim();
918
+ if (nodeId === '' || input === '') continue;
919
+ const key = `${nodeId}::${input}`;
920
+ if (seen.has(key)) continue;
921
+ seen.add(key);
922
+ rows.push({ nodeId, input });
923
+ if (rows.length >= MAX_PARAM_PICKS) break;
924
+ }
925
+ return rows;
926
+ }
927
+
928
+ export function normalizeParamPicks(value) {
929
+ if (value === null || value === undefined) return null;
930
+ if (!Array.isArray(value)) return null;
931
+ const seen = new Set();
932
+ const rows = [];
933
+ for (const entry of value) {
934
+ if (entry === null || typeof entry !== 'object') continue;
935
+ const nodeId = String(entry.nodeId ?? '').trim();
936
+ // `input` is the graph key; it is kept because that is what generate() writes into.
937
+ const input = String(entry.input ?? '').trim();
938
+ if (nodeId === '' || input === '') continue;
939
+ const key = `${nodeId}::${input}`;
940
+ if (seen.has(key)) continue;
941
+ seen.add(key);
942
+ const rawValue = entry.value;
943
+ const kind = normalizeParamKind(entry, input);
944
+ rows.push({
945
+ name: String(entry.name ?? '').trim().slice(0, 60) || input,
946
+ nodeId,
947
+ input,
948
+ // Keep the type: a number stays a number, or the graph receives "20" where it
949
+ // expects 20 and ComfyUI rejects the prompt. A switch is the same argument one
950
+ // step further: `true` has to stay a boolean, and "true" has to be coerced here
951
+ // rather than at submit time, or the form shows "false" for a truthy string.
952
+ value: kind === 'switch'
953
+ ? normalizeSwitchValue(rawValue)
954
+ : (typeof rawValue === 'number' || typeof rawValue === 'string' ? rawValue : ''),
955
+ desc: String(entry.desc ?? '').trim().slice(0, 200),
956
+ /**
957
+ * What kind of value this parameter takes: value / model / seed / switch.
958
+ *
959
+ * "model" makes the console render a dropdown of files from `folder` instead of
960
+ * a free-text box, so the operator picks a LoRA from the LoRA folder rather than
961
+ * typing a filename that must match byte for byte.
962
+ *
963
+ * "seed" is a sampling seed, and one of the two kinds with a mode: random (draw a
964
+ * fresh 12-digit number per run) or fixed (use `seedMode`'s number every time).
965
+ * The mode is stored here rather than in the graph because it is a property of
966
+ * the form — writing it into `inputs` would submit a key ComfyUI rejects.
967
+ *
968
+ * "switch" is a boolean input, offered as true/false and nothing else. Its value
969
+ * is always a real boolean (see normalizeSwitchValue): a BOOLEAN input rejects
970
+ * "true" and 1, and that rejection only shows up after the job is queued.
971
+ *
972
+ * Model parameters are NSFW-only. Not a content judgement — picking model files
973
+ * is an operator capability, and the SFW surface stays a plain parameter form.
974
+ * The gate is enforced on the server (see visibleParams), so a SFW account
975
+ * cannot obtain one by hand-editing a request.
976
+ */
977
+ kind,
978
+ /** Model folder to list; only meaningful when kind is "model". */
979
+ folder: String(entry.folder ?? '').trim().slice(0, 40),
980
+ /** Seed mode; only meaningful when kind is "seed". */
981
+ ...(kind === 'seed'
982
+ ? { seedMode: String(entry.seedMode ?? '') === 'fixed' ? 'fixed' : 'random' }
983
+ : {}),
984
+ });
985
+ if (rows.length >= MAX_PARAM_PICKS) break;
986
+ }
987
+ return rows;
988
+ }
989
+
990
+ /**
991
+ * Parse a free-text parameter list into the four-field shape.
992
+ *
993
+ * The point is speed: an operator pastes a block copied from a note or a chat and gets rows,
994
+ * rather than clicking "add" forty times. Accepted per line, in any of these shapes:
995
+ *
996
+ * 步数 | 3 | steps | 采样步数
997
+ * 步数, 3, steps, 采样步数
998
+ * 步数 = 3 (nodeId then assumed to be the first node that has such an input)
999
+ * 步数 3
1000
+ *
1001
+ * Fields are `name, value, nodeId, desc` — the order a person writes when describing a
1002
+ * parameter: what it is called, what it is set to, where it lives, why.
1003
+ *
1004
+ * Lines that cannot be understood are returned in `problems` rather than dropped silently:
1005
+ * a paste that half-parses is worse than one that says which line it did not like.
1006
+ */
1007
+ export function parseParamText(text, api = null) {
1008
+ const rows = [];
1009
+ const problems = [];
1010
+ const lines = String(text ?? '').split(/\r?\n/);
1011
+ for (let index = 0; index < lines.length; index += 1) {
1012
+ const raw = lines[index].trim();
1013
+ if (raw === '' || raw.startsWith('#')) continue;
1014
+ // '|' and ',' and tab are all things people paste; '=' only separates name from value.
1015
+ const parts = raw.split(/\s*[|,,\t]\s*/).map((part) => part.trim());
1016
+ const equal = /^([^=]+)=(.+)$/.exec(raw);
1017
+ let name;
1018
+ let value;
1019
+ let nodeId;
1020
+ let desc;
1021
+ if (parts.length >= 2) {
1022
+ [name, value, nodeId = '', desc = ''] = parts;
1023
+ } else if (equal !== null) {
1024
+ name = equal[1].trim();
1025
+ value = equal[2].trim();
1026
+ nodeId = '';
1027
+ desc = '';
1028
+ } else {
1029
+ const spaced = /^(\S+)\s+(.+)$/.exec(raw);
1030
+ if (spaced === null) {
1031
+ problems.push(`第 ${index + 1} 行看不懂:${raw.slice(0, 40)}`);
1032
+ continue;
1033
+ }
1034
+ name = spaced[1];
1035
+ value = spaced[2].trim();
1036
+ nodeId = '';
1037
+ desc = '';
1038
+ }
1039
+ if (name === '' || value === '') {
1040
+ problems.push(`第 ${index + 1} 行缺少名称或默认值:${raw.slice(0, 40)}`);
1041
+ continue;
1042
+ }
1043
+ /**
1044
+ * The name must match an input key in the graph EXACTLY.
1045
+ *
1046
+ * Fuzzy matching was tried and rejected: "步数" quietly bound to `steps`, and when
1047
+ * two nodes both had a `steps` the paste silently picked one. A parameter that binds
1048
+ * to the wrong node fails at run time with an error that names neither, so this
1049
+ * refuses instead — an unrecognised name is reported with the node ids that DO have
1050
+ * a key of that name, which is the information needed to fix it.
1051
+ *
1052
+ * `nodeId` may be given to disambiguate; without it, the name must be unique.
1053
+ */
1054
+ const matches = Object.entries(api ?? {}).filter(([, node]) =>
1055
+ Object.prototype.hasOwnProperty.call(node?.inputs ?? {}, name),
1056
+ );
1057
+ let chosen = nodeId;
1058
+ if (chosen !== '') {
1059
+ const node = api?.[chosen];
1060
+ if (node === undefined) {
1061
+ problems.push(`第 ${index + 1} 行的节点 ${chosen} 在 JSON 里不存在:${name}`);
1062
+ continue;
1063
+ }
1064
+ if (!Object.prototype.hasOwnProperty.call(node.inputs ?? {}, name)) {
1065
+ problems.push(`第 ${index + 1} 行:节点 ${chosen} 没有输入 ${name}`);
1066
+ continue;
1067
+ }
1068
+ } else if (matches.length === 1) {
1069
+ chosen = String(matches[0][0]);
1070
+ } else if (matches.length === 0) {
1071
+ problems.push(`第 ${index + 1} 行:JSON 里找不到名为 ${name} 的输入`);
1072
+ continue;
1073
+ } else {
1074
+ const ids = matches.map(([id, node]) => `${id}(${String(node?.class_type ?? '')})`).join('、');
1075
+ problems.push(`第 ${index + 1} 行:${name} 出现在多个节点上(${ids}),请补上节点 id`);
1076
+ continue;
1077
+ }
1078
+ const numeric = /^-?\d+(\.\d+)?$/.test(value);
1079
+ rows.push({
1080
+ // The name IS the graph key; they are not two things that could disagree.
1081
+ name: name.slice(0, 60),
1082
+ nodeId: chosen,
1083
+ input: name.slice(0, 60),
1084
+ value: numeric ? Number(value) : value,
1085
+ desc: desc.slice(0, 200),
1086
+ });
1087
+ if (rows.length >= MAX_PARAM_PICKS) break;
1088
+ }
1089
+ return { params: rows, problems };
1090
+ }
1091
+
1092
+ /**
1093
+ * Accept a heads object from a caller and keep exactly two trimmed strings.
1094
+ *
1095
+ * `null` means "the caller said nothing", which is different from "the caller
1096
+ * cleared the heads": an empty string is a real value, and it is how a workflow
1097
+ * declares that it must have no prefix at all.
1098
+ */
1099
+ export function normalizeHeads(value) {
1100
+ if (value === null || value === undefined || typeof value !== 'object') return null;
1101
+ const positive = typeof value.positive === 'string' ? value.positive.trim() : '';
1102
+ const negative = typeof value.negative === 'string' ? value.negative.trim() : '';
1103
+ return { positive, negative };
1104
+ }
1105
+
1106
+ /**
1107
+ * Ordered positive-prompt layers, e.g. [{name:'面部'},{name:'躯干'},{name:'环境'}].
1108
+ *
1109
+ * Why a workflow-level list and not three fixed fields: which parts a person wants to
1110
+ * describe separately depends on the graph. A portrait wants face/hair/clothes, a scene
1111
+ * wants foreground/subject/background, an upscaler wants none. So the *workflow* declares
1112
+ * the layer names and their order, and the console renders one box per layer.
1113
+ *
1114
+ * Order is the array order and nothing else — that is the whole contract, because the
1115
+ * assembled prompt is what the model sees. Names are labels only; an empty name is
1116
+ * allowed and the console shows it as "第 N 层".
1117
+ *
1118
+ * `null` means "unchanged / not provided"; `[]` means "this workflow has no layers",
1119
+ * which is a real value and must clear a previous definition.
1120
+ */
1121
+ export function normalizeLayers(value) {
1122
+ if (value === null || value === undefined) return null;
1123
+ if (!Array.isArray(value)) return null;
1124
+ return value
1125
+ .filter((row) => row !== null && typeof row === 'object')
1126
+ .map((row) => ({ name: typeof row.name === 'string' ? row.name.trim().slice(0, 40) : '' }))
1127
+ .slice(0, MAX_LAYERS);
1128
+ }
1129
+
1130
+ /** Cap: more than this is not a layered prompt, it is a spreadsheet. */
1131
+ export const MAX_LAYERS = 8;
1132
+
1133
+ /**
1134
+ * Per-node prompt layers, as `{ nodeId: [{name}, …] }`.
1135
+ *
1136
+ * Why per NODE and not per workflow: a graph with several text nodes needs its own layering
1137
+ * for each of them. A negative encoder wants 排除项 / 画质 / 水印; a negative prompt is not
1138
+ * "the same layers as the positive". The old workflow-level list forced every node to share
1139
+ * one shape, so the console could only ever layer the positive and the negative had to be one
1140
+ * blob.
1141
+ *
1142
+ * Shape mirrors `nodeHeads`: a map keyed by nodeId, `null` = unchanged, `{}` = clear every
1143
+ * per-node definition. A node with no entry simply has no layers (one box), which is the
1144
+ * right default for a graph that never needed them.
1145
+ *
1146
+ * Order inside a node's array is the assemble order and nothing else; names are labels, and
1147
+ * an empty name is shown as "第 N 层".
1148
+ */
1149
+ export function normalizeNodeLayers(value) {
1150
+ if (value === null || value === undefined) return null;
1151
+ if (typeof value !== 'object' || Array.isArray(value)) return null;
1152
+ const out = {};
1153
+ for (const [key, rows] of Object.entries(value)) {
1154
+ const nodeId = String(key ?? '').trim();
1155
+ if (nodeId === '') continue;
1156
+ /**
1157
+ * An EMPTY list is kept, and it is load-bearing.
1158
+ *
1159
+ * `[]` means "this node is not layered"; no entry means "fall back to the
1160
+ * workflow-level list". Dropping empty arrays collapsed the first into the second, so
1161
+ * deleting a node's last layer silently restored the old shared split on the next
1162
+ * render — the operator removed 面部/躯干 and 面部/躯干 came back.
1163
+ */
1164
+ if (rows === null || (Array.isArray(rows) && rows.length === 0)) {
1165
+ out[nodeId] = [];
1166
+ continue;
1167
+ }
1168
+ if (!Array.isArray(rows)) continue;
1169
+ const list = rows
1170
+ .filter((row) => row !== null && typeof row === 'object')
1171
+ .map((row) => ({ name: typeof row.name === 'string' ? row.name.trim().slice(0, 40) : '' }))
1172
+ .slice(0, MAX_LAYERS);
1173
+ out[nodeId] = list;
1174
+ }
1175
+ return out;
1176
+ }
1177
+
1178
+ /**
1179
+ * The layer definitions one text node should use.
1180
+ *
1181
+ * Per-node wins; the workflow-level `layers` is the fallback so workflows written before this
1182
+ * change keep their single set of layer names on their positive encoder instead of silently
1183
+ * losing them.
1184
+ */
1185
+ export function layersForNode(nodeId, nodeLayers = null, workflowLayers = null) {
1186
+ const map = normalizeNodeLayers(nodeLayers) ?? {};
1187
+ const id = String(nodeId ?? '');
1188
+ if (map[id] !== undefined) return map[id];
1189
+ const fallback = Array.isArray(workflowLayers) ? workflowLayers : [];
1190
+ return fallback
1191
+ .filter((row) => row !== null && typeof row === 'object')
1192
+ .map((row) => ({ name: typeof row.name === 'string' ? row.name.trim() : '' }))
1193
+ .slice(0, MAX_LAYERS);
1194
+ }
1195
+
1196
+ /**
1197
+ * Assemble layered text into one positive prompt, in declared order.
1198
+ *
1199
+ * Empty layers are skipped rather than joining to an empty slot, so a person can leave
1200
+ * 躯干 blank without producing "面部, , 环境". Joined with ", " — the same separator the
1201
+ * rest of the prompt pipeline uses, so the result is indistinguishable from someone having
1202
+ * typed the whole thing by hand.
1203
+ */
1204
+ export function joinLayers(values) {
1205
+ return values
1206
+ .map((value) => String(value ?? '').trim().replace(/^[,,]\s*|\s*[,,]$/g, ''))
1207
+ .filter((value) => value !== '')
1208
+ .join(', ');
1209
+ }