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',
'size',
'clip_skip',
'denoising_strength',
]

View File

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

View File

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

View File

@@ -756,6 +756,14 @@ class RecipeManagementHandler:
)
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
metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "",

View File

@@ -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:
@@ -2114,8 +2142,18 @@ class RecipeScanner:
if not recipe:
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
formatted_recipe = {**recipe} # Copy all fields
formatted_recipe = {**merged_recipe}
# Format file path to URL
if "file_path" in formatted_recipe:
@@ -2149,6 +2187,30 @@ class RecipeScanner:
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:
"""Format file path as URL for serving in web UI"""
if not file_path:

View File

@@ -12,6 +12,7 @@ from dataclasses import dataclass
from typing import Any, Dict, Iterable, Optional
from ...config import config
from ...recipes.constants import GEN_PARAM_KEYS
from ...utils.utils import calculate_recipe_fingerprint
from .errors import RecipeNotFoundError, RecipeValidationError
@@ -90,23 +91,7 @@ class RecipePersistenceService:
current_time = time.time()
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))
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)
gen_params = self._sanitize_gen_params_for_storage(metadata)
fingerprint = calculate_recipe_fingerprint(loras_data)
recipe_data: Dict[str, Any] = {
@@ -133,6 +118,7 @@ class RecipePersistenceService:
json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path)
with open(json_path, "w", encoding="utf-8") as file_obj:
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:
"""Delete an existing recipe."""

View File

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

View File

@@ -31,6 +31,20 @@ export function extractRecipeId(filePath) {
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
* @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 (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// 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) {
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
* @returns {Promise<Object>} The updated recipe data
*/
export async function updateRecipeMetadata(filePath, updates) {
export async function updateRecipeMetadata(filePath, updates, options = {}) {
try {
state.loadingManager.showSimpleLoading('Saving metadata...');
const listFilePath = options.listFilePath || filePath;
// Extract recipeId from filePath (basename without extension)
const recipeId = extractRecipeId(filePath);
@@ -359,7 +376,7 @@ export async function updateRecipeMetadata(filePath, updates) {
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',
headers: {
'Content-Type': 'application/json',
@@ -374,7 +391,7 @@ export async function updateRecipeMetadata(filePath, updates) {
throw new Error(data.error || 'Failed to update recipe');
}
state.virtualScroller.updateSingleItem(filePath, updates);
state.virtualScroller.updateSingleItem(listFilePath, updates);
return data;
} catch (error) {

View File

@@ -3,16 +3,105 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.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 { 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 = {};
this.recipeHydrationRequestId = 0;
this.resetLocalEditState();
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() {
this.setupCopyButtons();
this.setupPromptEditors();
@@ -87,8 +176,10 @@ class RecipeModal {
}
showRecipeDetails(recipe) {
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
// Store the full recipe for editing
this.currentRecipe = recipe;
this.currentRecipe = hydratedRecipe;
this.resetPromptEditors();
// Set modal title with edit icon
@@ -96,11 +187,11 @@ class RecipeModal {
if (modalTitle) {
modalTitle.innerHTML = `
<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>
</div>
<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>
`;
@@ -122,8 +213,9 @@ class RecipeModal {
}
// Store the recipe ID for copy syntax API call
this.recipeId = recipe.id;
this.filePath = recipe.file_path;
this.recipeId = hydratedRecipe.id;
this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact');
@@ -143,11 +235,11 @@ class RecipeModal {
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
const maxVisibleTags = 5;
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
@@ -184,7 +276,7 @@ class RecipeModal {
// Add all tags to tooltip
if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = '';
recipe.tags.forEach(tag => {
hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
@@ -201,8 +293,8 @@ class RecipeModal {
const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input
if (recipe.tags && recipe.tags.length > 0) {
tagsInput.value = recipe.tags.join(', ');
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = hydratedRecipe.tags.join(', ');
}
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
@@ -222,49 +314,15 @@ class RecipeModal {
// Set recipe image
const mediaContainer = document.getElementById('recipePreviewContainer');
if (mediaContainer) {
// Stop any playing video before replacing content
const existingVideo = mediaContainer.querySelector('video');
if (existingVideo) {
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);
}
this.syncPreviewMedia(hydratedRecipe);
mediaContainer.querySelector('.source-url-container')?.remove();
mediaContainer.querySelector('.source-url-editor')?.remove();
// Add source URL container if the recipe has a source_path
const sourceUrlContainer = document.createElement('div');
sourceUrlContainer.className = 'source-url-container';
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0;
const sourceUrl = hasSourceUrl ? recipe.source_path : '';
const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
sourceUrlContainer.innerHTML = `
@@ -293,40 +351,273 @@ 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);
}
// 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 negativePromptElement = document.getElementById('recipeNegativePrompt');
const otherParamsElement = document.getElementById('recipeOtherParams');
const promptInput = document.getElementById('recipePromptInput');
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
const promptFieldsOnly = options.promptFieldsOnly === true;
const sanitizedGenParams = this.sanitizeGenParams(genParams);
if (recipe.gen_params) {
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
if (sanitizedGenParams) {
if (!promptFieldsOnly) {
this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available');
this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available');
if (promptInput) {
promptInput.value = recipe.gen_params.prompt || '';
if (promptInput) {
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) {
// Clear previous params
otherParamsElement.innerHTML = '';
// Add all other parameters except prompt and 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) {
const paramTag = document.createElement('div');
paramTag.className = 'param-tag';
@@ -338,22 +629,68 @@ class RecipeModal {
}
}
// If no other params, show a message
if (otherParamsElement.children.length === 0) {
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
}
}
} else {
// No generation parameters available
return;
}
if (!promptFieldsOnly) {
this.renderPromptContent(promptElement, '', 'No prompt information available');
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
if (promptInput) promptInput.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 resourceDivider = document.getElementById('recipeResourceDivider');
const lorasListElement = document.getElementById('recipeLorasList');
const lorasCountElement = document.getElementById('recipeLorasCount');
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
if (checkpointContainer) {
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 missingLorasCount = 0;
let deletedLorasCount = 0;
if (recipe.loras && recipe.loras.length > 0) {
recipe.loras.forEach(lora => {
if (lora.isDeleted) {
deletedLorasCount++;
} else if (!lora.inLibrary) {
allLorasAvailable = false;
missingLorasCount++;
}
});
}
loras.forEach(lora => {
if (lora.isDeleted) {
deletedLorasCount++;
} else if (!lora.inLibrary) {
allLorasAvailable = false;
missingLorasCount++;
}
});
// Set LoRAs count and status
if (lorasCountElement && recipe.loras) {
const totalCount = recipe.loras.length;
// Create status indicator based on LoRA states
if (lorasCountElement) {
const totalCount = loras.length;
let statusHTML = '';
if (totalCount > 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>`;
} else if (missingLorasCount > 0) {
// Some LoRAs are missing (prioritize showing missing over deleted)
statusHTML = `<div class="recipe-status missing">
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
<div class="missing-tooltip">Click to download missing LoRAs</div>
</div>`;
} 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>`;
}
}
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
// Add event listeners for buttons and status indicators
setTimeout(() => {
// Set up click handler for View LoRAs button
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
}
// Add click handler for missing LoRAs status
const missingStatus = document.querySelector('.recipe-status.missing');
if (missingStatus && missingLorasCount > 0) {
missingStatus.classList.add('clickable');
@@ -425,13 +746,12 @@ class RecipeModal {
}, 100);
}
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
lorasListElement.innerHTML = recipe.loras.map(lora => {
if (lorasListElement && loras.length > 0) {
lorasListElement.innerHTML = loras.map(lora => {
const existsLocally = lora.inLibrary;
const isDeleted = lora.isDeleted;
const localPath = lora.localPath || '';
// Create status badge based on LoRA state
let localStatus;
if (existsLocally) {
localStatus = `
@@ -441,7 +761,7 @@ class RecipeModal {
</div>`;
} else if (isDeleted) {
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>
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
</div>`;
@@ -452,7 +772,6 @@ class RecipeModal {
</div>`;
}
// Check if preview is a video
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
const previewMedia = isPreviewVideo ?
`<video class="thumbnail-video" autoplay loop muted playsinline>
@@ -460,7 +779,6 @@ class RecipeModal {
</video>` :
`<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';
if (existsLocally) {
loraItemClass += ' exists-locally';
@@ -471,7 +789,7 @@ class RecipeModal {
}
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">
${previewMedia}
</div>
@@ -485,7 +803,7 @@ class RecipeModal {
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</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">
<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>
@@ -503,15 +821,12 @@ class RecipeModal {
`;
}).join('');
// Add event listeners for reconnect functionality
setTimeout(() => {
this.setupReconnectButtons();
this.setupLoraItemsClickable();
}, 100);
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
this.recipeLorasSyntax = '';
} else if (lorasListElement) {
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
this.recipeLorasSyntax = '';
@@ -522,9 +837,31 @@ class RecipeModal {
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
}
}
// Show the modal
modalManager.showModal('recipeModal');
updateSourceUrlDisplay(sourcePath, options = {}) {
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
@@ -535,6 +872,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('title');
input.focus();
input.select();
}
@@ -553,19 +891,23 @@ class RecipeModal {
titleContainer.querySelector('.content-text').textContent = newTitle;
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { title: newTitle })
updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.nameUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.title = newTitle;
this.commitField('title');
})
.catch(error => {
// Error is handled in the API function
// Reset the UI if needed
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
this.clearFieldDirty('title');
});
} else {
this.clearFieldDirty('title');
}
// Hide editor
@@ -581,6 +923,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.title || '';
this.clearFieldDirty('title');
// Hide editor
editor.classList.remove('active');
@@ -596,6 +939,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus();
}
}
@@ -623,20 +967,24 @@ class RecipeModal {
if (tagsChanged) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags })
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('tags');
});
} else {
this.clearFieldDirty('tags');
}
// Hide editor
@@ -708,6 +1056,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor
editor.classList.remove('active');
@@ -748,6 +1097,7 @@ class RecipeModal {
}
if (input) {
input.addEventListener('input', () => this.markFieldDirty(config.field));
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -840,9 +1190,10 @@ 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);
this.hidePromptEditor(config);
return;
}
@@ -857,14 +1208,17 @@ class RecipeModal {
...promptState,
isSaving: true,
};
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
this.currentRecipe.gen_params = nextGenParams;
this.renderPromptContent(content, nextValue, config.placeholder);
showToast(config.successKey, {}, 'success', config.successFallback);
this.commitField(config.field);
} catch (error) {
this.renderPromptContent(content, currentValue, config.placeholder);
input.value = currentValue;
this.clearFieldDirty(config.field);
} finally {
this.clearFieldDirty(config.field);
this.hidePromptEditor(config);
}
}
@@ -872,10 +1226,10 @@ class RecipeModal {
cancelPromptEdit(config) {
const input = document.getElementById(config.inputId);
if (input) {
const initialValue = this.promptEditorState[config.field]?.initialValue;
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
input.value = this.currentRecipe?.gen_params?.[config.field] || '';
}
this.clearFieldDirty(config.field);
this.hidePromptEditor(config);
}
@@ -918,11 +1272,16 @@ class RecipeModal {
sourceUrlInput.focus();
});
sourceUrlInput.addEventListener('input', () => {
this.sourceUrlEditState.isDirty = true;
});
// Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active');
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
@@ -930,23 +1289,24 @@ class RecipeModal {
const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl !== this.currentRecipe.source_path) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
// Update source URL in the UI
sourceUrlText.textContent = newSourceUrl || 'No source URL';
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
newSourceUrl.startsWith('https://')) ?
'Click to open source URL' : 'No valid URL';
this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
// Update the current recipe object
this.currentRecipe.source_path = newSourceUrl;
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('source_path');
});
} else {
this.clearFieldDirty('source_path');
}
// Hide editor
@@ -1286,7 +1646,7 @@ class RecipeModal {
this.showRecipeDetails(this.currentRecipe);
}, 500);
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
loras: this.currentRecipe.loras
});
} else {

View File

@@ -103,6 +103,16 @@ export class DoctorManager {
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) {
if (this.loadingState) {
this.loadingState.classList.toggle('visible', isLoading);
@@ -308,7 +318,7 @@ export class DoctorManager {
await this.repairCache();
break;
case 'reload-page':
window.location.reload();
this.reloadUi();
break;
default:
break;

View File

@@ -1443,12 +1443,12 @@ export class SettingsManager {
// Add empty row for new path if no paths exist
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}`);
if (!container) return;
@@ -1472,7 +1472,7 @@ export class SettingsManager {
container.appendChild(row);
// Focus the input if it's empty (new row)
if (!path) {
if (!path && shouldFocus) {
const input = row.querySelector('.extra-folder-path-input');
if (input) {
setTimeout(() => input.focus(), 0);

View File

@@ -5,6 +5,9 @@ const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(),
hide: vi.fn(),
}));
const virtualScrollerMock = vi.hoisted(() => ({
updateSingleItem: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
return {
@@ -20,12 +23,13 @@ vi.mock('../../../static/js/state/index.js', () => {
return {
state: {
loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
},
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', () => {
beforeEach(() => {
@@ -111,4 +115,37 @@ describe('RecipeSidebarApiClient bulk operations', () => {
});
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();
});
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(() => {
vi.useRealTimers();
delete global.fetch;
delete document.hidden;
Object.defineProperty(window, 'location', {
@@ -231,6 +232,51 @@ describe('SettingsManager library controls', () => {
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 () => {
const manager = createManager();
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
def test_merge_priority():
request_params = {"prompt": "from request", "steps": 20}
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
def test_merge_priority_and_normalization():
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}}
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():
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
assert merged["prompt"] == "from civitai"
assert merged["cfg"] == 7.0
assert merged["seed"] == 123
assert merged == {
"prompt": "from request",
"steps": 20,
"cfg_scale": 7.5,
"negative_prompt": "bad",
"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)
assert merged["prompt"] == "from embedded"
assert merged["seed"] == 123
def test_merge_raw_embedded():
# Test when embedded metadata is just the gen_params themselves
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
merged = GenParamsMerger.merge(None, None, embedded_metadata)
assert merged["prompt"] == "from raw embedded"
assert merged["seed"] == 456
assert merged == {
"prompt": "from raw embedded",
"seed": 456,
"sampler": "karras",
}
def test_merge_none_values():
merged = GenParamsMerger.merge(None, None, None)
assert merged == {}
def test_merge_filters_blacklisted_keys():
request_params = {"prompt": "test", "id": "should-be-removed", "checkpoint": "should-not-be-here"}
civitai_meta = {"cfg": 7, "url": "remove-me"}
embedded_metadata = {"seed": 123, "hash": "remove-also"}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert "prompt" in merged
assert "cfg" in merged
assert "seed" in merged
assert "id" not in merged
assert "url" not in merged
assert "hash" not in merged
assert "checkpoint" not in merged
def test_merge_filters_meta_and_normalizes_keys():
def test_merge_filters_unknown_and_blacklisted_keys():
request_params = {
"prompt": "test",
"id": "should-be-removed",
"checkpoint": "should-not-be-here",
"raw_metadata": {"prompt": "remove"},
}
civitai_meta = {
"Version": "ComfyUI",
"RNG": "cpu",
"cfgScale": 7,
"url": "remove-me",
}
embedded_metadata = {
"seed": 123,
"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 = {
"prompt": "masterpiece",
"cfgScale": 5,
"clipSkip": 2,
"negativePrompt": "low quality",
"meta": {"irrelevant": "data"},
"Size": "1024x1024",
"draft": False,
"workflow": "txt2img",
"civitaiResources": [{"type": "checkpoint"}]
"Denoising strength": 0.35,
}
request_params = {
"cfg_scale": 5.0,
"clip_skip": "2",
"Steps": 30
}
merged = GenParamsMerger.merge(request_params, civitai_meta)
assert "meta" not in merged
assert "cfgScale" not in merged
assert "clipSkip" not in merged
assert "negativePrompt" not in merged
assert "Size" not in merged
assert "draft" not in merged
assert "workflow" not in merged
assert "civitaiResources" not in merged
assert merged["cfg_scale"] == 5.0 # From request_params
assert merged["clip_skip"] == "2" # From request_params
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
assert merged == {
"cfg_scale": 5.0,
"clip_skip": "2",
"negative_prompt": "low quality",
"size": "1024x1024",
"denoising_strength": 0.35,
}
def test_merge_none_values():
assert GenParamsMerger.merge(None, None, None) == {}

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"
@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
async def test_get_paginated_data_filters_by_checkpoint_hash(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"]
@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
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(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"]
@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
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
exif_utils = DummyExifUtils()