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.recipe_routes import RecipeRoutes
|
||||
from .services.lora_scanner import LoraScanner
|
||||
from .services.recipe_scanner import RecipeScanner
|
||||
from .services.file_monitor import LoraFileMonitor
|
||||
from .services.lora_cache import LoraCache
|
||||
from .services.recipe_cache import RecipeCache
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -70,24 +72,27 @@ class LoraManager:
|
||||
app['lora_monitor'] = monitor
|
||||
|
||||
# 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
|
||||
app.on_shutdown.append(cls._cleanup)
|
||||
app.on_shutdown.append(ApiRoutes.cleanup)
|
||||
|
||||
@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"""
|
||||
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:
|
||||
print(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||
logger.error(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _initialize_cache(cls, scanner: LoraScanner):
|
||||
"""Initialize cache in background"""
|
||||
async def _initialize_lora_cache(cls, scanner: LoraScanner):
|
||||
"""Initialize lora cache in background"""
|
||||
try:
|
||||
# 设置初始缓存占位
|
||||
scanner._cache = LoraCache(
|
||||
@@ -100,7 +105,26 @@ class LoraManager:
|
||||
# 分阶段加载缓存
|
||||
await scanner.get_cached_data(force_refresh=True)
|
||||
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
|
||||
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_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
||||
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
||||
app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
|
||||
|
||||
# Start cache initialization
|
||||
app.on_startup.append(routes._init_cache)
|
||||
@@ -435,8 +436,13 @@ class RecipeRoutes:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
# 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)
|
||||
try:
|
||||
# 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({
|
||||
'success': True,
|
||||
@@ -447,4 +453,49 @@ class RecipeRoutes:
|
||||
|
||||
except Exception as e:
|
||||
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)
|
||||
@@ -605,7 +605,7 @@ class LoraScanner:
|
||||
|
||||
# Update hash index with new path
|
||||
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
|
||||
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]:
|
||||
"""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
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
|
||||
@@ -58,4 +58,28 @@ class RecipeCache:
|
||||
"""
|
||||
async with self._lock:
|
||||
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._initialization_lock = asyncio.Lock()
|
||||
self._initialization_task: Optional[asyncio.Task] = None
|
||||
self._is_initializing = False
|
||||
if lora_scanner:
|
||||
self._lora_scanner = lora_scanner
|
||||
self._initialized = True
|
||||
|
||||
# Initialization will be scheduled by LoraManager
|
||||
|
||||
@property
|
||||
def recipes_dir(self) -> str:
|
||||
@@ -51,86 +54,79 @@ class RecipeScanner:
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||
"""Get cached recipe data, refresh if needed"""
|
||||
async with self._initialization_lock:
|
||||
|
||||
# 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
|
||||
|
||||
# If cache is already initialized and no refresh is needed, return it immediately
|
||||
if self._cache is not None and not force_refresh:
|
||||
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
|
||||
logger.info("Recipe Manager: Cache initialization completed")
|
||||
# If another initialization is already in progress, wait for it to complete
|
||||
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:
|
||||
logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True)
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||
|
||||
async def scan_all_recipes(self) -> List[Dict]:
|
||||
"""Scan all recipe JSON files and return metadata"""
|
||||
@@ -215,35 +211,6 @@ class RecipeScanner:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
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:
|
||||
"""Update LoRA information with hash and file_name
|
||||
|
||||
@@ -510,6 +477,7 @@ class RecipeScanner:
|
||||
for lora in item['loras']:
|
||||
if 'hash' in lora and lora['hash']:
|
||||
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 = {
|
||||
'items': paginated_items,
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
.recipe-card {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-base);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: transform 160ms ease-out;
|
||||
aspect-ratio: 896/1152;
|
||||
max-width: 260px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
.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(-2px);
|
||||
background: oklch(100% 0 0 / 0.6);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.recipe-card:focus-visible {
|
||||
@@ -28,7 +48,7 @@
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--lora-accent);
|
||||
background: var(--lora-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -36,20 +56,21 @@
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 2;
|
||||
font-size: 0.9em;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: var(--space-2);
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
max-width: 1400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
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);
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
@@ -129,6 +150,15 @@
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
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
|
||||
this.recipeData = await response.json();
|
||||
|
||||
console.log('Recipe data:', this.recipeData);
|
||||
|
||||
// Check if we have an error message
|
||||
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
|
||||
const supportToggle = document.getElementById('supportToggleBtn');
|
||||
if (supportToggle) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { showToast } from './utils/uiHelpers.js';
|
||||
import { state } from './state/index.js';
|
||||
import { initializeCommonComponents } from './common.js';
|
||||
import { ImportManager } from './managers/ImportManager.js';
|
||||
import { RecipeCard } from './components/RecipeCard.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
|
||||
class RecipeManager {
|
||||
constructor() {
|
||||
@@ -14,6 +16,9 @@ class RecipeManager {
|
||||
// Initialize ImportManager
|
||||
this.importManager = new ImportManager();
|
||||
|
||||
// Initialize RecipeModal
|
||||
this.recipeModal = new RecipeModal();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -121,97 +126,15 @@ class RecipeManager {
|
||||
|
||||
// Create recipe cards
|
||||
data.items.forEach(recipe => {
|
||||
const card = this.createRecipeCard(recipe);
|
||||
grid.appendChild(card);
|
||||
const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe));
|
||||
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) {
|
||||
// TODO: Implement recipe details view
|
||||
console.log('Recipe details:', recipe);
|
||||
showToast(`Viewing ${recipe.title}`, 'info');
|
||||
this.recipeModal.showRecipeDetails(recipe);
|
||||
}
|
||||
|
||||
// Will be implemented later:
|
||||
// - Recipe details view
|
||||
// - Recipe tag filtering
|
||||
// - Recipe search and filters
|
||||
|
||||
// Add a method to handle recipe import
|
||||
importRecipes() {
|
||||
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">
|
||||
<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-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="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
|
||||
@@ -33,79 +34,6 @@
|
||||
|
||||
<!-- Resource loading strategy -->
|
||||
<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>
|
||||
<body>
|
||||
{% include 'components/header.html' %}
|
||||
@@ -115,6 +43,7 @@
|
||||
{% include 'components/loading.html' %}
|
||||
{% include 'components/context_menu.html' %}
|
||||
{% include 'components/import_modal.html' %}
|
||||
{% include 'components/recipe_modal.html' %}
|
||||
|
||||
<div class="container">
|
||||
{% if is_initializing %}
|
||||
|
||||
Reference in New Issue
Block a user