Compare commits

...

6 Commits

Author SHA1 Message Date
Will Miao
39c083db79 fix(recipes): preserve legacy gen params in modal flows 2026-04-12 21:25:54 +08:00
Will Miao
55e9e4bb6f fix(recipes): sanitize remote import gen params 2026-04-12 20:29:01 +08:00
Will Miao
0253d001e6 fix(recipe): hydrate stale modal data from recipe json 2026-04-12 19:22:58 +08:00
Will Miao
9998da3241 fix(ui): refresh stale model page versions 2026-04-11 20:11:21 +08:00
Will Miao
6666a72775 fix(doctor): center status badge 2026-04-11 16:28:14 +08:00
Will Miao
5f1bd894b9 fix(settings): prevent library modal focus jump 2026-04-11 16:20:37 +08:00
19 changed files with 2785 additions and 283 deletions

View File

@@ -13,4 +13,5 @@ GEN_PARAM_KEYS = [
'seed', 'seed',
'size', 'size',
'clip_skip', 'clip_skip',
'denoising_strength',
] ]

View File

@@ -1,27 +1,33 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import logging import logging
from .constants import GEN_PARAM_KEYS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class GenParamsMerger: class GenParamsMerger:
"""Utility to merge generation parameters from multiple sources with priority.""" """Utility to merge generation parameters from multiple sources with priority."""
ALLOWED_KEYS = set(GEN_PARAM_KEYS)
BLACKLISTED_KEYS = { BLACKLISTED_KEYS = {
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta", "id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
"draft", "extra", "width", "height", "process", "quantity", "workflow", "draft", "extra", "width", "height", "process", "quantity", "workflow",
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date", "baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
"experimental", "civitaiResources", "civitai_resources", "Civitai resources", "experimental", "civitaiResources", "civitai_resources", "Civitai resources",
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash", "modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
"checkpoint", "checksum", "model_checksum" "checkpoint", "checksum", "model_checksum", "raw_metadata",
} }
NORMALIZATION_MAPPING = { NORMALIZATION_MAPPING = {
# Civitai specific "cfg": "cfg_scale",
"cfgScale": "cfg_scale", "cfgScale": "cfg_scale",
"clipSkip": "clip_skip", "clipSkip": "clip_skip",
"negativePrompt": "negative_prompt", "negativePrompt": "negative_prompt",
# Case variations
"Sampler": "sampler", "Sampler": "sampler",
"sampler_name": "sampler",
"scheduler": "sampler",
"Steps": "steps", "Steps": "steps",
"Seed": "seed", "Seed": "seed",
"Size": "size", "Size": "size",
@@ -36,63 +42,40 @@ class GenParamsMerger:
def merge( def merge(
request_params: Optional[Dict[str, Any]] = None, request_params: Optional[Dict[str, Any]] = None,
civitai_meta: Optional[Dict[str, Any]] = None, civitai_meta: Optional[Dict[str, Any]] = None,
embedded_metadata: Optional[Dict[str, Any]] = None embedded_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Merge generation parameters from three sources. Merge generation parameters from three sources.
Priority: request_params > civitai_meta > embedded_metadata
Args:
request_params: Params provided directly in the import request
civitai_meta: Params from Civitai Image API 'meta' field
embedded_metadata: Params extracted from image EXIF/embedded metadata
Returns:
Merged parameters dictionary
"""
result = {}
# 1. Start with embedded metadata (lowest priority) Priority: request_params > civitai_meta > embedded_metadata
"""
result: Dict[str, Any] = {}
if embedded_metadata: if embedded_metadata:
# If it's a full recipe metadata, we use its gen_params if "gen_params" in embedded_metadata and isinstance(
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict): embedded_metadata["gen_params"], dict
):
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"]) GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
else: else:
# Otherwise assume the dict itself contains gen_params
GenParamsMerger._update_normalized(result, embedded_metadata) GenParamsMerger._update_normalized(result, embedded_metadata)
# 2. Layer Civitai meta (medium priority)
if civitai_meta: if civitai_meta:
GenParamsMerger._update_normalized(result, civitai_meta) GenParamsMerger._update_normalized(result, civitai_meta)
# 3. Layer request params (highest priority)
if request_params: if request_params:
GenParamsMerger._update_normalized(result, request_params) GenParamsMerger._update_normalized(result, request_params)
# Filter out blacklisted keys and also the original camelCase keys if they were normalized return result
final_result = {}
for k, v in result.items():
if k in GenParamsMerger.BLACKLISTED_KEYS:
continue
if k in GenParamsMerger.NORMALIZATION_MAPPING:
continue
final_result[k] = v
return final_result
@staticmethod @staticmethod
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None: def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
"""Update target dict with normalized keys from source.""" """Update target dict with normalized, persistence-safe keys from source."""
for k, v in source.items(): for key, value in source.items():
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k) if key in GenParamsMerger.BLACKLISTED_KEYS:
target[normalized_key] = v continue
# Also keep the original key for now if it's not the same,
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed? normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
# Actually, if we rename it, we should probably NOT keep both in 'target' if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
# because we want to filter them out at the end anyway. continue
if normalized_key != k:
# If we are overwriting an existing snake_case key with a camelCase one's value, target[normalized_key] = value
# that's fine because of the priority order of calls to _update_normalized.
pass
target[k] = v

View File

@@ -64,7 +64,6 @@ class ModelPageView:
self._settings = settings_service self._settings = settings_service
self._server_i18n = server_i18n self._server_i18n = server_i18n
self._logger = logger self._logger = logger
self._app_version = self._get_app_version()
def _load_supporters(self) -> dict: def _load_supporters(self) -> dict:
"""Load supporters data from JSON file.""" """Load supporters data from JSON file."""
@@ -155,7 +154,7 @@ class ModelPageView:
"request": request, "request": request,
"folders": [], "folders": [],
"t": self._server_i18n.get_translation, "t": self._server_i18n.get_translation,
"version": self._app_version, "version": self._get_app_version(),
} }
if not is_initializing: if not is_initializing:

View File

@@ -756,6 +756,14 @@ class RecipeManagementHandler:
) )
gen_params_request = self._parse_gen_params(params.get("gen_params")) gen_params_request = self._parse_gen_params(params.get("gen_params"))
self._logger.info(
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
image_url,
sorted(gen_params_request.keys()) if gen_params_request else [],
len(lora_entries),
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
# 2. Initial Metadata Construction # 2. Initial Metadata Construction
metadata: Dict[str, Any] = { metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "", "base_model": params.get("base_model", "") or "",

View File

@@ -952,6 +952,30 @@ class RecipeScanner:
except Exception as exc: except Exception as exc:
logger.debug("Failed to update FTS index for recipe: %s", 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: async def _enrich_cache_metadata(self) -> None:
"""Perform remote metadata enrichment after the initial scan.""" """Perform remote metadata enrichment after the initial scan."""
@@ -1345,6 +1369,7 @@ class RecipeScanner:
# Ensure gen_params exists # Ensure gen_params exists
if "gen_params" not in recipe_data: if "gen_params" not in recipe_data:
recipe_data["gen_params"] = {} recipe_data["gen_params"] = {}
recipe_data = self._normalize_recipe_gen_params(recipe_data)
# Update lora information with local paths and availability # Update lora information with local paths and availability
lora_metadata_updated = await self._update_lora_information(recipe_data) 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) end_idx = min(start_idx + page_size, total_items)
# Get paginated 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 # Add inLibrary information and URLs for each recipe
for item in paginated_items: for item in paginated_items:
@@ -2114,8 +2142,18 @@ class RecipeScanner:
if not recipe: if not recipe:
return None return None
# Prefer the on-disk recipe JSON for fields that are not persisted in the
# SQLite cache yet, such as source_path.
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"):
if field not in recipe_json:
merged_recipe.pop(field, None)
merged_recipe.update(recipe_json)
# Format the recipe with all needed information # Format the recipe with all needed information
formatted_recipe = {**recipe} # Copy all fields formatted_recipe = {**merged_recipe}
# Format file path to URL # Format file path to URL
if "file_path" in formatted_recipe: if "file_path" in formatted_recipe:
@@ -2149,6 +2187,30 @@ class RecipeScanner:
return formatted_recipe return formatted_recipe
async def _load_recipe_json(self, recipe_id: str) -> Optional[Dict[str, Any]]:
"""Load the raw recipe JSON payload for a recipe ID if it exists."""
recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
return None
try:
with open(recipe_json_path, "r", encoding="utf-8") as f:
recipe_data = json.load(f)
except Exception as exc:
logger.debug(
"Failed to load recipe JSON for %s from %s: %s",
recipe_id,
recipe_json_path,
exc,
)
return None
if not isinstance(recipe_data, dict):
return None
return self._normalize_recipe_gen_params(recipe_data)
def _format_file_url(self, file_path: str) -> str: def _format_file_url(self, file_path: str) -> str:
"""Format file path as URL for serving in web UI""" """Format file path as URL for serving in web UI"""
if not file_path: if not file_path:

View File

@@ -12,6 +12,7 @@ from dataclasses import dataclass
from typing import Any, Dict, Iterable, Optional from typing import Any, Dict, Iterable, Optional
from ...config import config from ...config import config
from ...recipes.constants import GEN_PARAM_KEYS
from ...utils.utils import calculate_recipe_fingerprint from ...utils.utils import calculate_recipe_fingerprint
from .errors import RecipeNotFoundError, RecipeValidationError from .errors import RecipeNotFoundError, RecipeValidationError
@@ -90,23 +91,7 @@ class RecipePersistenceService:
current_time = time.time() current_time = time.time()
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])] loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata)) checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
gen_params = self._sanitize_gen_params_for_storage(metadata)
gen_params = metadata.get("gen_params") or {}
if not gen_params and "raw_metadata" in metadata:
raw_metadata = metadata.get("raw_metadata", {})
gen_params = {
"prompt": raw_metadata.get("prompt", ""),
"negative_prompt": raw_metadata.get("negative_prompt", ""),
"steps": raw_metadata.get("steps", ""),
"sampler": raw_metadata.get("sampler", ""),
"cfg_scale": raw_metadata.get("cfg_scale", ""),
"seed": raw_metadata.get("seed", ""),
"size": raw_metadata.get("size", ""),
"clip_skip": raw_metadata.get("clip_skip", ""),
}
# Drop checkpoint duplication from generation parameters to store it only at top level
gen_params.pop("checkpoint", None)
fingerprint = calculate_recipe_fingerprint(loras_data) fingerprint = calculate_recipe_fingerprint(loras_data)
recipe_data: Dict[str, Any] = { recipe_data: Dict[str, Any] = {
@@ -133,6 +118,7 @@ class RecipePersistenceService:
json_filename = f"{recipe_id}.recipe.json" json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename) json_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path) json_path = os.path.normpath(json_path)
with open(json_path, "w", encoding="utf-8") as file_obj: with open(json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False) json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
@@ -152,6 +138,30 @@ class RecipePersistenceService:
} }
) )
@staticmethod
def _sanitize_gen_params_for_storage(metadata: dict[str, Any]) -> dict[str, Any]:
gen_params = metadata.get("gen_params")
if isinstance(gen_params, dict) and gen_params:
source = gen_params
else:
source = metadata.get("raw_metadata")
if not isinstance(source, dict):
return {}
allowed_keys = set(GEN_PARAM_KEYS)
sanitized: dict[str, Any] = {}
for key in allowed_keys:
if key not in source:
continue
value = source.get(key)
if value in (None, ""):
continue
sanitized[key] = value
sanitized.pop("checkpoint", None)
return sanitized
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult: async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
"""Delete an existing recipe.""" """Delete an existing recipe."""

View File

@@ -9,6 +9,9 @@
} }
.doctor-status-badge { .doctor-status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px; min-width: 18px;
height: 18px; height: 18px;
padding: 0 5px; padding: 0 5px;
@@ -16,7 +19,7 @@
background: var(--lora-error); background: var(--lora-error);
color: #fff; color: #fff;
font-size: 11px; font-size: 11px;
line-height: 18px; line-height: 1;
font-weight: 700; font-weight: 700;
} }

View File

@@ -31,6 +31,20 @@ export function extractRecipeId(filePath) {
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename; return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
} }
export async function fetchRecipeDetails(recipeId) {
if (!recipeId) {
throw new Error('Unable to determine recipe ID');
}
const encodedRecipeId = encodeURIComponent(recipeId);
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}`);
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
}
return response.json();
}
/** /**
* Fetch recipes with pagination for virtual scrolling * Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch * @param {number} page - Page number to fetch
@@ -61,7 +75,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
// If we have a specific recipe ID to load // If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe // Special case: load specific recipe
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`); const response = await fetch(
`${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(pageState.customFilter.recipeId)}`
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`); throw new Error(`Failed to load recipe: ${response.statusText}`);
@@ -349,9 +365,10 @@ export function createRecipeCard(recipe) {
* @param {Object} updates - The metadata updates to apply * @param {Object} updates - The metadata updates to apply
* @returns {Promise<Object>} The updated recipe data * @returns {Promise<Object>} The updated recipe data
*/ */
export async function updateRecipeMetadata(filePath, updates) { export async function updateRecipeMetadata(filePath, updates, options = {}) {
try { try {
state.loadingManager.showSimpleLoading('Saving metadata...'); state.loadingManager.showSimpleLoading('Saving metadata...');
const listFilePath = options.listFilePath || filePath;
// Extract recipeId from filePath (basename without extension) // Extract recipeId from filePath (basename without extension)
const recipeId = extractRecipeId(filePath); const recipeId = extractRecipeId(filePath);
@@ -359,7 +376,7 @@ export async function updateRecipeMetadata(filePath, updates) {
throw new Error('Unable to determine recipe ID'); throw new Error('Unable to determine recipe ID');
} }
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, { const response = await fetch(`${RECIPE_ENDPOINTS.update}/${encodeURIComponent(recipeId)}/update`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -374,7 +391,7 @@ export async function updateRecipeMetadata(filePath, updates) {
throw new Error(data.error || 'Failed to update recipe'); throw new Error(data.error || 'Failed to update recipe');
} }
state.virtualScroller.updateSingleItem(filePath, updates); state.virtualScroller.updateSingleItem(listFilePath, updates);
return data; return data;
} catch (error) { } catch (error) {

View File

@@ -3,16 +3,105 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js'; import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js'; import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.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 { class RecipeModal {
constructor() { constructor() {
this.promptEditorState = {}; this.promptEditorState = {};
this.recipeHydrationRequestId = 0;
this.resetLocalEditState();
this.init(); this.init();
} }
createLocalEditState() {
return {
title: { commitVersion: 0, isDirty: false },
tags: { commitVersion: 0, isDirty: false },
prompt: { commitVersion: 0, isDirty: false },
negative_prompt: { commitVersion: 0, isDirty: false },
source_path: { commitVersion: 0, isDirty: false },
};
}
resetLocalEditState() {
this.localEditState = this.createLocalEditState();
this.sourceUrlEditState = this.localEditState.source_path;
}
getLocalEditState(field) {
if (!this.localEditState[field]) {
this.localEditState[field] = { commitVersion: 0, isDirty: false };
}
return this.localEditState[field];
}
markFieldDirty(field) {
this.getLocalEditState(field).isDirty = true;
}
clearFieldDirty(field) {
this.getLocalEditState(field).isDirty = false;
}
commitField(field) {
const fieldState = this.getLocalEditState(field);
fieldState.isDirty = false;
fieldState.commitVersion += 1;
}
captureLocalEditVersions() {
return Object.fromEntries(
Object.entries(this.localEditState).map(([field, state]) => [
field,
state.commitVersion,
])
);
}
shouldPreserveField(field, requestVersions) {
const fieldState = this.getLocalEditState(field);
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
return fieldState.isDirty || fieldState.commitVersion !== requestVersion;
}
hasFieldCommittedSinceRequest(field, requestVersions) {
const fieldState = this.getLocalEditState(field);
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
return fieldState.commitVersion !== requestVersion;
}
init() { init() {
this.setupCopyButtons(); this.setupCopyButtons();
this.setupPromptEditors(); this.setupPromptEditors();
@@ -87,8 +176,10 @@ class RecipeModal {
} }
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
// Store the full recipe for editing // Store the full recipe for editing
this.currentRecipe = recipe; this.currentRecipe = hydratedRecipe;
this.resetPromptEditors(); this.resetPromptEditors();
// Set modal title with edit icon // Set modal title with edit icon
@@ -96,11 +187,11 @@ class RecipeModal {
if (modalTitle) { if (modalTitle) {
modalTitle.innerHTML = ` modalTitle.innerHTML = `
<div class="editable-content"> <div class="editable-content">
<span class="content-text">${recipe.title || 'Recipe Details'}</span> <span class="content-text">${hydratedRecipe.title || 'Recipe Details'}</span>
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button> <button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
</div> </div>
<div id="recipeTitleEditor" class="content-editor"> <div id="recipeTitleEditor" class="content-editor">
<input type="text" class="title-input" value="${recipe.title || ''}"> <input type="text" class="title-input" value="${hydratedRecipe.title || ''}">
</div> </div>
`; `;
@@ -122,8 +213,9 @@ class RecipeModal {
} }
// Store the recipe ID for copy syntax API call // Store the recipe ID for copy syntax API call
this.recipeId = recipe.id; this.recipeId = hydratedRecipe.id;
this.filePath = recipe.file_path; this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist // Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact'); const tagsCompactElement = document.getElementById('recipeTagsCompact');
@@ -143,11 +235,11 @@ class RecipeModal {
const tagsDisplay = tagsCompactElement.querySelector('.tags-display'); const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
if (recipe.tags && recipe.tags.length > 0) { if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed // Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5; const maxVisibleTags = 5;
const visibleTags = recipe.tags.slice(0, maxVisibleTags); const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : []; const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags // Add visible tags
visibleTags.forEach(tag => { visibleTags.forEach(tag => {
@@ -184,7 +276,7 @@ class RecipeModal {
// Add all tags to tooltip // Add all tags to tooltip
if (tagsTooltipContent) { if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = ''; tagsTooltipContent.innerHTML = '';
recipe.tags.forEach(tag => { hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div'); const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag'; tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag; tooltipTag.textContent = tag;
@@ -201,8 +293,8 @@ class RecipeModal {
const tagsInput = tagsCompactElement.querySelector('.tags-input'); const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input // Set current tags in the input
if (recipe.tags && recipe.tags.length > 0) { if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = recipe.tags.join(', '); tagsInput.value = hydratedRecipe.tags.join(', ');
} }
editTagsIcon.addEventListener('click', () => this.showTagsEditor()); editTagsIcon.addEventListener('click', () => this.showTagsEditor());
@@ -222,49 +314,15 @@ class RecipeModal {
// Set recipe image // Set recipe image
const mediaContainer = document.getElementById('recipePreviewContainer'); const mediaContainer = document.getElementById('recipePreviewContainer');
if (mediaContainer) { if (mediaContainer) {
// Stop any playing video before replacing content this.syncPreviewMedia(hydratedRecipe);
const existingVideo = mediaContainer.querySelector('video'); mediaContainer.querySelector('.source-url-container')?.remove();
if (existingVideo) { mediaContainer.querySelector('.source-url-editor')?.remove();
existingVideo.pause();
existingVideo.currentTime = 0;
}
// Clear the container
mediaContainer.innerHTML = '';
// Ensure file_url exists, fallback to file_path if needed
const imageUrl = recipe.file_url ||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
// Check if the file is a video (mp4)
const isVideo = imageUrl.toLowerCase().endsWith('.mp4');
if (isVideo) {
const videoElement = document.createElement('video');
videoElement.id = 'recipeModalVideo';
videoElement.src = imageUrl;
videoElement.controls = true;
videoElement.autoplay = false;
videoElement.loop = true;
videoElement.muted = true;
videoElement.className = 'recipe-preview-media';
videoElement.alt = recipe.title || 'Recipe Preview';
mediaContainer.appendChild(videoElement);
} else {
const imgElement = document.createElement('img');
imgElement.id = 'recipeModalImage';
imgElement.src = imageUrl;
imgElement.className = 'recipe-preview-media';
imgElement.alt = recipe.title || 'Recipe Preview';
mediaContainer.appendChild(imgElement);
}
// Add source URL container if the recipe has a source_path // Add source URL container if the recipe has a source_path
const sourceUrlContainer = document.createElement('div'); const sourceUrlContainer = document.createElement('div');
sourceUrlContainer.className = 'source-url-container'; sourceUrlContainer.className = 'source-url-container';
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0; const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
const sourceUrl = hasSourceUrl ? recipe.source_path : ''; const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://')); const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
sourceUrlContainer.innerHTML = ` sourceUrlContainer.innerHTML = `
@@ -293,40 +351,273 @@ class RecipeModal {
mediaContainer.appendChild(sourceUrlContainer); mediaContainer.appendChild(sourceUrlContainer);
mediaContainer.appendChild(sourceUrlEditor); 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(() => { setTimeout(() => {
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
return;
}
this.setupSourceUrlHandlers(); this.setupSourceUrlHandlers();
}, 50); }, 50);
} }
// Set generation parameters this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
// Show the modal
modalManager.showModal('recipeModal');
if (this.recipeId) {
const hydrationRequestId = ++this.recipeHydrationRequestId;
const requestEditVersions = this.captureLocalEditVersions();
this.hydrateRecipeDetails(
this.recipeId,
hydrationRequestId,
requestEditVersions
);
}
}
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
try {
const fullRecipe = await fetchRecipeDetails(recipeId);
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
return;
}
const nextRecipe = { ...this.currentRecipe };
if (!this.hasFieldCommittedSinceRequest('title', requestEditVersions) && fullRecipe.title !== undefined) {
nextRecipe.title = fullRecipe.title;
}
if (!this.hasFieldCommittedSinceRequest('tags', requestEditVersions) && fullRecipe.tags !== undefined) {
nextRecipe.tags = Array.isArray(fullRecipe.tags) ? [...fullRecipe.tags] : fullRecipe.tags;
}
if (!this.hasFieldCommittedSinceRequest('source_path', requestEditVersions)) {
nextRecipe.source_path = fullRecipe.source_path || '';
}
const previousFilePath = nextRecipe.file_path;
if (fullRecipe.file_path !== undefined) {
nextRecipe.file_path = fullRecipe.file_path;
}
if (fullRecipe.file_url !== undefined) {
nextRecipe.file_url = fullRecipe.file_url;
}
if (fullRecipe.preview_url !== undefined) {
nextRecipe.preview_url = fullRecipe.preview_url;
}
if (
fullRecipe.file_path !== undefined &&
fullRecipe.file_path !== previousFilePath &&
fullRecipe.file_url === undefined &&
fullRecipe.preview_url === undefined
) {
delete nextRecipe.file_url;
delete nextRecipe.preview_url;
}
if (fullRecipe.gen_params !== undefined) {
const previousGenParams = nextRecipe.gen_params || {};
const incomingGenParams = { ...(fullRecipe.gen_params || {}) };
for (const [key, value] of Object.entries(previousGenParams)) {
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
incomingGenParams[key] = value;
}
}
nextRecipe.gen_params = incomingGenParams;
} else {
const previousGenParams = nextRecipe.gen_params || {};
const preservedGenParams = {};
for (const [key, value] of Object.entries(previousGenParams)) {
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
preservedGenParams[key] = value;
}
}
nextRecipe.gen_params = preservedGenParams;
}
if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint;
} else {
delete nextRecipe.checkpoint;
}
if (fullRecipe.loras !== undefined) {
nextRecipe.loras = Array.isArray(fullRecipe.loras) ? [...fullRecipe.loras] : fullRecipe.loras;
} else {
delete nextRecipe.loras;
}
this.currentRecipe = nextRecipe;
this.filePath = this.currentRecipe.file_path || this.filePath;
this.syncHydratedRecipeFields(requestEditVersions);
} catch (error) {
// Keep the cached recipe visible if hydration fails.
console.warn('Failed to hydrate recipe details:', error);
}
}
syncHydratedRecipeFields(requestEditVersions = {}) {
this.syncPreviewMedia(this.currentRecipe);
if (!this.shouldPreserveField('title', requestEditVersions)) {
this.syncTitleDisplay(this.currentRecipe?.title || '');
}
if (!this.shouldPreserveField('tags', requestEditVersions)) {
this.syncTagsDisplay(this.currentRecipe?.tags || []);
}
if (!this.shouldPreserveField('prompt', requestEditVersions)) {
this.syncPromptField(
'prompt',
this.currentRecipe?.gen_params?.prompt || '',
'No prompt information available'
);
}
if (!this.shouldPreserveField('negative_prompt', requestEditVersions)) {
this.syncPromptField(
'negative_prompt',
this.currentRecipe?.gen_params?.negative_prompt || '',
'No negative prompt information available'
);
}
this.syncGenerationParams(this.currentRecipe?.gen_params, { promptFieldsOnly: true });
this.syncResourcesSection(this.currentRecipe);
if (!this.shouldPreserveField('source_path', requestEditVersions)) {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
} else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
}
}
getPreviewMediaUrl(recipe = {}) {
return recipe.file_url ||
recipe.preview_url ||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
}
syncPreviewMedia(recipe = {}) {
const mediaContainer = document.getElementById('recipePreviewContainer');
if (!mediaContainer) {
return;
}
const previewUrl = this.getPreviewMediaUrl(recipe);
const isVideo = previewUrl.toLowerCase().endsWith('.mp4');
const expectedElementId = isVideo ? 'recipeModalVideo' : 'recipeModalImage';
let previewElement = mediaContainer.querySelector(`#${expectedElementId}`);
const existingPreviewElement = mediaContainer.querySelector('.recipe-preview-media');
if (!previewElement || (existingPreviewElement && existingPreviewElement !== previewElement)) {
if (existingPreviewElement?.tagName === 'VIDEO') {
const existingVideo = existingPreviewElement;
existingVideo.pause();
existingVideo.currentTime = 0;
}
existingPreviewElement?.remove();
previewElement = document.createElement(isVideo ? 'video' : 'img');
previewElement.id = expectedElementId;
previewElement.className = 'recipe-preview-media';
mediaContainer.prepend(previewElement);
}
previewElement.src = previewUrl;
previewElement.alt = recipe.title || 'Recipe Preview';
if (isVideo) {
previewElement.controls = true;
previewElement.autoplay = false;
previewElement.loop = true;
previewElement.muted = true;
}
}
getMetadataUpdateOptions() {
return this.listFilePath ? { listFilePath: this.listFilePath } : {};
}
syncTitleDisplay(title) {
const titleContainer = document.getElementById('recipeModalTitle');
if (!titleContainer) {
return;
}
const contentText = titleContainer.querySelector('.content-text');
if (contentText) {
contentText.textContent = title || 'Recipe Details';
}
const titleInput = titleContainer.querySelector('.title-input');
if (titleInput) {
titleInput.value = title || '';
}
}
syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (!tagsContainer) {
return;
}
this.updateTagsDisplay(tagsContainer, tags || []);
const tagsInput = tagsContainer.querySelector('.tags-input');
if (tagsInput) {
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
}
}
syncPromptField(field, value, placeholder) {
const contentId = field === 'prompt' ? 'recipePrompt' : 'recipeNegativePrompt';
const editorId = field === 'prompt' ? 'recipePromptEditor' : 'recipeNegativePromptEditor';
const inputId = field === 'prompt' ? 'recipePromptInput' : 'recipeNegativePromptInput';
this.renderPromptContent(document.getElementById(contentId), value, placeholder);
const input = document.getElementById(inputId);
if (input) {
input.value = value || '';
}
}
syncGenerationParams(genParams, options = {}) {
const promptElement = document.getElementById('recipePrompt'); const promptElement = document.getElementById('recipePrompt');
const negativePromptElement = document.getElementById('recipeNegativePrompt'); const negativePromptElement = document.getElementById('recipeNegativePrompt');
const otherParamsElement = document.getElementById('recipeOtherParams'); const otherParamsElement = document.getElementById('recipeOtherParams');
const promptInput = document.getElementById('recipePromptInput'); const promptInput = document.getElementById('recipePromptInput');
const negativePromptInput = document.getElementById('recipeNegativePromptInput'); const negativePromptInput = document.getElementById('recipeNegativePromptInput');
const promptFieldsOnly = options.promptFieldsOnly === true;
const sanitizedGenParams = this.sanitizeGenParams(genParams);
if (recipe.gen_params) { if (sanitizedGenParams) {
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available'); if (!promptFieldsOnly) {
this.renderPromptContent(negativePromptElement, recipe.gen_params.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) { if (promptInput) {
promptInput.value = recipe.gen_params.prompt || ''; promptInput.value = sanitizedGenParams.prompt || '';
}
if (negativePromptInput) {
negativePromptInput.value = sanitizedGenParams.negative_prompt || '';
}
} }
if (negativePromptInput) {
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
}
// Set other parameters
if (otherParamsElement) { if (otherParamsElement) {
// Clear previous params
otherParamsElement.innerHTML = ''; otherParamsElement.innerHTML = '';
// Add all other parameters except prompt and negative_prompt
const excludedParams = ['prompt', 'negative_prompt']; const excludedParams = ['prompt', 'negative_prompt'];
for (const [key, value] of Object.entries(recipe.gen_params)) { for (const [key, value] of Object.entries(sanitizedGenParams)) {
if (!excludedParams.includes(key) && value !== undefined && value !== null) { if (!excludedParams.includes(key) && value !== undefined && value !== null) {
const paramTag = document.createElement('div'); const paramTag = document.createElement('div');
paramTag.className = 'param-tag'; paramTag.className = 'param-tag';
@@ -338,22 +629,68 @@ class RecipeModal {
} }
} }
// If no other params, show a message
if (otherParamsElement.children.length === 0) { if (otherParamsElement.children.length === 0) {
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>'; otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
} }
} }
} else { return;
// No generation parameters available }
if (!promptFieldsOnly) {
this.renderPromptContent(promptElement, '', 'No prompt information available'); this.renderPromptContent(promptElement, '', 'No prompt information available');
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available'); this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
if (promptInput) promptInput.value = ''; if (promptInput) promptInput.value = '';
if (negativePromptInput) negativePromptInput.value = ''; if (negativePromptInput) negativePromptInput.value = '';
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
} }
if (otherParamsElement) {
otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
}
}
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 checkpointContainer = document.getElementById('recipeCheckpoint');
const resourceDivider = document.getElementById('recipeResourceDivider'); const resourceDivider = document.getElementById('recipeResourceDivider');
const lorasListElement = document.getElementById('recipeLorasList');
const lorasCountElement = document.getElementById('recipeLorasCount');
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
if (checkpointContainer) { if (checkpointContainer) {
checkpointContainer.innerHTML = ''; checkpointContainer.innerHTML = '';
@@ -364,59 +701,43 @@ class RecipeModal {
} }
} }
// Set LoRAs list and count
const lorasListElement = document.getElementById('recipeLorasList');
const lorasCountElement = document.getElementById('recipeLorasCount');
// Check all LoRAs status
let allLorasAvailable = true; let allLorasAvailable = true;
let missingLorasCount = 0; let missingLorasCount = 0;
let deletedLorasCount = 0; let deletedLorasCount = 0;
if (recipe.loras && recipe.loras.length > 0) { loras.forEach(lora => {
recipe.loras.forEach(lora => { if (lora.isDeleted) {
if (lora.isDeleted) { deletedLorasCount++;
deletedLorasCount++; } else if (!lora.inLibrary) {
} else if (!lora.inLibrary) { allLorasAvailable = false;
allLorasAvailable = false; missingLorasCount++;
missingLorasCount++; }
} });
});
}
// Set LoRAs count and status if (lorasCountElement) {
if (lorasCountElement && recipe.loras) { const totalCount = loras.length;
const totalCount = recipe.loras.length;
// Create status indicator based on LoRA states
let statusHTML = ''; let statusHTML = '';
if (totalCount > 0) { if (totalCount > 0) {
if (allLorasAvailable && deletedLorasCount === 0) { if (allLorasAvailable && deletedLorasCount === 0) {
// All LoRAs are available
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`; statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
} else if (missingLorasCount > 0) { } else if (missingLorasCount > 0) {
// Some LoRAs are missing (prioritize showing missing over deleted)
statusHTML = `<div class="recipe-status missing"> statusHTML = `<div class="recipe-status missing">
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing <i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
<div class="missing-tooltip">Click to download missing LoRAs</div> <div class="missing-tooltip">Click to download missing LoRAs</div>
</div>`; </div>`;
} else if (deletedLorasCount > 0 && missingLorasCount === 0) { } else if (deletedLorasCount > 0 && missingLorasCount === 0) {
// Some LoRAs are deleted but none are missing
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`; statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
} }
} }
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`; lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
// Add event listeners for buttons and status indicators
setTimeout(() => { setTimeout(() => {
// Set up click handler for View LoRAs button
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) { if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
} }
// Add click handler for missing LoRAs status
const missingStatus = document.querySelector('.recipe-status.missing'); const missingStatus = document.querySelector('.recipe-status.missing');
if (missingStatus && missingLorasCount > 0) { if (missingStatus && missingLorasCount > 0) {
missingStatus.classList.add('clickable'); missingStatus.classList.add('clickable');
@@ -425,13 +746,12 @@ class RecipeModal {
}, 100); }, 100);
} }
if (lorasListElement && recipe.loras && recipe.loras.length > 0) { if (lorasListElement && loras.length > 0) {
lorasListElement.innerHTML = recipe.loras.map(lora => { lorasListElement.innerHTML = loras.map(lora => {
const existsLocally = lora.inLibrary; const existsLocally = lora.inLibrary;
const isDeleted = lora.isDeleted; const isDeleted = lora.isDeleted;
const localPath = lora.localPath || ''; const localPath = lora.localPath || '';
// Create status badge based on LoRA state
let localStatus; let localStatus;
if (existsLocally) { if (existsLocally) {
localStatus = ` localStatus = `
@@ -441,7 +761,7 @@ class RecipeModal {
</div>`; </div>`;
} else if (isDeleted) { } else if (isDeleted) {
localStatus = ` localStatus = `
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}"> <div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span> <span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div> <div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
</div>`; </div>`;
@@ -452,7 +772,6 @@ class RecipeModal {
</div>`; </div>`;
} }
// Check if preview is a video
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4'); const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
const previewMedia = isPreviewVideo ? const previewMedia = isPreviewVideo ?
`<video class="thumbnail-video" autoplay loop muted playsinline> `<video class="thumbnail-video" autoplay loop muted playsinline>
@@ -460,7 +779,6 @@ class RecipeModal {
</video>` : </video>` :
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`; `<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
// Determine CSS class based on LoRA state
let loraItemClass = 'recipe-lora-item'; let loraItemClass = 'recipe-lora-item';
if (existsLocally) { if (existsLocally) {
loraItemClass += ' exists-locally'; loraItemClass += ' exists-locally';
@@ -471,7 +789,7 @@ class RecipeModal {
} }
return ` return `
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}"> <div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
<div class="recipe-lora-thumbnail"> <div class="recipe-lora-thumbnail">
${previewMedia} ${previewMedia}
</div> </div>
@@ -485,7 +803,7 @@ class RecipeModal {
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div> <div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''} ${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
</div> </div>
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}"> <div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
<div class="reconnect-instructions"> <div class="reconnect-instructions">
<p>Enter LoRA Syntax or Name to Reconnect:</p> <p>Enter LoRA Syntax or Name to Reconnect:</p>
<small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small> <small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
@@ -503,15 +821,12 @@ class RecipeModal {
`; `;
}).join(''); }).join('');
// Add event listeners for reconnect functionality
setTimeout(() => { setTimeout(() => {
this.setupReconnectButtons(); this.setupReconnectButtons();
this.setupLoraItemsClickable(); this.setupLoraItemsClickable();
}, 100); }, 100);
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
this.recipeLorasSyntax = ''; this.recipeLorasSyntax = '';
} else if (lorasListElement) { } else if (lorasListElement) {
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>'; lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
this.recipeLorasSyntax = ''; this.recipeLorasSyntax = '';
@@ -522,9 +837,31 @@ class RecipeModal {
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item'); const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none'; resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
} }
}
// Show the modal updateSourceUrlDisplay(sourcePath, options = {}) {
modalManager.showModal('recipeModal'); const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor');
if (!sourceUrlContainer || !sourceUrlEditor) {
return;
}
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
if (!sourceUrlText || !sourceUrlInput) {
return;
}
const normalizedSourcePath = typeof sourcePath === 'string' ? sourcePath.trim() : '';
const isValidUrl = normalizedSourcePath.startsWith('http://') || normalizedSourcePath.startsWith('https://');
sourceUrlText.textContent = normalizedSourcePath || 'No source URL';
sourceUrlText.title = normalizedSourcePath
? (isValidUrl ? 'Click to open source URL' : 'No valid URL')
: 'No valid URL';
if (options.forceInputSync || !sourceUrlEditor.classList.contains('active') || !this.sourceUrlEditState.isDirty) {
sourceUrlInput.value = normalizedSourcePath;
}
} }
// Title editing methods // Title editing methods
@@ -535,6 +872,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor'); const editor = titleContainer.querySelector('#recipeTitleEditor');
editor.classList.add('active'); editor.classList.add('active');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('title');
input.focus(); input.focus();
input.select(); input.select();
} }
@@ -553,19 +891,23 @@ class RecipeModal {
titleContainer.querySelector('.content-text').textContent = newTitle; titleContainer.querySelector('.content-text').textContent = newTitle;
// Update the recipe on the server // Update the recipe on the server
updateRecipeMetadata(this.filePath, { title: newTitle }) updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
.then(data => { .then(data => {
// Show success toast // Show success toast
showToast('toast.recipes.nameUpdated', {}, 'success'); showToast('toast.recipes.nameUpdated', {}, 'success');
// Update the current recipe object // Update the current recipe object
this.currentRecipe.title = newTitle; this.currentRecipe.title = newTitle;
this.commitField('title');
}) })
.catch(error => { .catch(error => {
// Error is handled in the API function // Error is handled in the API function
// Reset the UI if needed // Reset the UI if needed
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || ''; titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
this.clearFieldDirty('title');
}); });
} else {
this.clearFieldDirty('title');
} }
// Hide editor // Hide editor
@@ -581,6 +923,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor'); const editor = titleContainer.querySelector('#recipeTitleEditor');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.value = this.currentRecipe.title || ''; input.value = this.currentRecipe.title || '';
this.clearFieldDirty('title');
// Hide editor // Hide editor
editor.classList.remove('active'); editor.classList.remove('active');
@@ -596,6 +939,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor'); const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active'); editor.classList.add('active');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus(); input.focus();
} }
} }
@@ -623,20 +967,24 @@ class RecipeModal {
if (tagsChanged) { if (tagsChanged) {
// Update the recipe on the server // Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags }) updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => { .then(data => {
// Show success toast // Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success'); showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object // Update the current recipe object
this.currentRecipe.tags = newTags; this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI // Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags); this.updateTagsDisplay(tagsContainer, newTags);
}) })
.catch(error => { .catch(error => {
// Error is handled in the API function // Error is handled in the API function
this.clearFieldDirty('tags');
}); });
} else {
this.clearFieldDirty('tags');
} }
// Hide editor // Hide editor
@@ -708,6 +1056,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor'); const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : ''; input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor // Hide editor
editor.classList.remove('active'); editor.classList.remove('active');
@@ -748,6 +1097,7 @@ class RecipeModal {
} }
if (input) { if (input) {
input.addEventListener('input', () => this.markFieldDirty(config.field));
input.addEventListener('keydown', (event) => { input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
@@ -840,9 +1190,10 @@ class RecipeModal {
const currentGenParams = this.currentRecipe.gen_params || {}; const currentGenParams = this.currentRecipe.gen_params || {};
const nextValue = input.value.trim() === '' ? '' : input.value; const nextValue = input.value.trim() === '' ? '' : input.value;
const currentValue = currentGenParams[config.field] || ''; const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || '';
if (nextValue === currentValue) { if (nextValue === currentValue) {
this.clearFieldDirty(config.field);
this.hidePromptEditor(config); this.hidePromptEditor(config);
return; return;
} }
@@ -857,14 +1208,17 @@ class RecipeModal {
...promptState, ...promptState,
isSaving: true, isSaving: true,
}; };
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }); await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
this.currentRecipe.gen_params = nextGenParams; this.currentRecipe.gen_params = nextGenParams;
this.renderPromptContent(content, nextValue, config.placeholder); this.renderPromptContent(content, nextValue, config.placeholder);
showToast(config.successKey, {}, 'success', config.successFallback); showToast(config.successKey, {}, 'success', config.successFallback);
this.commitField(config.field);
} catch (error) { } catch (error) {
this.renderPromptContent(content, currentValue, config.placeholder); this.renderPromptContent(content, currentValue, config.placeholder);
input.value = currentValue; input.value = currentValue;
this.clearFieldDirty(config.field);
} finally { } finally {
this.clearFieldDirty(config.field);
this.hidePromptEditor(config); this.hidePromptEditor(config);
} }
} }
@@ -872,10 +1226,10 @@ class RecipeModal {
cancelPromptEdit(config) { cancelPromptEdit(config) {
const input = document.getElementById(config.inputId); const input = document.getElementById(config.inputId);
if (input) { if (input) {
const initialValue = this.promptEditorState[config.field]?.initialValue; input.value = this.currentRecipe?.gen_params?.[config.field] || '';
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
} }
this.clearFieldDirty(config.field);
this.hidePromptEditor(config); this.hidePromptEditor(config);
} }
@@ -918,11 +1272,16 @@ class RecipeModal {
sourceUrlInput.focus(); sourceUrlInput.focus();
}); });
sourceUrlInput.addEventListener('input', () => {
this.sourceUrlEditState.isDirty = true;
});
// Cancel editing // Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => { sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active'); sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide'); sourceUrlContainer.classList.remove('hide');
sourceUrlInput.value = this.currentRecipe.source_path || ''; this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
this.clearFieldDirty('source_path');
}); });
// Save new source URL // Save new source URL
@@ -930,23 +1289,24 @@ class RecipeModal {
const newSourceUrl = sourceUrlInput.value.trim(); const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl !== this.currentRecipe.source_path) { if (newSourceUrl !== this.currentRecipe.source_path) {
// Update the recipe on the server // Update the recipe on the server
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }) updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
.then(data => { .then(data => {
// Show success toast // Show success toast
showToast('toast.recipes.sourceUrlUpdated', {}, 'success'); showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
// Update source URL in the UI // Update source URL in the UI
sourceUrlText.textContent = newSourceUrl || 'No source URL'; this.commitField('source_path');
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') || this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
newSourceUrl.startsWith('https://')) ?
'Click to open source URL' : 'No valid URL';
// Update the current recipe object // Update the current recipe object
this.currentRecipe.source_path = newSourceUrl; this.currentRecipe.source_path = newSourceUrl;
}) })
.catch(error => { .catch(error => {
// Error is handled in the API function // Error is handled in the API function
this.clearFieldDirty('source_path');
}); });
} else {
this.clearFieldDirty('source_path');
} }
// Hide editor // Hide editor
@@ -1286,7 +1646,7 @@ class RecipeModal {
this.showRecipeDetails(this.currentRecipe); this.showRecipeDetails(this.currentRecipe);
}, 500); }, 500);
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, { state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
loras: this.currentRecipe.loras loras: this.currentRecipe.loras
}); });
} else { } else {

View File

@@ -103,6 +103,16 @@ export class DoctorManager {
return document.body?.dataset?.appVersion || ''; return document.body?.dataset?.appVersion || '';
} }
buildReloadUrl() {
const url = new URL(window.location.href);
url.searchParams.set('_lm_reload', Date.now().toString());
return url.toString();
}
reloadUi() {
window.location.replace(this.buildReloadUrl());
}
setLoading(isLoading) { setLoading(isLoading) {
if (this.loadingState) { if (this.loadingState) {
this.loadingState.classList.toggle('visible', isLoading); this.loadingState.classList.toggle('visible', isLoading);
@@ -308,7 +318,7 @@ export class DoctorManager {
await this.repairCache(); await this.repairCache();
break; break;
case 'reload-page': case 'reload-page':
window.location.reload(); this.reloadUi();
break; break;
default: default:
break; break;

View File

@@ -1443,12 +1443,12 @@ export class SettingsManager {
// Add empty row for new path if no paths exist // Add empty row for new path if no paths exist
if (paths.length === 0) { if (paths.length === 0) {
this.addExtraFolderPathRow(modelType, ''); this.addExtraFolderPathRow(modelType, '', false);
} }
}); });
} }
addExtraFolderPathRow(modelType, path = '') { addExtraFolderPathRow(modelType, path = '', shouldFocus = true) {
const container = document.getElementById(`extraFolderPaths-${modelType}`); const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return; if (!container) return;
@@ -1472,7 +1472,7 @@ export class SettingsManager {
container.appendChild(row); container.appendChild(row);
// Focus the input if it's empty (new row) // Focus the input if it's empty (new row)
if (!path) { if (!path && shouldFocus) {
const input = row.querySelector('.extra-folder-path-input'); const input = row.querySelector('.extra-folder-path-input');
if (input) { if (input) {
setTimeout(() => input.focus(), 0); setTimeout(() => input.focus(), 0);

View File

@@ -5,6 +5,9 @@ const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(), showSimpleLoading: vi.fn(),
hide: vi.fn(), hide: vi.fn(),
})); }));
const virtualScrollerMock = vi.hoisted(() => ({
updateSingleItem: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => { vi.mock('../../../static/js/utils/uiHelpers.js', () => {
return { return {
@@ -20,12 +23,13 @@ vi.mock('../../../static/js/state/index.js', () => {
return { return {
state: { state: {
loadingManager: loadingManagerMock, loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
}, },
getCurrentPageState: vi.fn(), getCurrentPageState: vi.fn(),
}; };
}); });
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js'; import { RecipeSidebarApiClient, fetchRecipeDetails, updateRecipeMetadata } from '../../../static/js/api/recipeApi.js';
describe('RecipeSidebarApiClient bulk operations', () => { describe('RecipeSidebarApiClient bulk operations', () => {
beforeEach(() => { beforeEach(() => {
@@ -111,4 +115,37 @@ describe('RecipeSidebarApiClient bulk operations', () => {
}); });
expect(loadingManagerMock.hide).toHaveBeenCalled(); expect(loadingManagerMock.hide).toHaveBeenCalled();
}); });
it('encodes recipe IDs when fetching recipe details', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ id: 'abc' }),
});
await fetchRecipeDetails('recipe#1?name=foo%bar');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar');
});
it('updates the virtual scroller using the original list path when provided', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await updateRecipeMetadata(
'/recipes/new-folder/recipe#1.webp',
{ title: 'Updated Title' },
{ listFilePath: '/recipes/old-folder/recipe#1.webp' }
);
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe%231/update',
expect.objectContaining({ method: 'PUT' })
);
expect(virtualScrollerMock.updateSingleItem).toHaveBeenCalledWith(
'/recipes/old-folder/recipe#1.webp',
{ title: 'Updated Title' }
);
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -53,4 +53,26 @@ describe('DoctorManager', () => {
expect(refreshSpy).not.toHaveBeenCalled(); expect(refreshSpy).not.toHaveBeenCalled();
}); });
it('builds a cache-busted reload URL that preserves the current location', () => {
renderDoctorFixture();
window.history.replaceState({}, '', '/loras?filter=active#details');
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
const manager = new DoctorManager();
const url = manager.buildReloadUrl();
expect(url).toBe('http://localhost:3000/loras?filter=active&_lm_reload=1234567890#details');
});
it('delegates reload-page actions to reloadUi', async () => {
renderDoctorFixture();
const manager = new DoctorManager();
const reloadSpy = vi.spyOn(manager, 'reloadUi').mockImplementation(() => undefined);
await manager.handleAction('reload-page');
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -96,6 +96,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
delete global.fetch; delete global.fetch;
delete document.hidden; delete document.hidden;
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
@@ -231,6 +232,51 @@ describe('SettingsManager library controls', () => {
expect(input.value).toBe('/custom/recipes'); expect(input.value).toBe('/custom/recipes');
}); });
it('does not autofocus empty extra folder path rows during initial settings load', async () => {
vi.useFakeTimers();
const manager = createManager();
document.body.innerHTML = `
<div id="extraFolderPaths-loras"></div>
<div id="extraFolderPaths-checkpoints"></div>
<div id="extraFolderPaths-unet"></div>
<div id="extraFolderPaths-embeddings"></div>
`;
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
state.global.settings = {
extra_folder_paths: {},
};
await manager.loadSettingsToUI();
await vi.runAllTimersAsync();
expect(focusSpy).not.toHaveBeenCalled();
});
it('still focuses an extra folder path row when it is added explicitly', async () => {
vi.useFakeTimers();
const manager = createManager();
document.body.innerHTML = '<div id="extraFolderPaths-embeddings"></div>';
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
manager.addExtraFolderPathRow('embeddings', '');
await vi.runAllTimersAsync();
expect(focusSpy).toHaveBeenCalledTimes(1);
});
it('shows loading while saving recipes_path', async () => { it('shows loading while saving recipes_path', async () => {
const manager = createManager(); const manager = createManager();
const input = document.createElement('input'); const input = document.createElement('input');

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from types import SimpleNamespace
import jinja2
from py.routes.handlers.model_handlers import ModelPageView
class DummySettings:
def get(self, key, default=None):
return default
class DummyI18n:
def __init__(self):
self.locale = None
def set_locale(self, locale):
self.locale = locale
def get_translation(self, key, default=None, **_kwargs):
return default or key
def create_template_filter(self):
return lambda key, *_args, **_kwargs: key
class DummyScanner:
def __init__(self):
self._cache = SimpleNamespace()
async def get_cached_data(self, *_args, **_kwargs):
return SimpleNamespace(folders=[])
class DummyService:
def __init__(self):
self.scanner = DummyScanner()
async def test_model_page_view_reads_version_per_request():
template_env = jinja2.Environment(
loader=jinja2.DictLoader({"dummy.html": "{{ version }}"}),
autoescape=True,
)
view = ModelPageView(
template_env=template_env,
template_name="dummy.html",
service=DummyService(),
settings_service=DummySettings(),
server_i18n=DummyI18n(),
logger=SimpleNamespace(
debug=lambda *_args, **_kwargs: None,
error=lambda *_args, **_kwargs: None,
),
)
view._get_app_version = lambda: "1.0.2-old"
first = await view.handle(SimpleNamespace())
view._get_app_version = lambda: "1.0.2-new"
second = await view.handle(SimpleNamespace())
assert first.text == "1.0.2-old"
assert second.text == "1.0.2-new"

View File

@@ -1,95 +1,86 @@
import pytest
from py.recipes.merger import GenParamsMerger from py.recipes.merger import GenParamsMerger
def test_merge_priority():
request_params = {"prompt": "from request", "steps": 20} def test_merge_priority_and_normalization():
civitai_meta = {"prompt": "from civitai", "cfg": 7.0} request_params = {"prompt": "from request", "Steps": 20, "cfg": 7.5}
civitai_meta = {"prompt": "from civitai", "cfgScale": 6.5, "negativePrompt": "bad"}
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}} embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata) merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert merged["prompt"] == "from request"
assert merged["steps"] == 20
assert merged["cfg"] == 7.0
assert merged["seed"] == 123
def test_merge_no_request_params(): assert merged == {
civitai_meta = {"prompt": "from civitai", "cfg": 7.0} "prompt": "from request",
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}} "steps": 20,
"cfg_scale": 7.5,
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata) "negative_prompt": "bad",
"seed": 123,
assert merged["prompt"] == "from civitai" }
assert merged["cfg"] == 7.0
assert merged["seed"] == 123
def test_merge_accepts_raw_embedded_metadata():
embedded_metadata = {"prompt": "from raw embedded", "seed": 456, "scheduler": "karras"}
def test_merge_only_embedded():
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(None, None, embedded_metadata) merged = GenParamsMerger.merge(None, None, embedded_metadata)
assert merged["prompt"] == "from embedded"
assert merged["seed"] == 123
def test_merge_raw_embedded(): assert merged == {
# Test when embedded metadata is just the gen_params themselves "prompt": "from raw embedded",
embedded_metadata = {"prompt": "from raw embedded", "seed": 456} "seed": 456,
"sampler": "karras",
merged = GenParamsMerger.merge(None, None, embedded_metadata) }
assert merged["prompt"] == "from raw embedded"
assert merged["seed"] == 456
def test_merge_none_values():
merged = GenParamsMerger.merge(None, None, None)
assert merged == {}
def test_merge_filters_blacklisted_keys(): def test_merge_filters_unknown_and_blacklisted_keys():
request_params = {"prompt": "test", "id": "should-be-removed", "checkpoint": "should-not-be-here"} request_params = {
civitai_meta = {"cfg": 7, "url": "remove-me"} "prompt": "test",
embedded_metadata = {"seed": 123, "hash": "remove-also"} "id": "should-be-removed",
"checkpoint": "should-not-be-here",
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata) "raw_metadata": {"prompt": "remove"},
}
assert "prompt" in merged civitai_meta = {
assert "cfg" in merged "Version": "ComfyUI",
assert "seed" in merged "RNG": "cpu",
assert "id" not in merged "cfgScale": 7,
assert "url" not in merged "url": "remove-me",
assert "hash" not in merged }
assert "checkpoint" not in merged embedded_metadata = {
"seed": 123,
def test_merge_filters_meta_and_normalizes_keys(): "hash": "remove-also",
"Discard penultimate sigma": True,
"eps_scaling_factor": 0.1,
}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert merged == {
"prompt": "test",
"cfg_scale": 7,
"seed": 123,
}
def test_merge_does_not_keep_original_key_variants():
civitai_meta = { civitai_meta = {
"prompt": "masterpiece",
"cfgScale": 5, "cfgScale": 5,
"clipSkip": 2, "clipSkip": 2,
"negativePrompt": "low quality", "negativePrompt": "low quality",
"meta": {"irrelevant": "data"},
"Size": "1024x1024", "Size": "1024x1024",
"draft": False, "Denoising strength": 0.35,
"workflow": "txt2img",
"civitaiResources": [{"type": "checkpoint"}]
} }
request_params = { request_params = {
"cfg_scale": 5.0, "cfg_scale": 5.0,
"clip_skip": "2", "clip_skip": "2",
"Steps": 30
} }
merged = GenParamsMerger.merge(request_params, civitai_meta) merged = GenParamsMerger.merge(request_params, civitai_meta)
assert "meta" not in merged assert merged == {
assert "cfgScale" not in merged "cfg_scale": 5.0,
assert "clipSkip" not in merged "clip_skip": "2",
assert "negativePrompt" not in merged "negative_prompt": "low quality",
assert "Size" not in merged "size": "1024x1024",
assert "draft" not in merged "denoising_strength": 0.35,
assert "workflow" not in merged }
assert "civitaiResources" not in merged
assert merged["cfg_scale"] == 5.0 # From request_params def test_merge_none_values():
assert merged["clip_skip"] == "2" # From request_params assert GenParamsMerger.merge(None, None, None) == {}
assert merged["negative_prompt"] == "low quality" # Normalized from civitai_meta
assert merged["size"] == "1024x1024" # Normalized from civitai_meta
assert merged["steps"] == 30 # Normalized from request_params

View File

@@ -358,6 +358,188 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
assert recipe["checkpoint"]["file_name"] == "by-id" assert recipe["checkpoint"]["file_name"] == "by-id"
@pytest.mark.asyncio
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "hydrate-me"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/hydrate-me.png",
"title": "Hydrated Recipe",
"source_path": "https://example.com/source",
"gen_params": {
"prompt": "prompt from json",
"negative_prompt": "negative from json",
},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/hydrate-me.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["title"] == "Hydrated Recipe"
assert recipe["source_path"] == "https://example.com/source"
assert recipe["gen_params"]["prompt"] == "prompt from json"
@pytest.mark.asyncio
async def test_get_recipe_by_id_normalizes_gen_params_aliases_without_dropping_metadata(
recipe_scanner,
):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "dirty-json-gen-params"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/dirty-json-gen-params.png",
"title": "Dirty Recipe",
"gen_params": {
"Prompt": "prompt from json",
"negativePrompt": "negative from json",
"cfgScale": 7,
"raw_metadata": {"prompt": "nested"},
"Version": "ComfyUI",
"RNG": "cpu",
},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/dirty-json-gen-params.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {"prompt": "cached prompt", "raw_metadata": {"bad": True}},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["gen_params"]["Prompt"] == "prompt from json"
assert recipe["gen_params"]["negativePrompt"] == "negative from json"
assert recipe["gen_params"]["cfgScale"] == 7
assert recipe["gen_params"]["raw_metadata"] == {"prompt": "nested"}
assert recipe["gen_params"]["Version"] == "ComfyUI"
assert recipe["gen_params"]["RNG"] == "cpu"
assert recipe["gen_params"]["prompt"] == "prompt from json"
assert recipe["gen_params"]["negative_prompt"] == "negative from json"
assert recipe["gen_params"]["cfg_scale"] == 7
@pytest.mark.asyncio
async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "move-me"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/new-location.png",
"title": "Moved Recipe",
"source_path": "https://example.com/moved",
"gen_params": {},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/old-location.png",
"title": "Cached Title",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["file_path"] == "/tmp/new-location.png"
assert recipe["title"] == "Moved Recipe"
assert recipe["source_path"] == "https://example.com/moved"
@pytest.mark.asyncio
async def test_get_recipe_by_id_drops_deleted_optional_json_fields(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "drop-optional-fields"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/drop-optional-fields.png",
"title": "Trimmed Recipe",
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/drop-optional-fields.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"source_path": "https://example.com/stale-source",
"checkpoint": {"name": "stale-checkpoint.safetensors"},
"loras": [{"modelName": "stale-lora"}],
"gen_params": {"prompt": "stale prompt"},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["title"] == "Trimmed Recipe"
assert "source_path" not in recipe
assert "checkpoint" not in recipe
assert "gen_params" not in recipe
assert "loras" not in recipe
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner): async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
@@ -401,6 +583,40 @@ async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
assert [item["id"] for item in result["items"]] == ["checkpoint-match"] assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
@pytest.mark.asyncio
async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping_metadata(
recipe_scanner,
):
scanner, _ = recipe_scanner
await scanner.add_recipe(
{
"id": "dirty-listing",
"file_path": str(Path(config.loras_roots[0]) / "dirty-listing.webp"),
"title": "Dirty Listing",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {
"Prompt": "a beautiful forest landscape",
"cfgScale": 7,
"Version": "ComfyUI",
"raw_metadata": {"bad": True},
},
}
)
await asyncio.sleep(0)
result = await scanner.get_paginated_data(page=1, page_size=10)
item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing")
assert item["gen_params"]["Prompt"] == "a beautiful forest landscape"
assert item["gen_params"]["cfgScale"] == 7
assert item["gen_params"]["Version"] == "ComfyUI"
assert item["gen_params"]["raw_metadata"] == {"bad": True}
assert item["gen_params"]["prompt"] == "a beautiful forest landscape"
assert item["gen_params"]["cfg_scale"] == 7
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner): async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner

View File

@@ -306,6 +306,120 @@ async def test_save_recipe_promotes_checkpoint_from_gen_params(tmp_path):
assert "checkpoint" not in stored["gen_params"] assert "checkpoint" not in stored["gen_params"]
@pytest.mark.asyncio
async def test_save_recipe_strips_non_persistable_gen_params(tmp_path):
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
metadata = {
"base_model": "Flux",
"loras": [],
"gen_params": {
"prompt": "hello world",
"negative_prompt": "bad hands",
"cfg_scale": 7,
"raw_metadata": {"prompt": "should not persist"},
"Version": "ComfyUI",
"RNG": "cpu",
"Schedule type": "karras",
"Discard penultimate sigma": True,
"eps_scaling_factor": 0.1,
},
}
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=b"img",
image_base64=None,
name="Sanitized",
tags=[],
metadata=metadata,
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["gen_params"] == {
"prompt": "hello world",
"negative_prompt": "bad hands",
"cfg_scale": 7,
}
@pytest.mark.asyncio
async def test_save_recipe_derives_allowed_fields_from_raw_metadata(tmp_path):
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
metadata = {
"base_model": "Flux",
"loras": [],
"raw_metadata": {
"prompt": "hello world",
"negative_prompt": "bad hands",
"steps": 30,
"sampler": "Euler",
"cfg_scale": 7,
"seed": 123,
"size": "1024x1024",
"clip_skip": 2,
"Version": "ComfyUI",
"raw_metadata": {"nested": True},
},
}
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=b"img",
image_base64=None,
name="Derived",
tags=[],
metadata=metadata,
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["gen_params"] == {
"prompt": "hello world",
"negative_prompt": "bad hands",
"steps": 30,
"sampler": "Euler",
"cfg_scale": 7,
"seed": 123,
"size": "1024x1024",
"clip_skip": 2,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path): async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
exif_utils = DummyExifUtils() exif_utils = DummyExifUtils()