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 = ` -
Enter LoRA Syntax or Name to Reconnect:
+ Example:<lora:Boris_Vallejo_BV_flux_D:1> or just Boris_Vallejo_BV_flux_D
+