feat: Add recipe metadata repair functionality with UI, API, and progress tracking.

This commit is contained in:
Will Miao
2025-12-23 21:50:58 +08:00
parent 00e6904664
commit 6330c65d41
20 changed files with 1005 additions and 60 deletions

View File

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