Prechádzať zdrojové kódy

feat(nodes): add generic ComfyUI Prompt node

Submits an API-format ComfyUI graph, polls /history until the job finishes,
and returns the generated image base64-encoded. Task-agnostic like
ollama-chat: the graph lives entirely in configuration, so nothing about
Z-Image or any particular style is baked into the node.

Placeholders use double angle brackets rather than double braces, for two
reasons found while testing against the live engine. The workflow engine
interpolates brace placeholders in node config before a node runs, so a
brace placeholder in the graph is consumed and blanked before the node sees
it. Separately, this QuickJS build mishandles literal braces in regex
patterns: the pattern matches but the replacement is silently dropped, so
String.replace cannot be used for this at all. Substitution therefore uses
split/join, which behaves correctly.

Substituted values are JSON-escaped, so a prompt containing quotes or
newlines cannot produce an unparseable graph, and unresolved placeholders
are reported by name rather than surfacing as an opaque JSON parse error.

Verified end to end against ComfyUI 0.28.0 with Z-Image Turbo: graph
submitted, image generated, downloaded through the binary-safe dataBase64
path, and stored in the database file store.
fszontagh 1 mesiac pred
rodič
commit
19252a82fd
1 zmenil súbory, kde vykonal 368 pridanie a 0 odobranie
  1. 368 0
      nodes/ai/comfyui-prompt.js

+ 368 - 0
nodes/ai/comfyui-prompt.js

@@ -0,0 +1,368 @@
+/**
+ * @node comfyui-prompt
+ * @name ComfyUI Prompt
+ * @category ai
+ * @version 1.0.0
+ * @description Submit an API-format workflow graph to ComfyUI, wait for it to finish, and return the generated image
+ * @icon image
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        baseUrl: {
+            type: 'string',
+            title: 'ComfyUI Base URL',
+            description: 'Base URL of the ComfyUI server',
+            default: 'http://127.0.0.1:8188'
+        },
+        workflowJson: {
+            type: 'string',
+            title: 'Workflow Graph',
+            description: 'ComfyUI API-format graph. Placeholders use double angle brackets around a dot path, and a seed placeholder yields a random seed. Double braces are deliberately not used, because the engine already interpolates those in node config.',
+            format: 'textarea',
+            default: ''
+        },
+        outputNodeId: {
+            type: 'string',
+            title: 'Output Node ID',
+            description: 'Graph node whose images are returned, normally the SaveImage node',
+            default: '9'
+        },
+        imageIndex: {
+            type: 'number',
+            title: 'Image Index',
+            description: 'Which image to return when the output node produced several',
+            default: 0
+        },
+        pollIntervalMs: {
+            type: 'number',
+            title: 'Poll Interval (ms)',
+            description: 'How often to check whether the job has finished',
+            default: 3000
+        },
+        timeoutMs: {
+            type: 'number',
+            title: 'Generation Timeout (ms)',
+            description: 'Give up waiting after this long. A cold model load can take minutes.',
+            default: 600000
+        },
+        returnImage: {
+            type: 'boolean',
+            title: 'Return Image Data',
+            description: 'Download the result and return it base64-encoded. Disable to return only the filename.',
+            default: true
+        },
+        retryCount: {
+            type: 'number',
+            title: 'Retries',
+            description: 'Extra attempts after a failed submission or generation',
+            default: 0
+        },
+        retryDelayMs: {
+            type: 'number',
+            title: 'Retry Delay (ms)',
+            description: 'Pause before the first retry. Doubles on each further attempt.',
+            default: 3000
+        },
+        retryMaxDelayMs: {
+            type: 'number',
+            title: 'Max Retry Delay (ms)',
+            description: 'Upper bound for the exponential backoff',
+            default: 30000
+        },
+        skipOnError: {
+            type: 'boolean',
+            title: 'Skip On Error',
+            description: 'Return success false instead of failing the workflow. Useful inside a loop.',
+            default: false
+        }
+    },
+    required: ['baseUrl', 'workflowJson']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: {
+            type: 'any',
+            description: 'Values available to placeholder substitution in the graph'
+        }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        success: { type: 'boolean', description: 'False when generation failed and Skip On Error is enabled' },
+        error: { type: 'string', description: 'Failure reason when success is false' },
+        imageBase64: { type: 'string', description: 'Generated image, base64-encoded' },
+        filename: { type: 'string', description: 'Filename ComfyUI assigned to the result' },
+        subfolder: { type: 'string', description: 'ComfyUI output subfolder' },
+        mimeType: { type: 'string', description: 'MIME type of the returned image' },
+        size: { type: 'number', description: 'Image size in bytes' },
+        promptId: { type: 'string', description: 'ComfyUI prompt id for the job' },
+        elapsedMs: { type: 'number', description: 'Time from submission to completion' },
+        attempts: { type: 'number', description: 'How many attempts were made' }
+    }
+};
+
+function getPath(root, path) {
+    if (!root || !path) {
+        return undefined;
+    }
+    const parts = String(path).split('.');
+    let current = root;
+    for (let i = 0; i < parts.length; i++) {
+        if (current === null || typeof current !== 'object') {
+            return undefined;
+        }
+        current = current[parts[i]];
+    }
+    return current;
+}
+
+// The graph is a JSON document, so substituted values must be JSON-escaped or a
+// prompt containing a quote or newline would produce an unparseable graph.
+function jsonFragment(value) {
+    if (value === undefined || value === null) {
+        return '';
+    }
+    if (typeof value === 'number' || typeof value === 'boolean') {
+        return String(value);
+    }
+    const encoded = JSON.stringify(String(value));
+    return encoded.substring(1, encoded.length - 1);
+}
+
+// Placeholders are <<path>>, deliberately not {{path}}: the workflow engine
+// interpolates {{...}} in node config before the node ever runs, which would
+// blank these out. Substitution uses split/join rather than String.replace
+// because this QuickJS build mishandles literal braces in regex patterns and
+// silently drops the replacement.
+function findPlaceholders(text) {
+    const found = [];
+    let rest = String(text);
+    while (true) {
+        const start = rest.indexOf('<<');
+        if (start === -1) { break; }
+        const end = rest.indexOf('>>', start + 2);
+        if (end === -1) { break; }
+        const key = rest.substring(start + 2, end).trim();
+        if (key.length > 0 && found.indexOf(key) === -1) {
+            found.push(key);
+        }
+        rest = rest.substring(end + 2);
+    }
+    return found;
+}
+
+function substitute(template, data) {
+    let out = String(template);
+    const keys = findPlaceholders(out);
+
+    for (let i = 0; i < keys.length; i++) {
+        const key = keys[i];
+        let value = getPath(data, key);
+
+        if (value === undefined && key === 'seed') {
+            value = Math.floor(Math.random() * 1000000000000000);
+        }
+        if (value === undefined) {
+            continue;
+        }
+
+        out = out.split('<<' + key + '>>').join(jsonFragment(value));
+    }
+
+    return out;
+}
+
+function httpJson(method, url, body) {
+    const options = { method: method, url: url, timeout: 60000 };
+    if (body) {
+        options.headers = { 'Content-Type': 'application/json' };
+        options.body = body;
+    }
+    const response = smartbotic.http.request(options);
+    if (response.status < 200 || response.status >= 300) {
+        const detail = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
+        throw new Error('ComfyUI HTTP ' + response.status + ': ' + String(detail).substring(0, 300));
+    }
+    return typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
+}
+
+function runOnce(config, data, base) {
+    const graphText = substitute(config.workflowJson, data);
+
+    // An unresolved placeholder is the usual cause of a parse failure, and the raw
+    // JSON error does not say which one, so name them explicitly.
+    const unresolved = findPlaceholders(graphText);
+    if (unresolved.length > 0) {
+        const available = data && typeof data === 'object' ? Object.keys(data).join(', ') : '(none)';
+        throw new Error('Unresolved placeholders in workflow graph: <<' + unresolved.join('>>, <<') +
+                        '>>. Paths are relative to the previous node output, whose top-level keys are: ' + available);
+    }
+
+    let graph;
+    try {
+        graph = JSON.parse(graphText);
+    } catch (e) {
+        // Show where it broke: an empty substitution leaves "key": , which is the
+        // usual cause and is impossible to spot from the parser message alone.
+        let where = graphText.indexOf(': ,');
+        if (where === -1) { where = graphText.indexOf(':,'); }
+        if (where === -1) { where = 0; }
+        const snippet = graphText.substring(Math.max(0, where - 80), where + 80);
+        throw new Error('Workflow graph is not valid JSON after substitution: ' + e.message +
+                        ' | near: ' + snippet);
+    }
+
+    const payload = graph.prompt ? graph : { prompt: graph };
+    payload.client_id = 'smartbotic-' + (config.clientSuffix || 'node');
+
+    const submitted = httpJson('POST', base + '/prompt', JSON.stringify(payload));
+    if (!submitted || !submitted.prompt_id) {
+        throw new Error('ComfyUI did not return a prompt_id');
+    }
+    if (submitted.node_errors && Object.keys(submitted.node_errors).length > 0) {
+        throw new Error('ComfyUI rejected the graph: ' + JSON.stringify(submitted.node_errors).substring(0, 300));
+    }
+
+    const promptId = submitted.prompt_id;
+    const interval = Number(config.pollIntervalMs) || 3000;
+    const timeout = Number(config.timeoutMs) || 600000;
+    const deadline = Date.now() + timeout;
+    const startedAt = Date.now();
+
+    let history = null;
+    while (Date.now() < deadline) {
+        smartbotic.utils.sleep(interval);
+        const entry = httpJson('GET', base + '/history/' + promptId, null);
+        if (entry && entry[promptId]) {
+            history = entry[promptId];
+            break;
+        }
+    }
+
+    if (!history) {
+        throw new Error('ComfyUI generation timed out after ' + timeout + 'ms (prompt ' + promptId + ')');
+    }
+
+    if (history.status && history.status.status_str && history.status.status_str !== 'success') {
+        throw new Error('ComfyUI generation failed: ' + history.status.status_str);
+    }
+
+    const outputs = history.outputs || {};
+    const nodeId = String(config.outputNodeId || '9');
+    const nodeOutput = outputs[nodeId];
+    if (!nodeOutput || !nodeOutput.images || nodeOutput.images.length === 0) {
+        throw new Error('Output node ' + nodeId + ' produced no images. Available: ' + Object.keys(outputs).join(', '));
+    }
+
+    const index = Number(config.imageIndex) || 0;
+    const image = nodeOutput.images[index] || nodeOutput.images[0];
+
+    const result = {
+        promptId: promptId,
+        filename: image.filename || '',
+        subfolder: image.subfolder || '',
+        elapsedMs: Date.now() - startedAt,
+        imageBase64: '',
+        mimeType: 'image/png',
+        size: 0
+    };
+
+    if (config.returnImage === false) {
+        return result;
+    }
+
+    const viewUrl = base + '/view?filename=' + encodeURIComponent(result.filename) +
+                    '&subfolder=' + encodeURIComponent(result.subfolder) + '&type=output';
+    const download = smartbotic.http.request({ method: 'GET', url: viewUrl, timeout: 120000 });
+    if (download.status < 200 || download.status >= 300) {
+        throw new Error('Failed to download generated image: HTTP ' + download.status);
+    }
+
+    // dataBase64 carries the raw bytes; response.data is UTF-8 validated and
+    // would destroy the PNG.
+    if (typeof download.dataBase64 !== 'string' || download.dataBase64.length === 0) {
+        throw new Error('Runtime returned no binary data for the generated image');
+    }
+
+    result.imageBase64 = download.dataBase64;
+    result.size = Math.floor(result.imageBase64.replace(/=+$/, '').length * 3 / 4);
+
+    const headers = download.headers || {};
+    if (headers['content-type']) {
+        result.mimeType = String(headers['content-type']).split(';')[0].trim();
+    }
+
+    return result;
+}
+
+module.exports = {
+    configSchema,
+    inputSchema,
+    outputSchema,
+
+    async execute(config, input, context) {
+        if (!config.workflowJson || String(config.workflowJson).trim().length === 0) {
+            throw new Error('A ComfyUI workflow graph is required');
+        }
+
+        const data = input && input.data !== undefined ? input.data : input;
+        const base = String(config.baseUrl).replace(/\/+$/, '');
+        const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
+
+        let lastError = '';
+        let used = 0;
+
+        for (let attempt = 1; attempt <= attempts; attempt++) {
+            used = attempt;
+            try {
+                const result = runOnce(config, data, base);
+                return {
+                    success: true,
+                    error: '',
+                    imageBase64: result.imageBase64,
+                    filename: result.filename,
+                    subfolder: result.subfolder,
+                    mimeType: result.mimeType,
+                    size: result.size,
+                    promptId: result.promptId,
+                    elapsedMs: result.elapsedMs,
+                    attempts: used
+                };
+            } catch (err) {
+                lastError = err && err.message ? err.message : String(err);
+                smartbotic.log.warn('comfyui-prompt: attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError);
+                if (attempt < attempts) {
+                    const baseDelay = Number(config.retryDelayMs) || 3000;
+                    const cap = Number(config.retryMaxDelayMs) || 30000;
+                    let delay = baseDelay * Math.pow(2, attempt - 1);
+                    if (delay > cap) { delay = cap; }
+                    smartbotic.utils.sleep(delay);
+                }
+            }
+        }
+
+        if (config.skipOnError !== true) {
+            throw new Error('comfyui-prompt failed after ' + used + ' attempt(s): ' + lastError);
+        }
+
+        smartbotic.log.warn('comfyui-prompt: skipping after ' + used + ' attempt(s)');
+        return {
+            success: false,
+            error: lastError,
+            imageBase64: '',
+            filename: '',
+            subfolder: '',
+            mimeType: '',
+            size: 0,
+            promptId: '',
+            elapsedMs: 0,
+            attempts: used
+        };
+    }
+};