From 069ebce8953a6a962f7b599f3532d68a250cdc77 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Sat, 29 Mar 2025 15:38:49 +0800 Subject: [PATCH] Add recipe syntax endpoint and update RecipeCard and RecipeModal for syntax fetching - Introduced a new API endpoint to retrieve recipe syntax for LoRAs, allowing for better integration with the frontend. - Updated RecipeCard to fetch recipe syntax from the backend instead of generating it locally. - Modified RecipeModal to store the recipe ID and fetch syntax when the copy button is clicked, improving user experience. - Enhanced error handling for fetching recipe syntax to provide clearer feedback to users. --- py/routes/recipe_routes.py | 89 +++++++++++++++++++++++++++-- static/js/components/RecipeCard.js | 28 ++++----- static/js/components/RecipeModal.js | 42 ++++++++++++-- static/js/managers/ImportManager.js | 33 ++++------- 4 files changed, 148 insertions(+), 44 deletions(-) diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index e6d0db55..24fa47ea 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -47,6 +47,9 @@ class RecipeRoutes: app.router.add_get('/api/recipe/{recipe_id}/share', routes.share_recipe) app.router.add_get('/api/recipe/{recipe_id}/share/download', routes.download_shared_recipe) + # Add new endpoint for getting recipe syntax + app.router.add_get('/api/recipe/{recipe_id}/syntax', routes.get_recipe_syntax) + # Start cache initialization app.on_startup.append(routes._init_cache) @@ -432,19 +435,20 @@ class RecipeRoutes: # Format loras data according to the recipe.json format loras_data = [] for lora in metadata.get("loras", []): - # Skip deleted LoRAs if they're marked to be excluded - if lora.get("isDeleted", False) and lora.get("exclude", False): - continue + # Modified: Always include deleted LoRAs in the recipe metadata + # Even if they're marked to be excluded, we still keep their identifying information + # The exclude flag will only be used to determine if they should be included in recipe syntax # Convert frontend lora format to recipe format lora_entry = { - "file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0], + "file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0] if lora.get("localPath") else "", "hash": lora.get("hash", "").lower() if lora.get("hash") else "", "strength": float(lora.get("weight", 1.0)), "modelVersionId": lora.get("id", ""), "modelName": lora.get("name", ""), "modelVersionName": lora.get("version", ""), - "isDeleted": lora.get("isDeleted", False) # Preserve deletion status in saved recipe + "isDeleted": lora.get("isDeleted", False), # Preserve deletion status in saved recipe + "exclude": lora.get("exclude", False) # Add exclude flag to the recipe } loras_data.append(lora_entry) @@ -905,3 +909,78 @@ class RecipeRoutes: except Exception as e: logger.error(f"Error saving recipe from widget: {e}", exc_info=True) return web.json_response({"error": str(e)}, status=500) + + async def get_recipe_syntax(self, request: web.Request) -> web.Response: + """Generate recipe syntax for LoRAs in the recipe, looking up proper file names using hash_index""" + try: + recipe_id = request.match_info['recipe_id'] + + # Get all recipes from cache + cache = await self.recipe_scanner.get_cached_data() + + # Find the specific recipe + recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None) + + if not recipe: + return web.json_response({"error": "Recipe not found"}, status=404) + + # Get the loras from the recipe + loras = recipe.get('loras', []) + + if not loras: + return web.json_response({"error": "No LoRAs found in this recipe"}, status=400) + + # Generate recipe syntax for all LoRAs that: + # 1. Are in the library (not deleted) OR + # 2. Are deleted but not marked for exclusion + lora_syntax_parts = [] + + # Access the hash_index from lora_scanner + hash_index = self.recipe_scanner._lora_scanner._hash_index + + for lora in loras: + # Skip loras that are deleted AND marked for exclusion + if lora.get("isDeleted", False) and lora.get("exclude", False): + continue + + # Get the strength + strength = lora.get("strength", 1.0) + + # Try to find the actual file name for this lora + file_name = None + hash_value = lora.get("hash", "").lower() + + if hash_value and hasattr(hash_index, "_hash_to_path"): + # Look up the file path from the hash + file_path = hash_index._hash_to_path.get(hash_value) + + if file_path: + # Extract the file name without extension from the path + file_name = os.path.splitext(os.path.basename(file_path))[0] + + # If hash lookup failed, fall back to modelVersionId lookup + if not file_name and lora.get("modelVersionId"): + # Search for files with matching modelVersionId + all_loras = await self.recipe_scanner._lora_scanner.get_cached_data() + for cached_lora in all_loras.raw_data: + if "civitai" in cached_lora and cached_lora["civitai"].get("id") == lora.get("modelVersionId"): + file_name = os.path.splitext(os.path.basename(cached_lora["path"]))[0] + break + + # If all lookups failed, use the file_name from the recipe + if not file_name: + file_name = lora.get("file_name", "unknown-lora") + + # Add to syntax parts + lora_syntax_parts.append(f"") + + # Join the LoRA syntax parts + lora_syntax = " ".join(lora_syntax_parts) + + return web.json_response({ + 'success': True, + 'syntax': lora_syntax + }) + except Exception as e: + logger.error(f"Error generating recipe syntax: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index ef332ada..8e3b1b7e 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -96,22 +96,24 @@ class RecipeCard { copyRecipeSyntax() { try { - // Generate recipe syntax in the format separated by spaces - const loras = this.recipe.loras || []; - if (loras.length === 0) { - showToast('No LoRAs in this recipe to copy', 'warning'); + // Get recipe ID + const recipeId = this.recipe.id; + if (!recipeId) { + showToast('Cannot copy recipe syntax: Missing recipe ID', 'error'); 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 ``; - }).join(' '); - - // Copy to clipboard - navigator.clipboard.writeText(syntax) + // Fallback if button not found + fetch(`/api/recipe/${recipeId}/syntax`) + .then(response => response.json()) + .then(data => { + if (data.success && data.syntax) { + return navigator.clipboard.writeText(data.syntax); + } else { + throw new Error(data.error || 'No syntax returned'); + } + }) .then(() => { showToast('Recipe syntax copied to clipboard', 'success'); }) diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index e193af1b..c25313ad 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -41,6 +41,9 @@ class RecipeModal { modalTitle.textContent = recipe.title || 'Recipe Details'; } + // Store the recipe ID for copy syntax API call + this.recipeId = recipe.id; + // Set recipe tags if they exist const tagsCompactElement = document.getElementById('recipeTagsCompact'); const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent'); @@ -265,10 +268,8 @@ class RecipeModal { `; }).join(''); - // Generate recipe syntax for copy button - this.recipeLorasSyntax = recipe.loras.map(lora => - `` - ).join(' '); + // Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API) + this.recipeLorasSyntax = ''; } else if (lorasListElement) { lorasListElement.innerHTML = '
No LoRAs associated with this recipe
'; @@ -301,11 +302,42 @@ class RecipeModal { if (copyRecipeSyntaxBtn) { copyRecipeSyntaxBtn.addEventListener('click', () => { - this.copyToClipboard(this.recipeLorasSyntax, 'Recipe syntax copied to clipboard'); + // Use backend API to get recipe syntax + this.fetchAndCopyRecipeSyntax(); }); } } + // Fetch recipe syntax from backend and copy to clipboard + async fetchAndCopyRecipeSyntax() { + if (!this.recipeId) { + showToast('No recipe ID available', 'error'); + return; + } + + try { + // Fetch recipe syntax from backend + const response = await fetch(`/api/recipe/${this.recipeId}/syntax`); + + if (!response.ok) { + throw new Error(`Failed to get recipe syntax: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success && data.syntax) { + // Copy to clipboard + await navigator.clipboard.writeText(data.syntax); + showToast('Recipe syntax copied to clipboard', 'success'); + } else { + throw new Error(data.error || 'No syntax returned from server'); + } + } catch (error) { + console.error('Error fetching recipe syntax:', error); + showToast(`Error copying recipe syntax: ${error.message}`, 'error'); + } + } + // Helper method to copy text to clipboard copyToClipboard(text, successMessage) { navigator.clipboard.writeText(text).then(() => { diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index 655cfe83..cfbb5c33 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -399,7 +399,7 @@ export class ImportManager { } } } - + // Update LoRA count information const totalLoras = this.recipeData.loras.length; const existingLoras = this.recipeData.loras.filter(lora => lora.existsLocally).length; @@ -549,33 +549,24 @@ export class ImportManager {
${deletedLoras} LoRA(s) have been deleted from Civitai
-
These LoRAs cannot be downloaded. If you continue, they will be removed from the recipe.
+
These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.
`; // Insert before the buttons container buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + } - // Update next button text to be more clear - nextButton.textContent = 'Continue Without Deleted LoRAs'; + // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" + // Otherwise show "Save Recipe" + const missingNotDeleted = this.recipeData.loras.filter( + lora => !lora.existsLocally && !lora.isDeleted + ).length; + + if (missingNotDeleted > 0) { + nextButton.textContent = 'Download Missing LoRAs'; } else { - // Remove warning if no deleted LoRAs - const warningMsg = document.getElementById('deletedLorasWarning'); - if (warningMsg) { - warningMsg.remove(); - } - - // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" - // Otherwise show "Save Recipe" - const missingNotDeleted = this.recipeData.loras.filter( - lora => !lora.existsLocally && !lora.isDeleted - ).length; - - if (missingNotDeleted > 0) { - nextButton.textContent = 'Download Missing LoRAs'; - } else { - nextButton.textContent = 'Save Recipe'; - } + nextButton.textContent = 'Save Recipe'; } }