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 `