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.
This commit is contained in:
Will Miao
2025-03-29 15:38:49 +08:00
parent 63aa4e188e
commit 069ebce895
4 changed files with 148 additions and 44 deletions

View File

@@ -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', routes.share_recipe)
app.router.add_get('/api/recipe/{recipe_id}/share/download', routes.download_shared_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 # Start cache initialization
app.on_startup.append(routes._init_cache) app.on_startup.append(routes._init_cache)
@@ -432,19 +435,20 @@ class RecipeRoutes:
# Format loras data according to the recipe.json format # Format loras data according to the recipe.json format
loras_data = [] loras_data = []
for lora in metadata.get("loras", []): for lora in metadata.get("loras", []):
# Skip deleted LoRAs if they're marked to be excluded # Modified: Always include deleted LoRAs in the recipe metadata
if lora.get("isDeleted", False) and lora.get("exclude", False): # Even if they're marked to be excluded, we still keep their identifying information
continue # The exclude flag will only be used to determine if they should be included in recipe syntax
# Convert frontend lora format to recipe format # Convert frontend lora format to recipe format
lora_entry = { 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 "", "hash": lora.get("hash", "").lower() if lora.get("hash") else "",
"strength": float(lora.get("weight", 1.0)), "strength": float(lora.get("weight", 1.0)),
"modelVersionId": lora.get("id", ""), "modelVersionId": lora.get("id", ""),
"modelName": lora.get("name", ""), "modelName": lora.get("name", ""),
"modelVersionName": lora.get("version", ""), "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) loras_data.append(lora_entry)
@@ -905,3 +909,78 @@ class RecipeRoutes:
except Exception as e: except Exception as e:
logger.error(f"Error saving recipe from widget: {e}", exc_info=True) logger.error(f"Error saving recipe from widget: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500) 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"<lora:{file_name}:{strength}>")
# 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)

View File

@@ -96,22 +96,24 @@ class RecipeCard {
copyRecipeSyntax() { copyRecipeSyntax() {
try { try {
// Generate recipe syntax in the format <lora:file_name:strength> separated by spaces // Get recipe ID
const loras = this.recipe.loras || []; const recipeId = this.recipe.id;
if (loras.length === 0) { if (!recipeId) {
showToast('No LoRAs in this recipe to copy', 'warning'); showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
return; return;
} }
const syntax = loras.map(lora => { // Fallback if button not found
// Use file_name if available, otherwise use empty placeholder fetch(`/api/recipe/${recipeId}/syntax`)
const fileName = lora.file_name || '[missing-lora]'; .then(response => response.json())
const strength = lora.strength || 1.0; .then(data => {
return `<lora:${fileName}:${strength}>`; if (data.success && data.syntax) {
}).join(' '); return navigator.clipboard.writeText(data.syntax);
} else {
// Copy to clipboard throw new Error(data.error || 'No syntax returned');
navigator.clipboard.writeText(syntax) }
})
.then(() => { .then(() => {
showToast('Recipe syntax copied to clipboard', 'success'); showToast('Recipe syntax copied to clipboard', 'success');
}) })

View File

@@ -41,6 +41,9 @@ class RecipeModal {
modalTitle.textContent = recipe.title || 'Recipe Details'; 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 // Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact'); const tagsCompactElement = document.getElementById('recipeTagsCompact');
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent'); const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
@@ -265,10 +268,8 @@ class RecipeModal {
`; `;
}).join(''); }).join('');
// Generate recipe syntax for copy button // Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
this.recipeLorasSyntax = recipe.loras.map(lora => this.recipeLorasSyntax = '';
`<lora:${lora.file_name}:${lora.strength || 1.0}>`
).join(' ');
} else if (lorasListElement) { } else if (lorasListElement) {
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>'; lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
@@ -301,11 +302,42 @@ class RecipeModal {
if (copyRecipeSyntaxBtn) { if (copyRecipeSyntaxBtn) {
copyRecipeSyntaxBtn.addEventListener('click', () => { 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 // Helper method to copy text to clipboard
copyToClipboard(text, successMessage) { copyToClipboard(text, successMessage) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {

View File

@@ -399,7 +399,7 @@ export class ImportManager {
} }
} }
} }
// Update LoRA count information // Update LoRA count information
const totalLoras = this.recipeData.loras.length; const totalLoras = this.recipeData.loras.length;
const existingLoras = this.recipeData.loras.filter(lora => lora.existsLocally).length; const existingLoras = this.recipeData.loras.filter(lora => lora.existsLocally).length;
@@ -549,33 +549,24 @@ export class ImportManager {
<div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div> <div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="warning-content"> <div class="warning-content">
<div class="warning-title">${deletedLoras} LoRA(s) have been deleted from Civitai</div> <div class="warning-title">${deletedLoras} LoRA(s) have been deleted from Civitai</div>
<div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will be removed from the recipe.</div> <div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.</div>
</div> </div>
`; `;
// Insert before the buttons container // Insert before the buttons container
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
}
// Update next button text to be more clear // If we have missing LoRAs (not deleted), show "Download Missing LoRAs"
nextButton.textContent = 'Continue Without Deleted 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 { } else {
// Remove warning if no deleted LoRAs nextButton.textContent = 'Save Recipe';
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';
}
} }
} }