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.
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Workflows on the far side of the gateway.
3
+ *
4
+ * ComfyUI keeps saved workflows as UI graphs (`/api/userdata?dir=workflows`),
5
+ * but `/prompt` only accepts the API form, so a remote console that offers
6
+ * "pick a workflow and press generate" has to convert. The conversion here is
7
+ * deliberately mechanical and conservative:
8
+ *
9
+ * - a link that feeds an input wins over that input's widget value;
10
+ * - widget values are assigned to inputs in `input.required`/`optional` order,
11
+ * honouring `control_after_generate` (seed rows carry a follow-up value that
12
+ * is not an input at all);
13
+ * - a widget value that is an object with `__value__` (a converted widget) is
14
+ * unwrapped;
15
+ * - nodes whose class is unknown to the instance are reported, not guessed.
16
+ *
17
+ * Anything the instance's own /object_info cannot describe is surfaced as a
18
+ * warning so the console can tell the user which node will not run, instead of
19
+ * shipping a prompt that fails upstream with a cryptic error.
20
+ */
21
+
22
+ /** Input names a class accepts, in widget order (ComfyUI's own rule). */
23
+ function inputNames(spec) {
24
+ const names = [];
25
+ const sections = [spec?.input?.required ?? {}, spec?.input?.optional ?? {}];
26
+ for (const section of sections) {
27
+ for (const [name, declaration] of Object.entries(section)) {
28
+ const type = Array.isArray(declaration) ? declaration[0] : declaration?.type;
29
+ // A required link declaration is a type name string; a widget is an
30
+ // array whose first element is either a list of options or a type.
31
+ names.push({ name, type, declaration });
32
+ }
33
+ }
34
+ return names;
35
+ }
36
+
37
+ /** ComfyUI marks a widget "socket only" by omitting it from the widget order. */
38
+ function widgetInputs(spec) {
39
+ const rows = [];
40
+ for (const entry of inputNames(spec)) {
41
+ const declaration = entry.declaration;
42
+ if (declaration === undefined) continue;
43
+ const isWidget =
44
+ Array.isArray(declaration) &&
45
+ declaration.length > 0 &&
46
+ (Array.isArray(declaration[0]) || ['INT', 'FLOAT', 'STRING', 'BOOLEAN'].includes(declaration[0]));
47
+ if (!isWidget) continue;
48
+ rows.push(entry);
49
+ }
50
+ return rows;
51
+ }
52
+
53
+ const UPLOAD_TYPES = new Set(['IMAGE', 'MASK', 'VIDEO', 'AUDIO']);
54
+ const TEXT_TYPES = new Set(['STRING']);
55
+
56
+ /**
57
+ * Convert one UI graph to API format.
58
+ * @returns {{prompt: object|null, warnings: string[], error?: string}}
59
+ */
60
+ export function graphToApi(graph, objectInfo) {
61
+ const warnings = [];
62
+ if (graph === null || typeof graph !== 'object') return { prompt: null, warnings, error: '工作流不是 JSON 对象' };
63
+ if (typeof graph.nodes !== 'object' || !Array.isArray(graph.nodes)) {
64
+ // Already API format: a map of node id to {class_type, inputs}.
65
+ const entries = Object.entries(graph);
66
+ const looksApi = entries.length > 0 && entries.every(([, node]) => node && typeof node === 'object' && typeof node.class_type === 'string');
67
+ if (looksApi) return { prompt: graph, warnings };
68
+ return { prompt: null, warnings, error: '无法识别的格式:既没有 nodes 数组,也不是 API 格式(class_type/inputs)' };
69
+ }
70
+
71
+ const nodes = graph.nodes.filter((node) => node !== null && typeof node === 'object');
72
+ const byId = new Map(nodes.map((node) => [String(node.id), node]));
73
+ // links: [linkId, originNode, originSlot, targetNode, targetSlot, type]
74
+ const linkById = new Map();
75
+ for (const link of Array.isArray(graph.links) ? graph.links : []) {
76
+ if (Array.isArray(link) && link.length >= 5) {
77
+ linkById.set(String(link[0]), { origin: String(link[1]), originSlot: Number(link[2]), type: link[5] });
78
+ }
79
+ }
80
+
81
+ const prompt = {};
82
+ for (const node of nodes) {
83
+ const classType = String(node.type ?? node.class_type ?? '');
84
+ if (classType === '' || classType === 'Reroute' || classType === 'Note' || classType === 'MarkdownNote') continue;
85
+ if (node.mode === 2 || node.mode === 4) continue; // muted / bypassed
86
+ const spec = objectInfo[classType];
87
+ if (spec === undefined) {
88
+ warnings.push(`实例里没有节点 ${classType}(#${node.id}),该节点会被跳过`);
89
+ continue;
90
+ }
91
+ const inputs = {};
92
+ // 1. Link-fed inputs win: the graph's wires are the real dataflow.
93
+ for (const input of Array.isArray(node.inputs) ? node.inputs : []) {
94
+ if (input === null || typeof input !== 'object') continue;
95
+ const link = input.link === undefined || input.link === null ? undefined : linkById.get(String(input.link));
96
+ if (link === undefined) continue;
97
+ inputs[input.name] = [link.origin, nodeSlotIndex(byId.get(link.origin), link.originSlot)];
98
+ }
99
+ // 2. Widget values fill the gaps, in declaration order.
100
+ const widgets = widgetInputs(spec);
101
+ const values = Array.isArray(node.widgets_values) ? node.widgets_values : [];
102
+ let cursor = 0;
103
+ for (const widget of widgets) {
104
+ if (cursor >= values.length) break;
105
+ let value = values[cursor];
106
+ cursor += 1;
107
+ // A seed widget writes TWO entries: the value and its control mode.
108
+ const declaration = widget.declaration;
109
+ // The options live in the second element as an object — a list there
110
+ // means "these are the allowed values", not widget options.
111
+ const options = declaration?.[1] !== null && typeof declaration?.[1] === 'object' && !Array.isArray(declaration[1])
112
+ ? declaration[1]
113
+ : {};
114
+ if (options.control_after_generate === true) cursor += 1;
115
+ if (value !== null && typeof value === 'object' && '__value__' in value) value = value.__value__;
116
+ if (widget.name in inputs) continue;
117
+ inputs[widget.name] = value;
118
+ }
119
+ prompt[String(node.id)] = { class_type: classType, inputs };
120
+ }
121
+ if (Object.keys(prompt).length === 0) {
122
+ return { prompt: null, warnings, error: '工作流里没有可用节点' };
123
+ }
124
+ return { prompt, warnings };
125
+ }
126
+
127
+ function nodeSlotIndex(node, slot) {
128
+ const outputs = Array.isArray(node?.outputs) ? node.outputs : [];
129
+ const output = outputs[slot];
130
+ if (output === undefined) return 0;
131
+ // The API addresses the slot that carries data; `links` on the UI output lists
132
+ // every outgoing link, and slot order matches unless a node declares an index.
133
+ return typeof output.slot_index === 'number' ? output.slot_index : slot;
134
+ }
135
+
136
+ /** Find the text inputs a person would type into (positive / negative). */
137
+ export function promptTargets(prompt, declared) {
138
+ // Which encoder is the negative one is not written on the encoder: a
139
+ // CLIPTextEncode only has `text`. The sampler's `negative` link is the truth,
140
+ // so trace it first and fall back to naming heuristics after.
141
+ const negative = new Set();
142
+ const positive = new Set();
143
+ for (const node of Object.values(prompt ?? {})) {
144
+ for (const [name, value] of Object.entries(node?.inputs ?? {})) {
145
+ if (!Array.isArray(value) || value.length < 1) continue;
146
+ const target = String(value[0]);
147
+ if (/^negative$/i.test(name)) negative.add(target);
148
+ else if (/^positive$/i.test(name)) positive.add(target);
149
+ }
150
+ }
151
+ /**
152
+ * An operator declaration, when there IS one, decides every node's role.
153
+ *
154
+ * Inference answers "what does the sampler think this is"; the declaration answers "what
155
+ * does the person running this rig want this slot to be". On a graph with several text
156
+ * nodes feeding one sampler, inference returns the same role for all of them and the
157
+ * console cannot tell the boxes apart — so when a declaration exists it is the WHOLE
158
+ * answer: a node it does not name is `ignored`, not "inferred".
159
+ *
160
+ * That last part changed in 0.7.25. The old rule kept an undeclared node's inferred role
161
+ * "so a partial declaration does not silently unlabel the rest", which sounded cautious
162
+ * and behaved the opposite way: choosing 不参与 for a node did nothing at all, because
163
+ * the node was simply absent from both lists and immediately re-inferred as positive.
164
+ * "Every node 不参与" was likewise inexpressible — the console always grew a box back.
165
+ */
166
+ const declaration = declared !== null && typeof declared === 'object' && !Array.isArray(declared) ? declared : null;
167
+ /**
168
+ * Declarations may address a SLOT (`nodeId::input`) or just a node.
169
+ *
170
+ * A single node can hold several string inputs — `StringFunction|pysssss` has text_a /
171
+ * text_b / text_c, and 3lora人物 feeds two of them. Keying only by node made those two
172
+ * inseparable: one role for both, one head for both, and the editor listed the node twice
173
+ * with no way to tell which row was which. Slot keys fix that, and a bare node key still
174
+ * works so old records keep their meaning (it applies to every input of that node).
175
+ */
176
+ const declaredPositive = new Set((declaration?.positive ?? []).map(String));
177
+ const declaredNegative = new Set((declaration?.negative ?? []).map(String));
178
+ /**
179
+ * Slots the operator parked explicitly, by slot key or bare node id.
180
+ *
181
+ * This is the only thing that can keep a slot holding TEXT on 不参与 — absence from both
182
+ * lists cannot, because absence also means "never classified". Without it 不参与 was
183
+ * one-way: a slot with text always fell back to its inferred role and came back on.
184
+ */
185
+ const declaredIgnored = new Set((declaration?.ignored ?? []).map(String));
186
+ /**
187
+ * `{positive: [], negative: []}` is "switch every slot OFF" — a decision, not silence.
188
+ *
189
+ * `null` means "never classified" and inference still runs. An object with both lists empty
190
+ * means the operator parked everything on purpose, so every slot must land on 不参与 even
191
+ * when it holds text.
192
+ */
193
+ const allOff = declaration !== null
194
+ && declaredPositive.size === 0
195
+ && declaredNegative.size === 0
196
+ && declaredIgnored.size === 0;
197
+ const rolesFor = (id, name) => {
198
+ const slot = `${id}::${name}`;
199
+ if (declaredNegative.has(slot)) return 'negative';
200
+ if (declaredPositive.has(slot)) return 'positive';
201
+ // An explicit park wins over inference, but not over an explicit assignment above.
202
+ if (declaredIgnored.has(slot)) return 'ignored';
203
+ if (allOff) return 'ignored';
204
+ /**
205
+ * A bare node id only speaks for a node with ONE text slot.
206
+ *
207
+ * It cannot say which of text_a / text_b / text_c it meant, so applying it to all three
208
+ * forced them into the same role — and worse, dragged the empty ones along with it.
209
+ * On a multi-slot node the bare id is ignored entirely; those rows fall through to the
210
+ * dormant-slot rule (不参与) and the operator assigns them per slot.
211
+ */
212
+ const siblings = Object.keys(prompt?.[id]?.inputs ?? {})
213
+ .filter((key) => /text/i.test(key) && (typeof prompt[id].inputs[key] === 'string' || Array.isArray(prompt[id].inputs[key])))
214
+ .length;
215
+ if (siblings <= 1) {
216
+ if (declaredNegative.has(id)) return 'negative';
217
+ if (declaredPositive.has(id)) return 'positive';
218
+ if (declaredIgnored.has(id)) return 'ignored';
219
+ }
220
+ return '';
221
+ };
222
+ /** Does the declaration mention this node at all — by slot or by whole node? */
223
+ const declaredAnywhere = (id, name) => {
224
+ const slot = `${id}::${name}`;
225
+ if (declaredPositive.has(slot) || declaredNegative.has(slot)) return true;
226
+ if (declaredPositive.has(id) || declaredNegative.has(id)) return true;
227
+ return false;
228
+ };
229
+ /**
230
+ * A declaration is "in force" when the caller passed one at all — an object, even with
231
+ * both lists empty. `null`/`undefined` means the workflow was never classified, and then
232
+ * inference still runs (that is every workflow written before this field existed).
233
+ */
234
+ const hasDeclaration = declaration !== null;
235
+
236
+ const rows = [];
237
+ for (const [id, node] of Object.entries(prompt ?? {})) {
238
+ const classType = String(node.class_type ?? '');
239
+ for (const [name, value] of Object.entries(node.inputs ?? {})) {
240
+ /**
241
+ * Every `text*` input of a text node is a SLOT — empty strings and links included.
242
+ *
243
+ * `StringFunction|pysssss` is the case this is built for. Its node 15 has:
244
+ *
245
+ * text_a "score_9, …, 1girl, solo" ← typed here
246
+ * text_b ["14", 0] ← wired from node 14
247
+ * text_c "" ← untouched, still a real input
248
+ *
249
+ * All three are places the node reads a prompt from, so all three get a box: the
250
+ * operator decides which one a tag goes into, and an empty one is exactly where a
251
+ * new line belongs. Hiding `text_c` because it happens to be empty made the panel
252
+ * show two boxes for a node with three inputs — the reported「节点15识别3个文本框
253
+ * 错误」, which was really "it shows the wrong two".
254
+ *
255
+ * A LINKED slot is included but flagged `wired`: its value comes from another node,
256
+ * so the console shows it read-only rather than pretending an edit would stick.
257
+ *
258
+ * `action` / `tidy_tags` are NOT slots: they hold enum words like "append" and
259
+ * "yes". The name test below is what keeps them out.
260
+ */
261
+ const isLink = Array.isArray(value);
262
+ if (!isLink && typeof value !== 'string') continue;
263
+ /**
264
+ * Which inputs are prompt TEXT.
265
+ *
266
+ * `text*` is the real rule: text, text_a, text_b, text_c, text_g, text_l. Those are
267
+ * the slots a person types into, whether they hold a string, an empty string, or a
268
+ * link.
269
+ *
270
+ * `clip`, `positive`, `negative` are different: they carry a CONDITIONING link
271
+ * (`["5", 1]`), never text. Accepting them as slots produced a wall of phantom rows
272
+ * — `3::positive`, `6::clip`, `10::clip` — one per encoder in the graph. They are
273
+ * only text when they genuinely hold a string (some custom nodes do that).
274
+ */
275
+ const isTextName = /text/i.test(name);
276
+ const isConditioningName = /^(clip|clip_l|t5xxl|prompt|positive|negative)$/i.test(name);
277
+ if (!isTextName && !(isConditioningName && !isLink)) continue;
278
+ let role = 'positive';
279
+ /**
280
+ * A slot is DORMANT when it has nothing to contribute — and dormant means 不参与.
281
+ *
282
+ * Two cases, same answer:
283
+ * - empty string — nothing typed, nowhere for a tag to go yet;
284
+ * - wired (a link) — the text comes from another node, so a box here would accept
285
+ * typing that the link overwrites at run time.
286
+ *
287
+ * Both still get a box in the EDITOR (every text slot is listed, so the operator can
288
+ * switch one on) but default to 不参与, which keeps them out of the console. That is
289
+ * the requested rule: 「空值默认归类不参与」. `15::text_c` is the case — a real input
290
+ * of the node, empty, editable in the workbench and absent from the run form.
291
+ */
292
+ const dormantSlot = isLink || String(value).trim() === '';
293
+ if (dormantSlot) role = 'ignored';
294
+ else if (negative.has(id) && !positive.has(id)) role = 'negative';
295
+ else if (/neg/i.test(name) || /neg/i.test(classType)) role = 'negative';
296
+ if (hasDeclaration) {
297
+ /**
298
+ * A slot-level declaration is the answer when present.
299
+ *
300
+ * When it is absent, do NOT blanket-assign `ignored` — that is what erased a
301
+ * real prompt. `3lora人物` stores a legacy `negative: ["7","15"]`; the bare id
302
+ * cannot say which of node 15's three inputs it meant, so it is ignored, and
303
+ * blanket-`ignored` then dropped `text_a` — which holds the actual positive
304
+ * prompt — out of the console entirely.
305
+ *
306
+ * The rule is the same one the no-declaration case uses: a slot with text keeps
307
+ * its inferred role, an empty slot is 不参与. A declaration only overrides the
308
+ * slots it names.
309
+ */
310
+ const slotRole = rolesFor(id, name);
311
+ if (slotRole !== '') role = slotRole;
312
+ else if (dormantSlot) role = 'ignored';
313
+ // else: keep the inferred role computed above — the slot has text of its own.
314
+ void declaredAnywhere;
315
+ }
316
+ rows.push({
317
+ nodeId: id,
318
+ input: name,
319
+ classType,
320
+ /**
321
+ * A wired slot carries no text of its own — report the link, and say so.
322
+ *
323
+ * `value` stays a string everywhere downstream (it is the encoder text), so a
324
+ * link becomes '' plus `wired: true`; the console renders that box read-only
325
+ * with a note instead of pretending an edit would survive.
326
+ */
327
+ value: isLink ? '' : value,
328
+ wired: isLink,
329
+ role,
330
+ });
331
+ }
332
+ }
333
+ // A CLIPTextEncode has exactly one `text`; several of them means positive and
334
+ // negative encoders, and the console shows both with their ids.
335
+ return rows;
336
+ }
337
+
338
+ /** Find the image inputs an upload can fill, and the node that will be written. */
339
+ /**
340
+ * Strip the annotation ComfyUI's own frontend appends to a filename.
341
+ *
342
+ * Its image dropdown offers entries like `Upscale_2x_Pure_00003_.png [output]` — the bracket
343
+ * says which folder the file came from. That string is a UI label, not a filename: passing it
344
+ * to the instance means "this file does not exist", and it also fails the extension check here,
345
+ * which is why 去除衣物's node 4 reported no image input at all.
346
+ *
347
+ * Only a trailing `[something]` is removed, so a legitimate name containing brackets in the
348
+ * middle survives.
349
+ */
350
+ export function stripNameAnnotation(value) {
351
+ return String(value ?? '').replace(/\s*\[[^\]]*\]\s*$/, '').trim();
352
+ }
353
+
354
+ /** The extension test every image/audio/video slot uses, applied to the de-annotated name. */
355
+ const MEDIA_FILE = /\.(png|jpe?g|webp|bmp|gif|tiff?|avif|mp4|webm|mov|mkv|mp3|wav|flac|ogg)$/i;
356
+
357
+ export function imageTargets(prompt, objectInfo) {
358
+ const rows = [];
359
+ for (const [id, node] of Object.entries(prompt ?? {})) {
360
+ const spec = objectInfo?.[node.class_type];
361
+ for (const [name, value] of Object.entries(node.inputs ?? {})) {
362
+ if (typeof value !== 'string') continue;
363
+ const declaration = spec?.input?.required?.[name] ?? spec?.input?.optional?.[name];
364
+ const type = Array.isArray(declaration) ? declaration[0] : undefined;
365
+ /**
366
+ * What counts as an image slot.
367
+ *
368
+ * The declaration is authoritative when the instance knows the class: IMAGE and
369
+ * MASK are both filled from an uploaded file.
370
+ *
371
+ * The name fallback is deliberately broad. It used to be an exact match on
372
+ * `image|image_path|mask|video`, which missed real inputs like `mask_path` — so a
373
+ * node holding BOTH an image and a mask reported a single slot, and the second one
374
+ * could never be set from the console even though the graph had two. Matching the
375
+ * concept anywhere in the name ("this input wants a picture") is what makes
376
+ * 「图生图可能有多个图片输入点」 actually reachable; the extension check below
377
+ * still rejects anything that is plainly not a file.
378
+ */
379
+ const nameIsImagey = /(^|_)(image|images|img|mask|masks|video|clip_frame|frame|init|reference|ref)(_|$)/i.test(name)
380
+ || /^(image|image_path|mask|mask_path|video|video_path)$/i.test(name);
381
+ const isImage = type === 'IMAGE' || type === 'MASK' || nameIsImagey;
382
+ /**
383
+ * Test the DE-ANNOTATED name, and report it so the console shows a real filename.
384
+ *
385
+ * `Upscale_2x_Pure_00003_.png [output]` is what the ComfyUI frontend stores when a
386
+ * file was picked from its dropdown; stripping is required both to recognise the
387
+ * slot and to submit something the instance can open.
388
+ */
389
+ const clean = stripNameAnnotation(value);
390
+ if (isImage && MEDIA_FILE.test(clean)) {
391
+ rows.push({ nodeId: id, input: name, classType: node.class_type, value: clean });
392
+ }
393
+ }
394
+ }
395
+ return rows;
396
+ }
397
+
398
+ /** Swap one text input's value without touching anything else. */
399
+ /**
400
+ * Write prompt text into a node.
401
+ *
402
+ * `input` names the exact slot when the caller knows it, and the caller almost always does now:
403
+ * a text node can hold several strings (`StringFunction|pysssss` has `action`, `tidy_tags`,
404
+ * `text_a`, `text_b`, `text_c`). Picking "the first string field" wrote `blue_sky` into `action`
405
+ * — the node's setting, not its prompt — and the operator's text silently never reached the
406
+ * graph. Same rule as `setImage`: name the slot, and only fall back when nobody named one.
407
+ */
408
+ export function setText(prompt, nodeId, value, input = '') {
409
+ const target = prompt?.[nodeId];
410
+ if (target === undefined || typeof target !== 'object') return false;
411
+ const inputs = target.inputs ?? {};
412
+ /**
413
+ * A named slot wins — but only if it currently holds a STRING.
414
+ *
415
+ * A wired slot holds a link (`["14", 0]`). Overwriting that with text would detach the other
416
+ * node and submit `"14,0,…"` as a literal, which ComfyUI accepts and then renders wrong.
417
+ */
418
+ if (typeof input === 'string' && input !== '') {
419
+ if (typeof inputs[input] !== 'string') return false;
420
+ inputs[input] = value;
421
+ return true;
422
+ }
423
+ /**
424
+ * No slot named (chain steps, older clients): prefer a field that looks like prompt text
425
+ * over the node's own settings. `action` / `tidy_tags` are enum words, not prompts.
426
+ */
427
+ const keys = Object.keys(inputs);
428
+ const looksLikePrompt = keys.find((key) => /text|prompt|clip/i.test(key) && typeof inputs[key] === 'string');
429
+ const fallback = looksLikePrompt ?? keys.find((key) => typeof inputs[key] === 'string');
430
+ if (fallback === undefined) return false;
431
+ inputs[fallback] = value;
432
+ return true;
433
+ }
434
+
435
+ /**
436
+ * Point an image input at a freshly uploaded file.
437
+ *
438
+ * `input` names the exact slot when the caller knows it. That matters once a graph has more
439
+ * than one image input on the same node: writing by node id alone picks the first matching
440
+ * field and leaves the other slot holding a filename nobody chose. When `input` is omitted
441
+ * (chain steps, older clients) the previous heuristic runs unchanged.
442
+ */
443
+ export function setImage(prompt, nodeId, filename, input = '') {
444
+ const target = prompt?.[nodeId];
445
+ if (target === undefined || typeof target !== 'object') return false;
446
+ const named = String(input ?? '');
447
+ if (named !== '' && target.inputs !== null && typeof target.inputs === 'object' && named in target.inputs) {
448
+ target.inputs[named] = filename;
449
+ return true;
450
+ }
451
+ for (const key of Object.keys(target.inputs ?? {})) {
452
+ const current = target.inputs[key];
453
+ /**
454
+ * Compare the de-annotated name: a stored `x.png [output]` is a real image slot even
455
+ * though the raw string does not end in an image extension.
456
+ */
457
+ if (typeof current === 'string' && MEDIA_FILE.test(stripNameAnnotation(current))) {
458
+ target.inputs[key] = filename;
459
+ return true;
460
+ }
461
+ }
462
+ // No file value yet: the first string field on a loader is the filename slot.
463
+ for (const key of Object.keys(target.inputs ?? {})) {
464
+ if (typeof target.inputs[key] === 'string') {
465
+ target.inputs[key] = filename;
466
+ return true;
467
+ }
468
+ }
469
+ return false;
470
+ }
471
+
472
+ export { UPLOAD_TYPES, TEXT_TYPES };
package/lib/ws.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * A dependency-free WebSocket server side: handshake plus text frames.
3
+ *
4
+ * The gateway only needs to *push* JSON at the official ComfyUI frontend
5
+ * (status / executing / progress / execution_success). Reading client frames
6
+ * is limited to ping/pong and close, which is all the frontend sends.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+
10
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
11
+
12
+ function acceptKey(key) {
13
+ return createHash('sha1').update(key + GUID).digest('base64');
14
+ }
15
+
16
+ /** Encode one text frame from the server (never masked). */
17
+ export function encodeText(text) {
18
+ const payload = Buffer.from(text, 'utf8');
19
+ const length = payload.length;
20
+ let header;
21
+ if (length < 126) {
22
+ header = Buffer.from([0x81, length]);
23
+ } else if (length < 65536) {
24
+ header = Buffer.alloc(4);
25
+ header[0] = 0x81;
26
+ header[1] = 126;
27
+ header.writeUInt16BE(length, 2);
28
+ } else {
29
+ header = Buffer.alloc(10);
30
+ header[0] = 0x81;
31
+ header[1] = 127;
32
+ header.writeBigUInt64BE(BigInt(length), 2);
33
+ }
34
+ return Buffer.concat([header, payload]);
35
+ }
36
+
37
+ export class WsServer {
38
+ constructor() {
39
+ /** @type {Set<import('node:net').Socket>} */
40
+ this.clients = new Set();
41
+ }
42
+
43
+ /**
44
+ * Complete the upgrade handshake for one socket.
45
+ * @returns {boolean} whether the socket is now a live client.
46
+ */
47
+ accept(request, socket) {
48
+ const key = request.headers['sec-websocket-key'];
49
+ if (typeof key !== 'string' || key === '') {
50
+ socket.destroy();
51
+ return false;
52
+ }
53
+ socket.write(
54
+ [
55
+ 'HTTP/1.1 101 Switching Protocols',
56
+ 'Upgrade: websocket',
57
+ 'Connection: Upgrade',
58
+ `Sec-WebSocket-Accept: ${acceptKey(key)}`,
59
+ '\r\n',
60
+ ].join('\r\n'),
61
+ );
62
+ socket.setNoDelay(true);
63
+ this.clients.add(socket);
64
+ const drop = () => this.clients.delete(socket);
65
+ socket.on('close', drop);
66
+ socket.on('error', drop);
67
+ socket.on('data', (chunk) => {
68
+ // Answer pings, honour close; ignore everything else.
69
+ if (chunk.length > 0 && (chunk[0] & 0x0f) === 0x8) {
70
+ socket.end();
71
+ this.clients.delete(socket);
72
+ } else if (chunk.length > 0 && (chunk[0] & 0x0f) === 0x9) {
73
+ socket.write(Buffer.from([0x8a, 0x00]));
74
+ }
75
+ });
76
+ return true;
77
+ }
78
+
79
+ /** Broadcast one JSON message; dead sockets are dropped silently. */
80
+ broadcast(type, data) {
81
+ if (this.clients.size === 0) return;
82
+ const frame = encodeText(JSON.stringify({ type, data }));
83
+ for (const socket of this.clients) {
84
+ try {
85
+ socket.write(frame);
86
+ } catch {
87
+ this.clients.delete(socket);
88
+ }
89
+ }
90
+ }
91
+
92
+ get size() {
93
+ return this.clients.size;
94
+ }
95
+
96
+ dispose() {
97
+ for (const socket of this.clients) {
98
+ try {
99
+ socket.destroy();
100
+ } catch {
101
+ // already gone
102
+ }
103
+ }
104
+ this.clients.clear();
105
+ }
106
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "title": "Multimodal Node",
3
+ "tab": "Multimodal",
4
+ "settingsSection": "Multimodal Node",
5
+ "summary": "One plugin, two identities: a service node opens ports, dispatches work and hosts the ComfyUI/Blender ports; a user node opens nothing and pulls templates, the queue and models from a service node, saving products locally."
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "title": "多模态节点",
3
+ "tab": "多模态",
4
+ "settingsSection": "多模态节点",
5
+ "summary": "一个插件两种身份:服务节点开门派发任务并托管 ComfyUI/Blender 端口;用户节点不开门,连服务节点拿模板、队列、模型,产物落本机目录。"
6
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "dsh-multimodal-node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "多模态节点:一个插件两种身份。服务节点开门(API 接入端口 + 网页端口),把任务排队分发到绑定的 ComfyUI 实例并托管本机 Blender 渲染;用户节点不开门,连服务节点拿工作流模板、当前队列、模型列表,产物落到本机目录。含网页账号与 apikey 两套凭据、SFW/NSFW 分级。",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "client.js",
16
+ "lib",
17
+ "cordis.patch.yml",
18
+ "locale/*.json",
19
+ "icon.svg",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "keywords": [
24
+ "deepseek",
25
+ "harness",
26
+ "dsh",
27
+ "dsh-plugin",
28
+ "comfyui",
29
+ "blender",
30
+ "multimodal",
31
+ "render-farm",
32
+ "task-pool",
33
+ "deepseek-harness"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22.19"
37
+ },
38
+ "scripts": {
39
+ "test": "node test/all.mjs",
40
+ "test:render": "node test/render.mjs",
41
+ "test:isolation": "node test/isolation.mjs",
42
+ "test:deps": "node test/deps.mjs",
43
+ "test:mutate": "node test/mutate.mjs",
44
+ "test:packgate": "node test/packgate.mjs",
45
+ "test:blender-real": "node test/blender-real.mjs",
46
+ "test:blender-edge": "node test/blender-edge.mjs"
47
+ },
48
+ "license": "MIT",
49
+ "author": "lc297948801",
50
+ "dsh": {
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ },
54
+ "client": {
55
+ "platform": "web",
56
+ "inject": [
57
+ "@deepseek-ai/dsh-client-connection",
58
+ "@deepseek-ai/dsh-client-locale",
59
+ "@deepseek-ai/dsh-client-ui-conversation",
60
+ "@deepseek-ai/dsh-client-ui-sidebar"
61
+ ]
62
+ }
63
+ }
64
+ }