From edd36427ac9f30dab0e285e0179ebb1e5a914da8 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Sat, 15 Mar 2025 20:08:26 +0800 Subject: [PATCH] Refactor recipe management to enhance initialization and metadata handling. Improve error logging during cache pre-warming, streamline recipe data structure, and ensure proper handling of generation parameters. Update UI components for missing LoRAs with improved summary and toggle functionality. Add new methods for adding recipes to cache and loading recipe data from JSON files. --- py/routes/recipe_routes.py | 84 +++++- py/services/recipe_cache.py | 18 +- py/services/recipe_scanner.py | 348 +++++++------------------ refs/recipe.json | 10 +- static/css/components/import-modal.css | 99 ++++++- static/js/managers/ImportManager.js | 66 ++++- templates/components/import_modal.html | 12 +- 7 files changed, 351 insertions(+), 286 deletions(-) diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 8325440c..99803f59 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -48,14 +48,26 @@ class RecipeRoutes: """Initialize cache on startup""" print("Pre-warming recipe cache...", file=sys.stderr) try: - # Diagnose lora scanner first - await self.recipe_scanner._lora_scanner.diagnose_hash_index() + # First, ensure the lora scanner is fully initialized + print("Initializing lora scanner...", file=sys.stderr) + lora_scanner = self.recipe_scanner._lora_scanner - # Force a cache refresh + # Get lora cache to ensure it's initialized + lora_cache = await lora_scanner.get_cached_data() + print(f"Lora scanner initialized with {len(lora_cache.raw_data)} loras", file=sys.stderr) + + # Verify hash index is built + if hasattr(lora_scanner, '_hash_index'): + hash_index_size = len(lora_scanner._hash_index._hash_to_path) if hasattr(lora_scanner._hash_index, '_hash_to_path') else 0 + print(f"Lora hash index contains {hash_index_size} entries", file=sys.stderr) + + # Now that lora scanner is initialized, initialize recipe cache + print("Initializing recipe cache...", file=sys.stderr) await self.recipe_scanner.get_cached_data(force_refresh=True) print("Recipe cache pre-warming complete", file=sys.stderr) except Exception as e: print(f"Error pre-warming recipe cache: {e}", file=sys.stderr) + logger.error(f"Error pre-warming recipe cache: {e}", exc_info=True) async def get_recipes(self, request: web.Request) -> web.Response: """API endpoint for getting paginated recipes""" @@ -221,6 +233,7 @@ class RecipeRoutes: # Check if this LoRA exists locally by SHA256 hash exists_locally = False local_path = None + sha256 = '' if civitai_info and 'files' in civitai_info: # Find the model file (type="Model") in the files list @@ -234,7 +247,7 @@ class RecipeRoutes: if exists_locally: local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256) - # Create LoRA entry + # Create LoRA entry for frontend display lora_entry = { 'id': model_version_id, 'name': resource.get('modelName', ''), @@ -243,6 +256,8 @@ class RecipeRoutes: 'weight': resource.get('weight', 1.0), 'existsLocally': exists_locally, 'localPath': local_path, + 'file_name': os.path.splitext(os.path.basename(local_path))[0] if local_path else '', + 'hash': sha256, 'thumbnailUrl': '', 'baseModel': '', 'size': 0, @@ -267,9 +282,24 @@ class RecipeRoutes: loras.append(lora_entry) + # Extract generation parameters for recipe metadata + gen_params = { + 'prompt': metadata.get('prompt', ''), + 'negative_prompt': metadata.get('negative_prompt', ''), + 'checkpoint': checkpoint, + 'steps': metadata.get('steps', ''), + 'sampler': metadata.get('sampler', ''), + 'cfg_scale': metadata.get('cfg_scale', ''), + 'seed': metadata.get('seed', ''), + 'size': metadata.get('size', ''), + 'clip_skip': metadata.get('clip_skip', '') + } + return web.json_response({ 'base_model': base_model, - 'loras': loras + 'loras': loras, + 'gen_params': gen_params, + 'raw_metadata': metadata # Include the raw metadata for saving }) except Exception as e: @@ -350,6 +380,39 @@ class RecipeRoutes: # Create the recipe JSON current_time = time.time() + + # Format loras data according to the recipe.json format + loras_data = [] + for lora in metadata.get("loras", []): + # Convert frontend lora format to recipe format + lora_entry = { + "file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0], + "hash": lora.get("hash", "").lower() if lora.get("hash") else "", + "strength": float(lora.get("weight", 1.0)), + "modelVersionId": lora.get("id", ""), + "modelName": lora.get("name", ""), + "modelVersionName": lora.get("version", "") + } + loras_data.append(lora_entry) + + # Format gen_params according to the recipe.json format + gen_params = metadata.get("gen_params", {}) + if not gen_params and "raw_metadata" in metadata: + # Extract from raw metadata if available + raw_metadata = metadata.get("raw_metadata", {}) + gen_params = { + "prompt": raw_metadata.get("prompt", ""), + "negative_prompt": raw_metadata.get("negative_prompt", ""), + "checkpoint": raw_metadata.get("checkpoint", {}), + "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", "") + } + + # Create the recipe data structure recipe_data = { "id": recipe_id, "file_path": image_path, @@ -357,8 +420,8 @@ class RecipeRoutes: "modified": current_time, "created_date": current_time, "base_model": metadata.get("base_model", ""), - "loras": metadata.get("loras", []), - "gen_params": metadata.get("gen_params", {}) + "loras": loras_data, + "gen_params": gen_params } # Add tags if provided @@ -370,8 +433,11 @@ class RecipeRoutes: json_path = os.path.join(recipes_dir, json_filename) with open(json_path, 'w', encoding='utf-8') as f: json.dump(recipe_data, f, indent=4, ensure_ascii=False) - # Force refresh the recipe cache - await self.recipe_scanner.get_cached_data(force_refresh=True) + + # Add the new recipe directly to the cache instead of forcing a refresh + cache = await self.recipe_scanner.get_cached_data() + await cache.add_recipe(recipe_data) + return web.json_response({ 'success': True, 'recipe_id': recipe_id, diff --git a/py/services/recipe_cache.py b/py/services/recipe_cache.py index c10f4131..87446677 100644 --- a/py/services/recipe_cache.py +++ b/py/services/recipe_cache.py @@ -27,11 +27,11 @@ class RecipeCache: reverse=True ) - async def update_recipe_metadata(self, file_path: str, metadata: Dict) -> bool: + async def update_recipe_metadata(self, recipe_id: str, metadata: Dict) -> bool: """Update metadata for a specific recipe in all cached data Args: - file_path: The file path of the recipe to update + recipe_id: The ID of the recipe to update metadata: The new metadata Returns: @@ -40,7 +40,7 @@ class RecipeCache: async with self._lock: # Update in raw_data for item in self.raw_data: - if item['file_path'] == file_path: + if item.get('id') == recipe_id: item.update(metadata) break else: @@ -48,4 +48,14 @@ class RecipeCache: # Resort to reflect changes await self.resort() - return True \ No newline at end of file + return True + + async def add_recipe(self, recipe_data: Dict) -> None: + """Add a new recipe to the cache + + Args: + recipe_data: The recipe data to add + """ + async with self._lock: + self.raw_data.append(recipe_data) + await self.resort() \ No newline at end of file diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 75e23e86..e2145418 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -5,49 +5,13 @@ import json import re from typing import List, Dict, Optional, Any from datetime import datetime -from ..utils.exif_utils import ExifUtils from ..config import config from .recipe_cache import RecipeCache from .lora_scanner import LoraScanner from .civitai_client import CivitaiClient import sys -print("Recipe Scanner module loaded", file=sys.stderr) - -def setup_logger(): - """Configure logger for recipe scanner""" - # First, print directly to stderr - print("Setting up recipe scanner logger", file=sys.stderr) - - # Create a stderr handler - handler = logging.StreamHandler(sys.stderr) - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - handler.setFormatter(formatter) - - # Configure recipe logger - recipe_logger = logging.getLogger(__name__) - recipe_logger.setLevel(logging.INFO) - - # Remove existing handlers if any - for h in recipe_logger.handlers: - recipe_logger.removeHandler(h) - - recipe_logger.addHandler(handler) - recipe_logger.propagate = False - - # Also ensure the root logger has a handler - root_logger = logging.getLogger() - root_logger.setLevel(logging.INFO) - - # Check if the root logger already has handlers - if not root_logger.handlers: - root_logger.addHandler(handler) - - print(f"Logger setup complete: {__name__}", file=sys.stderr) - return recipe_logger - -# Use our configured logger -logger = setup_logger() +logger = logging.getLogger(__name__) class RecipeScanner: """Service for scanning and managing recipe images""" @@ -132,13 +96,7 @@ class RecipeScanner: if self._lora_scanner: logger.info("Recipe Manager: Waiting for lora scanner initialization to complete") - # Force a fresh initialization of the lora scanner to ensure it's complete - lora_cache = await self._lora_scanner.get_cached_data(force_refresh=True) - - # Add a delay to ensure any background tasks complete - await asyncio.sleep(2) - - # Get the cache again to ensure we have the latest data + # Get the lora cache to ensure it's initialized lora_cache = await self._lora_scanner.get_cached_data() logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras") @@ -146,18 +104,6 @@ class RecipeScanner: if hasattr(self._lora_scanner, '_hash_index'): hash_index_size = len(self._lora_scanner._hash_index._hash_to_path) if hasattr(self._lora_scanner._hash_index, '_hash_to_path') else 0 logger.info(f"Recipe Manager: Lora hash index contains {hash_index_size} entries") - - # If hash index is empty but we have loras, consider this an error condition - if hash_index_size == 0 and len(lora_cache.raw_data) > 0: - logger.error("Recipe Manager: Lora hash index is empty despite having loras in cache") - await self._lora_scanner.diagnose_hash_index() - - # Wait another moment for hash index to potentially initialize - await asyncio.sleep(1) - - # Try to check again - hash_index_size = len(self._lora_scanner._hash_index._hash_to_path) if hasattr(self._lora_scanner._hash_index, '_hash_to_path') else 0 - logger.info(f"Recipe Manager: Lora hash index now contains {hash_index_size} entries") else: logger.warning("Recipe Manager: No lora hash index available") else: @@ -187,7 +133,7 @@ class RecipeScanner: ) async def scan_all_recipes(self) -> List[Dict]: - """Scan all recipe images and return metadata""" + """Scan all recipe JSON files and return metadata""" recipes = [] recipes_dir = self.recipes_dir @@ -195,20 +141,20 @@ class RecipeScanner: logger.warning(f"Recipes directory not found: {recipes_dir}") return recipes - # Get all jpg/jpeg files in the recipes directory - image_files = [] - logger.info(f"Scanning for recipe images in {recipes_dir}") + # Get all recipe JSON files in the recipes directory + recipe_files = [] + logger.info(f"Scanning for recipe JSON files in {recipes_dir}") for root, _, files in os.walk(recipes_dir): - image_count = sum(1 for f in files if f.lower().endswith(('.jpg', '.jpeg'))) - if image_count > 0: - logger.info(f"Found {image_count} potential recipe images in {root}") + recipe_count = sum(1 for f in files if f.lower().endswith('.recipe.json')) + if recipe_count > 0: + logger.info(f"Found {recipe_count} recipe files in {root}") for file in files: - if file.lower().endswith(('.jpg', '.jpeg')): - image_files.append(os.path.join(root, file)) + if file.lower().endswith('.recipe.json'): + recipe_files.append(os.path.join(root, file)) - # Process each image - for image_path in image_files: - recipe_data = await self._process_recipe_image(image_path) + # Process each recipe file + for recipe_path in recipe_files: + recipe_data = await self._load_recipe_file(recipe_path) if recipe_data: recipes.append(recipe_data) logger.info(f"Processed recipe: {recipe_data.get('title')}") @@ -217,87 +163,54 @@ class RecipeScanner: return recipes - async def _process_recipe_image(self, image_path: str) -> Optional[Dict]: - """Process a single recipe image and return metadata""" + async def _load_recipe_file(self, recipe_path: str) -> Optional[Dict]: + """Load recipe data from a JSON file""" try: - print(f"Processing recipe image: {image_path}", file=sys.stderr) - logger.info(f"Processing recipe image: {image_path}") + logger.info(f"Loading recipe file: {recipe_path}") - # Extract EXIF UserComment - user_comment = ExifUtils.extract_user_comment(image_path) - if not user_comment: - print(f"No EXIF UserComment found in {image_path}", file=sys.stderr) - logger.warning(f"No EXIF UserComment found in {image_path}") - return None - else: - print(f"Found UserComment: {user_comment[:50]}...", file=sys.stderr) + with open(recipe_path, 'r', encoding='utf-8') as f: + recipe_data = json.load(f) - # Parse generation parameters from UserComment - gen_params = ExifUtils.parse_recipe_metadata(user_comment) - if not gen_params: - print(f"Failed to parse recipe metadata from {image_path}", file=sys.stderr) - logger.warning(f"Failed to parse recipe metadata from {image_path}") + # Validate recipe data + if not recipe_data or not isinstance(recipe_data, dict): + logger.warning(f"Invalid recipe data in {recipe_path}") return None - # Get file info - stat = os.stat(image_path) - file_name = os.path.basename(image_path) - title = os.path.splitext(file_name)[0] + # Ensure required fields exist + required_fields = ['id', 'file_path', 'title'] + for field in required_fields: + if field not in recipe_data: + logger.warning(f"Missing required field '{field}' in {recipe_path}") + return None - # Check for existing recipe metadata - recipe_data = self._extract_recipe_metadata(user_comment) - if not recipe_data: - # Create new recipe data - recipe_data = { - 'id': file_name, - 'file_path': image_path, - 'title': title, - 'modified': stat.st_mtime, - 'created_date': stat.st_ctime, - 'file_size': stat.st_size, - 'loras': [], - 'gen_params': {} - } - - # Copy loras from gen_params to recipe_data with proper structure - for lora in gen_params.get('loras', []): - recipe_lora = { - 'file_name': '', - 'hash': lora.get('hash', '').lower() if lora.get('hash') else '', - 'strength': lora.get('weight', 1.0), - 'modelVersionId': lora.get('modelVersionId', ''), - 'modelName': lora.get('modelName', ''), - 'modelVersionName': lora.get('modelVersionName', '') - } - recipe_data['loras'].append(recipe_lora) + # Ensure the image file exists + image_path = recipe_data.get('file_path') + if not os.path.exists(image_path): + logger.warning(f"Recipe image not found: {image_path}") + # Try to find the image in the same directory as the recipe + recipe_dir = os.path.dirname(recipe_path) + image_filename = os.path.basename(image_path) + alternative_path = os.path.join(recipe_dir, image_filename) + if os.path.exists(alternative_path): + logger.info(f"Found alternative image path: {alternative_path}") + recipe_data['file_path'] = alternative_path + else: + logger.warning(f"Could not find alternative image path for {image_path}") - # Add generation parameters to recipe_data.gen_params instead of top level - recipe_data['gen_params'] = { - 'prompt': gen_params.get('prompt', ''), - 'negative_prompt': gen_params.get('negative_prompt', ''), - 'checkpoint': gen_params.get('checkpoint', None), - 'steps': gen_params.get('steps', ''), - 'sampler': gen_params.get('sampler', ''), - 'cfg_scale': gen_params.get('cfg_scale', ''), - 'seed': gen_params.get('seed', ''), - 'size': gen_params.get('size', ''), - 'clip_skip': gen_params.get('clip_skip', '') - } + # Ensure loras array exists + if 'loras' not in recipe_data: + recipe_data['loras'] = [] - # Update recipe metadata with missing information - metadata_updated = await self._update_recipe_metadata(recipe_data, user_comment) - recipe_data['_metadata_updated'] = metadata_updated + # Ensure gen_params exists + if 'gen_params' not in recipe_data: + recipe_data['gen_params'] = {} - # If metadata was updated, save back to image - if metadata_updated: - print(f"Updating metadata for {image_path}", file=sys.stderr) - logger.info(f"Updating metadata for {image_path}") - self._save_updated_metadata(image_path, user_comment, recipe_data) + # Update lora information with local paths and availability + await self._update_lora_information(recipe_data) return recipe_data except Exception as e: - print(f"Error processing recipe image {image_path}: {e}", file=sys.stderr) - logger.error(f"Error processing recipe image {image_path}: {e}") + logger.error(f"Error loading recipe file {recipe_path}: {e}") import traceback traceback.print_exc(file=sys.stderr) return None @@ -438,97 +351,7 @@ class RecipeScanner: logger.error(f"Error getting hash from Civitai: {e}") return None - def _save_updated_metadata(self, image_path: str, original_comment: str, recipe_data: Dict) -> None: - """Save updated metadata back to image file""" - try: - # Check if we already have a recipe metadata section - recipe_metadata_exists = "recipe metadata:" in original_comment.lower() - - # Prepare recipe metadata - recipe_metadata = { - 'id': recipe_data.get('id', ''), - 'file_path': recipe_data.get('file_path', ''), - 'title': recipe_data.get('title', ''), - 'modified': recipe_data.get('modified', 0), - 'created_date': recipe_data.get('created_date', 0), - 'base_model': recipe_data.get('base_model', ''), - 'loras': [], - 'gen_params': recipe_data.get('gen_params', {}) - } - - # Add lora data with only necessary fields (removing weight, adding modelVersionName) - for lora in recipe_data.get('loras', []): - lora_entry = { - 'file_name': lora.get('file_name', ''), - 'hash': lora.get('hash', '').lower() if lora.get('hash') else '', - 'strength': lora.get('strength', 1.0), - 'modelVersionId': lora.get('modelVersionId', ''), - 'modelName': lora.get('modelName', ''), - 'modelVersionName': lora.get('modelVersionName', '') - } - recipe_metadata['loras'].append(lora_entry) - - # Convert to JSON - recipe_metadata_json = json.dumps(recipe_metadata) - - # Create or update the recipe metadata section - if recipe_metadata_exists: - # Replace existing recipe metadata - updated_comment = re.sub( - r'recipe metadata: \{.*\}', - f'recipe metadata: {recipe_metadata_json}', - original_comment, - flags=re.IGNORECASE | re.DOTALL - ) - else: - # Append recipe metadata to the end - updated_comment = f"{original_comment}, recipe metadata: {recipe_metadata_json}" - - # Save back to image - logger.info(f"Saving updated metadata to {image_path}") - ExifUtils.update_user_comment(image_path, updated_comment) - - except Exception as e: - logger.error(f"Error saving updated metadata: {e}", exc_info=True) - - async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None): - """Get paginated and filtered recipe data - - Args: - page: Current page number (1-based) - page_size: Number of items per page - sort_by: Sort method ('name' or 'date') - search: Search term - """ - cache = await self.get_cached_data() - - # Get base dataset - filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name - - # Apply search filter - if search: - filtered_data = [ - item for item in filtered_data - if search.lower() in str(item.get('title', '')).lower() or - search.lower() in str(item.get('prompt', '')).lower() - ] - - # Calculate pagination - total_items = len(filtered_data) - start_idx = (page - 1) * page_size - end_idx = min(start_idx + page_size, total_items) - - result = { - 'items': filtered_data[start_idx:end_idx], - 'total': total_items, - 'page': page, - 'page_size': page_size, - 'total_pages': (total_items + page_size - 1) // page_size - } - - return result - - async def _update_recipe_metadata(self, recipe_data: Dict, original_comment: str) -> bool: + async def _update_recipe_metadata(self, recipe_data: Dict) -> bool: """Update recipe metadata with missing information Returns: @@ -587,9 +410,9 @@ class RecipeScanner: metadata_updated = True # Determine the base_model for the recipe based on loras - if recipe_data.get('loras'): + if recipe_data.get('loras') and not recipe_data.get('base_model'): base_model = await self._determine_base_model(recipe_data.get('loras', [])) - if base_model and (not recipe_data.get('base_model') or recipe_data['base_model'] != base_model): + if base_model: recipe_data['base_model'] = base_model metadata_updated = True @@ -651,22 +474,49 @@ class RecipeScanner: logger.error(f"Error getting base model for lora: {e}") return None - def _extract_recipe_metadata(self, user_comment: str) -> Optional[Dict]: - """Extract recipe metadata section from UserComment if it exists""" - try: - # Look for recipe metadata section - recipe_match = re.search(r'recipe metadata: (\{.*\})', user_comment, re.IGNORECASE | re.DOTALL) - if not recipe_match: - return None - - recipe_json = recipe_match.group(1) - recipe_data = json.loads(recipe_json) - - # Ensure loras array exists - if 'loras' not in recipe_data: - recipe_data['loras'] = [] - - return recipe_data - except Exception as e: - logger.error(f"Error extracting recipe metadata: {e}") - return None \ No newline at end of file + async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None): + """Get paginated and filtered recipe data + + Args: + page: Current page number (1-based) + page_size: Number of items per page + sort_by: Sort method ('name' or 'date') + search: Search term + """ + cache = await self.get_cached_data() + + # Get base dataset + filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name + + # Apply search filter + if search: + filtered_data = [ + item for item in filtered_data + if search.lower() in str(item.get('title', '')).lower() or + search.lower() in str(item.get('prompt', '')).lower() + ] + + # Calculate pagination + total_items = len(filtered_data) + start_idx = (page - 1) * page_size + end_idx = min(start_idx + page_size, total_items) + + # Get paginated items + paginated_items = filtered_data[start_idx:end_idx] + + # Add inLibrary information for each lora + for item in paginated_items: + if 'loras' in item: + for lora in item['loras']: + if 'hash' in lora and lora['hash']: + lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower()) + + result = { + 'items': paginated_items, + 'total': total_items, + 'page': page, + 'page_size': page_size, + 'total_pages': (total_items + page_size - 1) // page_size + } + + return result \ No newline at end of file diff --git a/refs/recipe.json b/refs/recipe.json index 2fae8487..bcf1d6b8 100644 --- a/refs/recipe.json +++ b/refs/recipe.json @@ -1,7 +1,7 @@ { - "id": "3", - "file_path": "D:/Workspace/ComfyUI/models/loras/recipes/3.jpg", - "title": "3", + "id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc", + "file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg", + "title": "a mysterious, steampunk-inspired character standing in a dramatic pose", "modified": 1741837612.3931093, "created_date": 1741492786.5581934, "base_model": "Flux.1 D", @@ -23,7 +23,7 @@ "modelVersionName": "ink-dynamic" }, { - "file_name": "", + "file_name": "ck-painterly-fantasy-000017", "hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420", "strength": 0.2, "modelVersionId": 1189379, @@ -39,7 +39,7 @@ "modelVersionName": "v1.0" }, { - "file_name": "", + "file_name": "Mezzotint_Artstyle_for_Flux_-_by_Ethanar", "hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e", "strength": 0.2, "modelVersionId": 757030, diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css index a83e5c08..7fbb3c20 100644 --- a/static/css/components/import-modal.css +++ b/static/css/components/import-modal.css @@ -395,26 +395,77 @@ opacity: 0.8; } -/* Missing LoRAs summary section */ +/* Improved Missing LoRAs summary section */ .missing-loras-summary { margin-bottom: var(--space-3); padding: var(--space-2); background: var(--bg-color); border-radius: var(--border-radius-sm); - border: 1px solid var(--lora-error); + border: 1px solid var(--border-color); } -.missing-loras-summary h3 { - margin-top: 0; - margin-bottom: var(--space-2); +.summary-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; +} + +.summary-header h3 { + margin: 0; font-size: 1.1em; color: var(--text-color); + display: flex; + align-items: center; + gap: var(--space-1); } -.total-size-info { - margin-bottom: var(--space-2); +.lora-count-badge { font-size: 0.9em; + font-weight: normal; + opacity: 0.7; +} + +.total-size-badge { + font-size: 0.85em; + font-weight: normal; + background: var(--lora-surface); + padding: 2px 8px; + border-radius: var(--border-radius-xs); + margin-left: var(--space-1); +} + +.toggle-list-btn { + background: none; + border: none; + cursor: pointer; color: var(--text-color); + padding: 4px 8px; + border-radius: var(--border-radius-xs); +} + +.toggle-list-btn:hover { + background: var(--lora-surface); +} + +.missing-loras-list { + max-height: 200px; + overflow-y: auto; + transition: max-height 0.3s ease, margin-top 0.3s ease, padding-top 0.3s ease; + margin-top: 0; + padding-top: 0; +} + +.missing-loras-list.collapsed { + max-height: 0; + overflow: hidden; + padding-top: 0; +} + +.missing-loras-list:not(.collapsed) { + margin-top: var(--space-1); + padding-top: var(--space-1); + border-top: 1px solid var(--border-color); } .missing-lora-item { @@ -429,13 +480,45 @@ border-bottom: none; } +.missing-lora-info { + display: flex; + flex-direction: column; + gap: 4px; +} + .missing-lora-name { font-weight: 500; - flex: 1; +} + +.lora-base-model { + font-size: 0.85em; + color: var(--lora-accent); + background: oklch(var(--lora-accent) / 0.1); + padding: 2px 6px; + border-radius: var(--border-radius-xs); + display: inline-block; } .missing-lora-size { font-size: 0.9em; color: var(--text-color); opacity: 0.8; +} + +/* Recipe name input select-all behavior */ +#recipeName:focus { + outline: 2px solid var(--lora-accent); +} + +/* Prevent layout shift with scrollbar */ +.modal-content { + overflow-y: scroll; /* Always show scrollbar */ + scrollbar-gutter: stable; /* Reserve space for scrollbar */ +} + +/* For browsers that don't support scrollbar-gutter */ +@supports not (scrollbar-gutter: stable) { + .modal-content { + padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */ + } } \ No newline at end of file diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index bc590183..be941af0 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -186,6 +186,11 @@ export class ImportManager { throw new Error('No LoRA information found in this image'); } + // Store generation parameters if available + if (this.recipeData.gen_params) { + console.log('Generation parameters found:', this.recipeData.gen_params); + } + // Find missing LoRAs this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); @@ -202,9 +207,24 @@ export class ImportManager { showRecipeDetailsStep() { this.showStep('detailsStep'); - // Set default recipe name from image filename + // Set default recipe name from prompt or image filename const recipeName = document.getElementById('recipeName'); - if (this.recipeImage && !recipeName.value) { + if (this.recipeData && this.recipeData.gen_params && this.recipeData.gen_params.prompt) { + // Use the first 15 words from the prompt as the default recipe name + const promptWords = this.recipeData.gen_params.prompt.split(' '); + const truncatedPrompt = promptWords.slice(0, 15).join(' '); + recipeName.value = truncatedPrompt; + this.recipeName = truncatedPrompt; + + // Set up click handler to select all text for easy editing + if (!recipeName.hasSelectAllHandler) { + recipeName.addEventListener('click', function() { + this.select(); + }); + recipeName.hasSelectAllHandler = true; + } + } else if (this.recipeImage && !recipeName.value) { + // Fallback to image filename if no prompt is available const fileName = this.recipeImage.name.split('.')[0]; recipeName.value = fileName; this.recipeName = fileName; @@ -386,17 +406,40 @@ export class ImportManager { totalSizeDisplay.textContent = this.formatFileSize(totalSize); } + // Update header to include count of missing LoRAs + const missingLorasHeader = document.querySelector('.summary-header h3'); + if (missingLorasHeader) { + missingLorasHeader.innerHTML = `Missing LoRAs (${this.missingLoras.length}) ${this.formatFileSize(totalSize)}`; + } + // Generate missing LoRAs list missingLorasList.innerHTML = this.missingLoras.map(lora => { const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size'; + const baseModel = lora.baseModel ? `${lora.baseModel}` : ''; return `
-
${lora.name}
+
+
${lora.name}
+ ${baseModel} +
${sizeDisplay}
`; }).join(''); + + // Set up toggle for missing LoRAs list + const toggleBtn = document.getElementById('toggleMissingLorasList'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + missingLorasList.classList.toggle('collapsed'); + const icon = toggleBtn.querySelector('i'); + if (icon) { + icon.classList.toggle('fa-chevron-down'); + icon.classList.toggle('fa-chevron-up'); + } + }); + } } // Fetch LoRA roots @@ -470,7 +513,16 @@ export class ImportManager { formData.append('image', this.recipeImage); formData.append('name', this.recipeName); formData.append('tags', JSON.stringify(this.recipeTags)); - formData.append('metadata', JSON.stringify(this.recipeData)); + + // Prepare complete metadata including generation parameters + const completeMetadata = { + base_model: this.recipeData.base_model || "", + loras: this.recipeData.loras || [], + gen_params: this.recipeData.gen_params || {}, + raw_metadata: this.recipeData.raw_metadata || {} + }; + + formData.append('metadata', JSON.stringify(completeMetadata)); // Send save request const response = await fetch('/api/recipes/save', { @@ -481,8 +533,7 @@ export class ImportManager { const result = await response.json(); if (result.success) { // Handle successful save - // Show success message for recipe save - showToast(`Recipe "${this.recipeName}" saved successfully`, 'success'); + // Check if we need to download LoRAs if (this.missingLoras.length > 0) { @@ -602,6 +653,9 @@ export class ImportManager { showToast(`Downloaded ${completedDownloads} of ${this.missingLoras.length} LoRAs`, 'warning'); } } + + // Show success message for recipe save + showToast(`Recipe "${this.recipeName}" saved successfully`, 'success'); // Close modal and reload recipes modalManager.closeModal('importModal'); diff --git a/templates/components/import_modal.html b/templates/components/import_modal.html index 3fb2db06..2fdc66a8 100644 --- a/templates/components/import_modal.html +++ b/templates/components/import_modal.html @@ -68,13 +68,15 @@