From 2f94a9773e8a7a918ca3f7ff4ba408d1a07935c5 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 18 Jul 2026 22:10:48 +0800 Subject: [PATCH] feat(workflow): redirect gen-params updates to connected Primitive nodes (#1026) When a KSampler marked as 'Send Gen Params Target' has widget inputs wired to Primitive nodes (PrimitiveNode, PrimitiveInt, PrimitiveFloat, etc.), sending gen params from the Lora Manager UI now updates the Primitive node's value instead of the KSampler widget. This is necessary because ComfyUI's execution engine reads from the connected input, ignoring the widget value when a wire is present. Also fix two minor issues found during review: - Remove unnecessary String() wrapping on numeric gen params (seed, steps, cfg) to preserve native types through the JSON/WS path - Correct misleading isNodeEnabled comment: LGraphEventMode values are 0=Always, 2=Never, 4=Bypass (not 'Normal/Enabled') --- static/js/utils/genParamsMapper.js | 2 +- static/js/utils/uiHelpers.js | 2 +- tests/frontend/utils/genParamsMapper.test.js | 8 +- web/comfyui/workflow_registry.js | 134 ++++++++++++++++++- 4 files changed, 139 insertions(+), 7 deletions(-) diff --git a/static/js/utils/genParamsMapper.js b/static/js/utils/genParamsMapper.js index 18e19fe6..2ba38087 100644 --- a/static/js/utils/genParamsMapper.js +++ b/static/js/utils/genParamsMapper.js @@ -254,7 +254,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) { const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key]; for (const candidate of candidates) { if (widgetSet.has(candidate.toLowerCase())) { - updates.push({ widgetName: candidate, value: String(value) }); + updates.push({ widgetName: candidate, value }); break; } } diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index ee777d90..ec9d2441 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -605,7 +605,7 @@ function isNodeEnabled(node) { if (!node) { return false; } - // ComfyUI node mode: 0 = Normal/Enabled, others = Always/Never/OnEvent + // ComfyUI node mode (LGraphEventMode): 0 = Always, 2 = Never, 4 = Bypass return node.mode === undefined || node.mode === 0; } diff --git a/tests/frontend/utils/genParamsMapper.test.js b/tests/frontend/utils/genParamsMapper.test.js index 4a5a9b82..b072f2ee 100644 --- a/tests/frontend/utils/genParamsMapper.test.js +++ b/tests/frontend/utils/genParamsMapper.test.js @@ -204,9 +204,9 @@ describe('findMatchingWidgets', () => { it('matches seed to seed widget', () => { const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); - expect(updates).toContainEqual({ widgetName: 'seed', value: '42' }); - expect(updates).toContainEqual({ widgetName: 'steps', value: '30' }); - expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' }); + expect(updates).toContainEqual({ widgetName: 'seed', value: 42 }); + expect(updates).toContainEqual({ widgetName: 'steps', value: 30 }); + expect(updates).toContainEqual({ widgetName: 'cfg', value: 7 }); expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' }); expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' }); }); @@ -221,7 +221,7 @@ describe('findMatchingWidgets', () => { const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); const seedUpdate = updates.find(u => u.widgetName === 'noise_seed'); expect(seedUpdate).toBeDefined(); - expect(seedUpdate.value).toBe('42'); + expect(seedUpdate.value).toBe(42); }); it('matches rgthree-style sampler widget name', () => { diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js index 3549d74e..32b4b994 100644 --- a/web/comfyui/workflow_registry.js +++ b/web/comfyui/workflow_registry.js @@ -1,6 +1,6 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback } from "./utils.js"; +import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js"; import { ensureLmStyles } from "./lm_styles_loader.js"; const DEBOUNCE_DELAY = 500; @@ -76,6 +76,84 @@ function fadeWidgetTextColor(widget, fromColor, toColor, duration) { return () => { if (rafId) cancelAnimationFrame(rafId); }; } +// --------------------------------------------------------------------------- +// Primitive node helpers +// --------------------------------------------------------------------------- + +/** + * Set of node type names that represent Primitive value nodes. + * Includes both the dynamic PrimitiveNode (created by double-clicking + * a widget input) and the static typed primitives from the node library. + */ +const PRIMITIVE_NODE_TYPES = new Set([ + "PrimitiveNode", // dynamic (double-click a widget input) + "PrimitiveInt", + "PrimitiveFloat", + "PrimitiveString", + "PrimitiveBoolean", + "PrimitiveStringMultiline", +]); + +/** + * Return true when `node` is any flavour of Primitive node. + * @param {Object} node - LiteGraph node instance + * @returns {boolean} + */ +function isPrimitiveNodeType(node) { + return PRIMITIVE_NODE_TYPES.has(node?.type); +} + +/** + * Find the 0-based input slot index whose widget name matches `widgetName`. + * Returns -1 when no matching input is found. + * + * Matching strategy (in order): + * 1. `input.widget?.name === widgetName` — direct widget ref (preferred) + * 2. `input.name === widgetName` — fallback by slot name + * + * @param {Object} node - LiteGraph node instance + * @param {string} widgetName + * @returns {number} + */ +function findInputSlotForWidget(node, widgetName) { + if (!node || !Array.isArray(node.inputs)) { + return -1; + } + return node.inputs.findIndex( + (inp) => inp?.widget?.name === widgetName || inp?.name === widgetName + ); +} + +/** + * If the input slot that backs `widgetName` on `node` is connected to a + * Primitive node, return that Primitive node. Otherwise return null. + * + * This is the key bridge for the "send gen params → Primitive" flow: + * when a KSampler widget (e.g. "steps") has an incoming wire from a + * Primitive node, we want to update the Primitive's value instead of the + * KSampler widget, because ComfyUI's execution engine reads from the + * connected input, not the widget. + * + * @param {Object} node - the target node (e.g. KSampler) + * @param {string} widgetName - e.g. "steps", "cfg", "seed" + * @returns {Object|null} - the connected Primitive node, or null + */ +function tryResolvePrimitiveConnection(node, widgetName) { + const slotIndex = findInputSlotForWidget(node, widgetName); + if (slotIndex === -1) return null; + + const input = node.inputs[slotIndex]; + if (input?.link == null) return null; + + const link = getLinkFromGraph(node.graph, input.link); + if (!link) return null; + + const originNode = node.graph?.getNodeById?.(link.origin_id); + if (!originNode) return null; + + return isPrimitiveNodeType(originNode) ? originNode : null; +} + app.registerExtension({ name: "LoraManager.WorkflowRegistry", @@ -309,6 +387,60 @@ app.registerExtension({ return; } + // ---- Redirect to connected Primitive node when present ---- + // When a widget input (e.g. "steps", "cfg", "seed" on KSampler) + // is wired to a Primitive node, the Primitive's value overrides + // the widget value during execution. Update the Primitive + // directly so the change actually takes effect. + if (widgetName) { + const primitiveNode = tryResolvePrimitiveConnection(node, widgetName); + if (primitiveNode) { + const primWidget = primitiveNode.widgets?.[0]; + if (primWidget) { + let primNewValue = value; + if (mode === "append") { + const sep = + primWidget.value && primWidget.value.length > 0 + ? " " + : ""; + primNewValue = primWidget.value + sep + value; + } + primWidget.value = primNewValue; + if ( + Array.isArray(primitiveNode.widgets_values) && + primitiveNode.widgets_values.length > 0 + ) { + primitiveNode.widgets_values[0] = primNewValue; + } + if (typeof primWidget.callback === "function") { + try { + primWidget.callback(primNewValue); + } catch (callbackError) { + console.error( + "LoRA Manager: primitive widget callback failed", + callbackError + ); + } + } + if (typeof primitiveNode.setDirtyCanvas === "function") { + primitiveNode.setDirtyCanvas(true); + } + if (typeof app.graph?.setDirtyCanvas === "function") { + app.graph.setDirtyCanvas(true, true); + } + this.flashWidget(primitiveNode, primWidget); + console.debug( + "LoRA Manager: redirected widget update to Primitive node %s (id=%d) ← %s = %o", + primitiveNode.type, + primitiveNode.id, + widgetName, + primNewValue + ); + return; + } + } + } + // ---- Update widget value ---- const widgetIndex = node.widgets.indexOf(targetWidget); let newValue = value;