dsh-comfyui 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/convert.js ADDED
@@ -0,0 +1,289 @@
1
+ /** Node types that exist only in the UI and carry no data flow. */
2
+ const UI_ONLY = new Set(['Note', 'StickyNote', 'Reroute', 'Fast Groups Bypasser (rgthree)']);
3
+ function isObject(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ function isWidgetSpec(typeSpec, options) {
7
+ return (typeof typeSpec === 'string' &&
8
+ (typeSpec === 'INT' || typeSpec === 'FLOAT' || typeSpec === 'STRING' || typeSpec === 'BOOLEAN')) || Array.isArray(typeSpec) || options?.widget?.name !== undefined;
9
+ }
10
+ /** The object_info input spec for one input name of a node class, if declared. */
11
+ function inputSpec(objectInfo, classType, name) {
12
+ const def = objectInfo[classType];
13
+ const input = isObject(def) && isObject(def.input) ? def.input : undefined;
14
+ if (input === undefined)
15
+ return undefined;
16
+ for (const group of ['required', 'optional']) {
17
+ const fields = isObject(input[group]) ? input[group] : {};
18
+ if (name in fields)
19
+ return fields[name];
20
+ }
21
+ return undefined;
22
+ }
23
+ /**
24
+ * Derive the ordered widget-input names for one node. The graph's own input
25
+ * array is the source of truth — widgets_values is stored in UI widget order,
26
+ * which includes dynamic sub-widgets (e.g. TextGenerate's sampling_mode.*) and
27
+ * still holds values for linked widget inputs. object_info only supplies the
28
+ * control_after_generate combo that follows an INT widget with that option.
29
+ */
30
+ function widgetNamesFor(classType, objectInfo, node) {
31
+ const names = [];
32
+ const addUnique = (name) => {
33
+ if (!names.includes(name))
34
+ names.push(name);
35
+ };
36
+ for (const entry of node.inputs ?? []) {
37
+ const name = entry.widget?.name;
38
+ if (typeof name !== 'string' || name === '')
39
+ continue;
40
+ addUnique(name);
41
+ const spec = inputSpec(objectInfo, classType, entry.name);
42
+ const typeSpec = Array.isArray(spec) ? spec[0] : undefined;
43
+ const options = Array.isArray(spec) && isObject(spec[1]) ? spec[1] : undefined;
44
+ if (typeSpec === 'INT' && options?.control_after_generate === true) {
45
+ addUnique('control_after_generate');
46
+ }
47
+ }
48
+ // DynamicCombo V3 widgets serialize in UI order: the master combo's value
49
+ // comes BEFORE its sub-widgets, while the graph's inputs array lists the
50
+ // sub-widgets first. Reorder the master combo ahead of its `prefix.*`
51
+ // sub-widgets so the zip below aligns with widgets_values.
52
+ const dynamicCombos = (node.inputs ?? [])
53
+ .map((entry) => entry.widget?.name)
54
+ .filter((name) => typeof name === 'string' && name !== '')
55
+ .filter((name) => {
56
+ const spec = inputSpec(objectInfo, classType, name);
57
+ return Array.isArray(spec) && spec[0] === 'COMFY_DYNAMICCOMBO_V3';
58
+ });
59
+ if (dynamicCombos.length > 0) {
60
+ const reordered = [];
61
+ for (const name of names) {
62
+ const master = dynamicCombos.find((prefix) => name.startsWith(`${prefix}.`));
63
+ if (master !== undefined) {
64
+ if (!reordered.includes(master))
65
+ reordered.push(master);
66
+ reordered.push(name);
67
+ continue;
68
+ }
69
+ if (!dynamicCombos.includes(name) || !names.some((other) => other.startsWith(`${name}.`))) {
70
+ reordered.push(name);
71
+ }
72
+ }
73
+ names.splice(0, names.length, ...reordered);
74
+ }
75
+ // Fallback: object_info-declared widget inputs the graph did not list.
76
+ const def = objectInfo[classType];
77
+ const input = isObject(def) && isObject(def.input) ? def.input : undefined;
78
+ if (input !== undefined) {
79
+ const collect = (group) => {
80
+ const fields = isObject(input[group]) ? input[group] : {};
81
+ for (const [key, spec] of Object.entries(fields)) {
82
+ if (spec === null || spec === undefined)
83
+ continue;
84
+ const [typeSpec, options] = Array.isArray(spec)
85
+ ? [spec[0], isObject(spec[1]) ? spec[1] : undefined]
86
+ : [undefined, undefined];
87
+ if (!isWidgetSpec(typeSpec, options))
88
+ continue;
89
+ addUnique(options?.widget?.name ?? key);
90
+ }
91
+ };
92
+ collect('required');
93
+ collect('optional');
94
+ }
95
+ return names;
96
+ }
97
+ /** Resolve a link to its effective origin, following Reroute and bypassed nodes. */
98
+ function resolveOutput(linkId, links, nodesById, objectInfo, warnings) {
99
+ const link = links.get(linkId);
100
+ if (link === undefined)
101
+ return 'missing';
102
+ return resolveOrigin(link[1], link[2], links, nodesById, objectInfo, warnings);
103
+ }
104
+ function resolveOrigin(nodeId, slot, links, nodesById, objectInfo, warnings) {
105
+ const node = nodesById.get(nodeId);
106
+ if (node === undefined)
107
+ return 'missing';
108
+ // Reroute: the output mirrors the node's input.
109
+ if (node.type === 'Reroute') {
110
+ const inputLink = (node.inputs ?? []).find((entry) => entry.link !== null)?.link;
111
+ if (inputLink === undefined || inputLink === null)
112
+ return 'missing';
113
+ return resolveOutput(inputLink, links, nodesById, objectInfo, warnings);
114
+ }
115
+ // Bypassed nodes act as pass-throughs: the output follows the first wired input.
116
+ if (node.mode === 4) {
117
+ const inputLink = (node.inputs ?? []).find((entry) => entry.link !== null)?.link;
118
+ if (inputLink === undefined || inputLink === null)
119
+ return 'missing';
120
+ return resolveOutput(inputLink, links, nodesById, objectInfo, warnings);
121
+ }
122
+ // Unregistered node types: a UI-only value source inlines its first widget;
123
+ // anything else wired into the chain is a hard conversion error.
124
+ if (objectInfo[node.type] === undefined) {
125
+ const value = Array.isArray(node.widgets_values) ? node.widgets_values[0] : undefined;
126
+ return value !== undefined ? value : 'missing';
127
+ }
128
+ // Node id references must be strings: the server keys prompts by string id
129
+ // and does a direct dict lookup (execution.py validate_inputs). Slots beyond
130
+ // the server-side output count are graph artifacts (e.g. SaveImage saving an
131
+ // extra UI output) — drop the reference and surface a warning.
132
+ const outputCount = Array.isArray(objectInfo[node.type]?.output)
133
+ ? objectInfo[node.type].output.length
134
+ : undefined;
135
+ if (outputCount !== undefined && slot >= outputCount) {
136
+ warnings.push(`节点 ${node.type} 的第 ${slot} 号输出在服务端不存在,已断开相关连线`);
137
+ return 'missing';
138
+ }
139
+ return [String(nodeId), slot];
140
+ }
141
+ /**
142
+ * Convert a UI-graph workflow (or one extracted component of it) to API
143
+ * format using the live node definitions.
144
+ * @param graph - parsed ComfyUI UI graph (v0.4 format).
145
+ * @param objectInfo - the server's `/object_info` response.
146
+ * @param options - `includeNodeIds` restricts conversion to one connected
147
+ * component (extraction); link resolution still uses the full graph, which
148
+ * is safe because components never share links.
149
+ */
150
+ export function convertGraphToApi(graph, objectInfo, options) {
151
+ if (!isObject(graph))
152
+ return { ok: false, error: '不是 ComfyUI 图格式(缺少 nodes/links)' };
153
+ const rawNodes = graph.nodes;
154
+ const rawLinks = graph.links;
155
+ if (!Array.isArray(rawNodes))
156
+ return { ok: false, error: '缺少 nodes 数组' };
157
+ if (!Array.isArray(rawLinks))
158
+ return { ok: false, error: '缺少 links 数组' };
159
+ const nodes = rawNodes.filter(isObject).map((raw) => {
160
+ const id = typeof raw.id === 'number' ? raw.id : Number(raw.id);
161
+ const inputs = Array.isArray(raw.inputs) ? raw.inputs.filter(isObject).map((entry) => ({
162
+ name: typeof entry.name === 'string' ? entry.name : '',
163
+ link: typeof entry.link === 'number' ? entry.link : null,
164
+ widget: isObject(entry.widget) ? entry.widget : undefined,
165
+ })) : [];
166
+ const outputs = Array.isArray(raw.outputs) ? raw.outputs.filter(isObject).map((entry) => ({
167
+ links: Array.isArray(entry.links) ? entry.links.filter((link) => typeof link === 'number') : [],
168
+ })) : [];
169
+ return {
170
+ id,
171
+ type: typeof raw.type === 'string' ? raw.type : '',
172
+ mode: typeof raw.mode === 'number' ? raw.mode : 0,
173
+ inputs,
174
+ outputs,
175
+ widgets_values: Array.isArray(raw.widgets_values) || isObject(raw.widgets_values)
176
+ ? raw.widgets_values
177
+ : undefined,
178
+ };
179
+ });
180
+ const links = new Map();
181
+ for (const raw of rawLinks) {
182
+ if (!Array.isArray(raw) || raw.length < 6)
183
+ continue;
184
+ const link = raw;
185
+ if (typeof link[0] === 'number')
186
+ links.set(link[0], link);
187
+ }
188
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
189
+ const included = options?.includeNodeIds;
190
+ const candidates = included !== undefined ? nodes.filter((node) => included.has(node.id)) : nodes;
191
+ const hasUsedOutput = (node) => (node.outputs ?? []).some((output) => output.links.some((linkId) => {
192
+ if (linkId === null)
193
+ return false;
194
+ const link = links.get(linkId);
195
+ return link !== undefined && nodesById.get(link[3]) !== undefined;
196
+ }));
197
+ const workflow = {};
198
+ const warnings = [];
199
+ for (const node of candidates) {
200
+ if (node.type === '' || UI_ONLY.has(node.type))
201
+ continue;
202
+ if (node.mode === 4)
203
+ continue;
204
+ if (node.type.startsWith('workflow')) {
205
+ return { ok: false, error: `包含子图节点 "${node.type}",暂不支持转换` };
206
+ }
207
+ if (objectInfo[node.type] === undefined) {
208
+ if (!hasUsedOutput(node) || node.type.startsWith('Primitive'))
209
+ continue;
210
+ return { ok: false, error: `包含未注册的 UI-only 节点 "${node.type}",无法转换` };
211
+ }
212
+ const inputs = {};
213
+ for (const entry of node.inputs ?? []) {
214
+ if (entry.link === undefined || entry.link === null)
215
+ continue;
216
+ const resolved = resolveOutput(entry.link, links, nodesById, objectInfo, warnings);
217
+ if (resolved !== 'missing')
218
+ inputs[entry.name] = resolved;
219
+ }
220
+ const values = node.widgets_values;
221
+ if (Array.isArray(values)) {
222
+ let valueIndex = 0;
223
+ for (const name of widgetNamesFor(node.type, objectInfo, node)) {
224
+ if (valueIndex >= values.length)
225
+ break;
226
+ if (!(name in inputs))
227
+ inputs[name] = values[valueIndex];
228
+ valueIndex++;
229
+ }
230
+ }
231
+ else if (isObject(values)) {
232
+ // Some nodes (e.g. VHS_VideoCombine) serialize widgets as a keyed object
233
+ // including internal UI state; copy the plain input values by name.
234
+ for (const [name, value] of Object.entries(values)) {
235
+ if (name === 'videopreview')
236
+ continue;
237
+ if (isObject(value) && value.hidden === true)
238
+ continue;
239
+ if (!(name in inputs))
240
+ inputs[name] = value;
241
+ }
242
+ }
243
+ // Collapse DynamicCombo V3 flat keys back into the API object shape:
244
+ // { key, inputs: { subWidget: value } } — the master combo's value is the
245
+ // selected option key, and its `master.sub` siblings become the sub-inputs.
246
+ for (const [name, value] of Object.entries(inputs)) {
247
+ const spec = inputSpec(objectInfo, node.type, name);
248
+ if (!Array.isArray(spec) || spec[0] !== 'COMFY_DYNAMICCOMBO_V3')
249
+ continue;
250
+ const sub = {};
251
+ for (const [other, otherValue] of Object.entries(inputs)) {
252
+ if (other.startsWith(`${name}.`))
253
+ sub[other.slice(name.length + 1)] = otherValue;
254
+ }
255
+ inputs[name] = { key: value, inputs: sub };
256
+ for (const subName of Object.keys(sub))
257
+ delete inputs[`${name}.${subName}`];
258
+ }
259
+ workflow[String(node.id)] = { class_type: node.type, inputs };
260
+ }
261
+ if (Object.keys(workflow).length === 0) {
262
+ return { ok: false, error: '转换结果为空(图中没有可执行的节点)' };
263
+ }
264
+ // Fail loudly on nodes whose required inputs are missing — the source graph
265
+ // itself is broken (inputs never wired), and the server would only echo a
266
+ // confusing per-node error at run time. Growable/lazy v3 inputs (template
267
+ // types like COMFY_AUTOGROW_V3) are skipped: their generated sub-inputs
268
+ // (values.a, sampling_mode.temperature, ...) satisfy them.
269
+ for (const [id, node] of Object.entries(workflow)) {
270
+ const def = objectInfo[node.class_type];
271
+ const required = isObject(def) && isObject(def.input) && isObject(def.input.required)
272
+ ? Object.keys(def.input.required)
273
+ : [];
274
+ const missing = required.filter((name) => {
275
+ if (name in node.inputs)
276
+ return false;
277
+ const spec = inputSpec(objectInfo, node.class_type, name);
278
+ const options = Array.isArray(spec) && isObject(spec[1]) ? spec[1] : undefined;
279
+ return options?.lazy !== true && options?.template === undefined;
280
+ });
281
+ if (missing.length > 0) {
282
+ return {
283
+ ok: false,
284
+ error: `节点 ${node.class_type}(id ${id})缺少必需输入:${missing.join('、')}——源工作流中这些输入没有连线`,
285
+ };
286
+ }
287
+ }
288
+ return { ok: true, workflow, warnings };
289
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Host hint: remember the origin browsers use to reach this web server, so
3
+ * generated media URLs (tool results) point at an address the requesting
4
+ * browser can actually load. The hint is derived from the Host header and the
5
+ * Referer of every /comfyui/* request the browser makes: a panel load records
6
+ * the Host header before any generation happens, and a media <img> fetch
7
+ * carries the page's own URL in Referer — which recovers the external origin
8
+ * even when an earlier generated URL already fell back to loopback.
9
+ *
10
+ * Loopback origins (127.0.0.1/localhost) never displace an already-seen
11
+ * external origin: server-side tool calls and local debug requests must not
12
+ * overwrite the address remote browsers use.
13
+ */
14
+ import type { IncomingMessage } from 'node:http';
15
+ /**
16
+ * The server's own first reachable LAN origin (e.g. http://192.168.1.5:3080),
17
+ * used as the media-URL fallback before loopback. Picks the first non-internal
18
+ * IPv4 that is not loopback or link-local. Returns undefined when no such
19
+ * address exists (no network interface), in which case callers keep loopback.
20
+ */
21
+ export declare function detectLanOrigin(port: number): string | undefined;
22
+ export interface HostHint {
23
+ /** Record the origin of one request (Host header + Referer + forwarded proto). */
24
+ record(request: IncomingMessage): void;
25
+ /** The best-known origin: the last external one, else the last loopback one, else undefined. */
26
+ origin(): string | undefined;
27
+ }
28
+ /** Create a host hint accumulator. */
29
+ export declare function createHostHint(): HostHint;
@@ -0,0 +1,73 @@
1
+ import { networkInterfaces } from 'node:os';
2
+ /**
3
+ * The server's own first reachable LAN origin (e.g. http://192.168.1.5:3080),
4
+ * used as the media-URL fallback before loopback. Picks the first non-internal
5
+ * IPv4 that is not loopback or link-local. Returns undefined when no such
6
+ * address exists (no network interface), in which case callers keep loopback.
7
+ */
8
+ export function detectLanOrigin(port) {
9
+ for (const list of Object.values(networkInterfaces())) {
10
+ if (list === undefined)
11
+ continue;
12
+ for (const net of list) {
13
+ if (net.family !== 'IPv4' || net.internal)
14
+ continue;
15
+ const ip = net.address;
16
+ if (ip === '127.0.0.1' || ip.startsWith('169.254.'))
17
+ continue;
18
+ return `http://${ip}:${port}`;
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+ /** Whether a Host header value names the local machine. */
24
+ function isLoopback(host) {
25
+ const bare = host.replace(/^\[/, '').replace(/\].*$/, '').replace(/:\d+$/, '').toLowerCase();
26
+ return bare === '127.0.0.1' || bare === 'localhost' || bare === '::1';
27
+ }
28
+ /** Extract the http(s) origin of a Referer header value, or undefined. */
29
+ function parseRefererOrigin(referer) {
30
+ const raw = Array.isArray(referer) ? referer[0] : referer;
31
+ if (typeof raw !== 'string' || raw === '')
32
+ return undefined;
33
+ try {
34
+ const url = new URL(raw);
35
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
36
+ return undefined;
37
+ return `${url.protocol}//${url.host}`.replace(/\/+$/, '');
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** Whether an origin string names the local machine. */
44
+ function isLoopbackOrigin(origin) {
45
+ const match = origin.match(/^https?:\/\/([^/:]+)/);
46
+ return match === null || isLoopback(match[1] ?? '');
47
+ }
48
+ /** Create a host hint accumulator. */
49
+ export function createHostHint() {
50
+ let loopback;
51
+ let external;
52
+ return {
53
+ record(request) {
54
+ const host = request.headers.host;
55
+ const forwarded = request.headers['x-forwarded-proto'];
56
+ const proto = typeof forwarded === 'string' ? (forwarded.split(',')[0]?.trim() || 'http') : 'http';
57
+ const hostOrigin = typeof host === 'string' && host !== ''
58
+ ? `${proto}://${host}`.replace(/\/+$/, '')
59
+ : undefined;
60
+ const refererOrigin = parseRefererOrigin(request.headers.referer);
61
+ const externalSignal = (hostOrigin !== undefined && !isLoopbackOrigin(hostOrigin))
62
+ ? hostOrigin
63
+ : (refererOrigin !== undefined && !isLoopbackOrigin(refererOrigin) ? refererOrigin : undefined);
64
+ if (externalSignal !== undefined)
65
+ external = externalSignal;
66
+ if (hostOrigin !== undefined && isLoopbackOrigin(hostOrigin))
67
+ loopback = hostOrigin;
68
+ },
69
+ origin() {
70
+ return external ?? loopback;
71
+ },
72
+ };
73
+ }
package/lib/http.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Small HTTP helpers for the dsh-comfyui routes, mirroring the shapes the
3
+ * dshmarket bundle uses for its own same-origin API.
4
+ */
5
+ import type { IncomingMessage, ServerResponse } from 'node:http';
6
+ /** Send a JSON response with no-store caching. */
7
+ export declare function sendJson(response: ServerResponse, status: number, body: unknown): void;
8
+ /** Read and parse a JSON request body; undefined when the body is empty. */
9
+ export declare function readJsonBody(request: IncomingMessage): Promise<unknown>;
10
+ /** Read a raw (possibly binary) request body, e.g. for multipart forwarding. */
11
+ export declare function readRawBody(request: IncomingMessage): Promise<Buffer>;
12
+ /** Whether a request originates from the page that served it (Origin vs Host). */
13
+ export declare function sameOrigin(request: IncomingMessage): boolean;
14
+ /** Human-readable error message from an unknown thrown value. */
15
+ export declare function errorMessage(error: unknown): string;
package/lib/http.js ADDED
@@ -0,0 +1,41 @@
1
+ /** Send a JSON response with no-store caching. */
2
+ export function sendJson(response, status, body) {
3
+ const payload = JSON.stringify(body);
4
+ response.writeHead(status, {
5
+ 'content-type': 'application/json; charset=utf-8',
6
+ 'cache-control': 'no-store',
7
+ });
8
+ response.end(payload);
9
+ }
10
+ /** Read and parse a JSON request body; undefined when the body is empty. */
11
+ export async function readJsonBody(request) {
12
+ const chunks = [];
13
+ for await (const chunk of request) {
14
+ chunks.push(chunk);
15
+ }
16
+ const text = Buffer.concat(chunks).toString('utf8');
17
+ if (text === '')
18
+ return undefined;
19
+ return JSON.parse(text);
20
+ }
21
+ /** Read a raw (possibly binary) request body, e.g. for multipart forwarding. */
22
+ export async function readRawBody(request) {
23
+ const chunks = [];
24
+ for await (const chunk of request)
25
+ chunks.push(chunk);
26
+ return Buffer.concat(chunks);
27
+ }
28
+ /** Whether a request originates from the page that served it (Origin vs Host). */
29
+ export function sameOrigin(request) {
30
+ const origin = request.headers.origin;
31
+ const host = request.headers.host;
32
+ if (origin === undefined)
33
+ return true;
34
+ if (host === undefined)
35
+ return false;
36
+ return origin === `http://${host}` || origin === `https://${host}`;
37
+ }
38
+ /** Human-readable error message from an unknown thrown value. */
39
+ export function errorMessage(error) {
40
+ return error instanceof Error ? error.message : String(error);
41
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * dsh-comfyui host entry: wires the tools, HTTP routes, and media proxy, and
3
+ * registers the `comfyui:` settings section so the browser settings page can
4
+ * persist config without editing cordis.yml. Everything unmounts with the
5
+ * plugin fiber.
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import { Config, type Config as ConfigType } from './config.js';
9
+ export declare const name = "dsh-comfyui";
10
+ export { Config };
11
+ /**
12
+ * Required services. `tools` is the model-facing registry the plugin writes
13
+ * into, so the fiber must wait for it: reading `ctx.tools` without declaring
14
+ * it here is what cordis rejects with `cannot get property "tools" without
15
+ * inject`. `webServer`, `settings`, and `credentials` stay OUT of this list —
16
+ * they are optional, and the plugin degrades gracefully without them (see
17
+ * apply).
18
+ */
19
+ export declare const inject: string[];
20
+ /**
21
+ * The plugin body. The loader validates the entry config against `Config`
22
+ * (defaults applied), then hands the resolved object to apply.
23
+ */
24
+ export declare function apply(ctx: Context, entryConfig: Partial<ConfigType>): Promise<void>;