diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 62608f44..240fa752 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -952,6 +952,30 @@ class RecipeScanner: except Exception as exc: logger.debug("Failed to update FTS index for recipe: %s", exc) + @staticmethod + def _normalize_recipe_gen_params(recipe_data: Dict[str, Any]) -> Dict[str, Any]: + """Return a recipe copy with normalized generation parameter aliases added.""" + + normalized_recipe = dict(recipe_data) + gen_params = recipe_data.get("gen_params") + if not isinstance(gen_params, dict): + return normalized_recipe + + normalized_gen_params = dict(gen_params) + for key, value in gen_params.items(): + if value in (None, ""): + continue + + normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key) + if normalized_key not in GenParamsMerger.ALLOWED_KEYS: + continue + + if normalized_gen_params.get(normalized_key) in (None, ""): + normalized_gen_params[normalized_key] = value + + normalized_recipe["gen_params"] = normalized_gen_params + return normalized_recipe + async def _enrich_cache_metadata(self) -> None: """Perform remote metadata enrichment after the initial scan.""" @@ -1345,6 +1369,7 @@ class RecipeScanner: # Ensure gen_params exists if "gen_params" not in recipe_data: recipe_data["gen_params"] = {} + recipe_data = self._normalize_recipe_gen_params(recipe_data) # Update lora information with local paths and availability lora_metadata_updated = await self._update_lora_information(recipe_data) @@ -2055,7 +2080,10 @@ class RecipeScanner: end_idx = min(start_idx + page_size, total_items) # Get paginated items - paginated_items = filtered_data[start_idx:end_idx] + paginated_items = [ + self._normalize_recipe_gen_params(item) + for item in filtered_data[start_idx:end_idx] + ] # Add inLibrary information and URLs for each recipe for item in paginated_items: @@ -2116,7 +2144,7 @@ class RecipeScanner: # Prefer the on-disk recipe JSON for fields that are not persisted in the # SQLite cache yet, such as source_path. - merged_recipe = {**recipe} + merged_recipe = self._normalize_recipe_gen_params({**recipe}) recipe_json = await self._load_recipe_json(recipe_id) if recipe_json: for field in ("source_path", "checkpoint", "loras", "gen_params"): @@ -2181,7 +2209,7 @@ class RecipeScanner: if not isinstance(recipe_data, dict): return None - return recipe_data + return self._normalize_recipe_gen_params(recipe_data) def _format_file_url(self, file_path: str) -> str: """Format file path as URL for serving in web UI""" diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 59fb83b7..37e3bf45 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -7,6 +7,36 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js'; import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; +const ALLOWED_GEN_PARAM_KEYS = new Set([ + 'prompt', + 'negative_prompt', + 'steps', + 'sampler', + 'cfg_scale', + 'seed', + 'size', + 'clip_skip', + 'denoising_strength', +]); + +const GEN_PARAM_NORMALIZATION = { + cfg: 'cfg_scale', + cfgScale: 'cfg_scale', + clipSkip: 'clip_skip', + negativePrompt: 'negative_prompt', + Sampler: 'sampler', + sampler_name: 'sampler', + scheduler: 'sampler', + Steps: 'steps', + Seed: 'seed', + Size: 'size', + Prompt: 'prompt', + 'Negative prompt': 'negative_prompt', + 'Cfg scale': 'cfg_scale', + 'Clip skip': 'clip_skip', + 'Denoising strength': 'denoising_strength', +}; + class RecipeModal { constructor() { this.promptEditorState = {}; @@ -321,8 +351,13 @@ class RecipeModal { mediaContainer.appendChild(sourceUrlContainer); mediaContainer.appendChild(sourceUrlEditor); - // Set up event listeners for source URL functionality + // Delay binding slightly so modal layout is stable, but skip if this render was torn down. + const sourceUrlContainerRef = sourceUrlContainer; + const sourceUrlEditorRef = sourceUrlEditor; setTimeout(() => { + if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) { + return; + } this.setupSourceUrlHandlers(); }, 50); } @@ -562,18 +597,19 @@ class RecipeModal { const promptInput = document.getElementById('recipePromptInput'); const negativePromptInput = document.getElementById('recipeNegativePromptInput'); const promptFieldsOnly = options.promptFieldsOnly === true; + const sanitizedGenParams = this.sanitizeGenParams(genParams); - if (genParams) { + if (sanitizedGenParams) { if (!promptFieldsOnly) { - this.renderPromptContent(promptElement, genParams.prompt, 'No prompt information available'); - this.renderPromptContent(negativePromptElement, genParams.negative_prompt, 'No negative prompt information available'); + this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available'); + this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available'); if (promptInput) { - promptInput.value = genParams.prompt || ''; + promptInput.value = sanitizedGenParams.prompt || ''; } if (negativePromptInput) { - negativePromptInput.value = genParams.negative_prompt || ''; + negativePromptInput.value = sanitizedGenParams.negative_prompt || ''; } } @@ -581,7 +617,7 @@ class RecipeModal { otherParamsElement.innerHTML = ''; const excludedParams = ['prompt', 'negative_prompt']; - for (const [key, value] of Object.entries(genParams)) { + for (const [key, value] of Object.entries(sanitizedGenParams)) { if (!excludedParams.includes(key) && value !== undefined && value !== null) { const paramTag = document.createElement('div'); paramTag.className = 'param-tag'; @@ -612,6 +648,43 @@ class RecipeModal { } } + sanitizeGenParams(genParams) { + if (!genParams || typeof genParams !== 'object') { + return null; + } + + const sanitized = {}; + + for (const [key, value] of Object.entries(genParams)) { + if (value === undefined || value === null || value === '') { + continue; + } + + if (!ALLOWED_GEN_PARAM_KEYS.has(key)) { + continue; + } + + sanitized[key] = value; + } + + for (const [key, value] of Object.entries(genParams)) { + if (value === undefined || value === null || value === '') { + continue; + } + + const normalizedKey = GEN_PARAM_NORMALIZATION[key] || key; + if (!ALLOWED_GEN_PARAM_KEYS.has(normalizedKey)) { + continue; + } + + if (sanitized[normalizedKey] === undefined || sanitized[normalizedKey] === null || sanitized[normalizedKey] === '') { + sanitized[normalizedKey] = value; + } + } + + return sanitized; + } + syncResourcesSection(recipe = {}) { const checkpointContainer = document.getElementById('recipeCheckpoint'); const resourceDivider = document.getElementById('recipeResourceDivider'); @@ -1117,7 +1190,7 @@ class RecipeModal { const currentGenParams = this.currentRecipe.gen_params || {}; const nextValue = input.value.trim() === '' ? '' : input.value; - const currentValue = currentGenParams[config.field] || ''; + const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || ''; if (nextValue === currentValue) { this.clearFieldDirty(config.field); diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index 4d351772..dcc4f2fa 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -1165,6 +1165,113 @@ describe('Interaction-level regression coverage', () => { expect(otherParamsText).not.toContain('cfg_scale'); }); + it('filters dirty generation params from recipe modal display', async () => { + document.body.innerHTML = ` +