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:
Will Miao
2025-04-03 16:55:19 +08:00
parent afee18f146
commit 6af12d1acc
4 changed files with 429 additions and 6 deletions

View File

@@ -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}")
# 如果获取缓存失败,也显示初始化页面

View File

@@ -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)

View File

@@ -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);

View File

@@ -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>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</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 };