mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
feat: Add recipe metadata repair functionality with UI, API, and progress tracking.
This commit is contained in:
@@ -15,6 +15,29 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
|
||||
showMenu(x, y, origin = null) {
|
||||
const contextOrigin = origin || { type: 'global' };
|
||||
|
||||
// Conditional visibility for recipes page
|
||||
const isRecipesPage = state.currentPageType === 'recipes';
|
||||
const modelUpdateItem = this.menu.querySelector('[data-action="check-model-updates"]');
|
||||
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
||||
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
|
||||
if (isRecipesPage) {
|
||||
modelUpdateItem?.classList.add('hidden');
|
||||
licenseRefreshItem?.classList.add('hidden');
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
licenseRefreshItem?.classList.remove('hidden');
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
super.showMenu(x, y, contextOrigin);
|
||||
}
|
||||
|
||||
@@ -40,6 +63,11 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to refresh missing license metadata:', error);
|
||||
});
|
||||
break;
|
||||
case 'repair-recipes':
|
||||
this.repairRecipes(menuItem).catch((error) => {
|
||||
console.error('Failed to repair recipes:', error);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
@@ -235,4 +263,78 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
|
||||
return `${displayName}s`;
|
||||
}
|
||||
|
||||
async repairRecipes(menuItem) {
|
||||
if (this._repairInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._repairInProgress = true;
|
||||
menuItem?.classList.add('disabled');
|
||||
|
||||
const loadingMessage = translate(
|
||||
'globalContextMenu.repairRecipes.loading',
|
||||
{},
|
||||
'Repairing recipe data...'
|
||||
);
|
||||
|
||||
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/repair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to start repair');
|
||||
}
|
||||
|
||||
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
|
||||
let isComplete = false;
|
||||
while (!isComplete && this._repairInProgress) {
|
||||
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
|
||||
if (progressResponse.ok) {
|
||||
const progressResult = await progressResponse.json();
|
||||
if (progressResult.success && progressResult.progress) {
|
||||
const p = progressResult.progress;
|
||||
if (p.status === 'processing') {
|
||||
const percent = (p.current / p.total) * 100;
|
||||
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
|
||||
} else if (p.status === 'completed') {
|
||||
isComplete = true;
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.repairRecipes.success',
|
||||
{ count: p.repaired },
|
||||
`Repaired ${p.repaired} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
|
||||
// Refresh recipes page if active
|
||||
if (window.recipesPage) {
|
||||
window.recipesPage.refresh();
|
||||
}
|
||||
} else if (p.status === 'error') {
|
||||
throw new Error(p.error || 'Repair failed');
|
||||
}
|
||||
} else if (progressResponse.status === 404) {
|
||||
// Progress might have finished quickly and been cleaned up
|
||||
isComplete = true;
|
||||
progressUI?.complete();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recipe repair failed:', error);
|
||||
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
|
||||
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this._repairInProgress = false;
|
||||
menuItem?.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
super('recipeContextMenu', '.model-card');
|
||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||
this.modelType = 'recipe';
|
||||
|
||||
|
||||
this.initNSFWSelector();
|
||||
}
|
||||
|
||||
@@ -25,20 +25,20 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||
return resetAndReload();
|
||||
}
|
||||
|
||||
|
||||
showMenu(x, y, card) {
|
||||
// Call the parent method first to handle basic positioning
|
||||
super.showMenu(x, y, card);
|
||||
|
||||
|
||||
// Get recipe data to check for missing LoRAs
|
||||
const recipeId = card.dataset.id;
|
||||
const missingLorasItem = this.menu.querySelector('.download-missing-item');
|
||||
|
||||
|
||||
if (recipeId && missingLorasItem) {
|
||||
// Check if this card has missing LoRAs
|
||||
const loraCountElement = card.querySelector('.lora-count');
|
||||
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
|
||||
|
||||
|
||||
// Show/hide the download missing LoRAs option based on missing status
|
||||
if (hasMissingLoras) {
|
||||
missingLorasItem.style.display = 'flex';
|
||||
@@ -47,7 +47,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleMenuAction(action) {
|
||||
// First try to handle with common actions from ModelContextMenuMixin
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
@@ -56,8 +56,8 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
|
||||
// Handle recipe-specific actions
|
||||
const recipeId = this.currentCard.dataset.id;
|
||||
|
||||
switch(action) {
|
||||
|
||||
switch (action) {
|
||||
case 'details':
|
||||
// Show recipe details
|
||||
this.currentCard.click();
|
||||
@@ -93,9 +93,13 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Download missing LoRAs
|
||||
this.downloadMissingLoRAs(recipeId);
|
||||
break;
|
||||
case 'repair':
|
||||
// Repair recipe metadata
|
||||
this.repairRecipe(recipeId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// New method to copy recipe syntax to clipboard
|
||||
copyRecipeSyntax() {
|
||||
const recipeId = this.currentCard.dataset.id;
|
||||
@@ -118,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// New method to send recipe to workflow
|
||||
sendRecipeToWorkflow(replaceMode) {
|
||||
const recipeId = this.currentCard.dataset.id;
|
||||
@@ -141,14 +145,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// View all LoRAs in the recipe
|
||||
viewRecipeLoRAs(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// First get the recipe details to access its LoRAs
|
||||
fetch(`/api/lm/recipe/${recipeId}`)
|
||||
.then(response => response.json())
|
||||
@@ -158,17 +162,17 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
removeSessionItem('filterRecipeName');
|
||||
removeSessionItem('viewLoraDetail');
|
||||
|
||||
|
||||
// Collect all hashes from the recipe's LoRAs
|
||||
const loraHashes = recipe.loras
|
||||
.filter(lora => lora.hash)
|
||||
.map(lora => lora.hash.toLowerCase());
|
||||
|
||||
|
||||
if (loraHashes.length > 0) {
|
||||
// Store the LoRA hashes and recipe name in session storage
|
||||
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
|
||||
setSessionItem('filterRecipeName', recipe.title);
|
||||
|
||||
|
||||
// Navigate to the LoRAs page
|
||||
window.location.href = '/loras';
|
||||
} else {
|
||||
@@ -180,34 +184,34 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Download missing LoRAs
|
||||
async downloadMissingLoRAs(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// First get the recipe details
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||
const recipe = await response.json();
|
||||
|
||||
|
||||
// Get missing LoRAs
|
||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
||||
|
||||
|
||||
if (missingLoras.length === 0) {
|
||||
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Show loading toast
|
||||
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
|
||||
|
||||
|
||||
// Get version info for each missing LoRA
|
||||
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
|
||||
let endpoint;
|
||||
|
||||
|
||||
// Determine which endpoint to use based on available data
|
||||
if (lora.modelVersionId) {
|
||||
endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
|
||||
@@ -217,52 +221,52 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
console.error("Missing both hash and modelVersionId for lora:", lora);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const versionResponse = await fetch(endpoint);
|
||||
const versionInfo = await versionResponse.json();
|
||||
|
||||
|
||||
// Return original lora data combined with version info
|
||||
return {
|
||||
...lora,
|
||||
civitaiInfo: versionInfo
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// Wait for all API calls to complete
|
||||
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
|
||||
|
||||
|
||||
// Filter out null values (failed requests)
|
||||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||||
|
||||
|
||||
if (validLoras.length === 0) {
|
||||
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Prepare data for import manager using the retrieved information
|
||||
const recipeData = {
|
||||
loras: validLoras.map(lora => {
|
||||
const civitaiInfo = lora.civitaiInfo;
|
||||
const modelFile = civitaiInfo.files ?
|
||||
const modelFile = civitaiInfo.files ?
|
||||
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
||||
|
||||
|
||||
return {
|
||||
// Basic lora info
|
||||
name: civitaiInfo.model?.name || lora.name,
|
||||
version: civitaiInfo.name || '',
|
||||
strength: lora.strength || 1.0,
|
||||
|
||||
|
||||
// Model identifiers
|
||||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||||
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
||||
|
||||
|
||||
// Metadata
|
||||
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
||||
baseModel: civitaiInfo.baseModel || '',
|
||||
downloadUrl: civitaiInfo.downloadUrl || '',
|
||||
size: modelFile ? (modelFile.sizeKB * 1024) : 0,
|
||||
file_name: modelFile ? modelFile.name.split('.')[0] : '',
|
||||
|
||||
|
||||
// Status flags
|
||||
existsLocally: false,
|
||||
isDeleted: civitaiInfo.error === "Model not found",
|
||||
@@ -271,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
// Call ImportManager's download missing LoRAs method
|
||||
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
||||
} catch (error) {
|
||||
@@ -283,6 +287,38 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Repair recipe metadata
|
||||
async repairRecipe(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('recipes.contextMenu.repair.starting', {}, 'info');
|
||||
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
if (result.repaired > 0) {
|
||||
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||
// Refresh the current card or reload
|
||||
this.resetAndReload();
|
||||
} else {
|
||||
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Repair failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error repairing recipe:', error);
|
||||
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mix in shared methods from ModelContextMenuMixin
|
||||
|
||||
Reference in New Issue
Block a user