From 6af12d1acc4b6e94e260e81ce9205fbdfe754d26 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 3 Apr 2025 16:55:19 +0800 Subject: [PATCH] Add reconnect functionality for deleted LoRAs in recipe modal - Introduced a new API endpoint to reconnect deleted LoRAs to local files. - Updated RecipeModal to include UI elements for reconnecting LoRAs, including input fields and buttons. - Enhanced CSS styles for deleted badges and reconnect containers to improve user experience. - Implemented event handling for reconnect actions, including input validation and API calls. - Updated recipe data handling to reflect changes after reconnecting LoRAs. --- py/routes/lora_routes.py | 2 +- py/routes/recipe_routes.py | 115 +++++++++++++++- static/css/components/recipe-modal.css | 134 +++++++++++++++++- static/js/components/RecipeModal.js | 184 ++++++++++++++++++++++++- 4 files changed, 429 insertions(+), 6 deletions(-) diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index 40ff8e98..196bfa25 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -89,7 +89,7 @@ class LoraRoutes: settings=settings, # Pass settings to template request=request # Pass the request object to the template ) - logger.info(f"Loras page loaded successfully with {len(cache.raw_data)} items") + logger.debug(f"Loras page loaded successfully with {len(cache.raw_data)} items") except Exception as cache_error: logger.error(f"Error loading cache data: {cache_error}") # 如果获取缓存失败,也显示初始化页面 diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index a6e3ef91..edf56ed9 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -53,6 +53,9 @@ class RecipeRoutes: # Add new endpoint for updating recipe metadata (name and tags) app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe) + # Add new endpoint for reconnecting deleted LoRAs + app.router.add_post('/api/recipe/lora/reconnect', routes.reconnect_lora) + # Start cache initialization app.on_startup.append(routes._init_cache) @@ -762,7 +765,7 @@ class RecipeRoutes: return web.json_response({"error": "Invalid workflow JSON"}, status=400) if not workflow_json: - return web.json_response({"error": "Missing required workflow_json field"}, status=400) + return web.json_response({"error": "Missing workflow JSON"}, status=400) # Find the latest image in the temp directory temp_dir = config.temp_directory @@ -1021,3 +1024,113 @@ class RecipeRoutes: except Exception as e: logger.error(f"Error updating recipe: {e}", exc_info=True) return web.json_response({"error": str(e)}, status=500) + + async def reconnect_lora(self, request: web.Request) -> web.Response: + """Reconnect a deleted LoRA in a recipe to a local LoRA file""" + try: + # Parse request data + data = await request.json() + + # Validate required fields + required_fields = ['recipe_id', 'lora_data', 'target_name'] + for field in required_fields: + if field not in data: + return web.json_response({ + "error": f"Missing required field: {field}" + }, status=400) + + recipe_id = data['recipe_id'] + lora_data = data['lora_data'] + target_name = data['target_name'] + + # Get recipe scanner + scanner = self.recipe_scanner + lora_scanner = scanner._lora_scanner + + # Check if recipe exists + recipe_path = os.path.join(scanner.recipes_dir, f"{recipe_id}.recipe.json") + if not os.path.exists(recipe_path): + return web.json_response({"error": "Recipe not found"}, status=404) + + # Find target LoRA by name + target_lora = await lora_scanner.get_lora_info_by_name(target_name) + if not target_lora: + return web.json_response({"error": f"Local LoRA not found with name: {target_name}"}, status=404) + + # Load recipe data + with open(recipe_path, 'r', encoding='utf-8') as f: + recipe_data = json.load(f) + + # Find the deleted LoRA in the recipe + found = False + updated_lora = None + + # Identification can be by hash, modelVersionId, or modelName + for i, lora in enumerate(recipe_data.get('loras', [])): + match_found = False + + # Try to match by available identifiers + if 'hash' in lora and 'hash' in lora_data and lora['hash'] == lora_data['hash']: + match_found = True + elif 'modelVersionId' in lora and 'modelVersionId' in lora_data and lora['modelVersionId'] == lora_data['modelVersionId']: + match_found = True + elif 'modelName' in lora and 'modelName' in lora_data and lora['modelName'] == lora_data['modelName']: + match_found = True + + if match_found: + # Update LoRA data + lora['isDeleted'] = False + lora['file_name'] = target_name + + # Update with information from the target LoRA + if 'sha256' in target_lora: + lora['hash'] = target_lora['sha256'].lower() + if target_lora.get("civitai"): + lora['modelName'] = target_lora['civitai']['model']['name'] + lora['modelVersionName'] = target_lora['civitai']['name'] + lora['modelVersionId'] = target_lora['civitai']['id'] + + # Keep original fields for identification + + # Mark as found and store updated lora + found = True + updated_lora = dict(lora) # Make a copy for response + break + + if not found: + return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404) + + # Save updated recipe + with open(recipe_path, 'w', encoding='utf-8') as f: + json.dump(recipe_data, f, indent=4, ensure_ascii=False) + + updated_lora['inLibrary'] = True + updated_lora['preview_url'] = target_lora['preview_url'] + updated_lora['localPath'] = target_lora['file_path'] + + # Update in cache if it exists + if scanner._cache is not None: + for cache_item in scanner._cache.raw_data: + if cache_item.get('id') == recipe_id: + # Replace loras array with updated version + cache_item['loras'] = recipe_data['loras'] + + # Resort the cache + asyncio.create_task(scanner._cache.resort()) + break + + # Update EXIF metadata if image exists + image_path = recipe_data.get('file_path') + if image_path and os.path.exists(image_path): + from ..utils.exif_utils import ExifUtils + ExifUtils.append_recipe_metadata(image_path, recipe_data) + + return web.json_response({ + "success": True, + "recipe_id": recipe_id, + "updated_lora": updated_lora + }) + + except Exception as e: + logger.error(f"Error reconnecting LoRA: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index 56a5a0ca..9e546c0c 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -584,7 +584,7 @@ font-size: 0.9em; } -/* Deleted badge */ +/* Deleted badge with reconnect functionality */ .deleted-badge { display: inline-flex; align-items: center; @@ -603,6 +603,138 @@ font-size: 0.9em; } +/* Add reconnect functionality styles */ +.deleted-badge.reconnectable { + position: relative; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.deleted-badge.reconnectable:hover { + background-color: var(--lora-accent); +} + +.deleted-badge .reconnect-tooltip { + position: absolute; + display: none; + background-color: var(--card-bg); + color: var(--text-color); + padding: 8px 12px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: var(--z-overlay); + width: max-content; + max-width: 200px; + font-size: 0.85rem; + font-weight: normal; + top: calc(100% + 5px); + left: 0; + margin-left: -100px; +} + +.deleted-badge.reconnectable:hover .reconnect-tooltip { + display: block; +} + +/* LoRA reconnect container */ +.lora-reconnect-container { + display: none; + flex-direction: column; + background: var(--lora-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: 12px; + margin-top: 10px; + gap: 10px; +} + +.lora-reconnect-container.active { + display: flex; +} + +.reconnect-instructions { + display: flex; + flex-direction: column; + gap: 5px; +} + +.reconnect-instructions p { + margin: 0; + font-size: 0.95em; + font-weight: 500; + color: var(--text-color); +} + +.reconnect-instructions small { + color: var(--text-color); + opacity: 0.7; + font-size: 0.85em; +} + +.reconnect-instructions code { + background: rgba(0, 0, 0, 0.1); + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; +} + +[data-theme="dark"] .reconnect-instructions code { + background: rgba(255, 255, 255, 0.1); +} + +.reconnect-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.reconnect-input { + width: calc(100% - 20px); + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.9em; +} + +.reconnect-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.reconnect-cancel-btn, +.reconnect-confirm-btn { + padding: 6px 12px; + border-radius: var(--border-radius-xs); + font-size: 0.85em; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.reconnect-cancel-btn { + background: var(--bg-color); + color: var(--text-color); + border: 1px solid var(--border-color); +} + +.reconnect-confirm-btn { + background: var(--lora-accent); + color: white; +} + +.reconnect-cancel-btn:hover { + background: var(--lora-surface); +} + +.reconnect-confirm-btn:hover { + background: color-mix(in oklch, var(--lora-accent), black 10%); +} + /* Recipe status partial state */ .recipe-status.partial { background: rgba(127, 127, 127, 0.1); diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 4ced48f4..5ae4483f 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -31,6 +31,16 @@ class RecipeModal { !event.target.closest('.edit-icon')) { this.saveTagsEdit(); } + + // Handle reconnect input + const reconnectContainers = document.querySelectorAll('.lora-reconnect-container'); + reconnectContainers.forEach(container => { + if (container.classList.contains('active') && + !container.contains(event.target) && + !event.target.closest('.deleted-badge.reconnectable')) { + this.hideReconnectInput(container); + } + }); }); } @@ -358,8 +368,9 @@ class RecipeModal { `; } else if (isDeleted) { localStatus = ` -
- Deleted +
+ Deleted +
Click to reconnect with a local LoRA
`; } else { localStatus = ` @@ -387,7 +398,7 @@ class RecipeModal { } return ` -
+
${previewMedia}
@@ -401,11 +412,29 @@ class RecipeModal {
Weight: ${lora.strength || 1.0}
${lora.baseModel ? `
${lora.baseModel}
` : ''}
+
+
+

Enter LoRA Syntax or Name to Reconnect:

+ Example: <lora:Boris_Vallejo_BV_flux_D:1> or just Boris_Vallejo_BV_flux_D +
+
+ +
+ + +
+
+
`; }).join(''); + // Add event listeners for reconnect functionality + setTimeout(() => { + this.setupReconnectButtons(); + }, 100); + // Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API) this.recipeLorasSyntax = ''; @@ -829,6 +858,155 @@ class RecipeModal { state.loadingManager.hide(); } } + + // New methods for reconnecting LoRAs + setupReconnectButtons() { + // Add event listeners to all deleted badges + const deletedBadges = document.querySelectorAll('.deleted-badge.reconnectable'); + deletedBadges.forEach(badge => { + badge.addEventListener('mouseenter', () => { + badge.querySelector('.badge-text').innerHTML = 'Reconnect'; + }); + + badge.addEventListener('mouseleave', () => { + badge.querySelector('.badge-text').innerHTML = ' Deleted'; + }); + + badge.addEventListener('click', (e) => { + const loraIndex = badge.getAttribute('data-lora-index'); + this.showReconnectInput(loraIndex); + }); + }); + + // Add event listeners to reconnect cancel buttons + const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn'); + cancelButtons.forEach(button => { + button.addEventListener('click', (e) => { + const container = button.closest('.lora-reconnect-container'); + this.hideReconnectInput(container); + }); + }); + + // Add event listeners to reconnect confirm buttons + const confirmButtons = document.querySelectorAll('.reconnect-confirm-btn'); + confirmButtons.forEach(button => { + button.addEventListener('click', (e) => { + const container = button.closest('.lora-reconnect-container'); + const input = container.querySelector('.reconnect-input'); + const loraIndex = container.getAttribute('data-lora-index'); + this.reconnectLora(loraIndex, input.value); + }); + }); + + // Add keydown handlers to reconnect inputs + const reconnectInputs = document.querySelectorAll('.reconnect-input'); + reconnectInputs.forEach(input => { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const container = input.closest('.lora-reconnect-container'); + const loraIndex = container.getAttribute('data-lora-index'); + this.reconnectLora(loraIndex, input.value); + } else if (e.key === 'Escape') { + const container = input.closest('.lora-reconnect-container'); + this.hideReconnectInput(container); + } + }); + }); + } + + showReconnectInput(loraIndex) { + // Hide any currently active reconnect containers + document.querySelectorAll('.lora-reconnect-container.active').forEach(active => { + active.classList.remove('active'); + }); + + // Show the reconnect container for this lora + const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`); + if (container) { + container.classList.add('active'); + const input = container.querySelector('.reconnect-input'); + input.focus(); + } + } + + hideReconnectInput(container) { + if (container && container.classList.contains('active')) { + container.classList.remove('active'); + const input = container.querySelector('.reconnect-input'); + if (input) input.value = ''; + } + } + + async reconnectLora(loraIndex, inputValue) { + if (!inputValue || !inputValue.trim()) { + showToast('Please enter a LoRA name or syntax', 'error'); + return; + } + + try { + // Parse input value to extract file_name + let loraSyntaxMatch = inputValue.match(/]+)(?::[^>]+)?>/); + let fileName = loraSyntaxMatch ? loraSyntaxMatch[1] : inputValue.trim(); + + // Remove any file extension if present + fileName = fileName.replace(/\.\w+$/, ''); + + // Get the deleted lora data + const deletedLora = this.currentRecipe.loras[loraIndex]; + if (!deletedLora) { + showToast('Error: Could not find the LoRA in the recipe', 'error'); + return; + } + + state.loadingManager.showSimpleLoading('Reconnecting LoRA...'); + + // Call API to reconnect the LoRA + const response = await fetch('/api/recipe/lora/reconnect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_id: this.recipeId, + lora_data: deletedLora, + target_name: fileName + }) + }); + + const result = await response.json(); + + if (result.success) { + // Hide the reconnect input + const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`); + this.hideReconnectInput(container); + + // Update the current recipe with the updated lora data + this.currentRecipe.loras[loraIndex] = result.updated_lora; + + // Show success message + showToast('LoRA reconnected successfully', 'success'); + + // Refresh modal to show updated content + setTimeout(() => { + this.showRecipeDetails(this.currentRecipe); + }, 500); + + // Refresh recipes list + if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') { + setTimeout(() => { + window.recipeManager.loadRecipes(true); + }, 1000); + } + } else { + showToast(`Error: ${result.error}`, 'error'); + } + } catch (error) { + console.error('Error reconnecting LoRA:', error); + showToast(`Error reconnecting LoRA: ${error.message}`, 'error'); + } finally { + state.loadingManager.hide(); + } + } } export { RecipeModal }; \ No newline at end of file