From d03d8545a3efc67c3e412989bb36064846192c29 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 18 Jan 2026 23:45:44 +0100 Subject: [PATCH] Add bulk download and cancel all functionality - Add Download All Missing button to download models without local matches - Button toggles to Cancel All (N) during active downloads - Fix notification showing null for info type messages - Preserve download state when dialog is closed and reopened - Show downloads in progress message instead of all available during downloads --- web/linker.js | 260 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 246 insertions(+), 14 deletions(-) diff --git a/web/linker.js b/web/linker.js index 6a790b4..ec70db1 100644 --- a/web/linker.js +++ b/web/linker.js @@ -130,6 +130,20 @@ class LinkerManagerDialog extends ComfyDialog { } createFooter() { + // Store reference to download all button so we can update its text + this.downloadAllButton = $el("button", { + textContent: "Download All Missing", + onclick: () => this.handleDownloadAllClick(), + className: "comfy-button", + style: { + padding: "8px 16px", + background: "#4CAF50", + color: "white", + border: "none", + borderRadius: "4px" + } + }); + return $el("div", { style: { padding: "16px", @@ -139,6 +153,7 @@ class LinkerManagerDialog extends ComfyDialog { gap: "8px" } }, [ + this.downloadAllButton, $el("button", { textContent: "Auto-Resolve 100% Matches", onclick: () => this.autoResolve100Percent(), @@ -150,9 +165,62 @@ class LinkerManagerDialog extends ComfyDialog { ]); } + /** + * Handle click on Download All / Cancel All button + */ + handleDownloadAllClick() { + if (Object.keys(this.activeDownloads).length > 0) { + // Cancel all active downloads + this.cancelAllDownloads(); + } else { + // Start downloading all missing + this.downloadAllMissing(); + } + } + + /** + * Cancel all active downloads + */ + async cancelAllDownloads() { + const downloadIds = Object.keys(this.activeDownloads); + if (downloadIds.length === 0) return; + + this.showNotification(`Cancelling ${downloadIds.length} download${downloadIds.length > 1 ? 's' : ''}...`, 'info'); + + for (const downloadId of downloadIds) { + try { + await api.fetchApi(`/model_linker/cancel/${downloadId}`, { + method: 'POST' + }); + } catch (error) { + console.error('Model Linker: Error cancelling download:', error); + } + } + } + + /** + * Update the Download All button state based on active downloads + */ + updateDownloadAllButtonState() { + if (!this.downloadAllButton) return; + + const activeCount = Object.keys(this.activeDownloads).length; + if (activeCount > 0) { + this.downloadAllButton.textContent = `Cancel All (${activeCount})`; + this.downloadAllButton.style.background = "#f44336"; // Red for cancel + } else { + this.downloadAllButton.textContent = "Download All Missing"; + this.downloadAllButton.style.background = "#4CAF50"; // Green for download + } + } + async show() { this.backdrop.style.display = "block"; this.element.style.display = "flex"; + + // Update button state in case there are active downloads + this.updateDownloadAllButtonState(); + await this.loadWorkflowData(); } @@ -194,6 +262,9 @@ class LinkerManagerDialog extends ComfyDialog { const data = await response.json(); this.displayMissingModels(this.contentElement, data); + + // Reconnect any active downloads to their new progress divs + this.reconnectActiveDownloads(); } catch (error) { console.error('Model Linker: Error loading workflow data:', error); @@ -220,17 +291,87 @@ class LinkerManagerDialog extends ComfyDialog { return null; } + /** + * Reconnect active downloads to their new progress div elements after UI refresh + */ + reconnectActiveDownloads() { + if (!this.contentElement) return; + + for (const [downloadId, info] of Object.entries(this.activeDownloads)) { + const { missing } = info; + if (!missing) continue; + + // Find the new progress div by ID + const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; + const newProgressDiv = this.contentElement.querySelector(`#${progressId}`); + const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); + + if (newProgressDiv) { + // Update the reference + info.progressDiv = newProgressDiv; + info.downloadBtn = newDownloadBtn; + + // Show that download is in progress + newProgressDiv.style.display = 'block'; + newProgressDiv.innerHTML = ` +
+
+
+
+
+ +
+
Downloading...
+
+ `; + + // Attach cancel handler + const cancelBtn = newProgressDiv.querySelector('.cancel-download-btn'); + if (cancelBtn) { + cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId)); + } + + // Update download button if exists + if (newDownloadBtn) { + newDownloadBtn.disabled = true; + newDownloadBtn.textContent = 'Downloading...'; + } + } + } + } + /** * Display missing models in the dialog */ displayMissingModels(container, data) { const missingModels = data.missing_models || []; const totalMissing = data.total_missing || 0; + + // Check if there are active downloads + const activeCount = Object.keys(this.activeDownloads).length; - if (totalMissing === 0) { + if (totalMissing === 0 && activeCount === 0) { container.innerHTML = '

✓ No missing models found. All models are available!

'; return; } + + // If no missing models but downloads are active, show a waiting message + if (totalMissing === 0 && activeCount > 0) { + container.innerHTML = ` +
+

+ ℹ ${activeCount} download${activeCount > 1 ? 's' : ''} in progress... +

+

+ Models will be automatically linked when downloads complete. +

+
+ `; + return; + } let html = `

Found ${totalMissing} missing model(s):

`; html += '
'; @@ -488,6 +629,35 @@ class LinkerManagerDialog extends ComfyDialog { * Show a notification banner (similar to ComfyUI's "Reconnecting" banner) */ showNotification(message, type = 'success') { + // Build children array, filtering out nulls + const children = []; + + if (type === 'success') { + children.push($el("span", { + textContent: "✓", + style: { + fontSize: "18px", + fontWeight: "bold" + } + })); + } else if (type === 'error') { + children.push($el("span", { + textContent: "×", + style: { + fontSize: "18px", + fontWeight: "bold" + } + })); + } else if (type === 'info') { + children.push($el("span", { + textContent: "ℹ", + style: { + fontSize: "18px", + fontWeight: "bold" + } + })); + } + // Create notification banner const notification = $el("div", { style: { @@ -512,19 +682,7 @@ class LinkerManagerDialog extends ComfyDialog { animation: "slideDown 0.3s ease" } }, [ - type === 'success' ? $el("span", { - textContent: "✓", - style: { - fontSize: "18px", - fontWeight: "bold" - } - }) : type === 'error' ? $el("span", { - textContent: "×", - style: { - fontSize: "18px", - fontWeight: "bold" - } - }) : null, + ...children, $el("span", { textContent: message }), @@ -753,6 +911,70 @@ class LinkerManagerDialog extends ComfyDialog { } } + /** + * Download all missing models that have download sources but no 100% local match + */ + async downloadAllMissing() { + if (!this.contentElement) return; + + try { + const workflow = this.getCurrentWorkflow(); + if (!workflow) { + this.showNotification('No workflow loaded', 'error'); + return; + } + + // Analyze workflow first + const analyzeResponse = await api.fetchApi('/model_linker/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow }) + }); + + if (!analyzeResponse.ok) { + throw new Error(`API error: ${analyzeResponse.status}`); + } + + const analyzeData = await analyzeResponse.json(); + const missingModels = analyzeData.missing_models || []; + + // Collect models that need downloading: + // - Have a download_source with valid URL + // - Do NOT have any 100% confidence local matches + const toDownload = []; + for (const missing of missingModels) { + const perfectMatches = (missing.matches || []).filter(m => m.confidence === 100); + + // Skip if has 100% local match or no download source + if (perfectMatches.length > 0 || !missing.download_source?.url) { + continue; + } + + toDownload.push(missing); + } + + if (toDownload.length === 0) { + this.showNotification('No models available for download (all have local matches or no download URLs).', 'info'); + return; + } + + // Start all downloads + this.showNotification(`Starting ${toDownload.length} download${toDownload.length > 1 ? 's' : ''}...`, 'info'); + + for (const missing of toDownload) { + // Use downloadModel which handles progress tracking + this.downloadModel(missing); + } + + // Update button state to show Cancel All + this.updateDownloadAllButtonState(); + + } catch (error) { + console.error('Model Linker: Error in downloadAllMissing:', error); + this.showNotification('Error starting downloads: ' + error.message, 'error'); + } + } + /** * Auto-resolve a model after download completes * Reloads the workflow analysis and resolves if the downloaded model is found @@ -915,6 +1137,9 @@ class LinkerManagerDialog extends ComfyDialog { const downloadId = data.download_id; this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn }; + // 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) { @@ -1002,6 +1227,7 @@ class LinkerManagerDialog extends ComfyDialog { downloadBtn.style.background = '#4CAF50'; } delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); this.showNotification(`Downloaded: ${progress.filename}`, 'success'); // Auto-resolve: Reload workflow data and try to resolve the downloaded model @@ -1019,6 +1245,7 @@ class LinkerManagerDialog extends ComfyDialog { downloadBtn.textContent = 'Retry'; } delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); } else if (progress.status === 'cancelled') { if (progressDiv) { @@ -1030,6 +1257,7 @@ class LinkerManagerDialog extends ComfyDialog { downloadBtn.style.background = '#4CAF50'; // Reset to green } delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); this.showNotification('Download cancelled', 'info'); } else { @@ -1053,6 +1281,7 @@ class LinkerManagerDialog extends ComfyDialog { } } delete this.activeDownloads[downloadId]; + this.updateDownloadAllButtonState(); } } @@ -1279,6 +1508,9 @@ class LinkerManagerDialog extends ComfyDialog { const downloadId = data.download_id; this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn: btn }; + // 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) {