mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 13:42:12 -03:00
Add initialization tracking to prevent multiple event listener attachments in context menu components. Use dataset.initialized flag to ensure NSFW selector events are only set up once per component instance. In ModelContextMenuMixin, replace DOM elements and reattach event listeners to avoid duplicates when components are reinitialized. This fixes issues where multiple click handlers could be attached to the same elements.
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
import { BaseContextMenu } from './BaseContextMenu.js';
|
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
|
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
|
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
|
import { state } from '../../state/index.js';
|
|
|
|
export class RecipeContextMenu extends BaseContextMenu {
|
|
constructor() {
|
|
super('recipeContextMenu', '.model-card');
|
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
|
this.modelType = 'recipe';
|
|
|
|
// Initialize NSFW Level Selector events only if not already initialized
|
|
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
|
this.initNSFWSelector();
|
|
this.nsfwSelector.dataset.initialized = 'true';
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
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 '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;
|
|
}
|
|
}
|
|
|
|
// 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 => 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",
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mix in shared methods from ModelContextMenuMixin
|
|
Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin); |