Files
ComfyUI-Lora-Manager/static/js/components/ContextMenu/RecipeContextMenu.js
T
Will Miao 303cca0d85 fix(download): accept newer CivitAI file types for primary file selection
Downloads failed with "No suitable file found in metadata" for models whose
only file uses newer CivitAI file types (e.g. 'Enhancement LoRA' for
Anima/AIR image-editing LoRAs) because the primary-file allowlist only
covered legacy types.

- unify the weights-type allowlist as MODEL_WEIGHT_FILE_TYPES
  (py/utils/constants.py) and apply it across download, recipe and
  metadata-refresh lookups
- mirror CivitAI's getPrimaryFile() semantics: prefer weights-type primary,
  fall back to weights files, then trust CivitAI's primary flag (excluding
  non-downloadable artifacts like Config/Archive/Workflow)
- mirror the allowlist in the frontend via shared isModelWeightFile() helper
- add regression tests for the Enhancement LoRA primary-file download,
  primary-flag fallback and weights-over-non-weights-primary preference
2026-08-12 21:14:23 +08:00

426 lines
17 KiB
JavaScript

import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { isModelWeightFile } from '../../utils/modelFileTypes.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
export class RecipeContextMenu extends BaseContextMenu {
constructor() {
super('recipeContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'recipe';
this.initNSFWSelector();
}
// Use the updateRecipeMetadata implementation from recipeApi
async saveModelMetadata(filePath, data) {
return updateRecipeMetadata(filePath, data);
}
// Override resetAndReload for recipe context
async resetAndReload() {
const { resetAndReload } = await import('../../api/recipeApi.js');
return resetAndReload(false, { preserveScroll: true });
}
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';
} else {
missingLorasItem.style.display = 'none';
}
}
}
handleMenuAction(action) {
// First try to handle with common actions from ModelContextMenuMixin
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
// Handle recipe-specific actions
const recipeId = this.currentCard.dataset.id;
switch (action) {
case 'details':
// Show recipe details
this.currentCard.click();
break;
case 'copy':
// Copy recipe syntax to clipboard
this.copyRecipeSyntax();
break;
case 'sendappend':
// Send recipe to workflow (append mode)
this.sendRecipeToWorkflow(false);
break;
case 'sendreplace':
// Send recipe to workflow (replace mode)
this.sendRecipeToWorkflow(true);
break;
case 'share':
// Share recipe
this.currentCard.querySelector('.fa-share-alt')?.click();
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'delete':
// Delete recipe
this.currentCard.querySelector('.fa-trash')?.click();
break;
case 'viewloras':
// View all LoRAs in the recipe
this.viewRecipeLoRAs(recipeId);
break;
case 'download-missing':
// Download missing LoRAs
this.downloadMissingLoRAs(recipeId);
break;
case 'repair':
// Repair recipe metadata
this.repairRecipe(recipeId);
break;
case 'rematch':
// Rematch recipe resources to local models
this.rematchRecipe(recipeId);
break;
case 'reimport':
this.reimportRecipe(recipeId);
break;
}
}
// New method to copy recipe syntax to clipboard
copyRecipeSyntax() {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('recipes.contextMenu.copyRecipe.missingId', {}, 'error');
return;
}
fetch(`/api/lm/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to copy recipe syntax: ', err);
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
});
}
// New method to send recipe to workflow
sendRecipeToWorkflow(replaceMode) {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('recipes.contextMenu.sendRecipe.missingId', {}, 'error');
return;
}
fetch(`/api/lm/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to send recipe to workflow: ', err);
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())
.then(recipe => {
// Clear any previous filters first
removeSessionItem('recipe_to_lora_filterLoraHash');
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 {
showToast('recipes.contextMenu.viewLoras.noLorasFound', {}, 'info');
}
})
.catch(error => {
console.error('Error loading recipe LoRAs:', error);
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}`;
} else if (lora.hash) {
endpoint = `/api/lm/loras/civitai/model/hash/${lora.hash}`;
} else {
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 ?
civitaiInfo.files.find(file => isModelWeightFile(file.type)) : null;
return {
// Basic lora info
name: civitaiInfo.model?.name || lora.name,
version: civitaiInfo.name || '',
strength: lora.strength || 1.0,
// Model identifiers
modelId: lora.modelId || lora.model_id || civitaiInfo.modelId,
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
id: 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",
isEarlyAccess: !!civitaiInfo.earlyAccessEndsAt,
earlyAccessEndsAt: civitaiInfo.earlyAccessEndsAt || ''
};
})
};
// Call ImportManager's download missing LoRAs method
window.importManager.downloadMissingLoras(recipeData, recipeId);
} catch (error) {
console.error('Error downloading missing LoRAs:', error);
showToast('recipes.contextMenu.downloadMissing.prepareError', { message: error.message }, 'error');
} finally {
if (state.loadingManager) {
state.loadingManager.hide();
}
}
}
// 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');
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
const filePath = this.currentCard?.dataset?.filepath;
if (filePath && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
} 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');
}
}
async rematchRecipe(recipeId) {
if (!recipeId) {
showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error');
return;
}
// Capture before any await: the menu's click handler nulls currentCard
const filePath = this.currentCard?.dataset?.filepath;
try {
showToast('Rematching recipe to local models...', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
const matchedEntries = result.matched_entries || result.rematched || 0;
const failures = result.errors || 0;
if (matchedEntries > 0) {
const toastKey = failures > 0
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
failures > 0 ? 'warning' : 'success'
);
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
if (filePath && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
} else if (result.unresolved_entries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
'info'
);
} else {
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
}
} else {
throw new Error(result.error || 'Rematch failed');
}
} catch (error) {
console.error('Error rematching recipe:', error);
showToast('toast.recipes.rematchFailed', { message: error.message }, 'error');
}
}
async reimportRecipe(recipeId) {
if (!recipeId) {
showToast('recipes.contextMenu.reimport.missingId', {}, 'error');
return;
}
state.loadingManager.showSimpleLoading('Re-importing recipe from source...');
try {
const response = await fetch(`/api/lm/recipe/${recipeId}/reimport`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
state.loadingManager.hide();
showToast('toast.recipes.reimportSuccess', {}, 'success');
const { resetAndReload } = await import('../../api/recipeApi.js');
resetAndReload(false, { preserveScroll: false });
} else {
throw new Error(result.error || 'Re-import failed');
}
} catch (error) {
console.error('Error reimporting recipe:', error);
state.loadingManager.hide();
showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error');
}
}
}
// Mix in shared methods from ModelContextMenuMixin
Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin);