diff --git a/web/linker.js b/web/linker.js index ec70db1..84770e6 100644 --- a/web/linker.js +++ b/web/linker.js @@ -1625,6 +1625,8 @@ class ModelLinker { this.buttonGroup = null; this.buttonId = "model-linker-button"; this.dialog = null; + this.isCheckingMissing = false; // Prevent multiple simultaneous checks + this.lastCheckedWorkflow = null; // Track to avoid duplicate checks } setup = async () => { @@ -1644,6 +1646,9 @@ class ModelLinker { } }); + // Listen for workflow load events to auto-check for missing models + this.setupAutoOpenOnMissingModels(); + // Try to use new ComfyUI button system (like ComfyUI Manager does) try { // Dynamic imports for ComfyUI's button components @@ -1670,6 +1675,430 @@ class ModelLinker { } } + /** + * Setup auto-open functionality when workflow is loaded with missing models + */ + setupAutoOpenOnMissingModels() { + // Watch for ComfyUI's Missing Models popup and inject our button + this.setupMissingModelsPopupObserver(); + + console.log('Model Linker: Missing models popup button injection enabled'); + } + + /** + * Setup MutationObserver to detect ComfyUI's Missing Models popup and inject our button + */ + setupMissingModelsPopupObserver() { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.checkAndInjectButton(node); + } + } + } + }); + + // Observe the entire document for added nodes + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + + /** + * Check if a node is the Missing Models popup and inject our buttons + */ + checkAndInjectButton(node) { + // Look for the Missing Models popup by finding elements with "Missing Models" text + const findMissingModelsDialog = (element) => { + // Check if this element or its children contain "Missing Models" heading + const headings = element.querySelectorAll ? element.querySelectorAll('h2, h3, [class*="title"], [class*="header"]') : []; + for (const heading of headings) { + if (heading.textContent?.includes('Missing Models')) { + return element; + } + } + // Also check text content directly + if (element.textContent?.includes('Missing Models') && + element.textContent?.includes('following models were not found')) { + return element; + } + return null; + }; + + const dialog = findMissingModelsDialog(node); + if (!dialog) return; + + // Check if we already injected buttons + if (dialog.querySelector('#model-linker-btn-container')) return; + + // Find a suitable place to inject the buttons + const injectButtons = () => { + // Create container for our buttons + const btnContainer = document.createElement('div'); + btnContainer.id = 'model-linker-btn-container'; + btnContainer.style.cssText = ` + display: flex; + gap: 8px; + margin: 12px 16px; + `; + + // Common button style + const btnStyle = ` + padding: 8px 14px; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.2s ease; + white-space: nowrap; + `; + + // Auto-resolve button + const autoResolveBtn = document.createElement('button'); + autoResolveBtn.id = 'model-linker-auto-resolve-btn'; + autoResolveBtn.textContent = '🔗 Auto-resolve 100%'; + autoResolveBtn.title = 'Automatically link models with 100% confidence matches'; + autoResolveBtn.style.cssText = btnStyle + `background: #2196F3;`; + + autoResolveBtn.addEventListener('mouseenter', () => { + autoResolveBtn.style.background = '#1976D2'; + }); + autoResolveBtn.addEventListener('mouseleave', () => { + autoResolveBtn.style.background = '#2196F3'; + }); + autoResolveBtn.addEventListener('click', async () => { + await this.handleAutoResolveInPopup(dialog, autoResolveBtn); + }); + + // Download missing button + const downloadBtn = document.createElement('button'); + downloadBtn.id = 'model-linker-download-btn'; + downloadBtn.textContent = '⬇ Download Missing'; + downloadBtn.title = 'Open Model Linker to download missing models with progress tracking'; + downloadBtn.style.cssText = btnStyle + `background: #4CAF50;`; + + downloadBtn.addEventListener('mouseenter', () => { + downloadBtn.style.background = '#45a049'; + }); + downloadBtn.addEventListener('mouseleave', () => { + downloadBtn.style.background = '#4CAF50'; + }); + downloadBtn.addEventListener('click', () => { + // Close the popup + const closeBtn = dialog.querySelector('button[class*="close"]') || + dialog.querySelector('svg')?.closest('button') || + Array.from(dialog.querySelectorAll('button')).find(b => b.textContent === '×' || b.innerHTML.includes('×')); + if (closeBtn) { + closeBtn.click(); + } + // Open Model Linker + this.openLinkerManager(); + }); + + btnContainer.appendChild(autoResolveBtn); + btnContainer.appendChild(downloadBtn); + + // Find the list of models (usually a scrollable container with the model items) + const modelList = dialog.querySelector('[style*="overflow"]') || + dialog.querySelector('[class*="list"]') || + dialog.querySelector('[class*="content"]'); + + if (modelList) { + // Insert before the model list + modelList.parentElement?.insertBefore(btnContainer, modelList); + } else { + // Find after the description text + const allElements = dialog.querySelectorAll('*'); + for (const el of allElements) { + if (el.textContent?.includes('following models were not found') && + el.children.length === 0) { + el.parentElement?.insertBefore(btnContainer, el.nextSibling); + break; + } + } + } + + console.log('Model Linker: Injected buttons into Missing Models popup'); + }; + + // Small delay to ensure popup is fully rendered + setTimeout(injectButtons, 100); + } + + /** + * Handle auto-resolve in the popup - resolve 100% matches and open Model Linker for remaining + */ + async handleAutoResolveInPopup(dialog, button) { + button.textContent = '⏳ Resolving...'; + button.disabled = true; + + // Close the popup first + const closeBtn = dialog.querySelector('button[class*="close"]') || + dialog.querySelector('svg')?.closest('button') || + Array.from(dialog.querySelectorAll('button')).find(b => + b.textContent === '×' || b.innerHTML.includes('×') || b.innerHTML.includes('close')); + + if (closeBtn) { + closeBtn.click(); + } + + // Small delay to let popup close + await new Promise(r => setTimeout(r, 200)); + + // Create dialog if needed + if (!this.dialog) { + this.dialog = new LinkerManagerDialog(); + } + + // Run auto-resolve for 100% matches + await this.dialog.autoResolve100Percent(); + + // Small delay then open the Model Linker UI to show remaining models + await new Promise(r => setTimeout(r, 300)); + + // Open Model Linker to handle remaining unlinked/downloadable models + this.openLinkerManager(); + } + + /** + * Mark resolved model items in the popup as linked (green) and hide download buttons + */ + removeResolvedFromPopup(dialog, resolvedFilenames) { + console.log('Model Linker: Looking for resolved filenames:', resolvedFilenames); + + // Strategy: For each filename, find text nodes containing it, + // then find the nearest Download button and mark that row + for (const filename of resolvedFilenames) { + // Get all text in the dialog and find elements containing our filename + const walker = document.createTreeWalker( + dialog, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent?.toLowerCase().includes(filename)) { + // Found text containing filename - now find parent with Download button + let parent = node.parentElement; + let attempts = 0; + + while (parent && parent !== dialog && attempts < 10) { + // Look for Download button at this level + const downloadBtn = Array.from(parent.querySelectorAll('button')) + .find(btn => btn.textContent?.includes('Download') && + !btn.id?.includes('model-linker')); + + if (downloadBtn) { + console.log('Model Linker: Found entry for', filename); + this.markEntryAsResolved(parent, downloadBtn); + break; + } + + parent = parent.parentElement; + attempts++; + } + + // Only process first match for this filename + break; + } + } + } + } + + /** + * Mark a model entry as resolved with visual feedback + */ + markEntryAsResolved(container, downloadBtn) { + // Already marked? + if (container.dataset.resolved === 'true') return; + container.dataset.resolved = 'true'; + + console.log('Model Linker: Marking entry as resolved', container); + + // Add green background/styling to the container + container.style.transition = 'all 0.3s ease'; + container.style.background = 'rgba(76, 175, 80, 0.2)'; + container.style.borderRadius = '6px'; + container.style.border = '1px solid #4CAF50'; + + // Hide the Download button and replace with badge + if (downloadBtn) { + // Create badge + const badge = document.createElement('span'); + badge.textContent = '✓ Linked'; + badge.style.cssText = ` + display: inline-flex; + align-items: center; + padding: 4px 12px; + background: #4CAF50; + color: white; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + `; + + // Replace download button with badge + downloadBtn.style.display = 'none'; + downloadBtn.parentElement?.insertBefore(badge, downloadBtn); + } + + // Find and hide Copy URL button + const allBtns = container.querySelectorAll('button'); + for (const btn of allBtns) { + if (btn.textContent?.includes('Copy URL')) { + btn.style.display = 'none'; + } + } + } + + /** + * Count remaining model items in the popup + */ + countRemainingItems(dialog) { + // Count elements that look like model entries (have Download buttons) + const downloadButtons = dialog.querySelectorAll('button'); + let count = 0; + for (const btn of downloadButtons) { + if (btn.textContent?.includes('Download') && !btn.id?.includes('model-linker')) { + count++; + } + } + return count; + } + + /** + * Update nodes directly in the graph without triggering a full workflow reload + * This prevents the Missing Models popup from closing + */ + updateNodesDirectly(resolutions) { + if (!app?.graph) { + console.warn('Model Linker: Cannot update nodes - graph not available'); + return; + } + + for (const resolution of resolutions) { + const nodeId = resolution.node_id; + const widgetIndex = resolution.widget_index; + const resolvedPath = resolution.resolved_path; + + // Find the node in the graph + const node = app.graph.getNodeById(nodeId); + if (!node) { + console.warn(`Model Linker: Node ${nodeId} not found in graph`); + continue; + } + + // Update the widget value + if (node.widgets && node.widgets[widgetIndex]) { + const widget = node.widgets[widgetIndex]; + widget.value = resolvedPath; + + // Trigger widget callback if it exists + if (widget.callback) { + widget.callback(resolvedPath, app.graph, node, null, null); + } + + console.log(`Model Linker: Updated node ${nodeId} widget ${widgetIndex} to ${resolvedPath}`); + } else if (node.widgets_values) { + // Fallback: update widgets_values array directly + node.widgets_values[widgetIndex] = resolvedPath; + console.log(`Model Linker: Updated node ${nodeId} widgets_values[${widgetIndex}] to ${resolvedPath}`); + } + + // Mark node as dirty to trigger redraw + if (node.setDirtyCanvas) { + node.setDirtyCanvas(true, true); + } + } + + // Trigger canvas redraw + if (app.graph.setDirtyCanvas) { + app.graph.setDirtyCanvas(true, true); + } + } + + /** + * Check if auto-open is enabled in user settings + */ + isAutoOpenEnabled() { + return localStorage.getItem('modelLinker.autoOpenOnMissing') !== 'false'; + } + + /** + * Set auto-open preference + */ + setAutoOpenEnabled(enabled) { + localStorage.setItem('modelLinker.autoOpenOnMissing', enabled ? 'true' : 'false'); + } + + /** + * Check for missing models and auto-open dialog if any are found + */ + async checkAndOpenForMissingModels() { + // Check if auto-open is enabled + if (!this.isAutoOpenEnabled()) { + return; + } + + // Prevent multiple simultaneous checks + if (this.isCheckingMissing) { + return; + } + + this.isCheckingMissing = true; + + try { + // Small delay to let workflow fully load + await new Promise(r => setTimeout(r, 500)); + + // Get current workflow + const workflow = app?.graph?.serialize(); + if (!workflow) { + return; + } + + // Create a simple hash to detect if workflow changed + const workflowHash = JSON.stringify(workflow.nodes?.map(n => n.type + ':' + JSON.stringify(n.widgets_values || []))); + + // Skip if we already checked this exact workflow + if (this.lastCheckedWorkflow === workflowHash) { + return; + } + this.lastCheckedWorkflow = workflowHash; + + // Call analyze endpoint to check for missing models + const response = await api.fetchApi('/model_linker/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow }) + }); + + if (!response.ok) { + console.warn('Model Linker: Failed to analyze workflow for missing models'); + return; + } + + const data = await response.json(); + + // Auto-open dialog if there are missing models + if (data.total_missing > 0) { + console.log(`Model Linker: Found ${data.total_missing} missing model(s), opening dialog...`); + this.openLinkerManager(); + } + + } catch (error) { + console.error('Model Linker: Error checking for missing models:', error); + } finally { + this.isCheckingMissing = false; + } + } + removeExistingButton() { // Remove any existing button by ID const existingButton = document.getElementById(this.buttonId);