mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-26 15:38:52 -03:00
feat: Add bulk download missing LoRAs feature for recipes
- Add BulkMissingLoraDownloadManager.js for handling bulk LoRA downloads - Add context menu item to bulk mode for downloading missing LoRAs - Add confirmation modal with deduplicated LoRA list preview - Implement sequential downloading with WebSocket progress updates - Fix CSS class naming conflicts to avoid import-modal.css collision - Update translations for 9 languages (en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he) - Style modal without internal scrolling for better UX
This commit is contained in:
@@ -151,7 +151,8 @@ body.modal-open {
|
||||
[data-theme="dark"] .changelog-section,
|
||||
[data-theme="dark"] .update-info,
|
||||
[data-theme="dark"] .info-item,
|
||||
[data-theme="dark"] .path-preview {
|
||||
[data-theme="dark"] .path-preview,
|
||||
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
@@ -349,3 +350,87 @@ button:disabled,
|
||||
margin-top: var(--space-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Bulk Download Missing LoRAs Modal */
|
||||
#bulkDownloadMissingLorasModal .modal-body {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .preview-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-1) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .lora-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .lora-version {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-left: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-note i {
|
||||
color: var(--lora-accent);
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
||||
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
export class BulkContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
|
||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||
|
||||
if (sendToWorkflowAppendItem) {
|
||||
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||
@@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (setContentRatingItem) {
|
||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||
}
|
||||
if (downloadMissingLorasItem) {
|
||||
// Only show for recipes page
|
||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||
@@ -178,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'delete-all':
|
||||
bulkManager.showBulkDeleteModal();
|
||||
break;
|
||||
case 'download-missing-loras':
|
||||
this.handleDownloadMissingLoras();
|
||||
break;
|
||||
case 'clear':
|
||||
bulkManager.clearSelection();
|
||||
break;
|
||||
@@ -185,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
console.warn(`Unknown bulk action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle downloading missing LoRAs for selected recipes
|
||||
*/
|
||||
async handleDownloadMissingLoras() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get selected recipes from the virtual scroller
|
||||
const selectedRecipes = [];
|
||||
state.selectedModels.forEach(filePath => {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${CSS.escape(filePath)}"]`);
|
||||
if (card && card.recipeData) {
|
||||
selectedRecipes.push(card.recipeData);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedRecipes.length === 0) {
|
||||
// Try to get recipes from virtual scroller state
|
||||
const items = state.virtualScroller?.items || [];
|
||||
items.forEach(recipe => {
|
||||
if (recipe.file_path && state.selectedModels.has(recipe.file_path)) {
|
||||
selectedRecipes.push(recipe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedRecipes.length === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1299,7 +1299,6 @@ class RecipeModal {
|
||||
|
||||
// New method to navigate to the LoRAs page
|
||||
navigateToLorasPage(specificLoraIndex = null) {
|
||||
debugger;
|
||||
// Close the current modal
|
||||
modalManager.closeModal('recipeModal');
|
||||
|
||||
|
||||
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal file
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal file
@@ -0,0 +1,357 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
|
||||
/**
|
||||
* Manager for downloading missing LoRAs for selected recipes in bulk
|
||||
*/
|
||||
export class BulkMissingLoraDownloadManager {
|
||||
constructor() {
|
||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||
this.pendingLoras = [];
|
||||
this.pendingRecipes = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect missing LoRAs from selected recipes with deduplication
|
||||
* @param {Array} selectedRecipes - Array of selected recipe objects
|
||||
* @returns {Object} - Object containing unique missing LoRAs and statistics
|
||||
*/
|
||||
collectMissingLoras(selectedRecipes) {
|
||||
const uniqueLoras = new Map(); // key: hash or modelVersionId, value: lora object
|
||||
const missingLorasByRecipe = new Map();
|
||||
let totalMissingCount = 0;
|
||||
|
||||
selectedRecipes.forEach(recipe => {
|
||||
const missingLoras = [];
|
||||
|
||||
if (recipe.loras && Array.isArray(recipe.loras)) {
|
||||
recipe.loras.forEach(lora => {
|
||||
// Only include LoRAs not in library and not deleted
|
||||
if (!lora.inLibrary && !lora.isDeleted) {
|
||||
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
|
||||
|
||||
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
|
||||
// Store the LoRA info
|
||||
uniqueLoras.set(uniqueKey, {
|
||||
...lora,
|
||||
modelId: lora.modelId || lora.model_id,
|
||||
id: lora.id || lora.modelVersionId,
|
||||
});
|
||||
}
|
||||
|
||||
missingLoras.push(lora);
|
||||
totalMissingCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (missingLoras.length > 0) {
|
||||
missingLorasByRecipe.set(recipe.id || recipe.file_path, {
|
||||
recipe,
|
||||
missingLoras
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
uniqueLoras: Array.from(uniqueLoras.values()),
|
||||
uniqueCount: uniqueLoras.size,
|
||||
totalMissingCount,
|
||||
missingLorasByRecipe
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation modal for downloading missing LoRAs
|
||||
* @param {Object} stats - Statistics about missing LoRAs
|
||||
* @returns {Promise<boolean>} - Whether user confirmed
|
||||
*/
|
||||
async showConfirmationModal(stats) {
|
||||
const { uniqueCount, totalMissingCount, uniqueLoras } = stats;
|
||||
|
||||
if (uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLoras', {}, 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store pending data for confirmation
|
||||
this.pendingLoras = uniqueLoras;
|
||||
|
||||
// Update modal content
|
||||
const messageEl = document.getElementById('bulkDownloadMissingLorasMessage');
|
||||
const listEl = document.getElementById('bulkDownloadMissingLorasList');
|
||||
const confirmBtn = document.getElementById('bulkDownloadMissingLorasConfirmBtn');
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.textContent = translate('modals.bulkDownloadMissingLoras.message', {
|
||||
uniqueCount,
|
||||
totalCount: totalMissingCount
|
||||
}, `Found ${uniqueCount} unique missing LoRAs (from ${totalMissingCount} total across selected recipes).`);
|
||||
}
|
||||
|
||||
if (listEl) {
|
||||
listEl.innerHTML = uniqueLoras.slice(0, 10).map(lora => `
|
||||
<li>
|
||||
<span class="lora-name">${lora.name || lora.file_name || 'Unknown'}</span>
|
||||
${lora.version ? `<span class="lora-version">${lora.version}</span>` : ''}
|
||||
</li>
|
||||
`).join('') +
|
||||
(uniqueLoras.length > 10 ? `
|
||||
<li class="more-items">${translate('modals.bulkDownloadMissingLoras.moreItems', { count: uniqueLoras.length - 10 }, `...and ${uniqueLoras.length - 10} more`)}</li>
|
||||
` : '');
|
||||
}
|
||||
|
||||
if (confirmBtn) {
|
||||
confirmBtn.innerHTML = `
|
||||
<i class="fas fa-download"></i>
|
||||
${translate('modals.bulkDownloadMissingLoras.downloadButton', { count: uniqueCount }, `Download ${uniqueCount} LoRA(s)`)}
|
||||
`;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
modalManager.showModal('bulkDownloadMissingLorasModal');
|
||||
|
||||
// Return a promise that will be resolved when user confirms or cancels
|
||||
return new Promise((resolve) => {
|
||||
this.confirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user confirms download in modal
|
||||
*/
|
||||
async confirmDownload() {
|
||||
modalManager.closeModal('bulkDownloadMissingLorasModal');
|
||||
|
||||
if (this.confirmResolve) {
|
||||
this.confirmResolve(true);
|
||||
this.confirmResolve = null;
|
||||
}
|
||||
|
||||
// Execute download
|
||||
await this.executeDownload(this.pendingLoras);
|
||||
this.pendingLoras = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Download missing LoRAs for selected recipes
|
||||
* @param {Array} selectedRecipes - Array of selected recipe objects
|
||||
*/
|
||||
async downloadMissingLoras(selectedRecipes) {
|
||||
if (!selectedRecipes || selectedRecipes.length === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store selected recipes
|
||||
this.pendingRecipes = selectedRecipes;
|
||||
|
||||
// Collect missing LoRAs with deduplication
|
||||
const stats = this.collectMissingLoras(selectedRecipes);
|
||||
|
||||
if (stats.uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation modal
|
||||
const confirmed = await this.showConfirmationModal(stats);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the download process
|
||||
* @param {Array} lorasToDownload - Array of unique LoRAs to download
|
||||
*/
|
||||
async executeDownload(lorasToDownload) {
|
||||
const totalLoras = lorasToDownload.length;
|
||||
|
||||
// Get LoRA root directory
|
||||
const loraRoot = await this.getLoraRoot();
|
||||
if (!loraRoot) {
|
||||
showToast('toast.recipes.noLoraRootConfigured', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate batch download ID
|
||||
const batchDownloadId = Date.now().toString();
|
||||
|
||||
// Use default paths
|
||||
const useDefaultPaths = true;
|
||||
|
||||
// Set up WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
|
||||
|
||||
// Show download progress UI
|
||||
const loadingManager = state.loadingManager;
|
||||
const updateProgress = loadingManager.showDownloadProgress(totalLoras);
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let currentLoraProgress = 0;
|
||||
|
||||
// Set up WebSocket message handler
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Handle download ID confirmation
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process progress updates
|
||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||
currentLoraProgress = data.progress;
|
||||
|
||||
const currentLora = lorasToDownload[completedDownloads + failedDownloads];
|
||||
const loraName = currentLora ? (currentLora.name || currentLora.file_name || 'Unknown') : '';
|
||||
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
bytesPerSecond: data.bytes_per_second
|
||||
};
|
||||
|
||||
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
|
||||
|
||||
// Update status message
|
||||
if (currentLoraProgress < 3) {
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.startingDownload',
|
||||
{ current: completedDownloads + failedDownloads + 1, total: totalLoras },
|
||||
`Starting download for LoRA ${completedDownloads + failedDownloads + 1}/${totalLoras}`
|
||||
)
|
||||
);
|
||||
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.downloadingLoras', {}, `Downloading LoRAs...`)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for WebSocket to connect
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = resolve;
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
// Download each LoRA sequentially
|
||||
for (let i = 0; i < lorasToDownload.length; i++) {
|
||||
const lora = lorasToDownload[i];
|
||||
|
||||
currentLoraProgress = 0;
|
||||
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.startingDownload',
|
||||
{ current: i + 1, total: totalLoras },
|
||||
`Starting download for LoRA ${i + 1}/${totalLoras}`
|
||||
)
|
||||
);
|
||||
updateProgress(0, completedDownloads, lora.name || lora.file_name || 'Unknown');
|
||||
|
||||
try {
|
||||
const modelId = lora.modelId || lora.model_id;
|
||||
const versionId = lora.id || lora.modelVersionId;
|
||||
|
||||
if (!modelId && !versionId) {
|
||||
console.warn(`Skipping LoRA without model/version ID:`, lora);
|
||||
failedDownloads++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await this.loraApiClient.downloadModel(
|
||||
modelId,
|
||||
versionId,
|
||||
loraRoot,
|
||||
'', // Empty relative path, use default paths
|
||||
useDefaultPaths,
|
||||
batchDownloadId
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||
failedDownloads++;
|
||||
} else {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
ws.close();
|
||||
|
||||
// Hide loading UI
|
||||
loadingManager.hide();
|
||||
|
||||
// Show completion message
|
||||
if (failedDownloads === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.downloadPartialSuccess', {
|
||||
completed: completedDownloads,
|
||||
total: totalLoras
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
// Refresh the recipes list to update LoRA status
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.loadRecipes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LoRA root directory from API
|
||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||
*/
|
||||
async getLoraRoot() {
|
||||
try {
|
||||
// Fetch available LoRA roots from API
|
||||
const rootsData = await this.loraApiClient.fetchModelRoots();
|
||||
|
||||
if (!rootsData || !rootsData.roots || rootsData.roots.length === 0) {
|
||||
console.error('No LoRA roots available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to get default root from settings
|
||||
const defaultRootKey = 'default_lora_root';
|
||||
const defaultRoot = state.global?.settings?.[defaultRootKey];
|
||||
|
||||
// If default root is set and exists in available roots, use it
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
return defaultRoot;
|
||||
}
|
||||
|
||||
// Otherwise, return the first available root
|
||||
return rootsData.roots[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting LoRA root:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const bulkMissingLoraDownloadManager = new BulkMissingLoraDownloadManager();
|
||||
|
||||
// Make available globally for HTML onclick handlers
|
||||
if (typeof window !== 'undefined') {
|
||||
window.bulkMissingLoraDownloadManager = bulkMissingLoraDownloadManager;
|
||||
}
|
||||
@@ -291,6 +291,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Register bulkDownloadMissingLorasModal
|
||||
const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal');
|
||||
if (bulkDownloadMissingLorasModal) {
|
||||
this.registerModal('bulkDownloadMissingLorasModal', {
|
||||
element: bulkDownloadMissingLorasModal,
|
||||
onClose: () => {
|
||||
this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user