mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
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.
This commit is contained in:
@@ -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}")
|
||||
# 如果获取缓存失败,也显示初始化页面
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
</div>`;
|
||||
} else if (isDeleted) {
|
||||
localStatus = `
|
||||
<div class="deleted-badge">
|
||||
<i class="fas fa-trash-alt"></i> Deleted
|
||||
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
||||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||||
</div>`;
|
||||
} else {
|
||||
localStatus = `
|
||||
@@ -387,7 +398,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${loraItemClass}">
|
||||
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<div class="recipe-lora-thumbnail">
|
||||
${previewMedia}
|
||||
</div>
|
||||
@@ -401,11 +412,29 @@ class RecipeModal {
|
||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||
</div>
|
||||
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||||
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="Enter LoRA name or syntax">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">Cancel</button>
|
||||
<button class="reconnect-confirm-btn">Reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = '<i class="fas fa-trash-alt"></i> 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(/<lora:([^:>]+)(?::[^>]+)?>/);
|
||||
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 };
|
||||
Reference in New Issue
Block a user