checkpoint

This commit is contained in:
Will Miao
2025-03-16 16:56:33 +08:00
parent 50babfd471
commit 406284a045
14 changed files with 1145 additions and 300 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
/* 响应式设计 */

View 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;
}
}

View 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 };

View 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 };

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
<div id="recipeModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('recipeModal')">&times;</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>

View File

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