diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 5f1c939..40517b5 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -490,6 +490,232 @@ export const resolveDownloadMethods = { }); }, + getDownloadProgressElementId(missing = {}) { + return `download-progress-${missing.node_id}-${missing.widget_index}`; + }, + + getDownloadButtonElementId(missing = {}) { + return `download-${missing.node_id}-${missing.widget_index}`; + }, + + getDownloadProgressStore() { + if (!(this.downloadProgressByMissingKey instanceof Map)) { + this.downloadProgressByMissingKey = new Map(); + } + return this.downloadProgressByMissingKey; + }, + + getDownloadStateKey(missing) { + return this.getMissingSearchKey?.(missing) || this.getMissingModelKey(missing); + }, + + rememberDownloadSnapshotForMissing(missing, snapshot = {}) { + if (!missing) return null; + + const key = this.getDownloadStateKey(missing); + const store = this.getDownloadProgressStore(); + const previous = store.get(key) || {}; + const progress = { + ...(previous.progress || {}), + ...(snapshot.progress || {}) + }; + const next = { + ...previous, + ...snapshot, + missing, + progress, + updatedAt: Date.now() + }; + + store.set(key, next); + return next; + }, + + rememberDownloadUiState(downloadId, info, progress = {}, options = {}) { + if (!info?.missing) return null; + + this.rememberDownloadLocation(info, progress); + const status = options.status || progress.status || info.lastStatus || 'starting'; + const snapshot = this.rememberDownloadSnapshotForMissing(info.missing, { + downloadId, + category: info.category || '', + filename: progress.filename || info.filename || '', + downloadPath: progress.path || info.downloadPath || '', + downloadDirectory: progress.directory || info.downloadDirectory || '', + baseDirectory: info.baseDirectory || '', + sourceUrl: info.sourceUrl || '', + progress: { + ...progress, + status, + filename: progress.filename || info.filename || '', + path: progress.path || info.downloadPath || '', + directory: progress.directory || info.downloadDirectory || '' + }, + status, + message: options.message || '', + type: options.type || '', + isActive: options.isActive ?? Boolean(this.activeDownloads?.[downloadId]) + }); + + info.lastProgress = snapshot.progress; + info.lastStatus = status; + info.statusSnapshot = snapshot; + return snapshot; + }, + + getActiveDownloadEntryForMissing(missing) { + if (!missing) return null; + const missingKey = this.getDownloadStateKey(missing); + + for (const [downloadId, info] of Object.entries(this.activeDownloads || {})) { + if (info?.missing && this.getDownloadStateKey(info.missing) === missingKey) { + return { downloadId, info }; + } + } + return null; + }, + + getDownloadSnapshotForMissing(missing) { + const active = this.getActiveDownloadEntryForMissing(missing); + if (active) { + return this.rememberDownloadUiState( + active.downloadId, + active.info, + active.info.lastProgress || { status: active.info.lastStatus || 'starting', progress: 0 }, + { isActive: true } + ); + } + return this.getDownloadProgressStore().get(this.getDownloadStateKey(missing)) || null; + }, + + resolveDownloadUiElements(target = {}) { + const missing = target?.missing; + if (!missing) return { progressDiv: null, downloadBtn: null }; + + const progressId = this.getDownloadProgressElementId(missing); + const buttonId = this.getDownloadButtonElementId(missing); + const connectedProgressDiv = target.progressDiv?.isConnected ? target.progressDiv : null; + const connectedDownloadBtn = target.downloadBtn?.isConnected ? target.downloadBtn : null; + const progressDiv = this.contentElement?.querySelector(`#${progressId}`) || connectedProgressDiv; + let downloadBtn = this.contentElement?.querySelector(`#${buttonId}`) || connectedDownloadBtn; + + if (!downloadBtn && target.sourceUrl && this.contentElement) { + downloadBtn = Array.from(this.contentElement.querySelectorAll('.search-download-btn')).find(btn => ( + btn.dataset.url === target.sourceUrl + && (!target.filename || btn.dataset.filename === target.filename) + )) || null; + } + + if ('progressDiv' in target) target.progressDiv = progressDiv; + if ('downloadBtn' in target) target.downloadBtn = downloadBtn; + return { progressDiv, downloadBtn }; + }, + + attachDownloadCancelHandler(progressDiv, downloadId) { + if (!progressDiv || !downloadId) return; + progressDiv.querySelectorAll('.cancel-download-btn, .cancel-download-btn-pending').forEach(cancelBtn => { + if (cancelBtn._hasListener) return; + cancelBtn._hasListener = true; + cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); + }); + }, + + renderDownloadSnapshot(downloadId, snapshot, elements = {}) { + if (!snapshot) return; + + const { progressDiv, downloadBtn } = elements.progressDiv || elements.downloadBtn + ? elements + : this.resolveDownloadUiElements(snapshot); + const progress = snapshot.progress || {}; + const status = snapshot.status || progress.status || ''; + const isActive = snapshot.isActive || Boolean(downloadId && this.activeDownloads?.[downloadId]); + const terminalStatuses = new Set(['cancelling', 'cancelled', 'error', 'refresh_error', 'completed_checking', 'completed']); + const shouldRenderProgress = status === 'starting' + || status === 'downloading' + || (isActive && !terminalStatuses.has(status)); + + if (progressDiv) { + progressDiv.classList.remove('mr-is-hidden'); + progressDiv.classList.add('mr-is-visible'); + + if (shouldRenderProgress) { + const canCancel = Boolean(downloadId); + const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0)); + const downloaded = this.formatBytes(progress.downloaded || 0); + const total = this.formatBytes(progress.total_size || 0); + const speed = progress.speed ? this.formatBytes(progress.speed) + '/s' : ''; + const leftText = progress.total_size + ? `${downloaded} / ${total} (${percent}%)` + : 'Connecting...'; + progressDiv.innerHTML = this.renderProgressWithAction({ + percent, + leftText, + rightText: speed, + actionClass: canCancel ? 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm' : '', + actionText: canCancel ? 'Cancel' : '', + actionDataAttr: canCancel ? `data-download-id="${downloadId}"` : '', + contextMenuModel: this.getDownloadFolderContext(progress, snapshot) + }); + this.attachDownloadCancelHandler(progressDiv, downloadId); + } else if (status === 'cancelled') { + progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning'); + } else if (status === 'cancelling') { + progressDiv.innerHTML = this.renderDownloadStatusMessage( + snapshot.message || 'Cancelling download...', + snapshot.type || 'info', + progress, + snapshot + ); + } else if (status === 'error' || status === 'refresh_error') { + progressDiv.innerHTML = this.renderDownloadStatusMessage( + snapshot.message || progress.error || 'Download failed', + snapshot.type || (status === 'refresh_error' ? 'warning' : 'error'), + progress, + snapshot + ); + } else if (status === 'completed_checking') { + progressDiv.innerHTML = this.renderDownloadStatusMessage( + snapshot.message || 'Download complete. Checking local matches...', + snapshot.type || 'success', + progress, + snapshot + ); + } else if (status === 'completed') { + progressDiv.innerHTML = this.renderDownloadStatusMessage( + snapshot.message || 'Download complete. Local matches updated.', + snapshot.type || 'success', + progress, + snapshot + ); + } + } + + if (!downloadBtn) return; + + if (shouldRenderProgress) { + const percent = Number(progress.progress); + downloadBtn.disabled = true; + downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary'); + downloadBtn.textContent = Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...'; + } else if (status === 'cancelling') { + downloadBtn.disabled = true; + downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary'); + downloadBtn.textContent = 'Cancelling...'; + } else if (status === 'error' || status === 'refresh_error') { + downloadBtn.disabled = false; + downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary'); + downloadBtn.textContent = 'Retry'; + } else { + this.restoreDownloadButtonReadyState(downloadBtn); + } + }, + + restoreDownloadProgressForMissing(missing) { + const snapshot = this.getDownloadSnapshotForMissing(missing); + if (!snapshot) return; + this.renderDownloadSnapshot(snapshot.downloadId, snapshot); + }, + async refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, { progressDiv = null, category = '', downloadPath = '', downloadDirectory = '' } = {}) { if (!missing) return []; @@ -605,31 +831,57 @@ export const resolveDownloadMethods = { return match.confidence === 100 || (downloadedLower && matchFilename.toLowerCase() === downloadedLower); }); + const message = perfectMatch?.model + ? 'Download complete. Exact local match is ready.' + : 'Download complete. Local matches updated.'; + const snapshot = this.rememberDownloadSnapshotForMissing(missing, { + downloadId: null, + category, + filename: downloadedFilename, + downloadPath, + downloadDirectory, + progress: { + status: 'completed', + filename: downloadedFilename, + path: downloadPath, + directory: downloadDirectory + }, + status: 'completed', + message, + type: 'success', + isActive: false + }); - if (progressDiv) { - progressDiv.innerHTML = this.renderDownloadStatusMessage( - perfectMatch?.model - ? 'Download complete. Exact local match is ready.' - : 'Download complete. Local matches updated.', - 'success', - { filename: downloadedFilename, path: downloadPath, directory: downloadDirectory }, - { category, downloadPath, downloadDirectory, filename: downloadedFilename } - ); - } - if (downloadBtn) { - this.restoreDownloadButtonReadyState(downloadBtn); - } + this.renderDownloadSnapshot( + null, + snapshot, + progressDiv || downloadBtn ? { progressDiv, downloadBtn } : {} + ); } catch (error) { console.error('Model Resolver: Error refreshing after download:', error); - if (progressDiv) { - progressDiv.innerHTML = this.renderDownloadStatusMessage( - `Downloaded, but local re-check failed: ${error.message}`, - 'warning', - { filename: downloadedFilename, path: downloadPath, directory: downloadDirectory }, - { category, downloadPath, downloadDirectory, filename: downloadedFilename } - ); - } + const snapshot = this.rememberDownloadSnapshotForMissing(missing, { + downloadId: null, + category, + filename: downloadedFilename, + downloadPath, + downloadDirectory, + progress: { + status: 'refresh_error', + filename: downloadedFilename, + path: downloadPath, + directory: downloadDirectory + }, + status: 'refresh_error', + message: `Downloaded, but local re-check failed: ${error.message}`, + type: 'warning', + isActive: false + }); + this.renderDownloadSnapshot( + null, + snapshot, + progressDiv || downloadBtn ? { progressDiv, downloadBtn } : {} + ); if (downloadBtn) { downloadBtn.disabled = false; downloadBtn.textContent = 'Link manually'; @@ -660,12 +912,25 @@ export const resolveDownloadMethods = { const category = targetSelection.category; const subfolder = targetSelection.subfolder; const baseDirectory = targetSelection.baseDirectory || ''; - const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; + const progressId = this.getDownloadProgressElementId(missing); const progressDiv = this.contentElement?.querySelector(`#${progressId}`); - const downloadBtn = this.contentElement?.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); + const downloadBtn = this.contentElement?.querySelector(`#${this.getDownloadButtonElementId(missing)}`); const tokens = this.getStoredTokens(); try { + this.rememberDownloadSnapshotForMissing(missing, { + downloadId: null, + category, + filename, + downloadPath: '', + downloadDirectory: '', + baseDirectory, + sourceUrl: source.url, + progress: { status: 'starting', progress: 0, filename }, + status: 'starting', + isActive: true + }); + // Disable button and show progress with cancel button immediately if (downloadBtn) { downloadBtn.disabled = true; @@ -719,24 +984,46 @@ export const resolveDownloadMethods = { filename, downloadPath: data.path || '', downloadDirectory: data.directory || '', - baseDirectory + baseDirectory, + sourceUrl: source.url }; + const snapshot = this.rememberDownloadUiState( + downloadId, + this.activeDownloads[downloadId], + { + status: 'starting', + progress: 0, + filename, + path: data.path || '', + directory: data.directory || '' + }, + { isActive: true } + ); // Update the Download All button state this.updateDownloadAllButtonState(); - - // Attach cancel handler to pending button (before polling replaces it) - const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending'); - if (pendingCancelBtn) { - pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); - } + this.renderDownloadSnapshot(downloadId, snapshot); this.pollDownloadProgress(downloadId); } catch (error) { console.error('Model Resolver: Download error:', error); + const snapshot = this.rememberDownloadSnapshotForMissing(missing, { + downloadId: null, + category, + filename, + downloadPath: '', + downloadDirectory: '', + baseDirectory, + sourceUrl: source.url, + progress: { status: 'error', filename, error: error.message }, + status: 'error', + message: error.message, + type: 'error', + isActive: false + }); if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error'); + this.renderDownloadSnapshot(null, snapshot, { progressDiv, downloadBtn }); } if (downloadBtn) { downloadBtn.disabled = false; @@ -761,52 +1048,25 @@ export const resolveDownloadMethods = { } const progress = await response.json(); - const { progressDiv, downloadBtn, missing, category } = info; - this.rememberDownloadLocation(info, progress); + const snapshot = this.rememberDownloadUiState(downloadId, info, progress, { isActive: true }); + const { progressDiv, downloadBtn } = this.resolveDownloadUiElements(info); + const { missing, category } = info; const downloadFolderContext = this.getDownloadFolderContext(progress, info); if (progress.status === 'downloading' || progress.status === 'starting') { - const percent = progress.progress || 0; - const downloaded = this.formatBytes(progress.downloaded || 0); - const total = this.formatBytes(progress.total_size || 0); - const speed = progress.speed ? this.formatBytes(progress.speed) + '/s' : ''; - - if (progressDiv) { - progressDiv.innerHTML = this.renderProgressWithAction({ - percent, - leftText: `${downloaded} / ${total} (${percent}%)`, - rightText: speed, - actionClass: 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm', - actionText: 'Cancel', - actionDataAttr: `data-download-id="${downloadId}"`, - contextMenuModel: downloadFolderContext - }); - // Attach cancel handler - const cancelBtn = progressDiv.querySelector('.cancel-download-btn'); - if (cancelBtn && !cancelBtn._hasListener) { - cancelBtn._hasListener = true; - cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); - } - } - if (downloadBtn) { - downloadBtn.textContent = `${percent}%`; - } + this.renderDownloadSnapshot(downloadId, snapshot, { progressDiv, downloadBtn }); // Continue polling setTimeout(() => this.pollDownloadProgress(downloadId), 1000); } else if (progress.status === 'completed') { - if (progressDiv) { - progressDiv.innerHTML = this.renderDownloadStatusMessage( - 'Download complete. Checking local matches...', - 'success', - progress, - info - ); - } - if (downloadBtn) { - this.restoreDownloadButtonReadyState(downloadBtn); - } + const completedSnapshot = this.rememberDownloadUiState(downloadId, info, progress, { + status: 'completed_checking', + message: 'Download complete. Checking local matches...', + type: 'success', + isActive: false + }); + this.renderDownloadSnapshot(downloadId, completedSnapshot, { progressDiv, downloadBtn }); delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); this.showNotification(`Downloaded: ${progress.filename}`, 'success', { @@ -816,9 +1076,10 @@ export const resolveDownloadMethods = { // Refresh local matches only for this downloaded missing model. // Small delay to ensure file system is updated. setTimeout(async () => { + const currentElements = this.resolveDownloadUiElements(info); await this.refreshAfterDownload(missing, progress.filename, { - progressDiv, - downloadBtn, + progressDiv: currentElements.progressDiv, + downloadBtn: currentElements.downloadBtn, category, downloadPath: progress.path || info.downloadPath || '', downloadDirectory: progress.directory || info.downloadDirectory || '' @@ -826,31 +1087,23 @@ export const resolveDownloadMethods = { }, 500); } else if (progress.status === 'error') { - if (progressDiv) { - progressDiv.innerHTML = this.renderDownloadStatusMessage( - progress.error || 'Download failed', - 'error', - progress, - info - ); - } - if (downloadBtn) { - downloadBtn.disabled = false; - downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary'); - downloadBtn.textContent = 'Retry'; - } + const errorSnapshot = this.rememberDownloadUiState(downloadId, info, progress, { + status: 'error', + message: progress.error || 'Download failed', + type: 'error', + isActive: false + }); + this.renderDownloadSnapshot(downloadId, errorSnapshot, { progressDiv, downloadBtn }); delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); } else if (progress.status === 'cancelled') { - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning'); - } - if (downloadBtn) { - downloadBtn.disabled = false; - downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary'); - downloadBtn.innerHTML = ' Download'; - } + const cancelledSnapshot = this.rememberDownloadUiState(downloadId, info, progress, { + status: 'cancelled', + type: 'warning', + isActive: false + }); + this.renderDownloadSnapshot(downloadId, cancelledSnapshot, { progressDiv, downloadBtn }); delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); this.showNotification('Download cancelled', 'info'); @@ -865,16 +1118,18 @@ export const resolveDownloadMethods = { const info = this.activeDownloads[downloadId]; // Update UI to show error state instead of just disappearing if (info) { - const { progressDiv, downloadBtn } = info; - if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage('Connection lost - download may have failed', 'error'); - } - if (downloadBtn) { - downloadBtn.disabled = false; - downloadBtn.textContent = 'Retry'; - downloadBtn.classList.remove('mr-btn-primary'); - downloadBtn.classList.add('mr-is-success-action'); - } + const snapshot = this.rememberDownloadUiState( + downloadId, + info, + { ...(info.lastProgress || {}), status: 'error' }, + { + status: 'error', + message: 'Connection lost - download may have failed', + type: 'error', + isActive: false + } + ); + this.renderDownloadSnapshot(downloadId, snapshot); } delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); @@ -906,8 +1161,19 @@ export const resolveDownloadMethods = { } const info = this.activeDownloads[downloadId]; - if (info?.progressDiv) { - info.progressDiv.innerHTML = this.renderStatusMessage('Cancelling download...', 'info'); + if (info) { + const snapshot = this.rememberDownloadUiState( + downloadId, + info, + info.lastProgress || { status: 'cancelling', progress: 0 }, + { + status: 'cancelling', + message: 'Cancelling download...', + type: 'info', + isActive: true + } + ); + this.renderDownloadSnapshot(downloadId, snapshot); } } catch (error) { @@ -1678,12 +1944,25 @@ export const resolveDownloadMethods = { * Download from search results */ async downloadFromSearch(missing, url, filename, category, btn) { - const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; + const progressId = this.getDownloadProgressElementId(missing); const progressDiv = this.contentElement?.querySelector(`#${progressId}`); const tokens = this.getStoredTokens(); const targetSelection = this.getDownloadTargetSelection(missing, category || missing.category || 'checkpoints'); try { + this.rememberDownloadSnapshotForMissing(missing, { + downloadId: null, + category: targetSelection.category, + filename, + downloadPath: '', + downloadDirectory: '', + baseDirectory: targetSelection.baseDirectory || '', + sourceUrl: url, + progress: { status: 'starting', progress: 0, filename }, + status: 'starting', + isActive: true + }); + btn.disabled = true; btn.classList.remove('mr-is-success-action', 'mr-btn-primary'); btn.textContent = 'Starting...'; @@ -1734,24 +2013,46 @@ export const resolveDownloadMethods = { filename, downloadPath: data.path || '', downloadDirectory: data.directory || '', - baseDirectory: targetSelection.baseDirectory || '' + baseDirectory: targetSelection.baseDirectory || '', + sourceUrl: url }; + const snapshot = this.rememberDownloadUiState( + downloadId, + this.activeDownloads[downloadId], + { + status: 'starting', + progress: 0, + filename, + path: data.path || '', + directory: data.directory || '' + }, + { isActive: true } + ); // Update the Download All button state this.updateDownloadAllButtonState(); - - // Attach cancel handler to pending button (before polling replaces it) - const pendingCancelBtn = progressDiv?.querySelector('.cancel-download-btn-pending'); - if (pendingCancelBtn) { - pendingCancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); - } + this.renderDownloadSnapshot(downloadId, snapshot); this.pollDownloadProgress(downloadId); } catch (error) { console.error('Model Resolver: Download error:', error); + const snapshot = this.rememberDownloadSnapshotForMissing(missing, { + downloadId: null, + category: targetSelection.category, + filename, + downloadPath: '', + downloadDirectory: '', + baseDirectory: targetSelection.baseDirectory || '', + sourceUrl: url, + progress: { status: 'error', filename, error: error.message }, + status: 'error', + message: error.message, + type: 'error', + isActive: false + }); if (progressDiv) { - progressDiv.innerHTML = this.renderStatusMessage(error.message, 'error'); + this.renderDownloadSnapshot(null, snapshot, { progressDiv, downloadBtn: btn }); } btn.disabled = false; btn.classList.remove('mr-is-success-action', 'mr-btn-primary'); diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 3d90259..86dee56 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -32,6 +32,7 @@ export class ResolverManagerDialog extends ComfyDialog { this.workflowLoadedModelCaches = new Map(); // workflow key -> loaded model inspector data this.workflowSearchResultCaches = new Map(); // workflow key -> search results by missing model this.activeDownloads = {}; // Track active downloads + this.downloadProgressByMissingKey = new Map(); // missing model key -> last known download UI state this.searchResultCache = new Map(); this.backgroundSearchJobs = new Map(); this.searchProgressTimers = new Map(); diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 16dc8f5..c84b499 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -1347,13 +1347,16 @@ export const searchPanelMethods = { const containerClass = contextMenuData ? 'mr-progress-container mr-download-folder-context' : 'mr-progress-container'; + const actionHtml = actionClass || actionText + ? `` + : ''; return `