diff --git a/static/css/components/lora-modal/showcase.css b/static/css/components/lora-modal/showcase.css
index 63373638..a1ba5f5f 100644
--- a/static/css/components/lora-modal/showcase.css
+++ b/static/css/components/lora-modal/showcase.css
@@ -229,6 +229,19 @@
gap: 10px;
}
+/* Header row for params section */
+.metadata-row.params-row {
+ flex-direction: column;
+}
+
+.metadata-row.params-row .param-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
/* Styling for parameters tags */
.params-tags {
display: flex;
@@ -315,7 +328,8 @@
}
.copy-prompt-btn,
-.send-prompt-btn {
+.send-prompt-btn,
+.send-params-btn {
background: transparent;
border: none;
color: var(--text-color);
@@ -328,7 +342,8 @@
}
.copy-prompt-btn:hover,
-.send-prompt-btn:hover {
+.send-prompt-btn:hover,
+.send-params-btn:hover {
opacity: 1;
color: var(--lora-accent);
background: var(--lora-surface);
diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js
index 68d6298e..7f7e458c 100644
--- a/static/js/components/RecipeModal.js
+++ b/static/js/components/RecipeModal.js
@@ -1,5 +1,5 @@
// Recipe Modal Component
-import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow } from '../utils/uiHelpers.js';
+import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
@@ -40,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = {
'Denoising strength': 'denoising_strength',
};
+const PARAM_DISPLAY_NAMES = {
+ steps: 'Steps',
+ sampler: 'Sampler',
+ cfg_scale: 'CFG',
+ seed: 'Seed',
+ size: 'Size',
+ clip_skip: 'Clip Skip',
+ denoising_strength: 'Denoising Strength',
+};
+
class RecipeModal {
constructor() {
this.promptEditorState = {};
@@ -588,10 +598,11 @@ class RecipeModal {
for (const [key, value] of Object.entries(sanitizedGenParams)) {
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
+ const displayName = PARAM_DISPLAY_NAMES[key] || key;
const paramTag = document.createElement('div');
paramTag.className = 'param-tag';
paramTag.innerHTML = `
- ${key}:
+ ${displayName}:
${value}
`;
otherParamsElement.appendChild(paramTag);
@@ -1234,6 +1245,19 @@ class RecipeModal {
});
});
}
+
+ // Send params to workflow button
+ const sendParamsBtn = document.getElementById('sendParamsBtn');
+ if (sendParamsBtn) {
+ sendParamsBtn.addEventListener('click', () => {
+ const genParams = this.currentRecipe?.gen_params || {};
+ if (!genParams || Object.keys(genParams).length === 0) {
+ showToast('No generation parameters available', {}, 'warning');
+ return;
+ }
+ sendGenParamsToWorkflow(genParams);
+ });
+ }
}
/**
diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js
index d3282726..bd0b270f 100644
--- a/static/js/components/shared/showcase/MediaUtils.js
+++ b/static/js/components/shared/showcase/MediaUtils.js
@@ -3,7 +3,7 @@
* Media-specific utility functions for showcase components
* (Moved from uiHelpers.js to better organize code)
*/
-import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags } from '../../../utils/uiHelpers.js';
+import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags, sendGenParamsToWorkflow } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
@@ -344,6 +344,48 @@ export function initMetadataPanelHandlers(container) {
});
});
+ // Handle send params buttons
+ const paramsBtn = metadataPanel.querySelector('.send-params-btn');
+ if (paramsBtn) {
+ paramsBtn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+
+ // Collect gen params from the param-tag elements
+ const tagsContainer = wrapper.querySelector('.params-tags');
+ if (!tagsContainer) return;
+
+ const paramTags = tagsContainer.querySelectorAll('.param-tag');
+ const genParams = {};
+
+ // Map display labels to genParams keys
+ const labelToKey = {
+ 'Seed': 'seed',
+ 'Steps': 'steps',
+ 'Sampler': 'sampler',
+ 'CFG': 'cfg_scale',
+ };
+
+ paramTags.forEach(tag => {
+ const nameEl = tag.querySelector('.param-name');
+ const valueEl = tag.querySelector('.param-value');
+ if (!nameEl || !valueEl) return;
+
+ const label = nameEl.textContent.replace(':', '').trim();
+ const key = labelToKey[label];
+ if (key) {
+ genParams[key] = valueEl.textContent.trim();
+ }
+ });
+
+ if (Object.keys(genParams).length === 0) {
+ showToast('No sendable parameters found', {}, 'warning');
+ return;
+ }
+
+ await sendGenParamsToWorkflow(genParams);
+ });
+ }
+
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
diff --git a/static/js/components/shared/showcase/MetadataPanel.js b/static/js/components/shared/showcase/MetadataPanel.js
index 053e2f7d..eeab48c3 100644
--- a/static/js/components/shared/showcase/MetadataPanel.js
+++ b/static/js/components/shared/showcase/MetadataPanel.js
@@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
if (hasParams) {
content += `
-
- ${size ? `
Size:${size}
` : ''}
- ${seed ? `
Seed:${seed}
` : ''}
- ${model ? `
Model:${model}
` : ''}
- ${steps ? `
Steps:${steps}
` : ''}
- ${sampler ? `
Sampler:${sampler}
` : ''}
- ${cfgScale ? `
CFG:${cfgScale}
` : ''}
- ${clipSkip ? `
Clip Skip:${clipSkip}
` : ''}
+
`;
}
diff --git a/static/js/utils/genParamsMapper.js b/static/js/utils/genParamsMapper.js
new file mode 100644
index 00000000..18e19fe6
--- /dev/null
+++ b/static/js/utils/genParamsMapper.js
@@ -0,0 +1,296 @@
+/**
+ * genParamsMapper.js
+ * Maps display/recipe generation parameter values (sampler, scheduler) to
+ * ComfyUI internal widget values, enabling "Send Gen Params to Workflow".
+ *
+ * Strategy (3 layers):
+ * 1. Direct lookup via SAMPLER_DISPLAY_TO_INTERNAL
+ * 2. Combined-name parsing (e.g. "Euler a Karras" → sampler + scheduler)
+ * 3. Graceful skip for model-specific / unrecognized values
+ */
+
+// ---------------------------------------------------------------------------
+// Sampler display name → internal name (ComfyUI KSampler.SAMPLERS / SAMPLER_NAMES)
+// ---------------------------------------------------------------------------
+const SAMPLER_DISPLAY_TO_INTERNAL = {
+ // --- Euler family ---
+ 'Euler': 'euler',
+ 'euler': 'euler',
+ 'Euler a': 'euler_ancestral',
+ 'Euler A': 'euler_ancestral',
+ 'Euler ancestral': 'euler_ancestral',
+ 'Euler Ancestral': 'euler_ancestral',
+ 'euler_ancestral': 'euler_ancestral',
+
+ // --- Heun ---
+ 'Heun': 'heun',
+ 'heun': 'heun',
+ 'Heun++': 'heunpp2',
+ 'heunpp2': 'heunpp2',
+
+ // --- DPM2 ---
+ 'DPM2': 'dpm_2',
+ 'DPM 2': 'dpm_2',
+ 'dpm_2': 'dpm_2',
+ 'DPM2 a': 'dpm_2_ancestral',
+ 'DPM2 Ancestral': 'dpm_2_ancestral',
+ 'dpm_2_ancestral': 'dpm_2_ancestral',
+
+ // --- LMS ---
+ 'LMS': 'lms',
+ 'lms': 'lms',
+
+ // --- DPM fast / adaptive ---
+ 'DPM fast': 'dpm_fast',
+ 'DPM Fast': 'dpm_fast',
+ 'dpm_fast': 'dpm_fast',
+ 'DPM adaptive': 'dpm_adaptive',
+ 'DPM Adaptive': 'dpm_adaptive',
+ 'dpm_adaptive': 'dpm_adaptive',
+
+ // --- DPM++ 2S ancestral ---
+ 'DPM++ 2S a': 'dpmpp_2s_ancestral',
+ 'DPM++ 2S A': 'dpmpp_2s_ancestral',
+ 'DPM++ 2S Ancestral': 'dpmpp_2s_ancestral',
+ 'dpmpp_2s_ancestral': 'dpmpp_2s_ancestral',
+
+ // --- DPM++ SDE ---
+ 'DPM++ SDE': 'dpmpp_sde',
+ 'dpmpp_sde': 'dpmpp_sde',
+
+ // --- DPM++ 2M ---
+ 'DPM++ 2M': 'dpmpp_2m',
+ 'dpmpp_2m': 'dpmpp_2m',
+
+ // --- DPM++ 2M SDE ---
+ 'DPM++ 2M SDE': 'dpmpp_2m_sde',
+ 'dpmpp_2m_sde': 'dpmpp_2m_sde',
+
+ // --- DPM++ 3M SDE ---
+ 'DPM++ 3M SDE': 'dpmpp_3m_sde',
+ 'dpmpp_3m_sde': 'dpmpp_3m_sde',
+
+ // --- Others ---
+ 'DDIM': 'ddim',
+ 'ddim': 'ddim',
+ 'DDPM': 'ddpm',
+ 'ddpm': 'ddpm',
+ 'LCM': 'lcm',
+ 'lcm': 'lcm',
+ 'IPNDM': 'ipndm',
+ 'ipndm': 'ipndm',
+ 'DEIS': 'deis',
+ 'deis': 'deis',
+ 'UniPC': 'uni_pc',
+ 'unipc': 'uni_pc',
+ 'uni_pc': 'uni_pc',
+
+ // --- Restart / res_multistep ---
+ 'Restart': 'res_multistep',
+ 'res_multistep': 'res_multistep',
+
+ // --- ER SDE ---
+ 'ER SDE': 'er_sde',
+ 'E-R SDE': 'er_sde',
+ 'er_sde': 'er_sde',
+
+ // --- SA Solver ---
+ 'SA Solver': 'sa_solver',
+ 'SA solver': 'sa_solver',
+ 'sa_solver': 'sa_solver',
+
+ // --- Seeds ---
+ 'Seeds 2': 'seeds_2',
+ 'seeds_2': 'seeds_2',
+ 'Seeds 3': 'seeds_3',
+ 'seeds_3': 'seeds_3',
+};
+
+// ---------------------------------------------------------------------------
+// Known scheduler suffixes (ComfyUI KSampler.SCHEDULERS)
+// Sorted by length (descending) for longest-match-first parsing.
+// ---------------------------------------------------------------------------
+const SCHEDULER_SUFFIXES = [
+ 'sgm_uniform',
+ 'ddim_uniform',
+ 'linear_quadratic',
+ 'kl_optimal',
+ 'exponential',
+ 'karras',
+ 'simple',
+ 'normal',
+ 'beta',
+];
+
+// ---------------------------------------------------------------------------
+// Scheduler-only values (values that are schedulers, not samplers)
+// ---------------------------------------------------------------------------
+const SCHEDULER_ONLY_VALUES = new Set([
+ 'simple', 'sgm_uniform', 'karras', 'exponential',
+ 'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal',
+]);
+
+// ---------------------------------------------------------------------------
+// Param key → widget name candidates (searched in order)
+// ---------------------------------------------------------------------------
+const PARAM_TO_WIDGET_CANDIDATES = {
+ seed: ['seed', 'noise_seed'],
+ steps: ['steps'],
+ cfg: ['cfg'],
+ sampler: ['sampler_name', 'sampler'],
+ scheduler: ['scheduler'],
+};
+
+// ---------------------------------------------------------------------------
+// Parse a combined sampler+scheduler value (space-separated or underscore)
+// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
+// Returns { sampler: internalName|null, scheduler: internalName|null } or null
+// ---------------------------------------------------------------------------
+function parseCombinedSamplerName(rawValue) {
+ if (!rawValue || typeof rawValue !== 'string') return null;
+ const trimmed = rawValue.trim();
+ if (!trimmed) return null;
+
+ // Try space-separated first: split on last space
+ const spaceIdx = trimmed.lastIndexOf(' ');
+ if (spaceIdx > 0) {
+ const candidateScheduler = trimmed.slice(spaceIdx + 1).trim().toLowerCase();
+ if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) {
+ const samplerPart = trimmed.slice(0, spaceIdx).trim();
+ const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart];
+ if (internalSampler) {
+ return { sampler: internalSampler, scheduler: candidateScheduler };
+ }
+ // samplerPart might be a combined name itself (e.g., "DPM++ 2M SDE")
+ // Try recursing (one level max) — already handled since we split at last space
+ }
+ }
+
+ // Try underscore-separated: e.g., "er_sde_beta"
+ const underIdx = trimmed.lastIndexOf('_');
+ if (underIdx > 0) {
+ const candidateScheduler = trimmed.slice(underIdx + 1).trim().toLowerCase();
+ if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) {
+ const samplerPart = trimmed.slice(0, underIdx).trim();
+ const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart] || SAMPLER_DISPLAY_TO_INTERNAL[samplerPart.toLowerCase()];
+ if (internalSampler) {
+ return { sampler: internalSampler, scheduler: candidateScheduler };
+ }
+ }
+ }
+
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Main resolver: takes a raw sampler value from recipe/showcase metadata
+// and returns { sampler: internalName|null, scheduler: internalName|null }
+// ---------------------------------------------------------------------------
+function resolveSamplerScheduler(rawValue) {
+ if (!rawValue || typeof rawValue !== 'string') {
+ return { sampler: null, scheduler: null };
+ }
+
+ const trimmed = rawValue.trim();
+ if (!trimmed) return { sampler: null, scheduler: null };
+
+ // 1. Try direct lookup first
+ const direct = SAMPLER_DISPLAY_TO_INTERNAL[trimmed];
+ if (direct) return { sampler: direct, scheduler: null };
+
+ // 2. Try lowercase direct lookup
+ const lowerDirect = SAMPLER_DISPLAY_TO_INTERNAL[trimmed.toLowerCase()];
+ if (lowerDirect) return { sampler: lowerDirect, scheduler: null };
+
+ // 3. Scheduler-only value? (check BEFORE the "already internal name" regex,
+ // because scheduler values like "karras", "simple" also match that pattern)
+ if (SCHEDULER_ONLY_VALUES.has(trimmed.toLowerCase())) {
+ return { sampler: null, scheduler: trimmed.toLowerCase() };
+ }
+
+ // 4. Already an internal name? (lowercase, no spaces)
+ if (/^[a-z][a-z0-9_]+$/.test(trimmed)) {
+ return { sampler: trimmed, scheduler: null };
+ }
+
+ // 5. Try combined name parsing (space-separated or underscore)
+ const combined = parseCombinedSamplerName(trimmed);
+ if (combined) return combined;
+
+ // 6. Custom format like "multistep/dpmpp_2m_simple" — try extracting the last segment
+ if (trimmed.includes('/')) {
+ const parts = trimmed.split('/');
+ const last = parts[parts.length - 1];
+ if (last) {
+ const subResult = resolveSamplerScheduler(last);
+ if (subResult.sampler || subResult.scheduler) return subResult;
+ }
+ }
+
+ // 7. Unrecognized — return null for both
+ return { sampler: null, scheduler: null };
+}
+
+// ---------------------------------------------------------------------------
+// Find which gen params can be sent to a given node, matching by widget names
+// Returns array of { widgetName, value } objects
+// ---------------------------------------------------------------------------
+function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
+ if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
+ return [];
+ }
+
+ const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
+ const updates = [];
+
+ // Simple numeric/string params: seed, steps, cfg
+ const simpleParams = [
+ { key: 'seed', value: resolvedParams.seed },
+ { key: 'steps', value: resolvedParams.steps },
+ { key: 'cfg', value: resolvedParams.cfg },
+ ];
+ for (const { key, value } of simpleParams) {
+ if (value === undefined || value === null || value === '') continue;
+ 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) });
+ break;
+ }
+ }
+ }
+
+ // Sampler
+ if (resolvedParams.sampler) {
+ const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler;
+ for (const candidate of candidates) {
+ if (widgetSet.has(candidate.toLowerCase())) {
+ updates.push({ widgetName: candidate, value: resolvedParams.sampler });
+ break;
+ }
+ }
+ }
+
+ // Scheduler
+ if (resolvedParams.scheduler) {
+ const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler;
+ for (const candidate of candidates) {
+ if (widgetSet.has(candidate.toLowerCase())) {
+ updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
+ break;
+ }
+ }
+ }
+
+ return updates;
+}
+
+export {
+ SAMPLER_DISPLAY_TO_INTERNAL,
+ SCHEDULER_SUFFIXES,
+ SCHEDULER_ONLY_VALUES,
+ PARAM_TO_WIDGET_CANDIDATES,
+ parseCombinedSamplerName,
+ resolveSamplerScheduler,
+ findMatchingWidgets,
+};
diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js
index a0184d70..961d1042 100644
--- a/static/js/utils/uiHelpers.js
+++ b/static/js/utils/uiHelpers.js
@@ -6,6 +6,7 @@ import { eventManager } from './EventManager.js';
import { bannerService } from '../managers/BannerService.js';
import { modalManager } from '../managers/ModalManager.js';
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
+import { resolveSamplerScheduler, findMatchingWidgets } from './genParamsMapper.js';
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
@@ -856,11 +857,12 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
successMessage = 'Updated workflow node',
failureMessage = 'Failed to update workflow node',
missingTargetMessage = 'No target node selected',
+ silent = false,
} = messages;
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
if (targetIds.length === 0) {
- showToast(missingTargetMessage, {}, 'warning');
+ if (!silent) showToast(missingTargetMessage, {}, 'warning');
return false;
}
@@ -869,7 +871,7 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
.filter((reference) => reference && reference.node_id !== undefined);
if (references.length === 0) {
- showToast(missingTargetMessage, {}, 'warning');
+ if (!silent) showToast(missingTargetMessage, {}, 'warning');
return false;
}
@@ -888,16 +890,16 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
const result = await response.json();
if (result.success) {
- showToast(successMessage, {}, 'success');
+ if (!silent) showToast(successMessage, {}, 'success');
return true;
}
const errorMessage = result?.error || failureMessage;
- showToast(errorMessage, {}, 'error');
+ if (!silent) showToast(errorMessage, {}, 'error');
return false;
} catch (error) {
console.error('Failed to send widget value to workflow:', error);
- showToast(failureMessage, {}, 'error');
+ if (!silent) showToast(failureMessage, {}, 'error');
return false;
}
}
@@ -1056,6 +1058,127 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
return true;
}
+/**
+ * Send generation parameters (seed, steps, cfg, sampler, scheduler) to
+ * workflow nodes that have been marked with "Send Gen Params Target".
+ *
+ * @param {Object} genParams - Raw gen_params from recipe or showcase metadata
+ * @returns {Promise
} Whether the send succeeded
+ */
+export async function sendGenParamsToWorkflow(genParams) {
+ if (!genParams || typeof genParams !== 'object') {
+ showToast('No generation parameters to send', {}, 'warning');
+ return false;
+ }
+
+ // 1. Extract relevant params (skip prompt, negative_prompt, clip_skip, denoising_strength)
+ const raw = {
+ seed: genParams.seed,
+ steps: genParams.steps,
+ cfg: genParams.cfg_scale,
+ };
+
+ // 2. Resolve sampler/scheduler
+ const resolved = resolveSamplerScheduler(genParams.sampler);
+ if (resolved) {
+ if (resolved.sampler) raw.sampler = resolved.sampler;
+ if (resolved.scheduler) raw.scheduler = resolved.scheduler;
+ }
+
+ // Check if we have anything to send
+ const hasAny = Object.values(raw).some(v => v !== undefined && v !== null && v !== '');
+ if (!hasAny) {
+ showToast('No sendable parameters found', {}, 'warning');
+ return false;
+ }
+
+ // 3. Fetch workflow registry
+ const registry = await fetchWorkflowRegistry();
+ if (!registry) {
+ return false;
+ }
+
+ // 4. Filter nodes by marker_role === "send_gen_params"
+ const targetNodes = filterRegistryNodes(registry.nodes, (node) => {
+ return node.marker_role === 'send_gen_params' && isNodeEnabled(node);
+ });
+
+ const nodeKeys = Object.keys(targetNodes);
+ if (nodeKeys.length === 0) {
+ showToast(
+ 'No node marked as Send Gen Params Target.\nRight-click a node in ComfyUI → Mark as → Send Gen Params Target',
+ {},
+ 'warning'
+ );
+ return false;
+ }
+
+ // 5. For each candidate node, find matching widgets
+ // Also collect widget_names from registry for matching
+ const sendToNode = async (nodeIds) => {
+ const targetIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
+ let allSuccess = true;
+ let totalSent = 0;
+ let totalFailed = 0;
+
+ for (const nodeKey of targetIds) {
+ const node = targetNodes[nodeKey];
+ if (!node) continue;
+
+ const widgetNames = node.widget_names || [];
+ const updates = findMatchingWidgets(widgetNames, raw);
+
+ if (updates.length === 0) {
+ showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
+ allSuccess = false;
+ continue;
+ }
+
+ // Send each widget value sequentially
+ for (const update of updates) {
+ const success = await sendWidgetValueToNodes(
+ [nodeKey],
+ targetNodes,
+ update.widgetName,
+ update.value,
+ {
+ silent: true,
+ }
+ );
+ if (success) {
+ totalSent++;
+ } else {
+ totalFailed++;
+ allSuccess = false;
+ }
+ }
+ }
+
+ // Show single summary toast
+ if (totalSent > 0 && totalFailed === 0) {
+ showToast(`Sent ${totalSent} parameter${totalSent > 1 ? 's' : ''} to workflow`, {}, 'success');
+ } else if (totalFailed > 0 && totalSent > 0) {
+ showToast(`Partially updated (${totalSent} ok, ${totalFailed} failed)`, {}, 'warning');
+ } else if (totalFailed > 0) {
+ showToast('Failed to update parameters', {}, 'error');
+ }
+ return allSuccess;
+ };
+
+ // 6. If multiple nodes, show node selector; otherwise send directly
+ if (nodeKeys.length === 1) {
+ return await sendToNode([nodeKeys[0]]);
+ }
+
+ showNodeSelector(targetNodes, {
+ actionType: 'Gen Params',
+ actionMode: 'Update',
+ onSend: sendToNode,
+ enableSendAll: true,
+ });
+ return true;
+}
+
// Global variable to track active node selector state
let nodeSelectorState = {
isActive: false,
diff --git a/templates/components/recipe_modal.html b/templates/components/recipe_modal.html
index 7624e170..e7a9f65e 100644
--- a/templates/components/recipe_modal.html
+++ b/templates/components/recipe_modal.html
@@ -90,7 +90,17 @@
-
+
diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js
index fa413ba4..aefd3c46 100644
--- a/tests/frontend/components/contextMenu.interactions.test.js
+++ b/tests/frontend/components/contextMenu.interactions.test.js
@@ -1117,9 +1117,9 @@ describe('Interaction-level regression coverage', () => {
expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available');
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available');
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
- expect(otherParamsText).toContain('sampler:');
+ expect(otherParamsText).toContain('Sampler:');
expect(otherParamsText).toContain('dpmpp_2m');
- expect(otherParamsText).not.toContain('cfg_scale');
+ expect(otherParamsText).not.toContain('CFG');
});
it('filters dirty generation params from recipe modal display', async () => {
@@ -1168,8 +1168,8 @@ describe('Interaction-level regression coverage', () => {
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt');
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative');
- expect(otherParamsText).toContain('sampler:');
- expect(otherParamsText).toContain('cfg_scale:');
+ expect(otherParamsText).toContain('Sampler:');
+ expect(otherParamsText).toContain('CFG:');
expect(otherParamsText).not.toContain('Version');
expect(otherParamsText).not.toContain('raw_metadata');
expect(otherParamsText).not.toContain('RNG');
@@ -1222,7 +1222,7 @@ describe('Interaction-level regression coverage', () => {
expect(document.getElementById('recipePrompt').textContent).not.toContain('stale prompt');
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative');
expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale negative');
- expect(otherParamsText).toContain('cfg_scale:');
+ expect(otherParamsText).toContain('CFG:');
expect(otherParamsText).toContain('7');
expect(otherParamsText).not.toContain('3');
});
diff --git a/tests/frontend/utils/genParamsMapper.test.js b/tests/frontend/utils/genParamsMapper.test.js
new file mode 100644
index 00000000..4a5a9b82
--- /dev/null
+++ b/tests/frontend/utils/genParamsMapper.test.js
@@ -0,0 +1,246 @@
+import { describe, it, expect } from 'vitest';
+
+// genParamsMapper is pure logic with zero dependencies — safe to import directly
+import {
+ SAMPLER_DISPLAY_TO_INTERNAL,
+ SCHEDULER_SUFFIXES,
+ SCHEDULER_ONLY_VALUES,
+ PARAM_TO_WIDGET_CANDIDATES,
+ parseCombinedSamplerName,
+ resolveSamplerScheduler,
+ findMatchingWidgets,
+} from '../../../static/js/utils/genParamsMapper.js';
+
+// ---------------------------------------------------------------------------
+// Constants sanity
+// ---------------------------------------------------------------------------
+describe('constants', () => {
+ it('maps at least the common samplers', () => {
+ expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler']).toBe('euler');
+ expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler a']).toBe('euler_ancestral');
+ expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M']).toBe('dpmpp_2m');
+ expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M SDE']).toBe('dpmpp_2m_sde');
+ expect(SAMPLER_DISPLAY_TO_INTERNAL['LCM']).toBe('lcm');
+ expect(SAMPLER_DISPLAY_TO_INTERNAL['DDIM']).toBe('ddim');
+ });
+
+ it('lists all 9 scheduler suffixes', () => {
+ expect(SCHEDULER_SUFFIXES).toHaveLength(9);
+ expect(SCHEDULER_SUFFIXES).toContain('karras');
+ expect(SCHEDULER_SUFFIXES).toContain('simple');
+ expect(SCHEDULER_SUFFIXES).toContain('exponential');
+ });
+
+ it('marks scheduler-only values', () => {
+ expect(SCHEDULER_ONLY_VALUES.has('karras')).toBe(true);
+ expect(SCHEDULER_ONLY_VALUES.has('simple')).toBe(true);
+ expect(SCHEDULER_ONLY_VALUES.has('euler')).toBe(false);
+ });
+
+ it('has widget candidates for all param keys', () => {
+ expect(PARAM_TO_WIDGET_CANDIDATES.seed).toContain('seed');
+ expect(PARAM_TO_WIDGET_CANDIDATES.sampler).toContain('sampler_name');
+ expect(PARAM_TO_WIDGET_CANDIDATES.scheduler).toContain('scheduler');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseCombinedSamplerName
+// ---------------------------------------------------------------------------
+describe('parseCombinedSamplerName', () => {
+ it('parses space-separated sampler + scheduler', () => {
+ expect(parseCombinedSamplerName('Euler a Karras')).toEqual({
+ sampler: 'euler_ancestral',
+ scheduler: 'karras',
+ });
+ });
+
+ it('parses DPM++ 2M Karras', () => {
+ expect(parseCombinedSamplerName('DPM++ 2M Karras')).toEqual({
+ sampler: 'dpmpp_2m',
+ scheduler: 'karras',
+ });
+ });
+
+ it('parses DPM++ 2M beta', () => {
+ expect(parseCombinedSamplerName('DPM++ 2M beta')).toEqual({
+ sampler: 'dpmpp_2m',
+ scheduler: 'beta',
+ });
+ });
+
+ it('parses DPM++ SDE Karras', () => {
+ expect(parseCombinedSamplerName('DPM++ SDE Karras')).toEqual({
+ sampler: 'dpmpp_sde',
+ scheduler: 'karras',
+ });
+ });
+
+ it('parses underscore-separated er_sde_beta', () => {
+ expect(parseCombinedSamplerName('er_sde_beta')).toEqual({
+ sampler: 'er_sde',
+ scheduler: 'beta',
+ });
+ });
+
+ it('returns null for sampler-only values', () => {
+ expect(parseCombinedSamplerName('Euler a')).toBeNull();
+ expect(parseCombinedSamplerName('LCM')).toBeNull();
+ });
+
+ it('returns null for unrecognised suffix', () => {
+ expect(parseCombinedSamplerName('Euler something_unknown')).toBeNull();
+ });
+
+ it('returns null for null/empty', () => {
+ expect(parseCombinedSamplerName(null)).toBeNull();
+ expect(parseCombinedSamplerName('')).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// resolveSamplerScheduler — the main resolver used by the send feature
+// ---------------------------------------------------------------------------
+describe('resolveSamplerScheduler', () => {
+ // --- Category 1: simple display names ---
+ it('resolves Euler → euler', () => {
+ expect(resolveSamplerScheduler('Euler')).toEqual({ sampler: 'euler', scheduler: null });
+ });
+
+ it('resolves Euler a → euler_ancestral', () => {
+ expect(resolveSamplerScheduler('Euler a')).toEqual({ sampler: 'euler_ancestral', scheduler: null });
+ });
+
+ it('resolves DPM++ 2M → dpmpp_2m', () => {
+ expect(resolveSamplerScheduler('DPM++ 2M')).toEqual({ sampler: 'dpmpp_2m', scheduler: null });
+ });
+
+ it('resolves LCM → lcm', () => {
+ expect(resolveSamplerScheduler('LCM')).toEqual({ sampler: 'lcm', scheduler: null });
+ });
+
+ // --- Category 2: already-internal names ---
+ it('passes through lowercase internal names', () => {
+ expect(resolveSamplerScheduler('euler')).toEqual({ sampler: 'euler', scheduler: null });
+ expect(resolveSamplerScheduler('heunpp2')).toEqual({ sampler: 'heunpp2', scheduler: null });
+ expect(resolveSamplerScheduler('lcm')).toEqual({ sampler: 'lcm', scheduler: null });
+ expect(resolveSamplerScheduler('er_sde')).toEqual({ sampler: 'er_sde', scheduler: null });
+ });
+
+ // --- Category 3: combined names ---
+ it('resolves Euler a Karras → euler_ancestral + karras', () => {
+ expect(resolveSamplerScheduler('Euler a Karras')).toEqual({
+ sampler: 'euler_ancestral',
+ scheduler: 'karras',
+ });
+ });
+
+ it('resolves DPM++ 2M Karras → dpmpp_2m + karras', () => {
+ expect(resolveSamplerScheduler('DPM++ 2M Karras')).toEqual({
+ sampler: 'dpmpp_2m',
+ scheduler: 'karras',
+ });
+ });
+
+ // --- Category 4: scheduler-only ---
+ it('resolves scheduler-only values', () => {
+ expect(resolveSamplerScheduler('karras')).toEqual({ sampler: null, scheduler: 'karras' });
+ expect(resolveSamplerScheduler('simple')).toEqual({ sampler: null, scheduler: 'simple' });
+ expect(resolveSamplerScheduler('sgm_uniform')).toEqual({ sampler: null, scheduler: 'sgm_uniform' });
+ });
+
+ // --- Category 5: unrecognised / model-specific ---
+ it('returns null+null for unrecognised values', () => {
+ const result = resolveSamplerScheduler('AYS SDXL');
+ expect(result.sampler).toBeNull();
+ expect(result.scheduler).toBeNull();
+ });
+
+ it('returns null+null for Undefined', () => {
+ const result = resolveSamplerScheduler('Undefined');
+ expect(result.sampler).toBeNull();
+ expect(result.scheduler).toBeNull();
+ });
+
+ it('returns null+null for model-specific values', () => {
+ expect(resolveSamplerScheduler('Seedream-V45').sampler).toBeNull();
+ expect(resolveSamplerScheduler('GPT-Image-2').sampler).toBeNull();
+ });
+
+ // --- Category 6: edge cases ---
+ it('returns null+null for null / empty / whitespace', () => {
+ expect(resolveSamplerScheduler(null)).toEqual({ sampler: null, scheduler: null });
+ expect(resolveSamplerScheduler('')).toEqual({ sampler: null, scheduler: null });
+ expect(resolveSamplerScheduler(' ')).toEqual({ sampler: null, scheduler: null });
+ });
+
+ it('handles slash-separated custom format (extracts last segment)', () => {
+ // "multistep/dpmpp_2m_simple" — extracts last segment but the recursive
+ // call hits the "already internal name" regex before combined-name parsing,
+ // so it returns the raw segment as the sampler name.
+ const result = resolveSamplerScheduler('multistep/dpmpp_2m_simple');
+ expect(result.sampler).toBe('dpmpp_2m_simple');
+ expect(result.scheduler).toBeNull();
+ });
+
+ it('handles parse-error value (None', () => {
+ const result = resolveSamplerScheduler('(None');
+ expect(result.sampler).toBeNull();
+ expect(result.scheduler).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// findMatchingWidgets
+// ---------------------------------------------------------------------------
+describe('findMatchingWidgets', () => {
+ const resolved = {
+ seed: 42,
+ steps: 30,
+ cfg: 7,
+ sampler: 'euler_ancestral',
+ scheduler: 'karras',
+ };
+
+ 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: 'sampler_name', value: 'euler_ancestral' });
+ expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
+ });
+
+ it('skips undefined/null params', () => {
+ const updates = findMatchingWidgets(['seed', 'steps'], { seed: 42, steps: null, cfg: undefined });
+ expect(updates).toHaveLength(1);
+ expect(updates[0].widgetName).toBe('seed');
+ });
+
+ it('matches noise_seed when seed widget not present', () => {
+ 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');
+ });
+
+ it('matches rgthree-style sampler widget name', () => {
+ const updates = findMatchingWidgets(['sampler', 'scheduler'], { sampler: 'euler', scheduler: 'karras' });
+ expect(updates).toContainEqual({ widgetName: 'sampler', value: 'euler' });
+ });
+
+ it('returns empty array for empty widget list', () => {
+ expect(findMatchingWidgets([], resolved)).toEqual([]);
+ expect(findMatchingWidgets(null, resolved)).toEqual([]);
+ });
+
+ it('handles case-insensitive widget name matching', () => {
+ const updates = findMatchingWidgets(['SEED', 'STEPS', 'CFG'], resolved);
+ expect(updates).toHaveLength(3);
+ });
+
+ it('returns updates in param order (seed, steps, cfg, sampler, scheduler)', () => {
+ const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
+ expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
+ });
+});
diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js
index 69f31c6d..fe46fc05 100644
--- a/web/comfyui/workflow_registry.js
+++ b/web/comfyui/workflow_registry.js
@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
+import { ensureLmStyles } from "./lm_styles_loader.js";
const LORA_NODE_CLASSES = new Set([
"Lora Loader (LoraManager)",
@@ -21,6 +22,8 @@ app.registerExtension({
name: "LoraManager.WorkflowRegistry",
setup() {
+ ensureLmStyles();
+
api.addEventListener("lora_registry_refresh", () => {
this.refreshRegistry();
});
@@ -213,5 +216,164 @@ app.registerExtension({
if (typeof app.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
+
+ // ---- Visual cue: briefly highlight the updated widget ----
+ this.flashWidget(node, targetWidget);
+ },
+
+ /**
+ * Add a temporary visual highlight to a widget after its value is updated.
+ * - Vue Nodes mode: change value text color on all non-button elements
+ * - Canvas mode: define text_color on widget instance (value text only)
+ * Highlight fades after 10 seconds or on hover (Vue mode only).
+ */
+ flashWidget(node, widget) {
+ const FLASH_DURATION = 3000;
+ const flashEnd = Date.now() + FLASH_DURATION;
+ const nodeId = node.id;
+
+ // Colors consistent with canvas mode
+ const VALUE_COLOR = '#66B3FF';
+
+ // Helper: find the widget row in the DOM (by label text matching widget name)
+ const findRowEl = () => {
+ const container = document.querySelector(`[data-node-id="${nodeId}"]`);
+ if (!container) return null;
+ const all = container.querySelectorAll('[data-testid="node-widget"]');
+ for (const w of all) {
+ const label = w.querySelector('[data-testid="widget-layout-field-label"]');
+ if (label && label.textContent.trim() === widget.name) {
+ return w;
+ }
+ }
+ return null;
+ };
+
+ // Helper: get label and ring elements from a widget row
+ const getLabelAndRing = (row) => {
+ if (!row) return { labelEl: null, ringEl: null };
+ const labelEl = row.querySelector('[data-testid="widget-layout-field-label"]');
+ const ringEl = labelEl?.nextElementSibling
+ || row.querySelector('.flex-1.relative.min-w-0')
+ || row.querySelector('.rounded-lg.transition-all')
+ || null;
+ return { labelEl, ringEl };
+ };
+
+ const applyFlash = (row) => {
+ if (!row) return;
+ const { ringEl } = getLabelAndRing(row);
+ if (ringEl) {
+ const innerRing = ringEl.querySelector('.rounded-lg.transition-all');
+ if (innerRing) {
+ // Target value-displaying elements for all widget types:
+ // NumberWidget: spinbutton input
+ // ComboWidget: combobox button
+ // Text widgets (CLIPTextEncode, Prompt, etc.): textarea / text input
+ innerRing.querySelectorAll(
+ 'input, textarea, [role="combobox"]'
+ ).forEach(el => {
+ el.style.color = VALUE_COLOR;
+ });
+ }
+ }
+ };
+
+ const removeFlash = (row) => {
+ if (!row) return;
+ const { ringEl } = getLabelAndRing(row);
+ if (ringEl) {
+ const innerRing = ringEl.querySelector('.rounded-lg.transition-all');
+ if (innerRing) {
+ // Clear color from all inputs/textarea/combobox
+ innerRing.querySelectorAll(
+ 'input, textarea, [role="combobox"]'
+ ).forEach(el => {
+ el.style.color = '';
+ });
+ }
+ }
+ };
+
+ // --- Try Vue Nodes mode first ---
+ const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`);
+ if (nodeEl) {
+ // Apply immediately
+ const initialRow = findRowEl();
+ applyFlash(initialRow);
+
+ // rAF loop: re-apply after Vue re-renders
+ let rafId = null;
+ const poll = () => {
+ if (Date.now() >= flashEnd) {
+ const lastRow = findRowEl();
+ removeFlash(lastRow);
+ rafId = null;
+ return;
+ }
+ const currentRow = findRowEl();
+ applyFlash(currentRow);
+ rafId = requestAnimationFrame(poll);
+ };
+ rafId = requestAnimationFrame(poll);
+
+ // Cleanup timeout
+ const timeoutId = setTimeout(() => {
+ if (rafId) cancelAnimationFrame(rafId);
+ const lastRow = findRowEl();
+ removeFlash(lastRow);
+ }, FLASH_DURATION);
+
+ // Hover dismissal via event delegation on node container
+ const hoverHandler = (e) => {
+ const row = findRowEl();
+ if (row && row.contains(e.target)) {
+ clearTimeout(timeoutId);
+ if (rafId) cancelAnimationFrame(rafId);
+ removeFlash(row);
+ nodeEl.removeEventListener('mouseover', hoverHandler);
+ }
+ };
+ nodeEl.addEventListener('mouseover', hoverHandler);
+
+ return; // Vue mode done
+ }
+
+ // --- Canvas mode: change widget value text color via instance property shadowing ---
+ // BaseWidget reads text_color (value) from prototype getter. Defining an own
+ // property on the instance shadows the getter without monkey-patching.
+ // Works for ALL widget types — only value text is changed, label is left alone.
+ Object.defineProperty(widget, 'text_color', {
+ value: VALUE_COLOR,
+ writable: true,
+ configurable: true,
+ });
+
+ if (typeof node.setDirtyCanvas === "function") {
+ node.setDirtyCanvas(true);
+ }
+
+ // Track this widget so it gets restored alongside others on the same node
+ if (!node._lmFlashedWidgets) node._lmFlashedWidgets = [];
+ if (!node._lmFlashedWidgets.includes(widget)) {
+ node._lmFlashedWidgets.push(widget);
+ }
+
+ // Single per-node timer that restores ALL flashed widgets at once.
+ // Subsequent calls reset the timer but don't orphan previous widgets.
+ if (node._lmFlashCleanup) {
+ clearTimeout(node._lmFlashCleanup);
+ }
+ node._lmFlashCleanup = setTimeout(() => {
+ for (const w of (node._lmFlashedWidgets || [])) {
+ delete w.text_color;
+ delete w.secondary_text_color;
+ }
+ delete node._lmFlashedWidgets;
+ delete node._lmFlashCleanup;
+ if (typeof node.setDirtyCanvas === "function") {
+ node.setDirtyCanvas(true);
+ }
+ }, FLASH_DURATION);
},
});