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 = `
+
+ `;
+
+ // 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) {