mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
6 Commits
1817142a7b
...
39c083db79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c083db79 | ||
|
|
55e9e4bb6f | ||
|
|
0253d001e6 | ||
|
|
9998da3241 | ||
|
|
6666a72775 | ||
|
|
5f1bd894b9 |
@@ -13,4 +13,5 @@ GEN_PARAM_KEYS = [
|
||||
'seed',
|
||||
'size',
|
||||
'clip_skip',
|
||||
'denoising_strength',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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><lora:Boris_Vallejo_BV_flux_D:1></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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
66
tests/routes/test_model_page_view.py
Normal file
66
tests/routes/test_model_page_view.py
Normal 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"
|
||||
@@ -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) == {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user