mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-09 18:06:42 -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',
|
'seed',
|
||||||
'size',
|
'size',
|
||||||
'clip_skip',
|
'clip_skip',
|
||||||
|
'denoising_strength',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .constants import GEN_PARAM_KEYS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GenParamsMerger:
|
class GenParamsMerger:
|
||||||
"""Utility to merge generation parameters from multiple sources with priority."""
|
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||||
|
|
||||||
|
ALLOWED_KEYS = set(GEN_PARAM_KEYS)
|
||||||
|
|
||||||
BLACKLISTED_KEYS = {
|
BLACKLISTED_KEYS = {
|
||||||
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
||||||
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||||
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||||
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||||
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||||
"checkpoint", "checksum", "model_checksum"
|
"checkpoint", "checksum", "model_checksum", "raw_metadata",
|
||||||
}
|
}
|
||||||
|
|
||||||
NORMALIZATION_MAPPING = {
|
NORMALIZATION_MAPPING = {
|
||||||
# Civitai specific
|
"cfg": "cfg_scale",
|
||||||
"cfgScale": "cfg_scale",
|
"cfgScale": "cfg_scale",
|
||||||
"clipSkip": "clip_skip",
|
"clipSkip": "clip_skip",
|
||||||
"negativePrompt": "negative_prompt",
|
"negativePrompt": "negative_prompt",
|
||||||
# Case variations
|
|
||||||
"Sampler": "sampler",
|
"Sampler": "sampler",
|
||||||
|
"sampler_name": "sampler",
|
||||||
|
"scheduler": "sampler",
|
||||||
"Steps": "steps",
|
"Steps": "steps",
|
||||||
"Seed": "seed",
|
"Seed": "seed",
|
||||||
"Size": "size",
|
"Size": "size",
|
||||||
@@ -36,63 +42,40 @@ class GenParamsMerger:
|
|||||||
def merge(
|
def merge(
|
||||||
request_params: Optional[Dict[str, Any]] = None,
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
civitai_meta: Optional[Dict[str, Any]] = None,
|
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||||
embedded_metadata: Optional[Dict[str, Any]] = None
|
embedded_metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Merge generation parameters from three sources.
|
Merge generation parameters from three sources.
|
||||||
|
|
||||||
Priority: request_params > civitai_meta > embedded_metadata
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request_params: Params provided directly in the import request
|
|
||||||
civitai_meta: Params from Civitai Image API 'meta' field
|
|
||||||
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Merged parameters dictionary
|
|
||||||
"""
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
# 1. Start with embedded metadata (lowest priority)
|
Priority: request_params > civitai_meta > embedded_metadata
|
||||||
|
"""
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
|
||||||
if embedded_metadata:
|
if embedded_metadata:
|
||||||
# If it's a full recipe metadata, we use its gen_params
|
if "gen_params" in embedded_metadata and isinstance(
|
||||||
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
embedded_metadata["gen_params"], dict
|
||||||
|
):
|
||||||
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||||
else:
|
else:
|
||||||
# Otherwise assume the dict itself contains gen_params
|
|
||||||
GenParamsMerger._update_normalized(result, embedded_metadata)
|
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||||
|
|
||||||
# 2. Layer Civitai meta (medium priority)
|
|
||||||
if civitai_meta:
|
if civitai_meta:
|
||||||
GenParamsMerger._update_normalized(result, civitai_meta)
|
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||||
|
|
||||||
# 3. Layer request params (highest priority)
|
|
||||||
if request_params:
|
if request_params:
|
||||||
GenParamsMerger._update_normalized(result, request_params)
|
GenParamsMerger._update_normalized(result, request_params)
|
||||||
|
|
||||||
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
return result
|
||||||
final_result = {}
|
|
||||||
for k, v in result.items():
|
|
||||||
if k in GenParamsMerger.BLACKLISTED_KEYS:
|
|
||||||
continue
|
|
||||||
if k in GenParamsMerger.NORMALIZATION_MAPPING:
|
|
||||||
continue
|
|
||||||
final_result[k] = v
|
|
||||||
|
|
||||||
return final_result
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||||
"""Update target dict with normalized keys from source."""
|
"""Update target dict with normalized, persistence-safe keys from source."""
|
||||||
for k, v in source.items():
|
for key, value in source.items():
|
||||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
if key in GenParamsMerger.BLACKLISTED_KEYS:
|
||||||
target[normalized_key] = v
|
continue
|
||||||
# Also keep the original key for now if it's not the same,
|
|
||||||
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
|
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||||
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||||
# because we want to filter them out at the end anyway.
|
continue
|
||||||
if normalized_key != k:
|
|
||||||
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
target[normalized_key] = value
|
||||||
# that's fine because of the priority order of calls to _update_normalized.
|
|
||||||
pass
|
|
||||||
target[k] = v
|
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ class ModelPageView:
|
|||||||
self._settings = settings_service
|
self._settings = settings_service
|
||||||
self._server_i18n = server_i18n
|
self._server_i18n = server_i18n
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
self._app_version = self._get_app_version()
|
|
||||||
|
|
||||||
def _load_supporters(self) -> dict:
|
def _load_supporters(self) -> dict:
|
||||||
"""Load supporters data from JSON file."""
|
"""Load supporters data from JSON file."""
|
||||||
@@ -155,7 +154,7 @@ class ModelPageView:
|
|||||||
"request": request,
|
"request": request,
|
||||||
"folders": [],
|
"folders": [],
|
||||||
"t": self._server_i18n.get_translation,
|
"t": self._server_i18n.get_translation,
|
||||||
"version": self._app_version,
|
"version": self._get_app_version(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if not is_initializing:
|
if not is_initializing:
|
||||||
|
|||||||
@@ -756,6 +756,14 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
||||||
|
image_url,
|
||||||
|
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||||
|
len(lora_entries),
|
||||||
|
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Initial Metadata Construction
|
# 2. Initial Metadata Construction
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"base_model": params.get("base_model", "") or "",
|
"base_model": params.get("base_model", "") or "",
|
||||||
|
|||||||
@@ -952,6 +952,30 @@ class RecipeScanner:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to update FTS index for recipe: %s", exc)
|
logger.debug("Failed to update FTS index for recipe: %s", exc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_recipe_gen_params(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Return a recipe copy with normalized generation parameter aliases added."""
|
||||||
|
|
||||||
|
normalized_recipe = dict(recipe_data)
|
||||||
|
gen_params = recipe_data.get("gen_params")
|
||||||
|
if not isinstance(gen_params, dict):
|
||||||
|
return normalized_recipe
|
||||||
|
|
||||||
|
normalized_gen_params = dict(gen_params)
|
||||||
|
for key, value in gen_params.items():
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||||
|
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if normalized_gen_params.get(normalized_key) in (None, ""):
|
||||||
|
normalized_gen_params[normalized_key] = value
|
||||||
|
|
||||||
|
normalized_recipe["gen_params"] = normalized_gen_params
|
||||||
|
return normalized_recipe
|
||||||
|
|
||||||
async def _enrich_cache_metadata(self) -> None:
|
async def _enrich_cache_metadata(self) -> None:
|
||||||
"""Perform remote metadata enrichment after the initial scan."""
|
"""Perform remote metadata enrichment after the initial scan."""
|
||||||
|
|
||||||
@@ -1345,6 +1369,7 @@ class RecipeScanner:
|
|||||||
# Ensure gen_params exists
|
# Ensure gen_params exists
|
||||||
if "gen_params" not in recipe_data:
|
if "gen_params" not in recipe_data:
|
||||||
recipe_data["gen_params"] = {}
|
recipe_data["gen_params"] = {}
|
||||||
|
recipe_data = self._normalize_recipe_gen_params(recipe_data)
|
||||||
|
|
||||||
# Update lora information with local paths and availability
|
# Update lora information with local paths and availability
|
||||||
lora_metadata_updated = await self._update_lora_information(recipe_data)
|
lora_metadata_updated = await self._update_lora_information(recipe_data)
|
||||||
@@ -2055,7 +2080,10 @@ class RecipeScanner:
|
|||||||
end_idx = min(start_idx + page_size, total_items)
|
end_idx = min(start_idx + page_size, total_items)
|
||||||
|
|
||||||
# Get paginated items
|
# Get paginated items
|
||||||
paginated_items = filtered_data[start_idx:end_idx]
|
paginated_items = [
|
||||||
|
self._normalize_recipe_gen_params(item)
|
||||||
|
for item in filtered_data[start_idx:end_idx]
|
||||||
|
]
|
||||||
|
|
||||||
# Add inLibrary information and URLs for each recipe
|
# Add inLibrary information and URLs for each recipe
|
||||||
for item in paginated_items:
|
for item in paginated_items:
|
||||||
@@ -2114,8 +2142,18 @@ class RecipeScanner:
|
|||||||
if not recipe:
|
if not recipe:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Prefer the on-disk recipe JSON for fields that are not persisted in the
|
||||||
|
# SQLite cache yet, such as source_path.
|
||||||
|
merged_recipe = self._normalize_recipe_gen_params({**recipe})
|
||||||
|
recipe_json = await self._load_recipe_json(recipe_id)
|
||||||
|
if recipe_json:
|
||||||
|
for field in ("source_path", "checkpoint", "loras", "gen_params"):
|
||||||
|
if field not in recipe_json:
|
||||||
|
merged_recipe.pop(field, None)
|
||||||
|
merged_recipe.update(recipe_json)
|
||||||
|
|
||||||
# Format the recipe with all needed information
|
# Format the recipe with all needed information
|
||||||
formatted_recipe = {**recipe} # Copy all fields
|
formatted_recipe = {**merged_recipe}
|
||||||
|
|
||||||
# Format file path to URL
|
# Format file path to URL
|
||||||
if "file_path" in formatted_recipe:
|
if "file_path" in formatted_recipe:
|
||||||
@@ -2149,6 +2187,30 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return formatted_recipe
|
return formatted_recipe
|
||||||
|
|
||||||
|
async def _load_recipe_json(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Load the raw recipe JSON payload for a recipe ID if it exists."""
|
||||||
|
|
||||||
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(recipe_json_path, "r", encoding="utf-8") as f:
|
||||||
|
recipe_data = json.load(f)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to load recipe JSON for %s from %s: %s",
|
||||||
|
recipe_id,
|
||||||
|
recipe_json_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(recipe_data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._normalize_recipe_gen_params(recipe_data)
|
||||||
|
|
||||||
def _format_file_url(self, file_path: str) -> str:
|
def _format_file_url(self, file_path: str) -> str:
|
||||||
"""Format file path as URL for serving in web UI"""
|
"""Format file path as URL for serving in web UI"""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Dict, Iterable, Optional
|
from typing import Any, Dict, Iterable, Optional
|
||||||
|
|
||||||
from ...config import config
|
from ...config import config
|
||||||
|
from ...recipes.constants import GEN_PARAM_KEYS
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||||
|
|
||||||
@@ -90,23 +91,7 @@ class RecipePersistenceService:
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
||||||
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
||||||
|
gen_params = self._sanitize_gen_params_for_storage(metadata)
|
||||||
gen_params = metadata.get("gen_params") or {}
|
|
||||||
if not gen_params and "raw_metadata" in metadata:
|
|
||||||
raw_metadata = metadata.get("raw_metadata", {})
|
|
||||||
gen_params = {
|
|
||||||
"prompt": raw_metadata.get("prompt", ""),
|
|
||||||
"negative_prompt": raw_metadata.get("negative_prompt", ""),
|
|
||||||
"steps": raw_metadata.get("steps", ""),
|
|
||||||
"sampler": raw_metadata.get("sampler", ""),
|
|
||||||
"cfg_scale": raw_metadata.get("cfg_scale", ""),
|
|
||||||
"seed": raw_metadata.get("seed", ""),
|
|
||||||
"size": raw_metadata.get("size", ""),
|
|
||||||
"clip_skip": raw_metadata.get("clip_skip", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Drop checkpoint duplication from generation parameters to store it only at top level
|
|
||||||
gen_params.pop("checkpoint", None)
|
|
||||||
|
|
||||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||||
recipe_data: Dict[str, Any] = {
|
recipe_data: Dict[str, Any] = {
|
||||||
@@ -133,6 +118,7 @@ class RecipePersistenceService:
|
|||||||
json_filename = f"{recipe_id}.recipe.json"
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
json_path = os.path.join(recipes_dir, json_filename)
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
json_path = os.path.normpath(json_path)
|
json_path = os.path.normpath(json_path)
|
||||||
|
|
||||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -152,6 +138,30 @@ class RecipePersistenceService:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_gen_params_for_storage(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
gen_params = metadata.get("gen_params")
|
||||||
|
if isinstance(gen_params, dict) and gen_params:
|
||||||
|
source = gen_params
|
||||||
|
else:
|
||||||
|
source = metadata.get("raw_metadata")
|
||||||
|
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
allowed_keys = set(GEN_PARAM_KEYS)
|
||||||
|
sanitized: dict[str, Any] = {}
|
||||||
|
for key in allowed_keys:
|
||||||
|
if key not in source:
|
||||||
|
continue
|
||||||
|
value = source.get(key)
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
sanitized[key] = value
|
||||||
|
|
||||||
|
sanitized.pop("checkpoint", None)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||||
"""Delete an existing recipe."""
|
"""Delete an existing recipe."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doctor-status-badge {
|
.doctor-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
@@ -16,7 +19,7 @@
|
|||||||
background: var(--lora-error);
|
background: var(--lora-error);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 18px;
|
line-height: 1;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ export function extractRecipeId(filePath) {
|
|||||||
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchRecipeDetails(recipeId) {
|
||||||
|
if (!recipeId) {
|
||||||
|
throw new Error('Unable to determine recipe ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedRecipeId = encodeURIComponent(recipeId);
|
||||||
|
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch recipes with pagination for virtual scrolling
|
* Fetch recipes with pagination for virtual scrolling
|
||||||
* @param {number} page - Page number to fetch
|
* @param {number} page - Page number to fetch
|
||||||
@@ -61,7 +75,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
// If we have a specific recipe ID to load
|
// If we have a specific recipe ID to load
|
||||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||||
// Special case: load specific recipe
|
// Special case: load specific recipe
|
||||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
|
const response = await fetch(
|
||||||
|
`${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(pageState.customFilter.recipeId)}`
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||||
@@ -349,9 +365,10 @@ export function createRecipeCard(recipe) {
|
|||||||
* @param {Object} updates - The metadata updates to apply
|
* @param {Object} updates - The metadata updates to apply
|
||||||
* @returns {Promise<Object>} The updated recipe data
|
* @returns {Promise<Object>} The updated recipe data
|
||||||
*/
|
*/
|
||||||
export async function updateRecipeMetadata(filePath, updates) {
|
export async function updateRecipeMetadata(filePath, updates, options = {}) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||||
|
const listFilePath = options.listFilePath || filePath;
|
||||||
|
|
||||||
// Extract recipeId from filePath (basename without extension)
|
// Extract recipeId from filePath (basename without extension)
|
||||||
const recipeId = extractRecipeId(filePath);
|
const recipeId = extractRecipeId(filePath);
|
||||||
@@ -359,7 +376,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
throw new Error('Unable to determine recipe ID');
|
throw new Error('Unable to determine recipe ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${encodeURIComponent(recipeId)}/update`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -374,7 +391,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
throw new Error(data.error || 'Failed to update recipe');
|
throw new Error(data.error || 'Failed to update recipe');
|
||||||
}
|
}
|
||||||
|
|
||||||
state.virtualScroller.updateSingleItem(filePath, updates);
|
state.virtualScroller.updateSingleItem(listFilePath, updates);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,16 +3,105 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
|
|||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||||
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
|
|
||||||
|
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||||
|
'prompt',
|
||||||
|
'negative_prompt',
|
||||||
|
'steps',
|
||||||
|
'sampler',
|
||||||
|
'cfg_scale',
|
||||||
|
'seed',
|
||||||
|
'size',
|
||||||
|
'clip_skip',
|
||||||
|
'denoising_strength',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const GEN_PARAM_NORMALIZATION = {
|
||||||
|
cfg: 'cfg_scale',
|
||||||
|
cfgScale: 'cfg_scale',
|
||||||
|
clipSkip: 'clip_skip',
|
||||||
|
negativePrompt: 'negative_prompt',
|
||||||
|
Sampler: 'sampler',
|
||||||
|
sampler_name: 'sampler',
|
||||||
|
scheduler: 'sampler',
|
||||||
|
Steps: 'steps',
|
||||||
|
Seed: 'seed',
|
||||||
|
Size: 'size',
|
||||||
|
Prompt: 'prompt',
|
||||||
|
'Negative prompt': 'negative_prompt',
|
||||||
|
'Cfg scale': 'cfg_scale',
|
||||||
|
'Clip skip': 'clip_skip',
|
||||||
|
'Denoising strength': 'denoising_strength',
|
||||||
|
};
|
||||||
|
|
||||||
class RecipeModal {
|
class RecipeModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.promptEditorState = {};
|
this.promptEditorState = {};
|
||||||
|
this.recipeHydrationRequestId = 0;
|
||||||
|
this.resetLocalEditState();
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createLocalEditState() {
|
||||||
|
return {
|
||||||
|
title: { commitVersion: 0, isDirty: false },
|
||||||
|
tags: { commitVersion: 0, isDirty: false },
|
||||||
|
prompt: { commitVersion: 0, isDirty: false },
|
||||||
|
negative_prompt: { commitVersion: 0, isDirty: false },
|
||||||
|
source_path: { commitVersion: 0, isDirty: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLocalEditState() {
|
||||||
|
this.localEditState = this.createLocalEditState();
|
||||||
|
this.sourceUrlEditState = this.localEditState.source_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalEditState(field) {
|
||||||
|
if (!this.localEditState[field]) {
|
||||||
|
this.localEditState[field] = { commitVersion: 0, isDirty: false };
|
||||||
|
}
|
||||||
|
return this.localEditState[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
markFieldDirty(field) {
|
||||||
|
this.getLocalEditState(field).isDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFieldDirty(field) {
|
||||||
|
this.getLocalEditState(field).isDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitField(field) {
|
||||||
|
const fieldState = this.getLocalEditState(field);
|
||||||
|
fieldState.isDirty = false;
|
||||||
|
fieldState.commitVersion += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureLocalEditVersions() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(this.localEditState).map(([field, state]) => [
|
||||||
|
field,
|
||||||
|
state.commitVersion,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldPreserveField(field, requestVersions) {
|
||||||
|
const fieldState = this.getLocalEditState(field);
|
||||||
|
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
|
||||||
|
return fieldState.isDirty || fieldState.commitVersion !== requestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFieldCommittedSinceRequest(field, requestVersions) {
|
||||||
|
const fieldState = this.getLocalEditState(field);
|
||||||
|
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
|
||||||
|
return fieldState.commitVersion !== requestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.setupCopyButtons();
|
this.setupCopyButtons();
|
||||||
this.setupPromptEditors();
|
this.setupPromptEditors();
|
||||||
@@ -87,8 +176,10 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
|
const hydratedRecipe = recipe || {};
|
||||||
|
this.resetLocalEditState();
|
||||||
// Store the full recipe for editing
|
// Store the full recipe for editing
|
||||||
this.currentRecipe = recipe;
|
this.currentRecipe = hydratedRecipe;
|
||||||
this.resetPromptEditors();
|
this.resetPromptEditors();
|
||||||
|
|
||||||
// Set modal title with edit icon
|
// Set modal title with edit icon
|
||||||
@@ -96,11 +187,11 @@ class RecipeModal {
|
|||||||
if (modalTitle) {
|
if (modalTitle) {
|
||||||
modalTitle.innerHTML = `
|
modalTitle.innerHTML = `
|
||||||
<div class="editable-content">
|
<div class="editable-content">
|
||||||
<span class="content-text">${recipe.title || 'Recipe Details'}</span>
|
<span class="content-text">${hydratedRecipe.title || 'Recipe Details'}</span>
|
||||||
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
|
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="recipeTitleEditor" class="content-editor">
|
<div id="recipeTitleEditor" class="content-editor">
|
||||||
<input type="text" class="title-input" value="${recipe.title || ''}">
|
<input type="text" class="title-input" value="${hydratedRecipe.title || ''}">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -122,8 +213,9 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the recipe ID for copy syntax API call
|
// Store the recipe ID for copy syntax API call
|
||||||
this.recipeId = recipe.id;
|
this.recipeId = hydratedRecipe.id;
|
||||||
this.filePath = recipe.file_path;
|
this.filePath = hydratedRecipe.file_path;
|
||||||
|
this.listFilePath = hydratedRecipe.file_path;
|
||||||
|
|
||||||
// Set recipe tags if they exist
|
// Set recipe tags if they exist
|
||||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||||||
@@ -143,11 +235,11 @@ class RecipeModal {
|
|||||||
|
|
||||||
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
||||||
|
|
||||||
if (recipe.tags && recipe.tags.length > 0) {
|
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||||
const maxVisibleTags = 5;
|
const maxVisibleTags = 5;
|
||||||
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
|
||||||
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
|
||||||
|
|
||||||
// Add visible tags
|
// Add visible tags
|
||||||
visibleTags.forEach(tag => {
|
visibleTags.forEach(tag => {
|
||||||
@@ -184,7 +276,7 @@ class RecipeModal {
|
|||||||
// Add all tags to tooltip
|
// Add all tags to tooltip
|
||||||
if (tagsTooltipContent) {
|
if (tagsTooltipContent) {
|
||||||
tagsTooltipContent.innerHTML = '';
|
tagsTooltipContent.innerHTML = '';
|
||||||
recipe.tags.forEach(tag => {
|
hydratedRecipe.tags.forEach(tag => {
|
||||||
const tooltipTag = document.createElement('div');
|
const tooltipTag = document.createElement('div');
|
||||||
tooltipTag.className = 'tooltip-tag';
|
tooltipTag.className = 'tooltip-tag';
|
||||||
tooltipTag.textContent = tag;
|
tooltipTag.textContent = tag;
|
||||||
@@ -201,8 +293,8 @@ class RecipeModal {
|
|||||||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
||||||
|
|
||||||
// Set current tags in the input
|
// Set current tags in the input
|
||||||
if (recipe.tags && recipe.tags.length > 0) {
|
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||||
tagsInput.value = recipe.tags.join(', ');
|
tagsInput.value = hydratedRecipe.tags.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
||||||
@@ -222,49 +314,15 @@ class RecipeModal {
|
|||||||
// Set recipe image
|
// Set recipe image
|
||||||
const mediaContainer = document.getElementById('recipePreviewContainer');
|
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||||
if (mediaContainer) {
|
if (mediaContainer) {
|
||||||
// Stop any playing video before replacing content
|
this.syncPreviewMedia(hydratedRecipe);
|
||||||
const existingVideo = mediaContainer.querySelector('video');
|
mediaContainer.querySelector('.source-url-container')?.remove();
|
||||||
if (existingVideo) {
|
mediaContainer.querySelector('.source-url-editor')?.remove();
|
||||||
existingVideo.pause();
|
|
||||||
existingVideo.currentTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the container
|
|
||||||
mediaContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Ensure file_url exists, fallback to file_path if needed
|
|
||||||
const imageUrl = recipe.file_url ||
|
|
||||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
|
||||||
'/loras_static/images/no-preview.png');
|
|
||||||
|
|
||||||
// Check if the file is a video (mp4)
|
|
||||||
const isVideo = imageUrl.toLowerCase().endsWith('.mp4');
|
|
||||||
|
|
||||||
if (isVideo) {
|
|
||||||
const videoElement = document.createElement('video');
|
|
||||||
videoElement.id = 'recipeModalVideo';
|
|
||||||
videoElement.src = imageUrl;
|
|
||||||
videoElement.controls = true;
|
|
||||||
videoElement.autoplay = false;
|
|
||||||
videoElement.loop = true;
|
|
||||||
videoElement.muted = true;
|
|
||||||
videoElement.className = 'recipe-preview-media';
|
|
||||||
videoElement.alt = recipe.title || 'Recipe Preview';
|
|
||||||
mediaContainer.appendChild(videoElement);
|
|
||||||
} else {
|
|
||||||
const imgElement = document.createElement('img');
|
|
||||||
imgElement.id = 'recipeModalImage';
|
|
||||||
imgElement.src = imageUrl;
|
|
||||||
imgElement.className = 'recipe-preview-media';
|
|
||||||
imgElement.alt = recipe.title || 'Recipe Preview';
|
|
||||||
mediaContainer.appendChild(imgElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add source URL container if the recipe has a source_path
|
// Add source URL container if the recipe has a source_path
|
||||||
const sourceUrlContainer = document.createElement('div');
|
const sourceUrlContainer = document.createElement('div');
|
||||||
sourceUrlContainer.className = 'source-url-container';
|
sourceUrlContainer.className = 'source-url-container';
|
||||||
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0;
|
const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
|
||||||
const sourceUrl = hasSourceUrl ? recipe.source_path : '';
|
const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
|
||||||
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
|
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
|
||||||
|
|
||||||
sourceUrlContainer.innerHTML = `
|
sourceUrlContainer.innerHTML = `
|
||||||
@@ -293,40 +351,273 @@ class RecipeModal {
|
|||||||
mediaContainer.appendChild(sourceUrlContainer);
|
mediaContainer.appendChild(sourceUrlContainer);
|
||||||
mediaContainer.appendChild(sourceUrlEditor);
|
mediaContainer.appendChild(sourceUrlEditor);
|
||||||
|
|
||||||
// Set up event listeners for source URL functionality
|
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
|
||||||
|
const sourceUrlContainerRef = sourceUrlContainer;
|
||||||
|
const sourceUrlEditorRef = sourceUrlEditor;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setupSourceUrlHandlers();
|
this.setupSourceUrlHandlers();
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set generation parameters
|
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||||
|
this.syncResourcesSection(hydratedRecipe);
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
modalManager.showModal('recipeModal');
|
||||||
|
|
||||||
|
if (this.recipeId) {
|
||||||
|
const hydrationRequestId = ++this.recipeHydrationRequestId;
|
||||||
|
const requestEditVersions = this.captureLocalEditVersions();
|
||||||
|
this.hydrateRecipeDetails(
|
||||||
|
this.recipeId,
|
||||||
|
hydrationRequestId,
|
||||||
|
requestEditVersions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
|
||||||
|
try {
|
||||||
|
const fullRecipe = await fetchRecipeDetails(recipeId);
|
||||||
|
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRecipe = { ...this.currentRecipe };
|
||||||
|
|
||||||
|
if (!this.hasFieldCommittedSinceRequest('title', requestEditVersions) && fullRecipe.title !== undefined) {
|
||||||
|
nextRecipe.title = fullRecipe.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasFieldCommittedSinceRequest('tags', requestEditVersions) && fullRecipe.tags !== undefined) {
|
||||||
|
nextRecipe.tags = Array.isArray(fullRecipe.tags) ? [...fullRecipe.tags] : fullRecipe.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasFieldCommittedSinceRequest('source_path', requestEditVersions)) {
|
||||||
|
nextRecipe.source_path = fullRecipe.source_path || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousFilePath = nextRecipe.file_path;
|
||||||
|
if (fullRecipe.file_path !== undefined) {
|
||||||
|
nextRecipe.file_path = fullRecipe.file_path;
|
||||||
|
}
|
||||||
|
if (fullRecipe.file_url !== undefined) {
|
||||||
|
nextRecipe.file_url = fullRecipe.file_url;
|
||||||
|
}
|
||||||
|
if (fullRecipe.preview_url !== undefined) {
|
||||||
|
nextRecipe.preview_url = fullRecipe.preview_url;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fullRecipe.file_path !== undefined &&
|
||||||
|
fullRecipe.file_path !== previousFilePath &&
|
||||||
|
fullRecipe.file_url === undefined &&
|
||||||
|
fullRecipe.preview_url === undefined
|
||||||
|
) {
|
||||||
|
delete nextRecipe.file_url;
|
||||||
|
delete nextRecipe.preview_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRecipe.gen_params !== undefined) {
|
||||||
|
const previousGenParams = nextRecipe.gen_params || {};
|
||||||
|
const incomingGenParams = { ...(fullRecipe.gen_params || {}) };
|
||||||
|
for (const [key, value] of Object.entries(previousGenParams)) {
|
||||||
|
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
|
||||||
|
incomingGenParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRecipe.gen_params = incomingGenParams;
|
||||||
|
} else {
|
||||||
|
const previousGenParams = nextRecipe.gen_params || {};
|
||||||
|
const preservedGenParams = {};
|
||||||
|
for (const [key, value] of Object.entries(previousGenParams)) {
|
||||||
|
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
|
||||||
|
preservedGenParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRecipe.gen_params = preservedGenParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRecipe.checkpoint !== undefined) {
|
||||||
|
nextRecipe.checkpoint = fullRecipe.checkpoint;
|
||||||
|
} else {
|
||||||
|
delete nextRecipe.checkpoint;
|
||||||
|
}
|
||||||
|
if (fullRecipe.loras !== undefined) {
|
||||||
|
nextRecipe.loras = Array.isArray(fullRecipe.loras) ? [...fullRecipe.loras] : fullRecipe.loras;
|
||||||
|
} else {
|
||||||
|
delete nextRecipe.loras;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentRecipe = nextRecipe;
|
||||||
|
this.filePath = this.currentRecipe.file_path || this.filePath;
|
||||||
|
|
||||||
|
this.syncHydratedRecipeFields(requestEditVersions);
|
||||||
|
} catch (error) {
|
||||||
|
// Keep the cached recipe visible if hydration fails.
|
||||||
|
console.warn('Failed to hydrate recipe details:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHydratedRecipeFields(requestEditVersions = {}) {
|
||||||
|
this.syncPreviewMedia(this.currentRecipe);
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('title', requestEditVersions)) {
|
||||||
|
this.syncTitleDisplay(this.currentRecipe?.title || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('tags', requestEditVersions)) {
|
||||||
|
this.syncTagsDisplay(this.currentRecipe?.tags || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('prompt', requestEditVersions)) {
|
||||||
|
this.syncPromptField(
|
||||||
|
'prompt',
|
||||||
|
this.currentRecipe?.gen_params?.prompt || '',
|
||||||
|
'No prompt information available'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('negative_prompt', requestEditVersions)) {
|
||||||
|
this.syncPromptField(
|
||||||
|
'negative_prompt',
|
||||||
|
this.currentRecipe?.gen_params?.negative_prompt || '',
|
||||||
|
'No negative prompt information available'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncGenerationParams(this.currentRecipe?.gen_params, { promptFieldsOnly: true });
|
||||||
|
this.syncResourcesSection(this.currentRecipe);
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('source_path', requestEditVersions)) {
|
||||||
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
|
||||||
|
} else {
|
||||||
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewMediaUrl(recipe = {}) {
|
||||||
|
return recipe.file_url ||
|
||||||
|
recipe.preview_url ||
|
||||||
|
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||||
|
'/loras_static/images/no-preview.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPreviewMedia(recipe = {}) {
|
||||||
|
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||||
|
if (!mediaContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = this.getPreviewMediaUrl(recipe);
|
||||||
|
const isVideo = previewUrl.toLowerCase().endsWith('.mp4');
|
||||||
|
const expectedElementId = isVideo ? 'recipeModalVideo' : 'recipeModalImage';
|
||||||
|
let previewElement = mediaContainer.querySelector(`#${expectedElementId}`);
|
||||||
|
const existingPreviewElement = mediaContainer.querySelector('.recipe-preview-media');
|
||||||
|
|
||||||
|
if (!previewElement || (existingPreviewElement && existingPreviewElement !== previewElement)) {
|
||||||
|
if (existingPreviewElement?.tagName === 'VIDEO') {
|
||||||
|
const existingVideo = existingPreviewElement;
|
||||||
|
existingVideo.pause();
|
||||||
|
existingVideo.currentTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingPreviewElement?.remove();
|
||||||
|
previewElement = document.createElement(isVideo ? 'video' : 'img');
|
||||||
|
previewElement.id = expectedElementId;
|
||||||
|
previewElement.className = 'recipe-preview-media';
|
||||||
|
mediaContainer.prepend(previewElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewElement.src = previewUrl;
|
||||||
|
previewElement.alt = recipe.title || 'Recipe Preview';
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
previewElement.controls = true;
|
||||||
|
previewElement.autoplay = false;
|
||||||
|
previewElement.loop = true;
|
||||||
|
previewElement.muted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadataUpdateOptions() {
|
||||||
|
return this.listFilePath ? { listFilePath: this.listFilePath } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
syncTitleDisplay(title) {
|
||||||
|
const titleContainer = document.getElementById('recipeModalTitle');
|
||||||
|
if (!titleContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentText = titleContainer.querySelector('.content-text');
|
||||||
|
if (contentText) {
|
||||||
|
contentText.textContent = title || 'Recipe Details';
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleInput = titleContainer.querySelector('.title-input');
|
||||||
|
if (titleInput) {
|
||||||
|
titleInput.value = title || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncTagsDisplay(tags) {
|
||||||
|
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||||
|
if (!tagsContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateTagsDisplay(tagsContainer, tags || []);
|
||||||
|
|
||||||
|
const tagsInput = tagsContainer.querySelector('.tags-input');
|
||||||
|
if (tagsInput) {
|
||||||
|
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPromptField(field, value, placeholder) {
|
||||||
|
const contentId = field === 'prompt' ? 'recipePrompt' : 'recipeNegativePrompt';
|
||||||
|
const editorId = field === 'prompt' ? 'recipePromptEditor' : 'recipeNegativePromptEditor';
|
||||||
|
const inputId = field === 'prompt' ? 'recipePromptInput' : 'recipeNegativePromptInput';
|
||||||
|
|
||||||
|
this.renderPromptContent(document.getElementById(contentId), value, placeholder);
|
||||||
|
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) {
|
||||||
|
input.value = value || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncGenerationParams(genParams, options = {}) {
|
||||||
const promptElement = document.getElementById('recipePrompt');
|
const promptElement = document.getElementById('recipePrompt');
|
||||||
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||||
const otherParamsElement = document.getElementById('recipeOtherParams');
|
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||||
const promptInput = document.getElementById('recipePromptInput');
|
const promptInput = document.getElementById('recipePromptInput');
|
||||||
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
||||||
|
const promptFieldsOnly = options.promptFieldsOnly === true;
|
||||||
|
const sanitizedGenParams = this.sanitizeGenParams(genParams);
|
||||||
|
|
||||||
if (recipe.gen_params) {
|
if (sanitizedGenParams) {
|
||||||
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
|
if (!promptFieldsOnly) {
|
||||||
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
|
this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available');
|
||||||
|
this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available');
|
||||||
|
|
||||||
if (promptInput) {
|
if (promptInput) {
|
||||||
promptInput.value = recipe.gen_params.prompt || '';
|
promptInput.value = sanitizedGenParams.prompt || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (negativePromptInput) {
|
||||||
|
negativePromptInput.value = sanitizedGenParams.negative_prompt || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (negativePromptInput) {
|
|
||||||
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set other parameters
|
|
||||||
if (otherParamsElement) {
|
if (otherParamsElement) {
|
||||||
// Clear previous params
|
|
||||||
otherParamsElement.innerHTML = '';
|
otherParamsElement.innerHTML = '';
|
||||||
|
|
||||||
// Add all other parameters except prompt and negative_prompt
|
|
||||||
const excludedParams = ['prompt', 'negative_prompt'];
|
const excludedParams = ['prompt', 'negative_prompt'];
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(recipe.gen_params)) {
|
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
||||||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||||
const paramTag = document.createElement('div');
|
const paramTag = document.createElement('div');
|
||||||
paramTag.className = 'param-tag';
|
paramTag.className = 'param-tag';
|
||||||
@@ -338,22 +629,68 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no other params, show a message
|
|
||||||
if (otherParamsElement.children.length === 0) {
|
if (otherParamsElement.children.length === 0) {
|
||||||
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
// No generation parameters available
|
}
|
||||||
|
|
||||||
|
if (!promptFieldsOnly) {
|
||||||
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
||||||
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
||||||
if (promptInput) promptInput.value = '';
|
if (promptInput) promptInput.value = '';
|
||||||
if (negativePromptInput) negativePromptInput.value = '';
|
if (negativePromptInput) negativePromptInput.value = '';
|
||||||
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (otherParamsElement) {
|
||||||
|
otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeGenParams(genParams) {
|
||||||
|
if (!genParams || typeof genParams !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(genParams)) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_GEN_PARAM_KEYS.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(genParams)) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKey = GEN_PARAM_NORMALIZATION[key] || key;
|
||||||
|
if (!ALLOWED_GEN_PARAM_KEYS.has(normalizedKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized[normalizedKey] === undefined || sanitized[normalizedKey] === null || sanitized[normalizedKey] === '') {
|
||||||
|
sanitized[normalizedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncResourcesSection(recipe = {}) {
|
||||||
const checkpointContainer = document.getElementById('recipeCheckpoint');
|
const checkpointContainer = document.getElementById('recipeCheckpoint');
|
||||||
const resourceDivider = document.getElementById('recipeResourceDivider');
|
const resourceDivider = document.getElementById('recipeResourceDivider');
|
||||||
|
const lorasListElement = document.getElementById('recipeLorasList');
|
||||||
|
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||||
|
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
|
||||||
|
|
||||||
if (checkpointContainer) {
|
if (checkpointContainer) {
|
||||||
checkpointContainer.innerHTML = '';
|
checkpointContainer.innerHTML = '';
|
||||||
@@ -364,59 +701,43 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set LoRAs list and count
|
|
||||||
const lorasListElement = document.getElementById('recipeLorasList');
|
|
||||||
const lorasCountElement = document.getElementById('recipeLorasCount');
|
|
||||||
|
|
||||||
// Check all LoRAs status
|
|
||||||
let allLorasAvailable = true;
|
let allLorasAvailable = true;
|
||||||
let missingLorasCount = 0;
|
let missingLorasCount = 0;
|
||||||
let deletedLorasCount = 0;
|
let deletedLorasCount = 0;
|
||||||
|
|
||||||
if (recipe.loras && recipe.loras.length > 0) {
|
loras.forEach(lora => {
|
||||||
recipe.loras.forEach(lora => {
|
if (lora.isDeleted) {
|
||||||
if (lora.isDeleted) {
|
deletedLorasCount++;
|
||||||
deletedLorasCount++;
|
} else if (!lora.inLibrary) {
|
||||||
} else if (!lora.inLibrary) {
|
allLorasAvailable = false;
|
||||||
allLorasAvailable = false;
|
missingLorasCount++;
|
||||||
missingLorasCount++;
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set LoRAs count and status
|
if (lorasCountElement) {
|
||||||
if (lorasCountElement && recipe.loras) {
|
const totalCount = loras.length;
|
||||||
const totalCount = recipe.loras.length;
|
|
||||||
|
|
||||||
// Create status indicator based on LoRA states
|
|
||||||
let statusHTML = '';
|
let statusHTML = '';
|
||||||
if (totalCount > 0) {
|
if (totalCount > 0) {
|
||||||
if (allLorasAvailable && deletedLorasCount === 0) {
|
if (allLorasAvailable && deletedLorasCount === 0) {
|
||||||
// All LoRAs are available
|
|
||||||
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||||||
} else if (missingLorasCount > 0) {
|
} else if (missingLorasCount > 0) {
|
||||||
// Some LoRAs are missing (prioritize showing missing over deleted)
|
|
||||||
statusHTML = `<div class="recipe-status missing">
|
statusHTML = `<div class="recipe-status missing">
|
||||||
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
||||||
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
||||||
// Some LoRAs are deleted but none are missing
|
|
||||||
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||||
|
|
||||||
// Add event listeners for buttons and status indicators
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Set up click handler for View LoRAs button
|
|
||||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||||
if (viewRecipeLorasBtn) {
|
if (viewRecipeLorasBtn) {
|
||||||
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler for missing LoRAs status
|
|
||||||
const missingStatus = document.querySelector('.recipe-status.missing');
|
const missingStatus = document.querySelector('.recipe-status.missing');
|
||||||
if (missingStatus && missingLorasCount > 0) {
|
if (missingStatus && missingLorasCount > 0) {
|
||||||
missingStatus.classList.add('clickable');
|
missingStatus.classList.add('clickable');
|
||||||
@@ -425,13 +746,12 @@ class RecipeModal {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
if (lorasListElement && loras.length > 0) {
|
||||||
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
lorasListElement.innerHTML = loras.map(lora => {
|
||||||
const existsLocally = lora.inLibrary;
|
const existsLocally = lora.inLibrary;
|
||||||
const isDeleted = lora.isDeleted;
|
const isDeleted = lora.isDeleted;
|
||||||
const localPath = lora.localPath || '';
|
const localPath = lora.localPath || '';
|
||||||
|
|
||||||
// Create status badge based on LoRA state
|
|
||||||
let localStatus;
|
let localStatus;
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
localStatus = `
|
localStatus = `
|
||||||
@@ -441,7 +761,7 @@ class RecipeModal {
|
|||||||
</div>`;
|
</div>`;
|
||||||
} else if (isDeleted) {
|
} else if (isDeleted) {
|
||||||
localStatus = `
|
localStatus = `
|
||||||
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
|
<div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
|
||||||
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
||||||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -452,7 +772,6 @@ class RecipeModal {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if preview is a video
|
|
||||||
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
||||||
const previewMedia = isPreviewVideo ?
|
const previewMedia = isPreviewVideo ?
|
||||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||||
@@ -460,7 +779,6 @@ class RecipeModal {
|
|||||||
</video>` :
|
</video>` :
|
||||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
||||||
|
|
||||||
// Determine CSS class based on LoRA state
|
|
||||||
let loraItemClass = 'recipe-lora-item';
|
let loraItemClass = 'recipe-lora-item';
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
loraItemClass += ' exists-locally';
|
loraItemClass += ' exists-locally';
|
||||||
@@ -471,7 +789,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
|
<div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
|
||||||
<div class="recipe-lora-thumbnail">
|
<div class="recipe-lora-thumbnail">
|
||||||
${previewMedia}
|
${previewMedia}
|
||||||
</div>
|
</div>
|
||||||
@@ -485,7 +803,7 @@ class RecipeModal {
|
|||||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
|
<div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
|
||||||
<div class="reconnect-instructions">
|
<div class="reconnect-instructions">
|
||||||
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||||||
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
<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('');
|
}).join('');
|
||||||
|
|
||||||
// Add event listeners for reconnect functionality
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setupReconnectButtons();
|
this.setupReconnectButtons();
|
||||||
this.setupLoraItemsClickable();
|
this.setupLoraItemsClickable();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
|
||||||
this.recipeLorasSyntax = '';
|
this.recipeLorasSyntax = '';
|
||||||
|
|
||||||
} else if (lorasListElement) {
|
} else if (lorasListElement) {
|
||||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||||
this.recipeLorasSyntax = '';
|
this.recipeLorasSyntax = '';
|
||||||
@@ -522,9 +837,31 @@ class RecipeModal {
|
|||||||
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
|
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
|
||||||
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
|
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show the modal
|
updateSourceUrlDisplay(sourcePath, options = {}) {
|
||||||
modalManager.showModal('recipeModal');
|
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||||
|
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||||
|
if (!sourceUrlContainer || !sourceUrlEditor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
|
||||||
|
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
|
||||||
|
if (!sourceUrlText || !sourceUrlInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSourcePath = typeof sourcePath === 'string' ? sourcePath.trim() : '';
|
||||||
|
const isValidUrl = normalizedSourcePath.startsWith('http://') || normalizedSourcePath.startsWith('https://');
|
||||||
|
|
||||||
|
sourceUrlText.textContent = normalizedSourcePath || 'No source URL';
|
||||||
|
sourceUrlText.title = normalizedSourcePath
|
||||||
|
? (isValidUrl ? 'Click to open source URL' : 'No valid URL')
|
||||||
|
: 'No valid URL';
|
||||||
|
if (options.forceInputSync || !sourceUrlEditor.classList.contains('active') || !this.sourceUrlEditState.isDirty) {
|
||||||
|
sourceUrlInput.value = normalizedSourcePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title editing methods
|
// Title editing methods
|
||||||
@@ -535,6 +872,7 @@ class RecipeModal {
|
|||||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||||
editor.classList.add('active');
|
editor.classList.add('active');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
|
input.oninput = () => this.markFieldDirty('title');
|
||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
}
|
}
|
||||||
@@ -553,19 +891,23 @@ class RecipeModal {
|
|||||||
titleContainer.querySelector('.content-text').textContent = newTitle;
|
titleContainer.querySelector('.content-text').textContent = newTitle;
|
||||||
|
|
||||||
// Update the recipe on the server
|
// Update the recipe on the server
|
||||||
updateRecipeMetadata(this.filePath, { title: newTitle })
|
updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('toast.recipes.nameUpdated', {}, 'success');
|
showToast('toast.recipes.nameUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.title = newTitle;
|
this.currentRecipe.title = newTitle;
|
||||||
|
this.commitField('title');
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
// Error is handled in the API function
|
// Error is handled in the API function
|
||||||
// Reset the UI if needed
|
// Reset the UI if needed
|
||||||
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
|
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
|
||||||
|
this.clearFieldDirty('title');
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.clearFieldDirty('title');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
@@ -581,6 +923,7 @@ class RecipeModal {
|
|||||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
input.value = this.currentRecipe.title || '';
|
input.value = this.currentRecipe.title || '';
|
||||||
|
this.clearFieldDirty('title');
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
editor.classList.remove('active');
|
editor.classList.remove('active');
|
||||||
@@ -596,6 +939,7 @@ class RecipeModal {
|
|||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||||
editor.classList.add('active');
|
editor.classList.add('active');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
|
input.oninput = () => this.markFieldDirty('tags');
|
||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,20 +967,24 @@ class RecipeModal {
|
|||||||
|
|
||||||
if (tagsChanged) {
|
if (tagsChanged) {
|
||||||
// Update the recipe on the server
|
// Update the recipe on the server
|
||||||
updateRecipeMetadata(this.filePath, { tags: newTags })
|
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.tags = newTags;
|
this.currentRecipe.tags = newTags;
|
||||||
|
this.commitField('tags');
|
||||||
|
|
||||||
// Update tags in the UI
|
// Update tags in the UI
|
||||||
this.updateTagsDisplay(tagsContainer, newTags);
|
this.updateTagsDisplay(tagsContainer, newTags);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
// Error is handled in the API function
|
// Error is handled in the API function
|
||||||
|
this.clearFieldDirty('tags');
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.clearFieldDirty('tags');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
@@ -708,6 +1056,7 @@ class RecipeModal {
|
|||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
||||||
|
this.clearFieldDirty('tags');
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
editor.classList.remove('active');
|
editor.classList.remove('active');
|
||||||
@@ -748,6 +1097,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input) {
|
if (input) {
|
||||||
|
input.addEventListener('input', () => this.markFieldDirty(config.field));
|
||||||
input.addEventListener('keydown', (event) => {
|
input.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -840,9 +1190,10 @@ class RecipeModal {
|
|||||||
|
|
||||||
const currentGenParams = this.currentRecipe.gen_params || {};
|
const currentGenParams = this.currentRecipe.gen_params || {};
|
||||||
const nextValue = input.value.trim() === '' ? '' : input.value;
|
const nextValue = input.value.trim() === '' ? '' : input.value;
|
||||||
const currentValue = currentGenParams[config.field] || '';
|
const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || '';
|
||||||
|
|
||||||
if (nextValue === currentValue) {
|
if (nextValue === currentValue) {
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
this.hidePromptEditor(config);
|
this.hidePromptEditor(config);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -857,14 +1208,17 @@ class RecipeModal {
|
|||||||
...promptState,
|
...promptState,
|
||||||
isSaving: true,
|
isSaving: true,
|
||||||
};
|
};
|
||||||
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
|
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
|
||||||
this.currentRecipe.gen_params = nextGenParams;
|
this.currentRecipe.gen_params = nextGenParams;
|
||||||
this.renderPromptContent(content, nextValue, config.placeholder);
|
this.renderPromptContent(content, nextValue, config.placeholder);
|
||||||
showToast(config.successKey, {}, 'success', config.successFallback);
|
showToast(config.successKey, {}, 'success', config.successFallback);
|
||||||
|
this.commitField(config.field);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.renderPromptContent(content, currentValue, config.placeholder);
|
this.renderPromptContent(content, currentValue, config.placeholder);
|
||||||
input.value = currentValue;
|
input.value = currentValue;
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
} finally {
|
} finally {
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
this.hidePromptEditor(config);
|
this.hidePromptEditor(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -872,10 +1226,10 @@ class RecipeModal {
|
|||||||
cancelPromptEdit(config) {
|
cancelPromptEdit(config) {
|
||||||
const input = document.getElementById(config.inputId);
|
const input = document.getElementById(config.inputId);
|
||||||
if (input) {
|
if (input) {
|
||||||
const initialValue = this.promptEditorState[config.field]?.initialValue;
|
input.value = this.currentRecipe?.gen_params?.[config.field] || '';
|
||||||
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
this.hidePromptEditor(config);
|
this.hidePromptEditor(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,11 +1272,16 @@ class RecipeModal {
|
|||||||
sourceUrlInput.focus();
|
sourceUrlInput.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sourceUrlInput.addEventListener('input', () => {
|
||||||
|
this.sourceUrlEditState.isDirty = true;
|
||||||
|
});
|
||||||
|
|
||||||
// Cancel editing
|
// Cancel editing
|
||||||
sourceUrlCancelBtn.addEventListener('click', () => {
|
sourceUrlCancelBtn.addEventListener('click', () => {
|
||||||
sourceUrlEditor.classList.remove('active');
|
sourceUrlEditor.classList.remove('active');
|
||||||
sourceUrlContainer.classList.remove('hide');
|
sourceUrlContainer.classList.remove('hide');
|
||||||
sourceUrlInput.value = this.currentRecipe.source_path || '';
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
|
||||||
|
this.clearFieldDirty('source_path');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save new source URL
|
// Save new source URL
|
||||||
@@ -930,23 +1289,24 @@ class RecipeModal {
|
|||||||
const newSourceUrl = sourceUrlInput.value.trim();
|
const newSourceUrl = sourceUrlInput.value.trim();
|
||||||
if (newSourceUrl !== this.currentRecipe.source_path) {
|
if (newSourceUrl !== this.currentRecipe.source_path) {
|
||||||
// Update the recipe on the server
|
// Update the recipe on the server
|
||||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
|
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update source URL in the UI
|
// Update source URL in the UI
|
||||||
sourceUrlText.textContent = newSourceUrl || 'No source URL';
|
this.commitField('source_path');
|
||||||
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
|
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||||
newSourceUrl.startsWith('https://')) ?
|
|
||||||
'Click to open source URL' : 'No valid URL';
|
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.source_path = newSourceUrl;
|
this.currentRecipe.source_path = newSourceUrl;
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
// Error is handled in the API function
|
// Error is handled in the API function
|
||||||
|
this.clearFieldDirty('source_path');
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.clearFieldDirty('source_path');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
@@ -1286,7 +1646,7 @@ class RecipeModal {
|
|||||||
this.showRecipeDetails(this.currentRecipe);
|
this.showRecipeDetails(this.currentRecipe);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
|
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||||
loras: this.currentRecipe.loras
|
loras: this.currentRecipe.loras
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ export class DoctorManager {
|
|||||||
return document.body?.dataset?.appVersion || '';
|
return document.body?.dataset?.appVersion || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildReloadUrl() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('_lm_reload', Date.now().toString());
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadUi() {
|
||||||
|
window.location.replace(this.buildReloadUrl());
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(isLoading) {
|
setLoading(isLoading) {
|
||||||
if (this.loadingState) {
|
if (this.loadingState) {
|
||||||
this.loadingState.classList.toggle('visible', isLoading);
|
this.loadingState.classList.toggle('visible', isLoading);
|
||||||
@@ -308,7 +318,7 @@ export class DoctorManager {
|
|||||||
await this.repairCache();
|
await this.repairCache();
|
||||||
break;
|
break;
|
||||||
case 'reload-page':
|
case 'reload-page':
|
||||||
window.location.reload();
|
this.reloadUi();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1443,12 +1443,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
// Add empty row for new path if no paths exist
|
// Add empty row for new path if no paths exist
|
||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
this.addExtraFolderPathRow(modelType, '');
|
this.addExtraFolderPathRow(modelType, '', false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addExtraFolderPathRow(modelType, path = '') {
|
addExtraFolderPathRow(modelType, path = '', shouldFocus = true) {
|
||||||
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -1472,7 +1472,7 @@ export class SettingsManager {
|
|||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
|
|
||||||
// Focus the input if it's empty (new row)
|
// Focus the input if it's empty (new row)
|
||||||
if (!path) {
|
if (!path && shouldFocus) {
|
||||||
const input = row.querySelector('.extra-folder-path-input');
|
const input = row.querySelector('.extra-folder-path-input');
|
||||||
if (input) {
|
if (input) {
|
||||||
setTimeout(() => input.focus(), 0);
|
setTimeout(() => input.focus(), 0);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const loadingManagerMock = vi.hoisted(() => ({
|
|||||||
showSimpleLoading: vi.fn(),
|
showSimpleLoading: vi.fn(),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const virtualScrollerMock = vi.hoisted(() => ({
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||||
return {
|
return {
|
||||||
@@ -20,12 +23,13 @@ vi.mock('../../../static/js/state/index.js', () => {
|
|||||||
return {
|
return {
|
||||||
state: {
|
state: {
|
||||||
loadingManager: loadingManagerMock,
|
loadingManager: loadingManagerMock,
|
||||||
|
virtualScroller: virtualScrollerMock,
|
||||||
},
|
},
|
||||||
getCurrentPageState: vi.fn(),
|
getCurrentPageState: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
import { RecipeSidebarApiClient, fetchRecipeDetails, updateRecipeMetadata } from '../../../static/js/api/recipeApi.js';
|
||||||
|
|
||||||
describe('RecipeSidebarApiClient bulk operations', () => {
|
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -111,4 +115,37 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
});
|
});
|
||||||
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes recipe IDs when fetching recipe details', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'abc' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchRecipeDetails('recipe#1?name=foo%bar');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the virtual scroller using the original list path when provided', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateRecipeMetadata(
|
||||||
|
'/recipes/new-folder/recipe#1.webp',
|
||||||
|
{ title: 'Updated Title' },
|
||||||
|
{ listFilePath: '/recipes/old-folder/recipe#1.webp' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipe/recipe%231/update',
|
||||||
|
expect.objectContaining({ method: 'PUT' })
|
||||||
|
);
|
||||||
|
expect(virtualScrollerMock.updateSingleItem).toHaveBeenCalledWith(
|
||||||
|
'/recipes/old-folder/recipe#1.webp',
|
||||||
|
{ title: 'Updated Title' }
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,4 +53,26 @@ describe('DoctorManager', () => {
|
|||||||
|
|
||||||
expect(refreshSpy).not.toHaveBeenCalled();
|
expect(refreshSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a cache-busted reload URL that preserves the current location', () => {
|
||||||
|
renderDoctorFixture();
|
||||||
|
window.history.replaceState({}, '', '/loras?filter=active#details');
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
|
||||||
|
|
||||||
|
const manager = new DoctorManager();
|
||||||
|
|
||||||
|
const url = manager.buildReloadUrl();
|
||||||
|
|
||||||
|
expect(url).toBe('http://localhost:3000/loras?filter=active&_lm_reload=1234567890#details');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates reload-page actions to reloadUi', async () => {
|
||||||
|
renderDoctorFixture();
|
||||||
|
const manager = new DoctorManager();
|
||||||
|
const reloadSpy = vi.spyOn(manager, 'reloadUi').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await manager.handleAction('reload-page');
|
||||||
|
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
delete global.fetch;
|
delete global.fetch;
|
||||||
delete document.hidden;
|
delete document.hidden;
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
@@ -231,6 +232,51 @@ describe('SettingsManager library controls', () => {
|
|||||||
expect(input.value).toBe('/custom/recipes');
|
expect(input.value).toBe('/custom/recipes');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not autofocus empty extra folder path rows during initial settings load', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="extraFolderPaths-loras"></div>
|
||||||
|
<div id="extraFolderPaths-checkpoints"></div>
|
||||||
|
<div id="extraFolderPaths-unet"></div>
|
||||||
|
<div id="extraFolderPaths-embeddings"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
|
||||||
|
|
||||||
|
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
|
||||||
|
|
||||||
|
state.global.settings = {
|
||||||
|
extra_folder_paths: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.loadSettingsToUI();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still focuses an extra folder path row when it is added explicitly', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
document.body.innerHTML = '<div id="extraFolderPaths-embeddings"></div>';
|
||||||
|
|
||||||
|
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
|
||||||
|
|
||||||
|
manager.addExtraFolderPathRow('embeddings', '');
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows loading while saving recipes_path', async () => {
|
it('shows loading while saving recipes_path', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
|
|||||||
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
|
from py.recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
def test_merge_priority():
|
|
||||||
request_params = {"prompt": "from request", "steps": 20}
|
def test_merge_priority_and_normalization():
|
||||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
request_params = {"prompt": "from request", "Steps": 20, "cfg": 7.5}
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfgScale": 6.5, "negativePrompt": "bad"}
|
||||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
assert merged["prompt"] == "from request"
|
|
||||||
assert merged["steps"] == 20
|
|
||||||
assert merged["cfg"] == 7.0
|
|
||||||
assert merged["seed"] == 123
|
|
||||||
|
|
||||||
def test_merge_no_request_params():
|
assert merged == {
|
||||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
"prompt": "from request",
|
||||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
"steps": 20,
|
||||||
|
"cfg_scale": 7.5,
|
||||||
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
|
"negative_prompt": "bad",
|
||||||
|
"seed": 123,
|
||||||
assert merged["prompt"] == "from civitai"
|
}
|
||||||
assert merged["cfg"] == 7.0
|
|
||||||
assert merged["seed"] == 123
|
|
||||||
|
def test_merge_accepts_raw_embedded_metadata():
|
||||||
|
embedded_metadata = {"prompt": "from raw embedded", "seed": 456, "scheduler": "karras"}
|
||||||
|
|
||||||
def test_merge_only_embedded():
|
|
||||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
assert merged["prompt"] == "from embedded"
|
|
||||||
assert merged["seed"] == 123
|
|
||||||
|
|
||||||
def test_merge_raw_embedded():
|
assert merged == {
|
||||||
# Test when embedded metadata is just the gen_params themselves
|
"prompt": "from raw embedded",
|
||||||
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
|
"seed": 456,
|
||||||
|
"sampler": "karras",
|
||||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
}
|
||||||
|
|
||||||
assert merged["prompt"] == "from raw embedded"
|
|
||||||
assert merged["seed"] == 456
|
|
||||||
|
|
||||||
def test_merge_none_values():
|
|
||||||
merged = GenParamsMerger.merge(None, None, None)
|
|
||||||
assert merged == {}
|
|
||||||
|
|
||||||
def test_merge_filters_blacklisted_keys():
|
def test_merge_filters_unknown_and_blacklisted_keys():
|
||||||
request_params = {"prompt": "test", "id": "should-be-removed", "checkpoint": "should-not-be-here"}
|
request_params = {
|
||||||
civitai_meta = {"cfg": 7, "url": "remove-me"}
|
"prompt": "test",
|
||||||
embedded_metadata = {"seed": 123, "hash": "remove-also"}
|
"id": "should-be-removed",
|
||||||
|
"checkpoint": "should-not-be-here",
|
||||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
"raw_metadata": {"prompt": "remove"},
|
||||||
|
}
|
||||||
assert "prompt" in merged
|
civitai_meta = {
|
||||||
assert "cfg" in merged
|
"Version": "ComfyUI",
|
||||||
assert "seed" in merged
|
"RNG": "cpu",
|
||||||
assert "id" not in merged
|
"cfgScale": 7,
|
||||||
assert "url" not in merged
|
"url": "remove-me",
|
||||||
assert "hash" not in merged
|
}
|
||||||
assert "checkpoint" not in merged
|
embedded_metadata = {
|
||||||
|
"seed": 123,
|
||||||
def test_merge_filters_meta_and_normalizes_keys():
|
"hash": "remove-also",
|
||||||
|
"Discard penultimate sigma": True,
|
||||||
|
"eps_scaling_factor": 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged == {
|
||||||
|
"prompt": "test",
|
||||||
|
"cfg_scale": 7,
|
||||||
|
"seed": 123,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_does_not_keep_original_key_variants():
|
||||||
civitai_meta = {
|
civitai_meta = {
|
||||||
"prompt": "masterpiece",
|
|
||||||
"cfgScale": 5,
|
"cfgScale": 5,
|
||||||
"clipSkip": 2,
|
"clipSkip": 2,
|
||||||
"negativePrompt": "low quality",
|
"negativePrompt": "low quality",
|
||||||
"meta": {"irrelevant": "data"},
|
|
||||||
"Size": "1024x1024",
|
"Size": "1024x1024",
|
||||||
"draft": False,
|
"Denoising strength": 0.35,
|
||||||
"workflow": "txt2img",
|
|
||||||
"civitaiResources": [{"type": "checkpoint"}]
|
|
||||||
}
|
}
|
||||||
request_params = {
|
request_params = {
|
||||||
"cfg_scale": 5.0,
|
"cfg_scale": 5.0,
|
||||||
"clip_skip": "2",
|
"clip_skip": "2",
|
||||||
"Steps": 30
|
|
||||||
}
|
}
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(request_params, civitai_meta)
|
merged = GenParamsMerger.merge(request_params, civitai_meta)
|
||||||
|
|
||||||
assert "meta" not in merged
|
assert merged == {
|
||||||
assert "cfgScale" not in merged
|
"cfg_scale": 5.0,
|
||||||
assert "clipSkip" not in merged
|
"clip_skip": "2",
|
||||||
assert "negativePrompt" not in merged
|
"negative_prompt": "low quality",
|
||||||
assert "Size" not in merged
|
"size": "1024x1024",
|
||||||
assert "draft" not in merged
|
"denoising_strength": 0.35,
|
||||||
assert "workflow" not in merged
|
}
|
||||||
assert "civitaiResources" not in merged
|
|
||||||
|
|
||||||
assert merged["cfg_scale"] == 5.0 # From request_params
|
def test_merge_none_values():
|
||||||
assert merged["clip_skip"] == "2" # From request_params
|
assert GenParamsMerger.merge(None, None, None) == {}
|
||||||
assert merged["negative_prompt"] == "low quality" # Normalized from civitai_meta
|
|
||||||
assert merged["size"] == "1024x1024" # Normalized from civitai_meta
|
|
||||||
assert merged["steps"] == 30 # Normalized from request_params
|
|
||||||
|
|||||||
@@ -358,6 +358,188 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
|
|||||||
assert recipe["checkpoint"]["file_name"] == "by-id"
|
assert recipe["checkpoint"]["file_name"] == "by-id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "hydrate-me"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/hydrate-me.png",
|
||||||
|
"title": "Hydrated Recipe",
|
||||||
|
"source_path": "https://example.com/source",
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "prompt from json",
|
||||||
|
"negative_prompt": "negative from json",
|
||||||
|
},
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/hydrate-me.png",
|
||||||
|
"title": "Cached Recipe",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["title"] == "Hydrated Recipe"
|
||||||
|
assert recipe["source_path"] == "https://example.com/source"
|
||||||
|
assert recipe["gen_params"]["prompt"] == "prompt from json"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_normalizes_gen_params_aliases_without_dropping_metadata(
|
||||||
|
recipe_scanner,
|
||||||
|
):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "dirty-json-gen-params"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/dirty-json-gen-params.png",
|
||||||
|
"title": "Dirty Recipe",
|
||||||
|
"gen_params": {
|
||||||
|
"Prompt": "prompt from json",
|
||||||
|
"negativePrompt": "negative from json",
|
||||||
|
"cfgScale": 7,
|
||||||
|
"raw_metadata": {"prompt": "nested"},
|
||||||
|
"Version": "ComfyUI",
|
||||||
|
"RNG": "cpu",
|
||||||
|
},
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/dirty-json-gen-params.png",
|
||||||
|
"title": "Cached Recipe",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {"prompt": "cached prompt", "raw_metadata": {"bad": True}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["gen_params"]["Prompt"] == "prompt from json"
|
||||||
|
assert recipe["gen_params"]["negativePrompt"] == "negative from json"
|
||||||
|
assert recipe["gen_params"]["cfgScale"] == 7
|
||||||
|
assert recipe["gen_params"]["raw_metadata"] == {"prompt": "nested"}
|
||||||
|
assert recipe["gen_params"]["Version"] == "ComfyUI"
|
||||||
|
assert recipe["gen_params"]["RNG"] == "cpu"
|
||||||
|
assert recipe["gen_params"]["prompt"] == "prompt from json"
|
||||||
|
assert recipe["gen_params"]["negative_prompt"] == "negative from json"
|
||||||
|
assert recipe["gen_params"]["cfg_scale"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "move-me"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/new-location.png",
|
||||||
|
"title": "Moved Recipe",
|
||||||
|
"source_path": "https://example.com/moved",
|
||||||
|
"gen_params": {},
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/old-location.png",
|
||||||
|
"title": "Cached Title",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["file_path"] == "/tmp/new-location.png"
|
||||||
|
assert recipe["title"] == "Moved Recipe"
|
||||||
|
assert recipe["source_path"] == "https://example.com/moved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_drops_deleted_optional_json_fields(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "drop-optional-fields"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/drop-optional-fields.png",
|
||||||
|
"title": "Trimmed Recipe",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/drop-optional-fields.png",
|
||||||
|
"title": "Cached Recipe",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"source_path": "https://example.com/stale-source",
|
||||||
|
"checkpoint": {"name": "stale-checkpoint.safetensors"},
|
||||||
|
"loras": [{"modelName": "stale-lora"}],
|
||||||
|
"gen_params": {"prompt": "stale prompt"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["title"] == "Trimmed Recipe"
|
||||||
|
assert "source_path" not in recipe
|
||||||
|
assert "checkpoint" not in recipe
|
||||||
|
assert "gen_params" not in recipe
|
||||||
|
assert "loras" not in recipe
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
||||||
scanner, _ = recipe_scanner
|
scanner, _ = recipe_scanner
|
||||||
@@ -401,6 +583,40 @@ async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
|||||||
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
|
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping_metadata(
|
||||||
|
recipe_scanner,
|
||||||
|
):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": "dirty-listing",
|
||||||
|
"file_path": str(Path(config.loras_roots[0]) / "dirty-listing.webp"),
|
||||||
|
"title": "Dirty Listing",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"Prompt": "a beautiful forest landscape",
|
||||||
|
"cfgScale": 7,
|
||||||
|
"Version": "ComfyUI",
|
||||||
|
"raw_metadata": {"bad": True},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10)
|
||||||
|
item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing")
|
||||||
|
|
||||||
|
assert item["gen_params"]["Prompt"] == "a beautiful forest landscape"
|
||||||
|
assert item["gen_params"]["cfgScale"] == 7
|
||||||
|
assert item["gen_params"]["Version"] == "ComfyUI"
|
||||||
|
assert item["gen_params"]["raw_metadata"] == {"bad": True}
|
||||||
|
assert item["gen_params"]["prompt"] == "a beautiful forest landscape"
|
||||||
|
assert item["gen_params"]["cfg_scale"] == 7
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
|
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
|
||||||
scanner, _ = recipe_scanner
|
scanner, _ = recipe_scanner
|
||||||
|
|||||||
@@ -306,6 +306,120 @@ async def test_save_recipe_promotes_checkpoint_from_gen_params(tmp_path):
|
|||||||
assert "checkpoint" not in stored["gen_params"]
|
assert "checkpoint" not in stored["gen_params"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_strips_non_persistable_gen_params(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"base_model": "Flux",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "hello world",
|
||||||
|
"negative_prompt": "bad hands",
|
||||||
|
"cfg_scale": 7,
|
||||||
|
"raw_metadata": {"prompt": "should not persist"},
|
||||||
|
"Version": "ComfyUI",
|
||||||
|
"RNG": "cpu",
|
||||||
|
"Schedule type": "karras",
|
||||||
|
"Discard penultimate sigma": True,
|
||||||
|
"eps_scaling_factor": 0.1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await service.save_recipe(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
image_bytes=b"img",
|
||||||
|
image_base64=None,
|
||||||
|
name="Sanitized",
|
||||||
|
tags=[],
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
assert stored["gen_params"] == {
|
||||||
|
"prompt": "hello world",
|
||||||
|
"negative_prompt": "bad hands",
|
||||||
|
"cfg_scale": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_derives_allowed_fields_from_raw_metadata(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"base_model": "Flux",
|
||||||
|
"loras": [],
|
||||||
|
"raw_metadata": {
|
||||||
|
"prompt": "hello world",
|
||||||
|
"negative_prompt": "bad hands",
|
||||||
|
"steps": 30,
|
||||||
|
"sampler": "Euler",
|
||||||
|
"cfg_scale": 7,
|
||||||
|
"seed": 123,
|
||||||
|
"size": "1024x1024",
|
||||||
|
"clip_skip": 2,
|
||||||
|
"Version": "ComfyUI",
|
||||||
|
"raw_metadata": {"nested": True},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await service.save_recipe(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
image_bytes=b"img",
|
||||||
|
image_base64=None,
|
||||||
|
name="Derived",
|
||||||
|
tags=[],
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
assert stored["gen_params"] == {
|
||||||
|
"prompt": "hello world",
|
||||||
|
"negative_prompt": "bad hands",
|
||||||
|
"steps": 30,
|
||||||
|
"sampler": "Euler",
|
||||||
|
"cfg_scale": 7,
|
||||||
|
"seed": 123,
|
||||||
|
"size": "1024x1024",
|
||||||
|
"clip_skip": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
|
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
|
|||||||
Reference in New Issue
Block a user