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.

This commit is contained in:
Will Miao
2025-03-15 20:08:26 +08:00
parent 9f2289329c
commit edd36427ac
7 changed files with 351 additions and 286 deletions

View File

@@ -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,

View File

@@ -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
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()

View File

@@ -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
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

View File

@@ -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,

View File

@@ -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 */
}
}

View File

@@ -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 <span class="lora-count-badge">(${this.missingLoras.length})</span> <span id="totalDownloadSize" class="total-size-badge">${this.formatFileSize(totalSize)}</span>`;
}
// Generate missing LoRAs list
missingLorasList.innerHTML = this.missingLoras.map(lora => {
const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size';
const baseModel = lora.baseModel ? `<span class="lora-base-model">${lora.baseModel}</span>` : '';
return `
<div class="missing-lora-item">
<div class="missing-lora-name">${lora.name}</div>
<div class="missing-lora-info">
<div class="missing-lora-name">${lora.name}</div>
${baseModel}
</div>
<div class="missing-lora-size">${sizeDisplay}</div>
</div>
`;
}).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');

View File

@@ -68,13 +68,15 @@
<!-- Step 3: Download Location (if needed) -->
<div class="import-step" id="locationStep" style="display: none;">
<div class="location-selection">
<!-- Add missing LoRAs summary section -->
<!-- Improved missing LoRAs summary section -->
<div class="missing-loras-summary">
<h3>Missing LoRAs to Download</h3>
<div class="total-size-info">
Total download size: <span id="totalDownloadSize">Calculating...</span>
<div class="summary-header">
<h3>Missing LoRAs <span class="lora-count-badge">(0)</span> <span id="totalDownloadSize" class="total-size-badge">Calculating...</span></h3>
<button id="toggleMissingLorasList" class="toggle-list-btn">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div id="missingLorasList" class="missing-loras-list">
<div id="missingLorasList" class="missing-loras-list collapsed">
<!-- Missing LoRAs will be populated here -->
</div>
</div>