mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
checkpoint
This commit is contained in:
@@ -6,8 +6,10 @@ from .routes.lora_routes import LoraRoutes
|
|||||||
from .routes.api_routes import ApiRoutes
|
from .routes.api_routes import ApiRoutes
|
||||||
from .routes.recipe_routes import RecipeRoutes
|
from .routes.recipe_routes import RecipeRoutes
|
||||||
from .services.lora_scanner import LoraScanner
|
from .services.lora_scanner import LoraScanner
|
||||||
|
from .services.recipe_scanner import RecipeScanner
|
||||||
from .services.file_monitor import LoraFileMonitor
|
from .services.file_monitor import LoraFileMonitor
|
||||||
from .services.lora_cache import LoraCache
|
from .services.lora_cache import LoraCache
|
||||||
|
from .services.recipe_cache import RecipeCache
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -70,24 +72,27 @@ class LoraManager:
|
|||||||
app['lora_monitor'] = monitor
|
app['lora_monitor'] = monitor
|
||||||
|
|
||||||
# Schedule cache initialization using the application's startup handler
|
# Schedule cache initialization using the application's startup handler
|
||||||
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner))
|
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner, routes.recipe_scanner))
|
||||||
|
|
||||||
# Add cleanup
|
# Add cleanup
|
||||||
app.on_shutdown.append(cls._cleanup)
|
app.on_shutdown.append(cls._cleanup)
|
||||||
app.on_shutdown.append(ApiRoutes.cleanup)
|
app.on_shutdown.append(ApiRoutes.cleanup)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _schedule_cache_init(cls, scanner: LoraScanner):
|
async def _schedule_cache_init(cls, scanner: LoraScanner, recipe_scanner: RecipeScanner):
|
||||||
"""Schedule cache initialization in the running event loop"""
|
"""Schedule cache initialization in the running event loop"""
|
||||||
try:
|
try:
|
||||||
# 创建低优先级的初始化任务
|
# 创建低优先级的初始化任务
|
||||||
asyncio.create_task(cls._initialize_cache(scanner), name='lora_cache_init')
|
lora_task = asyncio.create_task(cls._initialize_lora_cache(scanner), name='lora_cache_init')
|
||||||
|
|
||||||
|
# Schedule recipe cache initialization with a delay to let lora scanner initialize first
|
||||||
|
recipe_task = asyncio.create_task(cls._initialize_recipe_cache(recipe_scanner, delay=2), name='recipe_cache_init')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
logger.error(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _initialize_cache(cls, scanner: LoraScanner):
|
async def _initialize_lora_cache(cls, scanner: LoraScanner):
|
||||||
"""Initialize cache in background"""
|
"""Initialize lora cache in background"""
|
||||||
try:
|
try:
|
||||||
# 设置初始缓存占位
|
# 设置初始缓存占位
|
||||||
scanner._cache = LoraCache(
|
scanner._cache = LoraCache(
|
||||||
@@ -100,7 +105,26 @@ class LoraManager:
|
|||||||
# 分阶段加载缓存
|
# 分阶段加载缓存
|
||||||
await scanner.get_cached_data(force_refresh=True)
|
await scanner.get_cached_data(force_refresh=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LoRA Manager: Error initializing cache: {e}")
|
logger.error(f"LoRA Manager: Error initializing lora cache: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _initialize_recipe_cache(cls, scanner: RecipeScanner, delay: float = 2.0):
|
||||||
|
"""Initialize recipe cache in background with a delay"""
|
||||||
|
try:
|
||||||
|
# Wait for the specified delay to let lora scanner initialize first
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# Set initial empty cache
|
||||||
|
scanner._cache = RecipeCache(
|
||||||
|
raw_data=[],
|
||||||
|
sorted_by_name=[],
|
||||||
|
sorted_by_date=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Force refresh to load the actual data
|
||||||
|
await scanner.get_cached_data(force_refresh=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LoRA Manager: Error initializing recipe cache: {e}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _cleanup(cls, app):
|
async def _cleanup(cls, app):
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class RecipeRoutes:
|
|||||||
app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
|
app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
|
||||||
app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
||||||
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
||||||
|
app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
|
||||||
|
|
||||||
# Start cache initialization
|
# Start cache initialization
|
||||||
app.on_startup.append(routes._init_cache)
|
app.on_startup.append(routes._init_cache)
|
||||||
@@ -435,8 +436,13 @@ class RecipeRoutes:
|
|||||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
# Add the new recipe directly to the cache instead of forcing a refresh
|
# Add the new recipe directly to the cache instead of forcing a refresh
|
||||||
cache = await self.recipe_scanner.get_cached_data()
|
try:
|
||||||
await cache.add_recipe(recipe_data)
|
# Use a timeout to prevent deadlocks
|
||||||
|
async with asyncio.timeout(5.0):
|
||||||
|
cache = await self.recipe_scanner.get_cached_data()
|
||||||
|
await cache.add_recipe(recipe_data)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Timeout adding recipe to cache - will be picked up on next refresh")
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -447,4 +453,49 @@ class RecipeRoutes:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving recipe: {e}", exc_info=True)
|
logger.error(f"Error saving recipe: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
"""Delete a recipe by ID"""
|
||||||
|
try:
|
||||||
|
recipe_id = request.match_info['recipe_id']
|
||||||
|
|
||||||
|
# Get recipes directory
|
||||||
|
recipes_dir = self.recipe_scanner.recipes_dir
|
||||||
|
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||||
|
return web.json_response({"error": "Recipes directory not found"}, status=404)
|
||||||
|
|
||||||
|
# Find recipe JSON file
|
||||||
|
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||||
|
if not os.path.exists(recipe_json_path):
|
||||||
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
|
# Load recipe data to get image path
|
||||||
|
with open(recipe_json_path, 'r', encoding='utf-8') as f:
|
||||||
|
recipe_data = json.load(f)
|
||||||
|
|
||||||
|
# Get image path
|
||||||
|
image_path = recipe_data.get('file_path')
|
||||||
|
|
||||||
|
# Delete recipe JSON file
|
||||||
|
os.remove(recipe_json_path)
|
||||||
|
logger.info(f"Deleted recipe JSON file: {recipe_json_path}")
|
||||||
|
|
||||||
|
# Delete recipe image if it exists
|
||||||
|
if image_path and os.path.exists(image_path):
|
||||||
|
os.remove(image_path)
|
||||||
|
logger.info(f"Deleted recipe image: {image_path}")
|
||||||
|
|
||||||
|
# Remove from cache without forcing a full refresh
|
||||||
|
try:
|
||||||
|
# Use a timeout to prevent deadlocks
|
||||||
|
async with asyncio.timeout(5.0):
|
||||||
|
cache = await self.recipe_scanner.get_cached_data(force_refresh=False)
|
||||||
|
await cache.remove_recipe(recipe_id)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Timeout removing recipe from cache - will be picked up on next refresh")
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "message": "Recipe deleted successfully"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting recipe: {e}", exc_info=True)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
@@ -605,7 +605,7 @@ class LoraScanner:
|
|||||||
|
|
||||||
# Update hash index with new path
|
# Update hash index with new path
|
||||||
if 'sha256' in metadata:
|
if 'sha256' in metadata:
|
||||||
self._hash_index.add_entry(metadata['sha256'], new_path)
|
self._hash_index.add_entry(metadata['sha256'].lower(), new_path)
|
||||||
|
|
||||||
# Update folders list
|
# Update folders list
|
||||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||||
@@ -658,7 +658,27 @@ class LoraScanner:
|
|||||||
|
|
||||||
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
|
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
|
||||||
"""Get hash for a LoRA by its file path"""
|
"""Get hash for a LoRA by its file path"""
|
||||||
return self._hash_index.get_hash(file_path)
|
return self._hash_index.get_hash(file_path)
|
||||||
|
|
||||||
|
def get_preview_url_by_hash(self, sha256: str) -> Optional[str]:
|
||||||
|
"""Get preview static URL for a LoRA by its hash"""
|
||||||
|
# Get the file path first
|
||||||
|
file_path = self._hash_index.get_path(sha256.lower())
|
||||||
|
if not file_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Determine the preview file path (typically same name with different extension)
|
||||||
|
base_name = os.path.splitext(file_path)[0]
|
||||||
|
preview_extensions = ['.preview.png', '.preview.jpeg', '.preview.jpg', '.preview.mp4',
|
||||||
|
'.png', '.jpeg', '.jpg', '.mp4']
|
||||||
|
|
||||||
|
for ext in preview_extensions:
|
||||||
|
preview_path = f"{base_name}{ext}"
|
||||||
|
if os.path.exists(preview_path):
|
||||||
|
# Convert to static URL using config
|
||||||
|
return config.get_preview_static_url(preview_path)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
# Add new method to get top tags
|
# Add new method to get top tags
|
||||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||||
|
|||||||
@@ -58,4 +58,28 @@ class RecipeCache:
|
|||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self.raw_data.append(recipe_data)
|
self.raw_data.append(recipe_data)
|
||||||
await self.resort()
|
await self.resort()
|
||||||
|
|
||||||
|
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||||
|
"""Remove a recipe from the cache by ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: The ID of the recipe to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the recipe was found and removed, False otherwise
|
||||||
|
"""
|
||||||
|
# Find the recipe in raw_data
|
||||||
|
recipe_index = next((i for i, recipe in enumerate(self.raw_data)
|
||||||
|
if recipe.get('id') == recipe_id), None)
|
||||||
|
|
||||||
|
if recipe_index is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Remove from raw_data
|
||||||
|
self.raw_data.pop(recipe_index)
|
||||||
|
|
||||||
|
# Resort to update sorted lists
|
||||||
|
await self.resort()
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -32,9 +32,12 @@ class RecipeScanner:
|
|||||||
self._cache: Optional[RecipeCache] = None
|
self._cache: Optional[RecipeCache] = None
|
||||||
self._initialization_lock = asyncio.Lock()
|
self._initialization_lock = asyncio.Lock()
|
||||||
self._initialization_task: Optional[asyncio.Task] = None
|
self._initialization_task: Optional[asyncio.Task] = None
|
||||||
|
self._is_initializing = False
|
||||||
if lora_scanner:
|
if lora_scanner:
|
||||||
self._lora_scanner = lora_scanner
|
self._lora_scanner = lora_scanner
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
|
# Initialization will be scheduled by LoraManager
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
@@ -51,86 +54,79 @@ class RecipeScanner:
|
|||||||
|
|
||||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||||
"""Get cached recipe data, refresh if needed"""
|
"""Get cached recipe data, refresh if needed"""
|
||||||
async with self._initialization_lock:
|
# If cache is already initialized and no refresh is needed, return it immediately
|
||||||
|
if self._cache is not None and not force_refresh:
|
||||||
# If cache is unitialized but needs to respond to request, return empty cache
|
|
||||||
if self._cache is None and not force_refresh:
|
|
||||||
return RecipeCache(
|
|
||||||
raw_data=[],
|
|
||||||
sorted_by_name=[],
|
|
||||||
sorted_by_date=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
# If initializing, wait for completion
|
|
||||||
if self._initialization_task and not self._initialization_task.done():
|
|
||||||
try:
|
|
||||||
await self._initialization_task
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Recipe cache initialization failed: {e}")
|
|
||||||
self._initialization_task = None
|
|
||||||
|
|
||||||
if (self._cache is None or force_refresh):
|
|
||||||
|
|
||||||
# Create new initialization task
|
|
||||||
if not self._initialization_task or self._initialization_task.done():
|
|
||||||
# First ensure the lora scanner is initialized
|
|
||||||
if self._lora_scanner:
|
|
||||||
await self._lora_scanner.get_cached_data()
|
|
||||||
|
|
||||||
self._initialization_task = asyncio.create_task(self._initialize_cache())
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._initialization_task
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Recipe cache initialization failed: {e}")
|
|
||||||
# If cache already exists, continue using old cache
|
|
||||||
if self._cache is None:
|
|
||||||
raise # If no cache, raise exception
|
|
||||||
|
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
async def _initialize_cache(self) -> None:
|
|
||||||
"""Initialize or refresh the cache"""
|
|
||||||
try:
|
|
||||||
# Ensure lora scanner is fully initialized first
|
|
||||||
if self._lora_scanner:
|
|
||||||
logger.info("Recipe Manager: Waiting for lora scanner initialization to complete")
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
# Verify hash index is built
|
|
||||||
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")
|
|
||||||
else:
|
|
||||||
logger.warning("Recipe Manager: No lora hash index available")
|
|
||||||
else:
|
|
||||||
logger.warning("Recipe Manager: No lora scanner available")
|
|
||||||
|
|
||||||
# Scan for recipe data
|
|
||||||
raw_data = await self.scan_all_recipes()
|
|
||||||
|
|
||||||
# Update cache
|
|
||||||
self._cache = RecipeCache(
|
|
||||||
raw_data=raw_data,
|
|
||||||
sorted_by_name=[],
|
|
||||||
sorted_by_date=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resort cache
|
|
||||||
await self._cache.resort()
|
|
||||||
|
|
||||||
self._initialization_task = None
|
# If another initialization is already in progress, wait for it to complete
|
||||||
logger.info("Recipe Manager: Cache initialization completed")
|
if self._is_initializing and not force_refresh:
|
||||||
|
logger.info("Initialization already in progress, returning current cache state")
|
||||||
|
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||||
|
|
||||||
|
# Try to acquire the lock with a timeout to prevent deadlocks
|
||||||
|
try:
|
||||||
|
# Use a timeout for acquiring the lock
|
||||||
|
async with asyncio.timeout(1.0):
|
||||||
|
async with self._initialization_lock:
|
||||||
|
# Check again after acquiring the lock
|
||||||
|
if self._cache is not None and not force_refresh:
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
# Mark as initializing to prevent concurrent initializations
|
||||||
|
self._is_initializing = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First ensure the lora scanner is initialized
|
||||||
|
if self._lora_scanner:
|
||||||
|
try:
|
||||||
|
logger.info("Recipe Manager: Waiting for lora scanner initialization")
|
||||||
|
lora_cache = await asyncio.wait_for(
|
||||||
|
self._lora_scanner.get_cached_data(),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("Timeout waiting for lora scanner initialization")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error waiting for lora scanner: {e}")
|
||||||
|
|
||||||
|
# Scan for recipe data
|
||||||
|
logger.info("Recipe Manager: Starting recipe scan")
|
||||||
|
raw_data = await self.scan_all_recipes()
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
self._cache = RecipeCache(
|
||||||
|
raw_data=raw_data,
|
||||||
|
sorted_by_name=[],
|
||||||
|
sorted_by_date=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resort cache
|
||||||
|
await self._cache.resort()
|
||||||
|
|
||||||
|
logger.info(f"Recipe Manager: Cache initialization completed with {len(raw_data)} recipes")
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True)
|
||||||
|
# Create empty cache on error
|
||||||
|
self._cache = RecipeCache(
|
||||||
|
raw_data=[],
|
||||||
|
sorted_by_name=[],
|
||||||
|
sorted_by_date=[]
|
||||||
|
)
|
||||||
|
return self._cache
|
||||||
|
finally:
|
||||||
|
# Mark initialization as complete
|
||||||
|
self._is_initializing = False
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# If we can't acquire the lock in time, return the current cache or an empty one
|
||||||
|
logger.warning("Timeout acquiring initialization lock - returning current cache state")
|
||||||
|
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True)
|
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||||
self._cache = RecipeCache(
|
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||||
raw_data=[],
|
|
||||||
sorted_by_name=[],
|
|
||||||
sorted_by_date=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scan_all_recipes(self) -> List[Dict]:
|
async def scan_all_recipes(self) -> List[Dict]:
|
||||||
"""Scan all recipe JSON files and return metadata"""
|
"""Scan all recipe JSON files and return metadata"""
|
||||||
@@ -215,35 +211,6 @@ class RecipeScanner:
|
|||||||
traceback.print_exc(file=sys.stderr)
|
traceback.print_exc(file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _create_basic_recipe_data(self, image_path: str) -> Dict:
|
|
||||||
"""Create basic recipe data from file information"""
|
|
||||||
file_name = os.path.basename(image_path)
|
|
||||||
title = os.path.splitext(file_name)[0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
'file_path': image_path.replace(os.sep, '/'),
|
|
||||||
'title': title,
|
|
||||||
'file_name': file_name,
|
|
||||||
'modified': os.path.getmtime(image_path),
|
|
||||||
'created_date': os.path.getctime(image_path),
|
|
||||||
'loras': []
|
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_created_date(self, user_comment: str) -> Optional[float]:
|
|
||||||
"""Extract creation date from UserComment if present"""
|
|
||||||
try:
|
|
||||||
# Look for Created Date pattern
|
|
||||||
created_date_match = re.search(r'Created Date: ([^,}]+)', user_comment)
|
|
||||||
if created_date_match:
|
|
||||||
date_str = created_date_match.group(1).strip()
|
|
||||||
# Parse ISO format date
|
|
||||||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
|
||||||
return dt.timestamp()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error extracting creation date: {e}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _update_lora_information(self, recipe_data: Dict) -> bool:
|
async def _update_lora_information(self, recipe_data: Dict) -> bool:
|
||||||
"""Update LoRA information with hash and file_name
|
"""Update LoRA information with hash and file_name
|
||||||
|
|
||||||
@@ -510,6 +477,7 @@ class RecipeScanner:
|
|||||||
for lora in item['loras']:
|
for lora in item['loras']:
|
||||||
if 'hash' in lora and lora['hash']:
|
if 'hash' in lora and lora['hash']:
|
||||||
lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower())
|
lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower())
|
||||||
|
lora['preview_url'] = self._lora_scanner.get_preview_url_by_hash(lora['hash'].lower())
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'items': paginated_items,
|
'items': paginated_items,
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
.recipe-card {
|
.recipe-tag-container {
|
||||||
background: var(--lora-surface);
|
display: flex;
|
||||||
border: 1px solid var(--lora-border);
|
flex-wrap: wrap;
|
||||||
border-radius: var(--border-radius-base);
|
gap: 0.5rem;
|
||||||
backdrop-filter: blur(16px);
|
margin-bottom: 1rem;
|
||||||
transition: transform 160ms ease-out;
|
}
|
||||||
aspect-ratio: 896/1152;
|
|
||||||
max-width: 260px;
|
.recipe-tag {
|
||||||
margin: 0 auto;
|
background: var(--lora-surface-hover);
|
||||||
position: relative;
|
color: var(--lora-text-secondary);
|
||||||
overflow: hidden;
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-tag:hover, .recipe-tag.active {
|
||||||
|
background: var(--lora-primary);
|
||||||
|
color: var(--lora-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-card:hover {
|
.recipe-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-3px);
|
||||||
background: oklch(100% 0 0 / 0.6);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-card:focus-visible {
|
.recipe-card:focus-visible {
|
||||||
@@ -28,7 +48,7 @@
|
|||||||
left: 8px;
|
left: 8px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: var(--lora-accent);
|
background: var(--lora-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -36,20 +56,21 @@
|
|||||||
color: white;
|
color: white;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
font-size: 0.9em;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-grid {
|
.recipe-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
gap: 12px;
|
gap: 1.5rem;
|
||||||
margin-top: var(--space-2);
|
margin-top: 1.5rem;
|
||||||
padding-top: 4px;
|
}
|
||||||
padding-bottom: 4px;
|
|
||||||
max-width: 1400px;
|
.placeholder-message {
|
||||||
margin-left: auto;
|
grid-column: 1 / -1;
|
||||||
margin-right: auto;
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--lora-surface-alt);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-preview {
|
.card-preview {
|
||||||
@@ -129,6 +150,15 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-count.ready {
|
||||||
|
background: rgba(46, 204, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-count.missing {
|
||||||
|
background: rgba(231, 76, 60, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
|
|||||||
367
static/css/components/recipe-modal.css
Normal file
367
static/css/components/recipe-modal.css
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
.recipe-modal-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Section: Preview and Gen Params */
|
||||||
|
.recipe-top-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe Preview */
|
||||||
|
.recipe-preview-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 360px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-preview-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generation Parameters */
|
||||||
|
.recipe-gen-params {
|
||||||
|
height: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-gen-params h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: var(--text-color);
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-params-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-header label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-content {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: var(--space-2);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Other Parameters */
|
||||||
|
.other-params {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-tag {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-tag .param-name {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Section: Resources */
|
||||||
|
.recipe-bottom-section {
|
||||||
|
max-height: 340px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-section-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-status.ready {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-status.missing {
|
||||||
|
background: oklch(var(--lora-error) / 0.1);
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-status i {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-section-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#recipeLorasCount {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recipeLorasCount i {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LoRAs List */
|
||||||
|
.recipe-loras-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-item.exists-locally {
|
||||||
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
|
border-left: 4px solid var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-item.missing-locally {
|
||||||
|
border-left: 4px solid var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-thumbnail {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-content h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
color: var(--text-color);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-info .base-model {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-version {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-weight {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-badge i {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--lora-error);
|
||||||
|
color: white;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-badge i {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-path {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.85em;
|
||||||
|
z-index: 10;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-badge:hover .local-path {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.recipe-top-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-preview-container {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-gen-params {
|
||||||
|
height: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
230
static/js/components/RecipeCard.js
Normal file
230
static/js/components/RecipeCard.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// Recipe Card Component
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
class RecipeCard {
|
||||||
|
constructor(recipe, clickHandler) {
|
||||||
|
this.recipe = recipe;
|
||||||
|
this.clickHandler = clickHandler;
|
||||||
|
this.element = this.createCardElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
createCardElement() {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'recipe-card';
|
||||||
|
card.dataset.filePath = this.recipe.file_path;
|
||||||
|
card.dataset.title = this.recipe.title;
|
||||||
|
card.dataset.created = this.recipe.created_date;
|
||||||
|
card.dataset.id = this.recipe.id || '';
|
||||||
|
|
||||||
|
// Get base model
|
||||||
|
const baseModel = this.recipe.base_model || '';
|
||||||
|
|
||||||
|
// Ensure loras array exists
|
||||||
|
const loras = this.recipe.loras || [];
|
||||||
|
const lorasCount = loras.length;
|
||||||
|
|
||||||
|
// Check if all LoRAs are available in the library
|
||||||
|
const missingLorasCount = loras.filter(lora => !lora.inLibrary).length;
|
||||||
|
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||||
|
|
||||||
|
// Ensure file_url exists, fallback to file_path if needed
|
||||||
|
const imageUrl = this.recipe.file_url ||
|
||||||
|
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
||||||
|
'/loras_static/images/no-preview.png');
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="recipe-indicator" title="Recipe">R</div>
|
||||||
|
<div class="card-preview">
|
||||||
|
<img src="${imageUrl}" alt="${this.recipe.title}">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="base-model-wrapper">
|
||||||
|
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
||||||
|
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
|
||||||
|
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="model-info">
|
||||||
|
<span class="model-name">${this.recipe.title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||||
|
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||||
|
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.attachEventListeners(card);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLoraStatusTitle(totalCount, missingCount) {
|
||||||
|
if (totalCount === 0) return "No LoRAs in this recipe";
|
||||||
|
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
||||||
|
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners(card) {
|
||||||
|
// Recipe card click event
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
this.clickHandler(this.recipe);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Share button click event - prevent propagation to card
|
||||||
|
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// TODO: Implement share functionality
|
||||||
|
showToast('Share functionality will be implemented later', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy button click event - prevent propagation to card
|
||||||
|
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.copyRecipeSyntax();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete button click event - prevent propagation to card
|
||||||
|
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.showDeleteConfirmation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyRecipeSyntax() {
|
||||||
|
try {
|
||||||
|
// Generate recipe syntax in the format <lora:file_name:strength> separated by spaces
|
||||||
|
const loras = this.recipe.loras || [];
|
||||||
|
if (loras.length === 0) {
|
||||||
|
showToast('No LoRAs in this recipe to copy', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syntax = loras.map(lora => {
|
||||||
|
// Use file_name if available, otherwise use empty placeholder
|
||||||
|
const fileName = lora.file_name || '[missing-lora]';
|
||||||
|
const strength = lora.strength || 1.0;
|
||||||
|
return `<lora:${fileName}:${strength}>`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
navigator.clipboard.writeText(syntax)
|
||||||
|
.then(() => {
|
||||||
|
showToast('Recipe syntax copied to clipboard', 'success');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
showToast('Failed to copy recipe syntax', 'error');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying recipe syntax:', error);
|
||||||
|
showToast('Error copying recipe syntax', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmation() {
|
||||||
|
try {
|
||||||
|
// Get recipe ID
|
||||||
|
const recipeId = this.recipe.id;
|
||||||
|
if (!recipeId) {
|
||||||
|
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up delete modal content
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
const deleteMessage = deleteModal.querySelector('.delete-message');
|
||||||
|
const deleteModelInfo = deleteModal.querySelector('.delete-model-info');
|
||||||
|
|
||||||
|
// Update modal content
|
||||||
|
deleteMessage.textContent = 'Are you sure you want to delete this recipe?';
|
||||||
|
deleteModelInfo.innerHTML = `
|
||||||
|
<div class="delete-preview">
|
||||||
|
<img src="${this.recipe.file_url || '/loras_static/images/no-preview.png'}" alt="${this.recipe.title}">
|
||||||
|
</div>
|
||||||
|
<div class="delete-info">
|
||||||
|
<h3>${this.recipe.title}</h3>
|
||||||
|
<p>This action cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Store recipe ID in the modal for the delete confirmation handler
|
||||||
|
deleteModal.dataset.recipeId = recipeId;
|
||||||
|
|
||||||
|
// Update the confirm delete button to use recipe delete handler
|
||||||
|
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||||
|
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
deleteModal.style.display = 'flex';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error showing delete confirmation:', error);
|
||||||
|
showToast('Error showing delete confirmation', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDeleteRecipe() {
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
const recipeId = deleteModal.dataset.recipeId;
|
||||||
|
|
||||||
|
if (!recipeId) {
|
||||||
|
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
||||||
|
closeDeleteModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||||
|
const originalText = deleteBtn.textContent;
|
||||||
|
deleteBtn.textContent = 'Deleting...';
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
|
||||||
|
// Call API to delete the recipe
|
||||||
|
fetch(`/api/recipe/${recipeId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete recipe');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
showToast('Recipe deleted successfully', 'success');
|
||||||
|
|
||||||
|
// Refresh the recipe list if we're on the recipes page
|
||||||
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
|
window.recipeManager.loadRecipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDeleteModal();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting recipe:', error);
|
||||||
|
showToast('Error deleting recipe: ' + error.message, 'error');
|
||||||
|
|
||||||
|
// Reset button state
|
||||||
|
deleteBtn.textContent = originalText;
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDeleteModal() {
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
deleteModal.style.display = 'none';
|
||||||
|
|
||||||
|
// Reset the delete button handler
|
||||||
|
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||||
|
deleteBtn.textContent = 'Delete';
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
deleteBtn.onclick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RecipeCard };
|
||||||
205
static/js/components/RecipeModal.js
Normal file
205
static/js/components/RecipeModal.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Recipe Modal Component
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
class RecipeModal {
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.setupCopyButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
showRecipeDetails(recipe) {
|
||||||
|
console.log(recipe);
|
||||||
|
// Set modal title
|
||||||
|
const modalTitle = document.getElementById('recipeModalTitle');
|
||||||
|
if (modalTitle) {
|
||||||
|
modalTitle.textContent = recipe.title || 'Recipe Details';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set recipe image
|
||||||
|
const modalImage = document.getElementById('recipeModalImage');
|
||||||
|
if (modalImage) {
|
||||||
|
// 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');
|
||||||
|
modalImage.src = imageUrl;
|
||||||
|
modalImage.alt = recipe.title || 'Recipe Preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set generation parameters
|
||||||
|
const promptElement = document.getElementById('recipePrompt');
|
||||||
|
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||||
|
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||||
|
|
||||||
|
if (recipe.gen_params) {
|
||||||
|
// Set prompt
|
||||||
|
if (promptElement && recipe.gen_params.prompt) {
|
||||||
|
promptElement.textContent = recipe.gen_params.prompt;
|
||||||
|
} else if (promptElement) {
|
||||||
|
promptElement.textContent = 'No prompt information available';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set negative prompt
|
||||||
|
if (negativePromptElement && recipe.gen_params.negative_prompt) {
|
||||||
|
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
|
||||||
|
} else if (negativePromptElement) {
|
||||||
|
negativePromptElement.textContent = 'No negative prompt information available';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set other parameters
|
||||||
|
if (otherParamsElement) {
|
||||||
|
// Clear previous params
|
||||||
|
otherParamsElement.innerHTML = '';
|
||||||
|
|
||||||
|
// Add all other parameters except prompt and negative_prompt
|
||||||
|
const excludedParams = ['prompt', 'negative_prompt'];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(recipe.gen_params)) {
|
||||||
|
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||||
|
const paramTag = document.createElement('div');
|
||||||
|
paramTag.className = 'param-tag';
|
||||||
|
paramTag.innerHTML = `
|
||||||
|
<span class="param-name">${key}:</span>
|
||||||
|
<span class="param-value">${value}</span>
|
||||||
|
`;
|
||||||
|
otherParamsElement.appendChild(paramTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no other params, show a message
|
||||||
|
if (otherParamsElement.children.length === 0) {
|
||||||
|
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No generation parameters available
|
||||||
|
if (promptElement) promptElement.textContent = 'No prompt information available';
|
||||||
|
if (negativePromptElement) negativePromptElement.textContent = 'No negative prompt information available';
|
||||||
|
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set LoRAs list and count
|
||||||
|
const lorasListElement = document.getElementById('recipeLorasList');
|
||||||
|
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||||
|
|
||||||
|
// 检查所有 LoRAs 是否都在库中
|
||||||
|
let allLorasAvailable = true;
|
||||||
|
let missingLorasCount = 0;
|
||||||
|
|
||||||
|
if (recipe.loras && recipe.loras.length > 0) {
|
||||||
|
recipe.loras.forEach(lora => {
|
||||||
|
if (!lora.inLibrary) {
|
||||||
|
allLorasAvailable = false;
|
||||||
|
missingLorasCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 LoRAs 计数和状态
|
||||||
|
if (lorasCountElement && recipe.loras) {
|
||||||
|
const totalCount = recipe.loras.length;
|
||||||
|
|
||||||
|
// 创建状态指示器
|
||||||
|
let statusHTML = '';
|
||||||
|
if (totalCount > 0) {
|
||||||
|
if (allLorasAvailable) {
|
||||||
|
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||||||
|
} else {
|
||||||
|
statusHTML = `<div class="recipe-status missing"><i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
||||||
|
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
||||||
|
const existsLocally = lora.inLibrary;
|
||||||
|
const localPath = lora.localPath || '';
|
||||||
|
|
||||||
|
// Create local status badge
|
||||||
|
const localStatus = existsLocally ?
|
||||||
|
`<div class="local-badge">
|
||||||
|
<i class="fas fa-check"></i> In Library
|
||||||
|
<div class="local-path">${localPath}</div>
|
||||||
|
</div>` :
|
||||||
|
`<div class="missing-badge">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="recipe-lora-item ${existsLocally ? 'exists-locally' : 'missing-locally'}">
|
||||||
|
<div class="recipe-lora-thumbnail">
|
||||||
|
<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">
|
||||||
|
</div>
|
||||||
|
<div class="recipe-lora-content">
|
||||||
|
<div class="recipe-lora-header">
|
||||||
|
<h4>${lora.modelName}</h4>
|
||||||
|
${localStatus}
|
||||||
|
</div>
|
||||||
|
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
||||||
|
<div class="recipe-lora-info">
|
||||||
|
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||||
|
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Generate recipe syntax for copy button
|
||||||
|
this.recipeLorasSyntax = recipe.loras.map(lora =>
|
||||||
|
`<lora:${lora.file_name}:${lora.strength || 1.0}>`
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
} else if (lorasListElement) {
|
||||||
|
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||||
|
this.recipeLorasSyntax = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
modalManager.showModal('recipeModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup copy buttons for prompts and recipe syntax
|
||||||
|
setupCopyButtons() {
|
||||||
|
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||||
|
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||||||
|
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
||||||
|
|
||||||
|
if (copyPromptBtn) {
|
||||||
|
copyPromptBtn.addEventListener('click', () => {
|
||||||
|
const promptText = document.getElementById('recipePrompt').textContent;
|
||||||
|
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyNegativePromptBtn) {
|
||||||
|
copyNegativePromptBtn.addEventListener('click', () => {
|
||||||
|
const negativePromptText = document.getElementById('recipeNegativePrompt').textContent;
|
||||||
|
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyRecipeSyntaxBtn) {
|
||||||
|
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
||||||
|
this.copyToClipboard(this.recipeLorasSyntax, 'Recipe syntax copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to copy text to clipboard
|
||||||
|
copyToClipboard(text, successMessage) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast(successMessage, 'success');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
showToast('Failed to copy text', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RecipeModal };
|
||||||
@@ -175,6 +175,8 @@ export class ImportManager {
|
|||||||
|
|
||||||
// Get recipe data from response
|
// Get recipe data from response
|
||||||
this.recipeData = await response.json();
|
this.recipeData = await response.json();
|
||||||
|
|
||||||
|
console.log('Recipe data:', this.recipeData);
|
||||||
|
|
||||||
// Check if we have an error message
|
// Check if we have an error message
|
||||||
if (this.recipeData.error) {
|
if (this.recipeData.error) {
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ export class ModalManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add recipeModal registration
|
||||||
|
this.registerModal('recipeModal', {
|
||||||
|
element: document.getElementById('recipeModal'),
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('recipeModal').element.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set up event listeners for modal toggles
|
// Set up event listeners for modal toggles
|
||||||
const supportToggle = document.getElementById('supportToggleBtn');
|
const supportToggle = document.getElementById('supportToggleBtn');
|
||||||
if (supportToggle) {
|
if (supportToggle) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { showToast } from './utils/uiHelpers.js';
|
|||||||
import { state } from './state/index.js';
|
import { state } from './state/index.js';
|
||||||
import { initializeCommonComponents } from './common.js';
|
import { initializeCommonComponents } from './common.js';
|
||||||
import { ImportManager } from './managers/ImportManager.js';
|
import { ImportManager } from './managers/ImportManager.js';
|
||||||
|
import { RecipeCard } from './components/RecipeCard.js';
|
||||||
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -14,6 +16,9 @@ class RecipeManager {
|
|||||||
// Initialize ImportManager
|
// Initialize ImportManager
|
||||||
this.importManager = new ImportManager();
|
this.importManager = new ImportManager();
|
||||||
|
|
||||||
|
// Initialize RecipeModal
|
||||||
|
this.recipeModal = new RecipeModal();
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,97 +126,15 @@ class RecipeManager {
|
|||||||
|
|
||||||
// Create recipe cards
|
// Create recipe cards
|
||||||
data.items.forEach(recipe => {
|
data.items.forEach(recipe => {
|
||||||
const card = this.createRecipeCard(recipe);
|
const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe));
|
||||||
grid.appendChild(card);
|
grid.appendChild(recipeCard.element);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createRecipeCard(recipe) {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'recipe-card';
|
|
||||||
card.dataset.filePath = recipe.file_path;
|
|
||||||
card.dataset.title = recipe.title;
|
|
||||||
card.dataset.created = recipe.created_date;
|
|
||||||
|
|
||||||
console.log(recipe);
|
|
||||||
|
|
||||||
// 获取 base model
|
|
||||||
const baseModel = recipe.base_model || '';
|
|
||||||
|
|
||||||
// 确保 loras 数组存在
|
|
||||||
const lorasCount = recipe.loras ? recipe.loras.length : 0;
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="recipe-indicator" title="Recipe">R</div>
|
|
||||||
<div class="card-preview">
|
|
||||||
<img src="${imageUrl}" alt="${recipe.title}">
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="base-model-wrapper">
|
|
||||||
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="card-actions">
|
|
||||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
|
||||||
<i class="fas fa-copy" title="Copy Recipe"></i>
|
|
||||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<div class="model-info">
|
|
||||||
<span class="model-name">${recipe.title}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lora-count" title="Number of LoRAs in this recipe">
|
|
||||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Recipe card click event
|
|
||||||
card.addEventListener('click', () => {
|
|
||||||
this.showRecipeDetails(recipe);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Share button click event - prevent propagation to card
|
|
||||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// TODO: Implement share functionality
|
|
||||||
showToast('Share functionality will be implemented later', 'info');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy button click event - prevent propagation to card
|
|
||||||
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// TODO: Implement copy functionality
|
|
||||||
showToast('Copy functionality will be implemented later', 'info');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete button click event - prevent propagation to card
|
|
||||||
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// TODO: Implement delete functionality
|
|
||||||
showToast('Delete functionality will be implemented later', 'info');
|
|
||||||
});
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a placeholder for recipe details method
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
// TODO: Implement recipe details view
|
this.recipeModal.showRecipeDetails(recipe);
|
||||||
console.log('Recipe details:', recipe);
|
|
||||||
showToast(`Viewing ${recipe.title}`, 'info');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will be implemented later:
|
|
||||||
// - Recipe details view
|
|
||||||
// - Recipe tag filtering
|
|
||||||
// - Recipe search and filters
|
|
||||||
|
|
||||||
// Add a method to handle recipe import
|
// Add a method to handle recipe import
|
||||||
importRecipes() {
|
importRecipes() {
|
||||||
this.importManager.showImportModal();
|
this.importManager.showImportModal();
|
||||||
|
|||||||
63
templates/components/recipe_modal.html
Normal file
63
templates/components/recipe_modal.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<div id="recipeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="close" onclick="modalManager.closeModal('recipeModal')">×</button>
|
||||||
|
|
||||||
|
<header class="recipe-modal-header">
|
||||||
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Top Section: Preview and Generation Parameters -->
|
||||||
|
<div class="recipe-top-section">
|
||||||
|
<div class="recipe-preview-container">
|
||||||
|
<img id="recipeModalImage" src="" alt="Recipe Preview">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section recipe-gen-params">
|
||||||
|
<h3>Generation Parameters</h3>
|
||||||
|
|
||||||
|
<div class="gen-params-container">
|
||||||
|
<!-- Prompt -->
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-header">
|
||||||
|
<label>Prompt</label>
|
||||||
|
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="param-content" id="recipePrompt"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Negative Prompt -->
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-header">
|
||||||
|
<label>Negative Prompt</label>
|
||||||
|
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Other Parameters -->
|
||||||
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Section: Resources -->
|
||||||
|
<div class="info-section recipe-bottom-section">
|
||||||
|
<div class="recipe-section-header">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div class="recipe-section-actions">
|
||||||
|
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
|
||||||
|
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/loras_static/css/style.css">
|
<link rel="stylesheet" href="/loras_static/css/style.css">
|
||||||
<link rel="stylesheet" href="/loras_static/css/components/recipe-card.css">
|
<link rel="stylesheet" href="/loras_static/css/components/recipe-card.css">
|
||||||
|
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css">
|
||||||
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css">
|
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
|
||||||
@@ -33,79 +34,6 @@
|
|||||||
|
|
||||||
<!-- Resource loading strategy -->
|
<!-- Resource loading strategy -->
|
||||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Recipe-specific styles */
|
|
||||||
.recipe-tag-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tag {
|
|
||||||
background: var(--lora-surface-hover);
|
|
||||||
color: var(--lora-text-secondary);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tag:hover, .recipe-tag.active {
|
|
||||||
background: var(--lora-primary);
|
|
||||||
color: var(--lora-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-card {
|
|
||||||
position: relative;
|
|
||||||
background: var(--lora-surface);
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-card:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: var(--lora-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-message {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
background: var(--lora-surface-alt);
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include 'components/header.html' %}
|
{% include 'components/header.html' %}
|
||||||
@@ -115,6 +43,7 @@
|
|||||||
{% include 'components/loading.html' %}
|
{% include 'components/loading.html' %}
|
||||||
{% include 'components/context_menu.html' %}
|
{% include 'components/context_menu.html' %}
|
||||||
{% include 'components/import_modal.html' %}
|
{% include 'components/import_modal.html' %}
|
||||||
|
{% include 'components/recipe_modal.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% if is_initializing %}
|
{% if is_initializing %}
|
||||||
|
|||||||
Reference in New Issue
Block a user