import { app } from "../../../../scripts/app.js"; import { api } from "../../../../scripts/api.js"; import { $el } from "../../../../scripts/ui.js"; import { createModuleLogger } from "../log_system/log_funcs.js"; import { logger as frontendLogger } from "../log_system/logger.js"; import { loadStylesWhenNeeded } from "../utils/css_loader.js"; import { isSidebarButtonActive } from "./utils/dom_patch_utils.js"; import { ResolverManagerDialog } from "./resolver_dialog.js"; import { showNotification } from "../utils/notification_utils.js"; import { serializeKeybindingCombo, keybindingsEqual, getComboLabel, } from "./utils/keybinding_utils.js"; import { buildModelResolverNodeMenu, getImmediateModelsForNode, getResolvedModelsForNode, isExistingResolvedModel, matchesWorkflowModelReference, toResolverContextModel, } from "./node_context_menu.js"; import { getCustomNodeModelAdapter, getCustomNodeModelListSignature as createCustomNodeModelListSignature, getCustomNodeModelStrengthSignature as createCustomNodeModelStrengthSignature, isCustomNodeModelWidget as matchesCustomNodeModelWidget, } from "./custom_nodes/registry.js"; const log = createModuleLogger('model_resolver'); export const MODEL_RESOLVER_OPEN_COMMAND_ID = "ModelResolver.OpenModelResolver"; export const MODEL_RESOLVER_OPEN_DEFAULT_KEYBINDING = Object.freeze({ commandId: MODEL_RESOLVER_OPEN_COMMAND_ID, combo: Object.freeze({ key: "|", ctrl: true, shift: true }), targetElementId: "graph-canvas", }); const OPEN_TOOLTIP_BASE = "Open Model Resolver to find or download missing workflow models."; const KEYBINDING_NEW_SETTING_ID = "Comfy.Keybinding.NewBindings"; const KEYBINDING_UNSET_SETTING_ID = "Comfy.Keybinding.UnsetBindings"; const LEGACY_WORKFLOW_HASH_MARKER_NODE_TYPE = "ModelResolverWorkflowHashes"; const WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE = "ModelResolverDependency"; const WORKFLOW_DEPENDENCY_MARKER_DISPLAY_NAME = "Model Resolver Opener"; const WORKFLOW_DEPENDENCY_MARKER_AUX_ID = "Azornes/Comfyui-Model-Resolver"; const WORKFLOW_DEPENDENCY_MARKER_REPOSITORY = "https://github.com/Azornes/Comfyui-Model-Resolver"; const WORKFLOW_DEPENDENCY_MARKER_CNR_ID = "comfyui-model-resolver"; const WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME = "Open Model Resolver"; const WORKFLOW_DEPENDENCY_MARKER_AUTO_ID = -918273646; const WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE = Object.freeze([160, 40]); const WORKFLOW_DEPENDENCY_MARKER_MIN_SIZE = Object.freeze([160, 40]); function getKeybindingSetting(id) { try { const value = app.ui?.settings?.getSettingValue?.(id); return Array.isArray(value) ? value : []; } catch (_error) { return []; } } function applyStoredFrontendLoggingPreference() { const stored = localStorage.getItem('ModelResolver.frontendLogsEnabled'); if (stored !== null) { frontendLogger.setEnabled(stored !== 'false'); } const storedLevel = localStorage.getItem('ModelResolver.frontendLogLevel'); if (storedLevel) { frontendLogger.setGlobalAndModuleLevel(frontendLogger.normalizeLevel(storedLevel)); } } // Main extension class export class ModelResolver { constructor() { this.resolverButton = null; this.buttonGroup = null; this.buttonId = "model-resolver-button"; this.sidebarTabId = "comfyui-model-resolver"; this.sidebarRegistered = false; this.openTooltipTargets = new Set(); this.openTooltip = this.buildOpenTooltip(); this.openTooltipRefreshFrame = null; this.dialog = null; this.isCheckingMissing = false; // Prevent multiple simultaneous checks this.lastCheckedWorkflow = null; // Track to avoid duplicate checks this.workflowHashMetadataCache = null; this.workflowHashMetadataSignature = null; this.workflowHashMetadataRefreshTimer = null; this.workflowHashMetadataRefreshing = false; this.workflowHashMetadataRefreshPending = false; this.workflowHashMetadataPendingWorkflow = null; this.workflowHashMetadataPendingSignature = null; this.workflowHashMetadataActiveSignature = null; this.workflowHashMetadataPreparing = false; this.nodeContextAnalysisData = null; this.nodeContextAnalysisSignature = null; this.nodeContextAnalysisPromise = null; this.nodeContextAnalysisTimer = null; this.customNodeModelAdapterStates = new WeakMap(); } setup = async () => { window.ModelResolver = this; applyStoredFrontendLoggingPreference(); loadStylesWhenNeeded(); // Remove any existing button this.removeExistingButton(); // Create dialog instance if (!this.dialog) { this.dialog = new ResolverManagerDialog(); window.ModelResolverDialog = this.dialog; } this.setupOpenShortcutTooltipTracking(); // Listen for workflow load events to auto-check for missing models this.setupAutoOpenOnMissingModels(); this.setupWorkflowHashMetadataInjection(); this.setupActiveWorkflowChangeListeners(); const sidebarRegistered = this.registerSidebarButton(); if (!sidebarRegistered) { await this.registerTopbarButton(); } else { this.attachSidebarButtonToggleHandler(); } } registerSidebarButton() { const registerSidebarTab = app.extensionManager?.registerSidebarTab; if (typeof registerSidebarTab !== "function") { return false; } if (this.sidebarRegistered || window.__ModelResolverSidebarRegistered) { this.sidebarRegistered = true; return true; } try { registerSidebarTab.call(app.extensionManager, { id: this.sidebarTabId, icon: "mdi mdi-link-variant", title: "Resolver", tooltip: this.openTooltip, type: "custom", render: (element) => this.renderSidebarPanel(element), }); this.sidebarRegistered = true; window.__ModelResolverSidebarRegistered = true; return true; } catch (error) { console.warn("Model Resolver: Sidebar tab registration failed, falling back to top menu button.", error); return false; } } attachSidebarButtonToggleHandler() { window.__ModelResolverSidebarToggleOwner = this; if (window.__ModelResolverSidebarToggleHandlerAttached) return; window.__ModelResolverSidebarToggleHandlerAttached = true; window.__ModelResolverSidebarToggleHandler = (event) => { window.__ModelResolverSidebarToggleOwner?.handleSidebarButtonClick(event); }; document.addEventListener('click', window.__ModelResolverSidebarToggleHandler, true); } setupOpenShortcutTooltipTracking() { window.__ModelResolverShortcutTooltipOwner = this; this.refreshOpenTooltip(); if (window.__ModelResolverShortcutTooltipHandlersAttached) return; window.__ModelResolverShortcutTooltipHandlersAttached = true; const refreshHandler = () => { window.__ModelResolverShortcutTooltipOwner?.scheduleOpenTooltipRefresh(); }; app.ui?.settings?.addEventListener?.(`${KEYBINDING_NEW_SETTING_ID}.change`, refreshHandler); app.ui?.settings?.addEventListener?.(`${KEYBINDING_UNSET_SETTING_ID}.change`, refreshHandler); window.addEventListener('storage', (event) => { if ( event.key === KEYBINDING_NEW_SETTING_ID || event.key === KEYBINDING_UNSET_SETTING_ID ) { refreshHandler(); } }); } scheduleOpenTooltipRefresh() { if (this.openTooltipRefreshFrame) { cancelAnimationFrame(this.openTooltipRefreshFrame); } this.openTooltipRefreshFrame = requestAnimationFrame(() => { this.openTooltipRefreshFrame = null; this.refreshOpenTooltip(); }); } getOpenKeybindings() { const byCombo = { [serializeKeybindingCombo(MODEL_RESOLVER_OPEN_DEFAULT_KEYBINDING.combo)]: MODEL_RESOLVER_OPEN_DEFAULT_KEYBINDING, }; for (const keybinding of getKeybindingSetting(KEYBINDING_UNSET_SETTING_ID)) { const serializedCombo = serializeKeybindingCombo(keybinding?.combo); if (keybindingsEqual(byCombo[serializedCombo], keybinding)) { delete byCombo[serializedCombo]; } } for (const keybinding of getKeybindingSetting(KEYBINDING_NEW_SETTING_ID)) { if (!keybinding?.combo) continue; byCombo[serializeKeybindingCombo(keybinding.combo)] = keybinding; } return Object.values(byCombo) .filter((keybinding) => keybinding?.commandId === MODEL_RESOLVER_OPEN_COMMAND_ID); } buildOpenTooltip() { const shortcutLabels = this.getOpenKeybindings() .map((keybinding) => getComboLabel(keybinding.combo)) .filter(Boolean); if (shortcutLabels.length === 0) { return `${OPEN_TOOLTIP_BASE} Shortcut: not assigned.`; } const label = shortcutLabels.length === 1 ? "Shortcut" : "Shortcuts"; return `${OPEN_TOOLTIP_BASE} ${label}: ${shortcutLabels.join(", ")}.`; } refreshOpenTooltip() { this.openTooltip = this.buildOpenTooltip(); this.applyOpenTooltipToTargets(); } trackOpenTooltipTarget(target) { if (!(target instanceof HTMLElement)) return; this.openTooltipTargets.add(target); this.applyTooltipToElement(target); } applyTooltipToElement(target) { if (!(target instanceof HTMLElement)) return; if (this.dialog?.setTooltip) { this.dialog.setTooltip(target, this.openTooltip); } else { target.setAttribute("data-tooltip", this.openTooltip); target.removeAttribute("title"); } } applyOpenTooltipToTargets() { const liveTargets = new Set(); for (const target of this.openTooltipTargets) { if (!(target instanceof HTMLElement) || !target.isConnected) continue; liveTargets.add(target); this.applyTooltipToElement(target); } this.openTooltipTargets = liveTargets; document.querySelectorAll(`.${this.sidebarTabId}-tab-button`).forEach((button) => { if (button instanceof HTMLElement) { this.applyTooltipToElement(button); } }); } getSidebarButton() { const button = document.querySelector(`.${this.sidebarTabId}-tab-button`); return button instanceof HTMLElement ? button : null; } getVisibleResolverButton() { const button = document.getElementById(this.buttonId); return button instanceof HTMLElement ? button : null; } handleResolverButtonClick = () => { this.openResolverManager(); } activateResolverButton = () => { const button = this.getSidebarButton() || this.getVisibleResolverButton(); if (button) { button.click(); return; } this.handleResolverButtonClick(); } getNodeContextWorkflowState() { const workflow = app?.graph?.serialize?.(); if (!workflow) return { workflow: null, signature: null }; const signature = this.dialog?.getMissingWorkflowSignature?.(workflow) || this.dialog?.getWorkflowSignature?.(workflow) || JSON.stringify(workflow); return { workflow, signature }; } getSubgraphDefinitionIdForNode(node, workflow = null) { const runtimeSubgraphId = node?.subgraph?.id || node?._subgraph?.id || node?.subgraph_id || ''; const nodeType = String(node?.type || '').trim(); const definitions = workflow?.definitions?.subgraphs; if (Array.isArray(definitions)) { const definition = definitions.find(item => ( String(item?.id || '') === nodeType )); if (definition?.id) return String(definition.id); } return runtimeSubgraphId ? String(runtimeSubgraphId) : ''; } getNodeContextScope(node, workflow = null) { const graph = node?.graph; const isTopLevel = !graph || graph === app?.graph; const subgraphInstanceId = this.getSubgraphDefinitionIdForNode(node, workflow); if (isTopLevel) { return { is_top_level: true, subgraph_id: subgraphInstanceId || undefined, subgraph_instance_id: subgraphInstanceId || undefined, is_subgraph_instance: Boolean(subgraphInstanceId), }; } const subgraphId = graph?._subgraph?.id || graph?.subgraph?.id || graph?._id || graph?.id || ''; return { is_top_level: false, subgraph_id: subgraphId ? String(subgraphId) : undefined, subgraph_instance_id: subgraphInstanceId || undefined, is_subgraph_instance: Boolean(subgraphInstanceId), }; } getCurrentNodeContextAnalysis(signature) { if ( signature && this.dialog?.cachedWorkflowSignature === signature && this.dialog?.cachedAnalysisData ) { return this.dialog.cachedAnalysisData; } if ( signature && this.nodeContextAnalysisSignature === signature && this.nodeContextAnalysisData ) { return this.nodeContextAnalysisData; } return null; } scheduleNodeContextMenuAnalysis(delay = 350) { if (this.nodeContextAnalysisTimer) { clearTimeout(this.nodeContextAnalysisTimer); } this.nodeContextAnalysisTimer = setTimeout(() => { this.nodeContextAnalysisTimer = null; this.refreshNodeContextMenuAnalysis(); }, delay); } async refreshNodeContextMenuAnalysis({ force = false } = {}) { if (!this.dialog) return null; const workflowRefreshPending = ( this.dialog.isVisible?.() && (this.dialog._workflowRefreshTimer || this.dialog._workflowRefreshRetryTimer) ); if (!force && workflowRefreshPending) { this.scheduleNodeContextMenuAnalysis(180); return null; } const { workflow, signature } = this.getNodeContextWorkflowState(); if (!workflow || !signature) return null; const cachedData = this.getCurrentNodeContextAnalysis(signature); if (!force && cachedData) return cachedData; if (this.nodeContextAnalysisPromise) return this.nodeContextAnalysisPromise; const analysisRequest = this.dialog.getWorkflowAnalysisRequest?.(workflow, { silent: true, }); if (!analysisRequest?.promise) return null; const analysisPromise = analysisRequest.promise .then((data) => { const currentState = this.getNodeContextWorkflowState(); if (currentState.signature === signature) { this.nodeContextAnalysisSignature = signature; this.nodeContextAnalysisData = data; } return data; }) .catch((error) => { log.debug('Model Resolver: node context analysis failed.', error); return null; }) .finally(() => { if (this.nodeContextAnalysisPromise === analysisPromise) { this.nodeContextAnalysisPromise = null; } const currentState = this.getNodeContextWorkflowState(); if (currentState.signature && currentState.signature !== signature) { this.scheduleNodeContextMenuAnalysis(0); } }); this.nodeContextAnalysisPromise = analysisPromise; return analysisPromise; } getResolvedModelsForNodeContextMenu(node) { const { workflow, signature } = this.getNodeContextWorkflowState(); const scope = this.getNodeContextScope(node, workflow); const data = this.getCurrentNodeContextAnalysis(signature); if (!data) { this.scheduleNodeContextMenuAnalysis(0); const previousData = this.nodeContextAnalysisData || this.dialog?.cachedAnalysisData; return getImmediateModelsForNode( previousData || {}, node, scope ); } return getResolvedModelsForNode( data, node?.id, scope ); } async ensureCurrentNodeContextAnalysis() { if (this.nodeContextAnalysisTimer) { clearTimeout(this.nodeContextAnalysisTimer); this.nodeContextAnalysisTimer = null; } for (let attempt = 0; attempt < 3; attempt += 1) { const { signature } = this.getNodeContextWorkflowState(); const cachedData = this.getCurrentNodeContextAnalysis(signature); if (cachedData) return cachedData; await this.refreshNodeContextMenuAnalysis({ force: true }); } const { signature } = this.getNodeContextWorkflowState(); return this.getCurrentNodeContextAnalysis(signature); } async resolveNodeContextMenuModel(model) { if (!model?.resolution_pending) return model; const data = await this.ensureCurrentNodeContextAnalysis(); const resolvedModel = (data?.resolved_models || []) .filter(candidate => isExistingResolvedModel(candidate)) .find(candidate => matchesWorkflowModelReference(candidate, model)); if (!resolvedModel) { showNotification('The selected model could not be resolved after the workflow update.', 'warning'); return null; } return resolvedModel; } configureNodeContextMenu(nodeType) { const isNodeInstance = Boolean( nodeType && !nodeType.prototype && nodeType.constructor?.prototype ); const target = isNodeInstance ? nodeType : nodeType?.prototype; if (!target) { return; } if (isNodeInstance) { const hasOwnMenuHook = Object.prototype.hasOwnProperty.call(target, 'getExtraMenuOptions') || Object.prototype.hasOwnProperty.call(target, 'getMenuOptions'); if (!hasOwnMenuHook || target.__modelResolverContextMenuInstancePatched) { return; } } else if (target.__modelResolverContextMenuPatched) { return; } const owner = this; const hasOwnExtraMenuOptions = Object.prototype.hasOwnProperty.call( target, 'getExtraMenuOptions' ); const hasOwnMenuOptions = Object.prototype.hasOwnProperty.call( target, 'getMenuOptions' ); const originalGetExtraMenuOptions = target.getExtraMenuOptions; const originalGetMenuOptions = target.getMenuOptions; const addModelResolverMenu = (node, targetOptions) => { if (!Array.isArray(targetOptions)) return; if (targetOptions.some(option => ( String(option?.className || '').includes('mr-model-resolver-node-menu') ))) { return; } const models = owner.getResolvedModelsForNodeContextMenu(node); const menu = buildModelResolverNodeMenu(models, { showInResolver: async model => { const resolvedModel = await owner.resolveNodeContextMenuModel(model); if (resolvedModel) { owner.showResolvedNodeModelInResolver(resolvedModel); } }, showInfo: async model => { const resolvedModel = await owner.resolveNodeContextMenuModel(model); if (resolvedModel) { owner.dialog?.showModelInfo(toResolverContextModel(resolvedModel)); } }, openContainingFolder: async model => { const resolvedModel = await owner.resolveNodeContextMenuModel(model); if (resolvedModel) { owner.dialog?.openContainingFolder(toResolverContextModel(resolvedModel)); } }, }, { formatCategory: category => owner.dialog?.getCategoryDisplayName?.(category), }); if (menu) targetOptions.unshift(menu); }; const originalOnWidgetChanged = target.onWidgetChanged; if (!isNodeInstance || hasOwnExtraMenuOptions) { target.getExtraMenuOptions = function(_, options) { const result = originalGetExtraMenuOptions?.apply(this, arguments); const targetOptions = Array.isArray(options) ? options : (Array.isArray(result) ? result : null); addModelResolverMenu(this, targetOptions); return result; }; } // ComfyUI treats the presence of getMenuOptions as an alternate menu // provider. Only wrap node types that already implement it; adding an // undefined method makes the native context menu return no options. if (typeof originalGetMenuOptions === 'function' && (!isNodeInstance || hasOwnMenuOptions)) { target.getMenuOptions = function() { const result = originalGetMenuOptions.apply(this, arguments); const targetOptions = Array.isArray(result) ? result : (Array.isArray(arguments[1]) ? arguments[1] : null); addModelResolverMenu(this, targetOptions); return result; }; } if (!isNodeInstance) { target.onWidgetChanged = function() { const result = originalOnWidgetChanged?.apply(this, arguments); const widgetName = arguments[0]; const handledByCustomNodeAdapter = owner.isCustomNodeModelWidget?.( this, widgetName ); if (handledByCustomNodeAdapter) { owner.customNodeModelAdapterStates?.get(this)?.notify?.(); } else if (!owner.dialog?.isWorkflowRefreshSuppressed?.()) { if (!owner.dialog?.isWorkflowStrengthWidgetName?.(widgetName)) { owner.scheduleNodeContextMenuAnalysis(); } owner.dialog?.scheduleActiveWorkflowRefresh?.('node-widget-change'); } return result; }; target.__modelResolverContextMenuPatched = true; } else { target.__modelResolverContextMenuInstancePatched = true; } } isCustomNodeModelWidget(node, widgetName = '') { return matchesCustomNodeModelWidget(node, widgetName); } getCustomNodeModelListSignature(node) { return createCustomNodeModelListSignature(node); } getCustomNodeModelStrengthSignature(node) { return createCustomNodeModelStrengthSignature(node); } configureCustomNodeModelAdapter(node) { const adapter = getCustomNodeModelAdapter(node); if (!adapter || !Array.isArray(node?.widgets)) return; const owner = this; if (!(this.customNodeModelAdapterStates instanceof WeakMap)) { this.customNodeModelAdapterStates = new WeakMap(); } let state = this.customNodeModelAdapterStates.get(node); if (!state) { state = { signature: this.getCustomNodeModelListSignature(node), strengthSignature: this.getCustomNodeModelStrengthSignature(node), }; this.customNodeModelAdapterStates.set(node, state); } else { state.signature = this.getCustomNodeModelListSignature(node); state.strengthSignature = this.getCustomNodeModelStrengthSignature(node); } const notifyIfModelsChanged = () => { const nextSignature = owner.getCustomNodeModelListSignature(node); const nextStrengthSignature = owner.getCustomNodeModelStrengthSignature(node); const modelListChanged = nextSignature !== state.signature; const strengthChanged = nextStrengthSignature !== state.strengthSignature; state.signature = nextSignature; state.strengthSignature = nextStrengthSignature; if (owner.dialog?.isWorkflowRefreshSuppressed?.()) return; if ( modelListChanged ) { owner.scheduleNodeContextMenuAnalysis(); owner.dialog?.scheduleActiveWorkflowRefresh?.('node-widget-change'); } else if (strengthChanged) { owner.dialog?.updateLoadedModelStrengthsFromNode?.(node); } }; state.notify = notifyIfModelsChanged; adapter.observe(node, notifyIfModelsChanged); } waitForResolverDialogReady(timeoutMs = 2500) { const startedAt = Date.now(); return new Promise((resolve) => { const check = () => { if (this.dialog?.isVisible?.() && this.dialog?.contentElement) { resolve(true); return; } if (Date.now() - startedAt >= timeoutMs) { resolve(false); return; } setTimeout(check, 40); }; check(); }); } waitForWorkflowModelSelection(request, timeoutMs = 120000) { const startedAt = Date.now(); return new Promise((resolve) => { const check = () => { if (!request || request.status !== 'pending') { resolve(request || null); return; } if (Date.now() - startedAt >= timeoutMs) { request.status = 'timeout'; if (this.dialog?.pendingWorkflowModelSelection === request) { this.dialog.pendingWorkflowModelSelection = null; } resolve(request); return; } setTimeout(check, 50); }; check(); }); } async showResolvedNodeModelInResolver(reference) { if (!this.dialog) return; const wasVisible = Boolean(this.dialog.isVisible?.()); const wasOnMissingTab = this.dialog.activeTab === 'missing'; const { signature } = this.getNodeContextWorkflowState(); const analysisData = this.getCurrentNodeContextAnalysis(signature); if (signature && analysisData) { this.dialog.cachedWorkflowSignature = signature; this.dialog.cachedAnalysisData = analysisData; } this.dialog.persistActiveTab?.('missing'); if (wasVisible && wasOnMissingTab) { const selected = this.dialog.selectWorkflowModelReference?.( reference, analysisData || this.dialog.cachedAnalysisData, { preferExistingBrowser: true } ); if (selected) return; } this.dialog.showResolvedModels = true; this.dialog.missingModelsTypeFilter = 'all'; this.dialog.missingModelsTypeFilterMenuOpen = false; try { localStorage.setItem(this.dialog.showResolvedModelsStorageKey, '1'); } catch (error) { log.debug('Model Resolver: failed to persist resolved-model visibility.', error); } const selectionRequest = this.dialog.queueWorkflowModelReferenceSelection?.(reference); if (!wasVisible) { this.dialog.activeTab = 'missing'; this.activateResolverButton(); } if (!await this.waitForResolverDialogReady()) { if (selectionRequest?.status === 'pending') { selectionRequest.status = 'open-failed'; } if (this.dialog.pendingWorkflowModelSelection === selectionRequest) { this.dialog.pendingWorkflowModelSelection = null; } showNotification('Could not open Model Resolver.', 'error'); return; } if (wasVisible && !wasOnMissingTab) { await this.dialog.switchTab?.('missing', { force: true }); } else if (wasVisible) { await this.dialog.loadWorkflowData?.(); } const completedRequest = await this.waitForWorkflowModelSelection(selectionRequest); if (completedRequest?.status === 'timeout') { showNotification('Could not select the model after workflow analysis completed.', 'warning'); } } handleSidebarButtonClick(event) { const target = event.target instanceof Element ? event.target : null; const button = target?.closest(`.${this.sidebarTabId}-tab-button`); if (!(button instanceof HTMLElement)) return; if (!this.dialog?.isVisible()) return; const wasDocked = this.dialog.docked; this.dialog.close({ collapseSidebar: false }); if (!wasDocked) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation?.(); requestAnimationFrame(() => { if (isSidebarButtonActive(button)) { button.click(); } }); } } renderSidebarPanel(element) { element.style.height = "100%"; element.classList.add("mr-sidebar-dock-panel"); if (!this.dialog) { this.dialog = new ResolverManagerDialog(); window.ModelResolverDialog = this.dialog; } this.dialog.rememberDockDropPreviewWidth?.(element); if (this.dialog.shouldOpenFromSidebarFloating()) { if (this.dialog.isVisible() && !this.dialog.docked) { this.dialog.close(); this.dialog.closeComfySidebar(element); return; } this.openResolverManager({ forceFloating: true, closeSidebarContainer: element, }); return; } element.replaceChildren(); this.openResolverManager({ dockContainer: element }); } async registerTopbarButton() { // Try to use new ComfyUI button system (like ComfyUI Manager does) try { // Dynamic imports for ComfyUI's button components const { ComfyButtonGroup } = await import("../../../../scripts/ui/components/buttonGroup.js"); const { ComfyButton } = await import("../../../../scripts/ui/components/button.js"); // Create button group with Model Resolver button const ModelResolverButton = new ComfyButton({ icon: "link-variant", action: this.handleResolverButtonClick, content: "Model Resolver", classList: "comfyui-button comfyui-menu-mobile-collapse" }).element; ModelResolverButton.id = this.buttonId; this.trackOpenTooltipTarget(ModelResolverButton); this.buttonGroup = new ComfyButtonGroup( ModelResolverButton ); // Insert before settings group in the menu app.menu?.settingsGroup.element.before(this.buttonGroup.element); } catch (_e) { // Fallback for older ComfyUI versions without the new button system log.debug('Model Resolver: New button system not available, using floating button fallback.'); this.createFloatingButton(); } } /** * 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(); log.debug('Model Resolver: Missing models popup button injection enabled'); } setupActiveWorkflowChangeListeners() { if (window.__ModelResolverWorkflowChangeHandlers) { for (const { target, event, handler, options } of window.__ModelResolverWorkflowChangeHandlers) { target?.removeEventListener?.(event, handler, options); } } if (!window.__ModelResolverHistoryPatched) { const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function(...args) { const result = originalPushState.apply(this, args); window.dispatchEvent(new Event('model-resolver-locationchange')); return result; }; history.replaceState = function(...args) { const result = originalReplaceState.apply(this, args); window.dispatchEvent(new Event('model-resolver-locationchange')); return result; }; window.__ModelResolverHistoryPatched = true; } if (!window.__ModelResolverLoadGraphDataPatched && typeof app.loadGraphData === 'function') { const originalLoadGraphData = app.loadGraphData; app.loadGraphData = function(...args) { if (args[0] && typeof args[0] === 'object') { window.__ModelResolverWorkflowHashMetadataOwner?.removeLegacyWorkflowHashMarkerNode(args[0]); } const result = originalLoadGraphData.apply(this, args); Promise.resolve(result).then(() => { setTimeout(() => { window.dispatchEvent(new Event('model-resolver-active-workflowchange')); }, 0); }, () => { setTimeout(() => { window.dispatchEvent(new Event('model-resolver-active-workflowchange')); }, 0); }); return result; }; window.__ModelResolverLoadGraphDataPatched = true; } window.__ModelResolverWorkflowChangeOwner = this; const routeHandler = () => { window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('route-change'); }; const activeWorkflowHandler = () => { window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('active-workflow-change'); }; const documentClickHandler = (event) => { const owner = window.__ModelResolverWorkflowChangeOwner; const target = event.target instanceof Element ? event.target : null; if (target?.closest('#model-resolver-modal, .model-resolver-backdrop')) return; if (!owner?.dialog?.isVisible()) return; if (!owner.isLikelyWorkflowTabClickTarget(target)) return; setTimeout(() => { if (window.__ModelResolverWorkflowChangeOwner === owner) { owner.handleActiveWorkflowRouteChange('workflow-tab-click'); } }, 0); }; const focusHandler = () => { window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('window-focus'); }; const visibilityHandler = () => { if (document.visibilityState === 'visible') { window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('visibility-change'); } }; window.addEventListener('hashchange', routeHandler); window.addEventListener('popstate', routeHandler); window.addEventListener('model-resolver-locationchange', routeHandler); window.addEventListener('model-resolver-active-workflowchange', activeWorkflowHandler); document.addEventListener('click', documentClickHandler, true); window.addEventListener('focus', focusHandler); document.addEventListener('visibilitychange', visibilityHandler); window.__ModelResolverWorkflowChangeHandlers = [ { target: window, event: 'hashchange', handler: routeHandler }, { target: window, event: 'popstate', handler: routeHandler }, { target: window, event: 'model-resolver-locationchange', handler: routeHandler }, { target: window, event: 'model-resolver-active-workflowchange', handler: activeWorkflowHandler }, { target: document, event: 'click', handler: documentClickHandler, options: true }, { target: window, event: 'focus', handler: focusHandler }, { target: document, event: 'visibilitychange', handler: visibilityHandler } ]; } isLikelyWorkflowTabClickTarget(target) { if (!(target instanceof Element)) return false; const tab = target.closest([ '[data-workflow-id]', '[data-workflow-name]', '[data-tab-id*="workflow" i]', '[aria-controls*="workflow" i]', '[class*="workflow"][class*="tab" i]', '[class*="tab"][class*="workflow" i]', '[class*="workflow"][class*="item" i]' ].join(',')); if (tab) return true; const roleTab = target.closest('[role="tab"]'); if (!roleTab) return false; const text = roleTab.textContent?.trim() || ''; const aria = roleTab.getAttribute('aria-label') || roleTab.getAttribute('title') || ''; return /workflow|unsaved|untitled/i.test(`${text} ${aria}`); } handleActiveWorkflowRouteChange(reason = 'workflow-change') { this.scheduleWorkflowHashMetadataRefresh(); if (!this.dialog?.isVisible()) return; this.dialog.scheduleActiveWorkflowRefresh(reason); } isWorkflowHashMetadataEnabled() { return localStorage.getItem('ModelResolver.workflowHashMetadataEnabled') !== 'false'; } isWorkflowDependencyMarkerEnabled() { return localStorage.getItem('ModelResolver.workflowDependencyMarkerEnabled') === 'true'; } setupWorkflowHashMetadataInjection() { window.__ModelResolverWorkflowHashMetadataOwner = this; if (window.__ModelResolverWorkflowHashMetadataPatched) { this.scheduleWorkflowHashMetadataRefresh(); this.configureWorkflowDependencyMarkerNodes(); return; } if (!app?.graph || typeof app.graph.serialize !== 'function') return; const originalSerialize = app.graph.serialize; app.graph.serialize = function(...args) { const workflow = originalSerialize.apply(this, args); window.__ModelResolverWorkflowHashMetadataOwner?.removeLegacyWorkflowHashMarkerNode(workflow); window.__ModelResolverWorkflowHashMetadataOwner?.configureSerializedWorkflowDependencyMarkerNodes(workflow); window.__ModelResolverWorkflowHashMetadataOwner?.injectWorkflowHashMetadata(workflow); window.__ModelResolverWorkflowHashMetadataOwner?.injectWorkflowDependencyMarker(workflow); window.__ModelResolverWorkflowHashMetadataOwner?.scheduleWorkflowHashMetadataRefresh(workflow); return workflow; }; window.__ModelResolverWorkflowHashMetadataPatched = true; this.configureWorkflowDependencyMarkerNodes(); this.scheduleWorkflowHashMetadataRefresh(); } removeLegacyWorkflowHashMarkerNode(workflow) { if (!workflow || !Array.isArray(workflow.nodes)) return workflow; workflow.nodes = workflow.nodes.filter((node) => node?.type !== LEGACY_WORKFLOW_HASH_MARKER_NODE_TYPE); return workflow; } isWorkflowDependencyMarkerNode(node) { if (!node) return false; const candidates = [ node.type, node.comfyClass, node.comfy_class, node.constructor?.ComfyClass, node.constructor?.comfyClass, node.constructor?.nodeData?.name, ]; return candidates.some((candidate) => candidate === WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE); } isWorkflowDependencyMarkerNodeType(nodeType, nodeData) { const candidates = [ nodeData?.name, nodeData?.node_id, nodeData?.class_type, nodeType?.ComfyClass, nodeType?.comfyClass, nodeType?.type, nodeType?.prototype?.comfyClass, ]; return candidates.some((candidate) => candidate === WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE); } configureWorkflowDependencyMarkerNodeType(nodeType, nodeData) { if (!this.isWorkflowDependencyMarkerNodeType(nodeType, nodeData)) return; const proto = nodeType?.prototype; if (!proto || proto.__modelResolverDependencyMarkerTypePatched) return; const resolver = this; const originalOnNodeCreated = proto.onNodeCreated; proto.onNodeCreated = function(...args) { const result = originalOnNodeCreated?.apply(this, args); resolver.configureWorkflowDependencyMarkerNode(this); return result; }; const originalComputeSize = proto.computeSize; proto.computeSize = function(...args) { originalComputeSize?.apply(this, args); return [...WORKFLOW_DEPENDENCY_MARKER_MIN_SIZE]; }; proto.__modelResolverDependencyMarkerTypePatched = true; } applyWorkflowDependencyMarkerSize(node) { if (!Array.isArray(node?.size)) return; const width = Number(node.size[0]); const height = Number(node.size[1]); if (!Number.isFinite(width) || !Number.isFinite(height)) { node.size = [...WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE]; return; } if (node.__modelResolverDependencySizeInitialized) return; const [defaultWidth, defaultHeight] = WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE; const shouldCompact = width > defaultWidth + 25 || height > defaultHeight + 16; if (shouldCompact) { const compactSize = [ Math.min(width, defaultWidth), Math.min(height, defaultHeight), ]; if (typeof node.setSize === 'function') { node.setSize(compactSize); } else { node.size = compactSize; } } node.__modelResolverDependencySizeInitialized = true; } configureWorkflowDependencyMarkerNode(node) { if (!this.isWorkflowDependencyMarkerNode(node)) return node; node.properties = node.properties && typeof node.properties === 'object' ? node.properties : {}; delete node.properties.cnr_id; node.properties.aux_id = WORKFLOW_DEPENDENCY_MARKER_AUX_ID; node.properties.repository = WORKFLOW_DEPENDENCY_MARKER_REPOSITORY; node.properties.registry_id = WORKFLOW_DEPENDENCY_MARKER_CNR_ID; node.properties.purpose = "Declares Model Resolver as an intentional workflow dependency."; node.properties["Node name for S&R"] = WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE; node.widgets = Array.isArray(node.widgets) ? node.widgets.filter((widget) => widget?.name !== 'note') : []; const existingOpenButton = node.widgets.find( (widget) => widget?.__modelResolverOpenButton || widget?.name === WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME ); if (existingOpenButton) { existingOpenButton.callback = () => this.activateResolverButton(); existingOpenButton.value = WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME; existingOpenButton.__modelResolverOpenButton = true; existingOpenButton.options = existingOpenButton.options && typeof existingOpenButton.options === 'object' ? existingOpenButton.options : {}; existingOpenButton.options.serialize = false; } else if (typeof node.addWidget === 'function') { const button = node.addWidget( "button", WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME, WORKFLOW_DEPENDENCY_MARKER_BUTTON_NAME, () => this.activateResolverButton(), { serialize: false } ); if (button) { button.__modelResolverOpenButton = true; button.options = button.options && typeof button.options === 'object' ? button.options : {}; button.options.serialize = false; } } this.applyWorkflowDependencyMarkerSize(node); if (!node.__modelResolverDependencySerializePatched) { const originalOnSerialize = node.onSerialize; node.onSerialize = function(serialized) { const result = typeof originalOnSerialize === 'function' ? originalOnSerialize.call(this, serialized) : undefined; window.__ModelResolverWorkflowHashMetadataOwner?.configureSerializedDependencyMarkerNode(serialized); return result; }; node.__modelResolverDependencySerializePatched = true; } node.graph?.setDirtyCanvas?.(true, true); app.canvas?.setDirty?.(true, true); return node; } configureWorkflowDependencyMarkerNodes(graph = app?.graph) { const nodes = graph?._nodes || graph?.nodes || []; if (!Array.isArray(nodes)) return; for (const node of nodes) { this.configureWorkflowDependencyMarkerNode(node); } } configureSerializedDependencyMarkerNode(node) { if (!node || node.type !== WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE) return node; node.properties = node.properties && typeof node.properties === 'object' ? node.properties : {}; delete node.properties.cnr_id; node.properties.aux_id = WORKFLOW_DEPENDENCY_MARKER_AUX_ID; node.properties.repository = WORKFLOW_DEPENDENCY_MARKER_REPOSITORY; node.properties.registry_id = WORKFLOW_DEPENDENCY_MARKER_CNR_ID; node.properties.purpose = "Declares Model Resolver as an intentional workflow dependency."; node.properties["Node name for S&R"] = WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE; node.widgets_values = []; return node; } configureSerializedWorkflowDependencyMarkerNodes(workflow) { if (!workflow || !Array.isArray(workflow.nodes)) return workflow; for (const node of workflow.nodes) { this.configureSerializedDependencyMarkerNode(node); } return workflow; } findWorkflowDependencyMarkerNode(graph = app?.graph) { const nodes = graph?._nodes || graph?.nodes || []; return Array.isArray(nodes) ? nodes.find((node) => this.isWorkflowDependencyMarkerNode(node)) || null : null; } getDependencyMarkerGraphPosition() { const visibleArea = app?.canvas?.ds?.visible_area; if (Array.isArray(visibleArea) && visibleArea.length >= 4) { const [x, y, w] = visibleArea; const dpi = Math.max(window.devicePixelRatio || 1, 1); return [x + Math.max(24, (w - 360) / dpi / 2), y + 48]; } return [0, 0]; } addWorkflowDependencyMarkerNode() { const graph = app?.graph; const liteGraph = window.LiteGraph; if (!graph || typeof liteGraph?.createNode !== 'function') { showNotification("Could not access the current workflow graph.", "error"); return null; } const existing = this.findWorkflowDependencyMarkerNode(graph); if (existing) { this.configureWorkflowDependencyMarkerNode(existing); app.canvas?.centerOnNode?.(existing); showNotification("Model Resolver opener node is already in this workflow.", "info"); return existing; } const node = liteGraph.createNode( WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE, WORKFLOW_DEPENDENCY_MARKER_DISPLAY_NAME, { pos: this.getDependencyMarkerGraphPosition() } ); if (!node) { showNotification("Model Resolver opener node is not available. Restart ComfyUI after updating the extension.", "error"); return null; } node.size = [...WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE]; this.configureWorkflowDependencyMarkerNode(node); graph.add(node); app.canvas?.centerOnNode?.(node); graph.setDirtyCanvas?.(true, true); app.canvas?.setDirty?.(true, true); showNotification("Model Resolver opener node added.", "success"); return node; } getWorkflowDependencyMarkerPosition(workflow) { const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes.filter((node) => node?.type !== WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE && Array.isArray(node?.pos)) : []; if (!nodes.length) return [0, 0]; const xs = nodes.map((node) => Number(node.pos[0])).filter(Number.isFinite); const ys = nodes.map((node) => Number(node.pos[1])).filter(Number.isFinite); if (!xs.length || !ys.length) return [0, 0]; return [Math.min(...xs), Math.min(...ys) - 170]; } getWorkflowDependencyMarkerId(workflow) { const usedIds = new Set( (Array.isArray(workflow?.nodes) ? workflow.nodes : []) .map((node) => Number(node?.id)) .filter(Number.isFinite) ); let id = WORKFLOW_DEPENDENCY_MARKER_AUTO_ID; while (usedIds.has(id)) id -= 1; return id; } createSerializedWorkflowDependencyMarkerNode(workflow) { return this.configureSerializedDependencyMarkerNode({ id: this.getWorkflowDependencyMarkerId(workflow), type: WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE, pos: this.getWorkflowDependencyMarkerPosition(workflow), size: [...WORKFLOW_DEPENDENCY_MARKER_DEFAULT_SIZE], flags: {}, order: -1, mode: 0, inputs: [], outputs: [], properties: {}, widgets_values: [], }); } injectWorkflowDependencyMarker(workflow) { if (!workflow || typeof workflow !== 'object') return workflow; if (!this.isWorkflowDependencyMarkerEnabled()) return workflow; workflow.nodes = Array.isArray(workflow.nodes) ? workflow.nodes : []; const existing = workflow.nodes.find((node) => node?.type === WORKFLOW_DEPENDENCY_MARKER_NODE_TYPE); if (existing) { this.configureSerializedDependencyMarkerNode(existing); return workflow; } workflow.nodes.push(this.createSerializedWorkflowDependencyMarkerNode(workflow)); return workflow; } injectWorkflowHashMetadata(workflow) { if (!workflow || typeof workflow !== 'object') return workflow; if (!this.isWorkflowHashMetadataEnabled()) return workflow; const cache = this.workflowHashMetadataCache; if (!cache || !Array.isArray(cache.models) || !cache.models.length) return workflow; workflow.extra = workflow.extra && typeof workflow.extra === 'object' ? workflow.extra : {}; workflow.extra.model_resolver_hashes = { version: 1, source: 'comfyui-model-resolver', models: cache.models, updated_at: cache.updated_at || Date.now() }; return workflow; } getWorkflowHashMetadataSignature(workflow) { if (!workflow || typeof workflow !== 'object') return ''; const dependencySignature = this.dialog?.getMissingWorkflowSignature?.(workflow) || this.dialog?.getWorkflowSignature?.(workflow); if (dependencySignature) return dependencySignature; const normalizeNode = (node = {}) => ({ id: node.id, type: node.type, mode: node.mode, widgets_values: node.widgets_values, inputs: Array.isArray(node.inputs) ? node.inputs.map(input => ({ name: input?.name, type: input?.type, link: input?.link, widget: input?.widget })) : [], outputs: Array.isArray(node.outputs) ? node.outputs.map(output => ({ name: output?.name, type: output?.type, links: output?.links })) : [], properties: node.properties }); const normalizeDefinition = (definition = {}) => ({ id: definition.id, name: definition.name, nodes: Array.isArray(definition.nodes) ? definition.nodes.map(normalizeNode) : [], links: Array.isArray(definition.links) ? definition.links : [] }); const definitions = workflow.definitions && typeof workflow.definitions === 'object' ? Object.fromEntries(Object.entries(workflow.definitions).map(([key, value]) => [ key, Array.isArray(value) ? value.map(normalizeDefinition) : normalizeDefinition(value) ])) : {}; try { return JSON.stringify({ nodes: Array.isArray(workflow.nodes) ? workflow.nodes.map(normalizeNode) : [], links: Array.isArray(workflow.links) ? workflow.links : [], definitions }); } catch (error) { log.debug('Model Resolver: workflow hash signature generation failed', error); return ''; } } armWorkflowHashMetadataRefresh(delay = 800) { if (this.workflowHashMetadataRefreshTimer) { clearTimeout(this.workflowHashMetadataRefreshTimer); } this.workflowHashMetadataRefreshTimer = setTimeout(() => { this.workflowHashMetadataRefreshTimer = null; this.flushWorkflowHashMetadataRefresh(); }, delay); } scheduleWorkflowHashMetadataRefresh(workflow = null) { if (!this.isWorkflowHashMetadataEnabled() || this.workflowHashMetadataPreparing) return; const signature = workflow ? this.getWorkflowHashMetadataSignature(workflow) : null; if ( signature && ( signature === this.workflowHashMetadataSignature || signature === this.workflowHashMetadataActiveSignature || ( this.workflowHashMetadataRefreshPending && signature === this.workflowHashMetadataPendingSignature ) ) ) { return; } this.workflowHashMetadataRefreshPending = true; this.workflowHashMetadataPendingWorkflow = workflow; this.workflowHashMetadataPendingSignature = signature; this.armWorkflowHashMetadataRefresh(); } async flushWorkflowHashMetadataRefresh() { if (!this.workflowHashMetadataRefreshPending) return; if (this.workflowHashMetadataRefreshing) { this.armWorkflowHashMetadataRefresh(200); return; } const workflow = this.workflowHashMetadataPendingWorkflow; const signature = this.workflowHashMetadataPendingSignature; this.workflowHashMetadataRefreshPending = false; this.workflowHashMetadataPendingWorkflow = null; this.workflowHashMetadataPendingSignature = null; await this.refreshWorkflowHashMetadata(workflow, signature); } async refreshWorkflowHashMetadata(workflow = null, scheduledSignature = null) { if (this.workflowHashMetadataRefreshing || !this.isWorkflowHashMetadataEnabled()) return; let currentWorkflow = workflow; if (!currentWorkflow) { this.workflowHashMetadataPreparing = true; try { currentWorkflow = app?.graph?.serialize?.(); } finally { this.workflowHashMetadataPreparing = false; } } if (!currentWorkflow) return; const signature = scheduledSignature || this.getWorkflowHashMetadataSignature(currentWorkflow); if (signature && signature === this.workflowHashMetadataSignature && this.workflowHashMetadataCache) { return; } this.workflowHashMetadataRefreshing = true; this.workflowHashMetadataActiveSignature = signature; try { const response = await api.fetchApi('/model_resolver/workflow-model-hashes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow: currentWorkflow }) }); if (!response.ok) return; const data = await response.json(); if (!data?.enabled) { this.workflowHashMetadataCache = null; this.workflowHashMetadataSignature = signature; return; } this.workflowHashMetadataCache = { models: Array.isArray(data.models) ? data.models : [], by_path: data.by_path || {}, by_node: data.by_node || {}, updated_at: Date.now() }; this.workflowHashMetadataSignature = signature; } catch (error) { log.debug('Model Resolver: workflow hash metadata refresh failed', error); } finally { this.workflowHashMetadataRefreshing = false; this.workflowHashMetadataActiveSignature = null; if ( this.workflowHashMetadataRefreshPending && !this.workflowHashMetadataRefreshTimer ) { this.armWorkflowHashMetadataRefresh(200); } } } /** * 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-resolver-btn-container')) return; // Find a suitable place to inject the button const injectButtons = () => { // Auto-resolve button (green) const autoResolveBtn = document.createElement('button'); autoResolveBtn.id = 'model-resolver-btn-container'; // Use this ID to prevent duplicate injection autoResolveBtn.className = 'mr-popup-auto-resolve-btn'; autoResolveBtn.textContent = '🔗 Auto-resolve 100%'; this.dialog?.setTooltip(autoResolveBtn, 'Link every missing model that already has an exact local match, then open Model Resolver for the rest.'); autoResolveBtn.addEventListener('click', async () => { await this.handleAutoResolveInPopup(dialog, autoResolveBtn); }); // Find the "Don't show this again" checkbox row and add button next to it const checkbox = dialog.querySelector('input[type="checkbox"]'); if (checkbox) { const checkboxRow = checkbox.closest('label') || checkbox.parentElement; if (checkboxRow && checkboxRow.parentElement) { // Make the parent a flex container to align checkbox and button checkboxRow.parentElement.classList.add('mr-popup-inline-actions'); // Insert button at the beginning (left side) checkboxRow.parentElement.insertBefore(autoResolveBtn, checkboxRow); return; } } // Fallback: Find the list of models and insert before it const modelList = dialog.querySelector('[style*="overflow"]') || dialog.querySelector('[class*="list"]') || dialog.querySelector('[class*="content"]'); if (modelList) { // Create a wrapper and insert before the model list const wrapper = document.createElement('div'); wrapper.className = 'mr-popup-actions-wrap'; wrapper.appendChild(autoResolveBtn); modelList.parentElement?.insertBefore(wrapper, 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(autoResolveBtn, el.nextSibling); break; } } } log.debug('Model Resolver: 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 Resolver 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 ResolverManagerDialog(); window.ModelResolverDialog = this.dialog; } // Run auto-resolve for 100% matches - returns the updated workflow const updatedWorkflow = await this.dialog.autoResolve100Percent(); return updatedWorkflow; } /** * Check if auto-open preference is enabled */ isAutoOpenEnabled() { return localStorage.getItem('ModelResolver.autoOpenOnMissing') === 'true'; } openResolverForDetectedMissingModels() { if (this.dialog?.isVisible?.()) { this.dialog.scheduleActiveWorkflowRefresh?.('auto-open-missing-models'); return; } this.activateResolverButton(); } /** * 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; // Reuse the background context-menu analysis to avoid scanning twice. const data = await this.refreshNodeContextMenuAnalysis(); if (!data) { console.warn('Model Resolver: Failed to analyze workflow for missing models'); return; } // Auto-open dialog if there are missing models if (data.total_missing > 0) { log.debug(`Model Resolver: Found ${data.total_missing} missing model(s), opening dialog...`); this.openResolverForDetectedMissingModels(); } } catch (error) { console.error('Model Resolver: Error checking for missing models:', error); } finally { this.isCheckingMissing = false; } } removeExistingButton() { // Remove any existing button by ID const existingButton = document.getElementById(this.buttonId); if (existingButton) { existingButton.remove(); } document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => { const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim(); if (label === 'Model Resolver') { button.closest('.comfyui-button-group')?.remove(); } }); // Remove button group if it exists if (this.buttonGroup?.element?.parentNode) { this.buttonGroup.element.remove(); this.buttonGroup = null; } // Also remove the stored reference if it exists if (this.resolverButton && this.resolverButton.parentNode) { this.resolverButton.remove(); this.resolverButton = null; } } createFloatingButton() { // Create a floating button as fallback for legacy ComfyUI versions this.resolverButton = $el("button", { id: this.buttonId, textContent: "🔗 Model Resolver", onclick: this.handleResolverButtonClick, className: "model-resolver-floating-button" }); document.body.appendChild(this.resolverButton); this.trackOpenTooltipTarget(this.resolverButton); } async openResolverManager(options = {}) { try { if (!this.dialog) { this.dialog = new ResolverManagerDialog(); window.ModelResolverDialog = this.dialog; } if (options.dockContainer && !options.forceFloating) { await this.dialog.showDocked(options.dockContainer, options.workflow || null); return; } const wasDocked = this.dialog.docked; const showPromise = this.dialog.show(options.workflow || null); if (options.closeSidebarContainer && !wasDocked) { this.dialog.closeComfySidebar(options.closeSidebarContainer); } await showPromise; } catch (error) { console.error("🔗 Model Resolver: Error creating/showing dialog:", error); showNotification("Error opening Model Resolver: " + error.message, "error"); } } }