diff --git a/tests/js/workflow-config-to-array.test.js b/tests/js/workflow-config-to-array.test.js new file mode 100644 index 0000000..a97d958 --- /dev/null +++ b/tests/js/workflow-config-to-array.test.js @@ -0,0 +1,55 @@ +// workflowConfigToArray lives in an ES module that imports ComfyUI's frontend, so this test slices the +// pure helper functions out of the source and evaluates them (same trick as other tests that avoid the DOM). +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const src = fs.readFileSync(path.join(__dirname, '../../web/conf_builder/conf-builder-workflow-sweep.js'), 'utf8'); + +function grab(name) { + const re = new RegExp('(?:export )?function ' + name + '\\('); + const m = re.exec(src); + assert.ok(m, 'function ' + name + ' not found'); + let i = src.indexOf('{', m.index), depth = 0, j = i; + for (; j < src.length; j++) { + if (src[j] === '{') depth++; + else if (src[j] === '}') { depth--; if (depth === 0) break; } + } + return src.slice(m.index, j + 1).replace(/^export /, ''); +} + +const code = ['defaultWorkflowState', 'ensureWorkflowState', 'newWorkflowArray', 'workflowConfigToArray'].map(grab).join('\n'); +const { workflowConfigToArray } = new Function(code + '\nreturn { workflowConfigToArray };')(); + +const cfg = { + model_type: 'workflow', name: 'My sweep', workflow: { '3': { class_type: 'KSampler', inputs: { seed: 1 } } }, + workflow_source: 'wf.json', workflow_output: { node_id: '9', output_index: 0 }, workflow_run_other_outputs: true, + workflow_bindings: [{ id: 'b1', kind: 'literal', targets: [{ node_id: '3', input: 'seed' }], values: [1, 2] }], + positive: [['a cat']], negative: 'bad', _prompt_source: 'custom', +}; + +test('restores workflow, bindings, output and custom prompts', () => { + const a = workflowConfigToArray(cfg, 0); + assert.strictEqual(a.kind, 'workflow'); + assert.strictEqual(a.name, 'My sweep'); + assert.deepStrictEqual(a.workflow.api_prompt, cfg.workflow); + assert.strictEqual(a.workflow.source_name, 'wf.json'); + assert.deepStrictEqual(a.workflow.output, { node_id: '9', output_index: 0 }); + assert.strictEqual(a.workflow.run_other_outputs, true); + assert.deepStrictEqual(a.workflow.bindings, cfg.workflow_bindings); + assert.strictEqual(a.use_custom_prompts, true); + assert.deepStrictEqual(a.positive_prompt_groups, [['a cat']]); + assert.strictEqual(a.negative_prompt, 'bad'); +}); + +test('global prompts are not copied into the array; odd input never throws', () => { + const a = workflowConfigToArray({ ...cfg, _prompt_source: 'global' }, 2); + assert.strictEqual(a.use_custom_prompts, false); + assert.deepStrictEqual(a.positive_prompt_groups, []); + const b = workflowConfigToArray(null, 1); + assert.strictEqual(b.kind, 'workflow'); + assert.strictEqual(b.name, 'Workflow 2'); + assert.strictEqual(b.workflow.api_prompt, null); + assert.deepStrictEqual(b.workflow.bindings, []); +}); diff --git a/web/conf_builder/conf-builder-main.js b/web/conf_builder/conf-builder-main.js index aafc363..a4d4769 100644 --- a/web/conf_builder/conf-builder-main.js +++ b/web/conf_builder/conf-builder-main.js @@ -8,6 +8,17 @@ import { app } from "../../../scripts/app.js"; // Cache-busting timestamp (regenerated on every page load) const CACHE_BUST = Date.now(); +// Small info toast (falls back to alert when the frontend has no toast extension) +function notify(detail) { + try { + if (app.extensionManager?.toast?.add) { + app.extensionManager.toast.add({ severity: "info", summary: "USCG", detail, life: 8000 }); + return; + } + } catch (e) { /* fall through */ } + alert(detail); +} + // Module storage let utilities, uiComponents, configManagement, distributionModule; @@ -418,7 +429,12 @@ app.registerExtension({ if (configs && !Array.isArray(configs) && configs.configs) { configs = configs.configs; } - let loadedArrays = utilities.convertConfigsToConfigArrays(configs); + // Workflow Sweep entries (model_type "workflow") restore through workflowConfigToArray; + // standard entries keep the existing path. Original order is preserved. + const isWfCfg = (c) => c && c.model_type === "workflow"; + const wfCfgs = configs.filter(isWfCfg); + const stdCfgs = configs.filter(c => !isWfCfg(c)); + let loadedArrays = stdCfgs.length ? utilities.convertConfigsToConfigArrays(stdCfgs) : []; const normalize = (str) => str.replace(/\\/g, "/"); loadedArrays.forEach(arr => { @@ -486,6 +502,15 @@ app.registerExtension({ if (arr.kind === undefined) arr.kind = "standard"; }); + if (wfCfgs.length) { + const { workflowConfigToArray } = await import("./conf-builder-workflow-sweep.js"); + const wfArrays = wfCfgs.map((c, i) => workflowConfigToArray(c, i)); + const merged = []; + let si = 0, wi = 0; + configs.forEach(c => merged.push(isWfCfg(c) ? wfArrays[wi++] : loadedArrays[si++])); + loadedArrays = merged.filter(Boolean); + } + this.state.config_arrays = loadedArrays; // Restore global prompts from loaded configs @@ -515,6 +540,11 @@ app.registerExtension({ } } + // A sweep session saved before its configs were stored: nothing to restore, say so + if (meta.workflow_sweep && !meta.configs_json) { + notify("This session was made by a Workflow Sweep before its configs were saved. Load the workflow JSON again into a Workflow Sweep array; finished jobs are skipped by fingerprint."); + } + this.state.session_name = sessionName; this.saveState(); this.renderUI(); diff --git a/web/conf_builder/conf-builder-utilities.js b/web/conf_builder/conf-builder-utilities.js index 704240e..9721efd 100644 --- a/web/conf_builder/conf-builder-utilities.js +++ b/web/conf_builder/conf-builder-utilities.js @@ -656,6 +656,8 @@ export function convertConfigsToConfigArrays(configs) { }; configs.forEach((config, idx) => { + // Workflow Sweep entries are restored by workflowConfigToArray (loadSession); never build a standard array from one + if (config && config.model_type === "workflow") return; const loraValue = config.lora; let loras = []; let hasCombined = false; diff --git a/web/conf_builder/conf-builder-workflow-sweep.js b/web/conf_builder/conf-builder-workflow-sweep.js index 8527a92..d6ef7e1 100644 --- a/web/conf_builder/conf-builder-workflow-sweep.js +++ b/web/conf_builder/conf-builder-workflow-sweep.js @@ -53,6 +53,30 @@ export function newWorkflowArray(index) { }; } +/** + * Inverse of config_builder_node._workflow_array_to_config: a saved workflow config entry (manifest + * meta.configs_json) back into a Builder array of kind "workflow". Never throws on odd input. + */ +export function workflowConfigToArray(cfg, idx = 0) { + const arr = newWorkflowArray(idx + 1); + cfg = (cfg && typeof cfg === "object") ? cfg : {}; + if (cfg.name) arr.name = String(cfg.name); + arr.workflow = { + source_name: String(cfg.workflow_source || ""), + api_prompt: (cfg.workflow && typeof cfg.workflow === "object" && Object.keys(cfg.workflow).length) ? cfg.workflow : null, + output: (cfg.workflow_output && typeof cfg.workflow_output === "object") ? cfg.workflow_output : {}, + run_other_outputs: !!cfg.workflow_run_other_outputs, + bindings: Array.isArray(cfg.workflow_bindings) ? cfg.workflow_bindings : [], + }; + if (cfg._prompt_source === "custom" && Array.isArray(cfg.positive)) { + arr.positive_prompt_groups = cfg.positive; + arr.negative_prompt = typeof cfg.negative === "string" ? cfg.negative : ""; + arr.use_custom_prompts = true; + } + ensureWorkflowState(arr); + return arr; +} + function el(tag, props = {}, css = "") { const e = document.createElement(tag); Object.assign(e, props);