feat: send gen params to workflow with visual cues

- Add genParamsMapper.js: sampler/scheduler display→internal mapping,
  combined-name parsing, widget matching
- Add sendGenParamsToWorkflow() in uiHelpers.js: resolves sampler,
  fetches registry by send_gen_params marker, sends via update-node-widget
- Add send-params-btn UI in showcase hover panel and recipe modal
- Add flashWidget() in workflow_registry.js: text-color visual cue
  on updated widget values (Vue: inline style + CSS, canvas: property shadow)
- Add silent option to sendWidgetValueToNodes for consolidated toast
- Normalize param display labels (cfg_scale→CFG, etc.) in recipe modal
- Add 33 tests for genParamsMapper; update existing test assertions
This commit is contained in:
Will Miao
2026-06-24 15:39:57 +08:00
parent cd2628a0ee
commit 71a459422f
10 changed files with 952 additions and 24 deletions

View File

@@ -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);

View File

@@ -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 = `
<span class="param-name">${key}:</span>
<span class="param-name">${displayName}:</span>
<span class="param-value">${value}</span>
`;
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);
});
}
}
/**

View File

@@ -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;

View File

@@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
if (hasParams) {
content += `
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
<div class="metadata-row params-row">
<div class="param-header">
<span class="metadata-label">Params:</span>
<div class="param-actions">
<button class="send-params-btn" title="Send Params to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div>
</div>
`;
}

View File

@@ -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,
};

View File

@@ -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<boolean>} 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,

View File

@@ -90,7 +90,17 @@
</div>
<!-- Other Parameters -->
<div class="other-params" id="recipeOtherParams"></div>
<div class="param-group info-item">
<div class="param-header">
<label>Params</label>
<div class="param-actions">
<button class="copy-btn" id="sendParamsBtn" title="Send Params to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
</div>

View File

@@ -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');
});

View File

@@ -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']);
});
});

View File

@@ -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);
},
});