import { app } from "../../../../../scripts/app.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { startSplitterDrag } from "../utils/splitter_drag.js"; const FOOTER_VERSION_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; const FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS = 5 * 60 * 1000; export const queueMethods = { createContent() { // Wrap the body in a two-column layout: left = items, right = queued panel const body = $el("div", { id: "model-resolver-body" }); this.contentElement = $el("div.mr-scrollable", { id: "model-resolver-content" }); this.queueElement = $el("div", { id: "model-resolver-queue" }, [ this.createQueuePanel() ]); // Splitter between content and queue this.splitterElement = $el("div", { id: "model-resolver-splitter", ondragstart: (e) => e.preventDefault() }); body.appendChild(this.contentElement); body.appendChild(this.splitterElement); body.appendChild(this.queueElement); this.observeQueueSplitContainer(body); // Restore saved queue width and wire splitter try { const savedSplit = localStorage.getItem('model_resolver_split_w'); if (savedSplit) { const w = parseInt(savedSplit, 10); if (!isNaN(w) && w > 0) { this.queueElement.style.width = `${w}px`; this.queueElement.style.flexBasis = `${w}px`; this.queueElement.style.flexGrow = '0'; this.queueElement.style.flexShrink = '0'; } } } catch (_e) { } try { const onSplitMouseDown = (e) => this.startSplitDrag(e); this.splitterElement.addEventListener(typeof PointerEvent === 'function' ? 'pointerdown' : 'mousedown', onSplitMouseDown); this._splitterMouseDown = onSplitMouseDown; } catch (_e) { } // Toggle icon always visible try { this.queueToggleIcon = $el("button", { id: "queue-toggle-icon", type: "button", "aria-label": "Toggle queued selections", onclick: (event) => this.onQueueEdgeClick(event) }); this.queueToggleIcon.addEventListener( typeof PointerEvent === 'function' ? 'pointerdown' : 'mousedown', (event) => this.startQueueEdgeDrag(event) ); body.appendChild(this.queueToggleIcon); this.updateQueueToggleIcon(); } catch (_e) { } // Restore queue collapsed state try { const col = localStorage.getItem('model_resolver_queue_collapsed'); if (col === '1') this.setQueueCollapsed(true, { persist: false, updatePanel: false }); } catch (_e) { } return body; }, createQueuePanel() { this.queuePanelActiveTab = this.queuePanelActiveTab || 'queued'; this.queueDownloadsActiveTab = this.queueDownloadsActiveTab || 'active'; this.loadDownloadHistory?.(); this.queueClearButton = $el("button", { id: "queue-clear", className: "mr-btn mr-btn-secondary mr-btn-sm", textContent: "Clear All", onclick: () => this.clearAllQueued() }); this.queueQueuedTabButton = $el("button.mr-tab.mr-queue-tab.mr-tab-active", { type: "button", "data-tab": "queued", role: "tab", onclick: () => this.setQueuePanelTab('queued') }, [$el("span.mr-tab-label", { textContent: "Queued (0)" })]); this.queueDownloadsTabButton = $el("button.mr-tab.mr-queue-tab", { type: "button", "data-tab": "downloads", role: "tab", onclick: () => this.setQueuePanelTab('downloads') }, [$el("span.mr-tab-label", { textContent: "Downloads (0)" })]); this.queueTabs = $el("div.mr-tabs.mr-queue-tabs", { role: "tablist", "aria-label": "Queue panel views" }, [ this.queueQueuedTabButton, this.queueDownloadsTabButton ]); this.queueHeader = $el("div.mr-queue-header", {}, [ this.queueClearButton ]); // Scrollable list this.queueList = $el("div#queue-list.mr-queue-list"); this.queueList.addEventListener('click', (event) => { const button = event.target instanceof Element ? event.target.closest('.mr-queue-remove') : null; if (!(button instanceof HTMLElement)) return; const index = Number.parseInt(button.dataset.queueIndex || '-1', 10); if (!Number.isInteger(index) || index < 0) return; event.preventDefault(); event.stopPropagation(); this.removeQueuedByIndex(index); }); const panel = $el("div.mr-queue-panel", {}, [ $el("div.mr-queue-stack", {}, [this.queueTabs, this.queueHeader, this.queueList]) ]); return panel; }, setQueuePanelTab(tab) { const nextTab = tab === 'downloads' ? 'downloads' : 'queued'; if (this.queuePanelActiveTab === nextTab) return; this.queuePanelActiveTab = nextTab; this.updateQueuePanel(); }, setQueueDownloadsTab(tab) { const nextTab = tab === 'history' ? 'history' : 'active'; if (this.queueDownloadsActiveTab === nextTab) return; this.queueDownloadsActiveTab = nextTab; this.updateQueuePanel(); }, updateQueuePanel({ force = false } = {}) { if (!this.queueList || !this.queueHeader) return; if (this._queuePanelUpdateTimer) { clearTimeout(this._queuePanelUpdateTimer); this._queuePanelUpdateTimer = null; } if (!force && !this.isQueuePanelVisible()) { this._queuePanelDirty = true; return; } this._queuePanelDirty = false; const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; const downloads = this.getActiveQueuePanelDownloads(); const activeTab = this.queuePanelActiveTab === 'downloads' ? 'downloads' : 'queued'; const history = activeTab === 'downloads' ? this.getDownloadHistory() : []; const clearBtn = this.queueClearButton || this.queueHeader.querySelector('#queue-clear'); const showActions = activeTab === 'queued'; this.queueHeader.style.display = showActions ? '' : 'none'; if (clearBtn) clearBtn.style.display = showActions ? '' : 'none'; this.updateQueuePanelTabs(list.length, downloads.length, activeTab); if (activeTab === 'downloads') { this.renderDownloadsPanel(downloads, history); return; } this.renderQueuedSelections(list); }, requestQueuePanelUpdate(delayMs = 180, { force = false } = {}) { if (!this.queueList || !this.queueHeader) return; if (!force && !this.isQueuePanelVisible()) { this._queuePanelDirty = true; return; } if (this._queuePanelUpdateTimer) return; this._queuePanelUpdateTimer = setTimeout(() => { this._queuePanelUpdateTimer = null; this.updateQueuePanel({ force }); }, delayMs); }, isQueuePanelVisible() { return Boolean( this.queueElement && this.activeTab === 'missing' && !this.queueCollapsed && this.queueElement.style.display !== 'none' ); }, clearQueueContentSplitFlex() { if (!(this.contentElement instanceof HTMLElement)) return; this.contentElement.style.removeProperty('flex-basis'); this.contentElement.style.removeProperty('flex-grow'); this.contentElement.style.removeProperty('flex-shrink'); }, updateQueuePanelTabs(queueCount, downloadCount, activeTab) { const queuedTab = this.queueQueuedTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="queued"]'); const downloadsTab = this.queueDownloadsTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="downloads"]'); const setTabLabel = (tab, label) => { const labelEl = tab?.querySelector?.('.mr-tab-label'); if (labelEl) { labelEl.textContent = label; } else if (tab) { tab.textContent = label; } }; if (queuedTab) { setTabLabel(queuedTab, `Queued (${queueCount})`); queuedTab.classList.toggle('mr-tab-active', activeTab === 'queued'); queuedTab.setAttribute('aria-selected', activeTab === 'queued' ? 'true' : 'false'); } if (downloadsTab) { setTabLabel(downloadsTab, `Downloads (${downloadCount})`); downloadsTab.classList.toggle('mr-tab-active', activeTab === 'downloads'); downloadsTab.setAttribute('aria-selected', activeTab === 'downloads' ? 'true' : 'false'); } }, renderQueuedSelections(list) { if (!list.length) { this.queueList.innerHTML = '
No selections queued.
'; return; } let html = '
'; for (let i = 0; i < list.length; i++) { const r = list[i]; const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString(); const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node'); const orig = (r.original_path || '').toString(); html += `
`; html += `
${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}
`; html += `
Original${this.escapeHtml(orig)}
`; html += `
Selected${this.escapeHtml(label)}
`; html += `
`; html += `
`; } html += '
'; this.queueList.innerHTML = html; }, getActiveQueuePanelDownloads() { const entries = Object.entries(this.activeDownloads || {}); if (!entries.length) return []; return entries .filter(([, info]) => Boolean(info?.missing)) .map(([downloadId, info]) => ({ downloadId, info })); }, getWorkflowLabelFromRouteKey(routeKey = '') { const rawRoute = String(routeKey || '').split('\n')[0].trim(); if (!rawRoute) return ''; const decodeLabel = (value = '') => { try { return decodeURIComponent(String(value || '').replace(/\+/g, ' ')); } catch (_error) { return String(value || ''); } }; const cleanRoutePart = (value = '') => this.cleanWorkflowLabel?.(decodeLabel(value)) || decodeLabel(value).trim(); const commonRouteParts = new Set(['', '#', '/', 'workflow', 'workflows', 'view', 'tab', 'tabs', 'graph', 'graphs']); try { const base = typeof window !== 'undefined' && window.location?.origin ? window.location.origin : 'http://localhost'; const url = new URL(rawRoute, base); const candidates = [ url.searchParams.get('workflow'), url.searchParams.get('workflowName'), url.searchParams.get('workflow_name'), url.searchParams.get('name'), url.searchParams.get('filename'), url.searchParams.get('file'), url.hash ? new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '').get('workflow') : '', url.hash ? new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '').get('workflowName') : '', url.hash ? new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '').get('name') : '' ]; const found = candidates.map(cleanRoutePart).find(Boolean); if (found) return found; } catch (_error) { /* fall through to manual route parsing */ } try { const hash = rawRoute.startsWith('#') ? rawRoute.slice(1) : rawRoute; const paramsText = hash.includes('?') ? hash.slice(hash.indexOf('?') + 1) : hash.replace(/^\?/, ''); const params = new URLSearchParams(paramsText); const candidates = [ params.get('workflow'), params.get('workflowName'), params.get('workflow_name'), params.get('name'), params.get('filename'), params.get('file'), params.get('workflowId'), params.get('workflow_id'), params.get('tab'), params.get('id') ]; const found = candidates.map(cleanRoutePart).find(Boolean); if (found) return found; } catch (_error) { /* fall through to readable route */ } const withoutQuery = rawRoute.split('?')[0].split('&')[0]; const parts = withoutQuery .replace(/^#/, '') .split(/[\\/]/) .map(part => cleanRoutePart(part)) .filter(part => !commonRouteParts.has(part.toLowerCase())); if (parts.length) return parts[parts.length - 1]; return cleanRoutePart(rawRoute .replace(/^#\/?/, '') .replace(/^\?/, '') .replace(/[?&](workflow|workflowName|workflow_name|name|filename|file|workflowId|workflow_id|tab|id)=/i, ' ') .replace(/[=&]+/g, ' ')); }, cleanWorkflowLabel(label = '') { return String(label || '') .replace(/[\r\n\t]+/g, ' ') .replace(/\s+/g, ' ') .replace(/^[*•\s]+|[×✕✖*•\s]+$/g, '') .trim(); }, getWorkflowTabActiveSelectors() { return [ '[data-workflow-name][aria-selected="true"]', '[data-workflow-name].active', '[data-workflow-name].selected', '[data-workflow-id][aria-selected="true"]', '[data-workflow-id][data-active="true"]', '[data-workflow-id].active', '[data-workflow-id].selected', '[data-tab-id*="workflow" i][aria-selected="true"]', '[data-tab-id*="workflow" i][data-active="true"]', '[class*="workflow" i][class*="tab" i].active', '[class*="workflow" i][class*="tab" i].selected', '[class*="workflow" i][class*="tab" i].p-highlight', '[class*="workflow" i][class*="tab" i].p-tab-active', '[class*="tab" i][class*="workflow" i].active', '[class*="tab" i][class*="workflow" i].selected', '[class*="tab" i][class*="workflow" i].p-highlight', '[class*="tab" i][class*="workflow" i].p-tab-active', '[role="tab"][aria-selected="true"]', '[role="tab"][data-active="true"]', '[role="tab"].active', '[role="tab"].selected', '[role="tab"].p-highlight', '[role="tab"].p-tab-active' ]; }, getWorkflowTabSearchSelectors() { return [ '[data-workflow-name]', '[data-workflow-id]', '[data-tab-id*="workflow" i]', '[aria-controls*="workflow" i]', '[class*="workflow" i][class*="tab" i]', '[class*="tab" i][class*="workflow" i]', '[role="tab"]' ]; }, getWorkflowTabElementClassText(element) { return String(element?.getAttribute?.('class') || ''); }, getWorkflowTabElementIdentity(element) { if (!element) return null; const workflowTabId = String( element.getAttribute?.('data-workflow-id') || element.dataset?.workflowId || element.getAttribute?.('data-tab-id') || element.dataset?.tabId || '' ).trim(); const workflowTabName = String( element.getAttribute?.('data-workflow-name') || element.dataset?.workflowName || element.getAttribute?.('data-tab-title') || element.dataset?.tabTitle || '' ).trim(); const workflowTabAriaControls = String(element.getAttribute?.('aria-controls') || '').trim(); const title = String(element.getAttribute?.('title') || '').trim(); const ariaLabel = String(element.getAttribute?.('aria-label') || '').trim(); const workflowTabText = this.cleanWorkflowLabel(element.textContent || ''); const workflowLabel = this.cleanWorkflowLabel(workflowTabName || title || ariaLabel || workflowTabText); return { element, workflowLabel, workflowTabId, workflowTabName, workflowTabAriaControls, workflowTabText, workflowTabTitle: title, workflowTabAriaLabel: ariaLabel }; }, isLikelyWorkflowTabElement(element, label = '') { if (!element) return false; if (element.closest?.('#model-resolver-modal, .model-resolver-backdrop')) return false; const attrText = [ element.getAttribute?.('data-workflow-id') || '', element.getAttribute?.('data-workflow-name') || '', element.getAttribute?.('data-tab-id') || '', element.getAttribute?.('aria-controls') || '', element.getAttribute?.('title') || '', element.getAttribute?.('aria-label') || '', this.getWorkflowTabElementClassText(element) ].join(' '); if (/workflow|untitled|unsaved|\.json/i.test(`${label} ${attrText}`)) return true; if (element.hasAttribute?.('data-workflow-id') || element.hasAttribute?.('data-workflow-name')) return true; if (element.matches?.('[role="tab"]')) { const normalized = this.cleanWorkflowLabel(label).toLowerCase(); if (!normalized) return false; if (/^(queued|downloads|missing|loaded|options|settings|queue|history|logs|console|manager)$/i.test(normalized)) { return false; } return true; } return false; }, isWorkflowTabElementActive(element) { if (!element) return false; const classList = element.classList; return element.getAttribute?.('aria-selected') === 'true' || element.getAttribute?.('data-active') === 'true' || classList?.contains('active') || classList?.contains('selected') || classList?.contains('p-highlight') || classList?.contains('p-tab-active'); }, findActiveWorkflowTabElement() { if (typeof document === 'undefined') return null; for (const selector of this.getWorkflowTabActiveSelectors()) { let elements; try { elements = Array.from(document.querySelectorAll(selector)); } catch (_error) { continue; } for (const element of elements) { const identity = this.getWorkflowTabElementIdentity(element); if (!identity?.workflowLabel && !identity?.workflowTabId && !identity?.workflowTabName) continue; if (!this.isLikelyWorkflowTabElement(element, identity.workflowLabel)) continue; return identity; } } return null; }, getActiveWorkflowTabContext() { const activeTab = this.findActiveWorkflowTabElement?.(); const routeKey = this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || ''; const workflow = this.getCurrentWorkflow?.(); const workflowSignature = this.activeWorkflowSignature || (workflow ? this.getWorkflowSignature?.(workflow) : '') || ''; const workflowId = this.getWorkflowIdFromWorkflow?.(workflow) || this.getActiveWorkflowId?.() || ''; const fallbackLabel = this.getWorkflowLabelFromComfyState?.() || activeTab?.workflowLabel || this.getWorkflowLabelFromRouteKey?.(routeKey) || 'Current workflow'; return { workflowId, workflowRouteKey: routeKey, workflowSignature, workflowLabel: activeTab?.workflowLabel || fallbackLabel, workflowTabId: activeTab?.workflowTabId || '', workflowTabName: activeTab?.workflowTabName || '', workflowTabAriaControls: activeTab?.workflowTabAriaControls || '', workflowTabText: activeTab?.workflowTabText || '' }; }, getWorkflowLabelFromComfyState() { const candidates = []; try { const activeWorkflow = app?.workflowManager?.activeWorkflow || app?.workflowManager?.workflow || app?.ui?.workflow?.activeWorkflow || app?.canvas?.workflow; candidates.push( activeWorkflow?.name, activeWorkflow?.title, activeWorkflow?.filename, activeWorkflow?.path?.split?.(/[\\/]/)?.pop?.(), activeWorkflow?.workflow?.name, activeWorkflow?.workflow?.title ); } catch (_error) { /* ignore unavailable Comfy internals */ } for (const candidate of candidates) { const label = this.cleanWorkflowLabel(candidate); if (label) return label; } return ''; }, normalizeWorkflowId(value = '') { return String(value || '').trim().toLowerCase(); }, isLikelyWorkflowId(value = '') { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim()); }, getWorkflowIdFromWorkflow(workflow = null) { const candidates = [ workflow?.id, workflow?.workflow_id, workflow?.workflowId, workflow?.workflow?.id, workflow?.workflow?.workflow_id, workflow?.workflow?.workflowId, workflow?.extra?.workflow?.id, workflow?.extra?.workflow_id, workflow?.extra?.workflowId ]; for (const candidate of candidates) { const workflowId = String(candidate || '').trim(); if (workflowId) return workflowId; } return ''; }, getActiveWorkflowId() { try { const workflow = this.getCurrentWorkflow?.(); const serializedId = this.getWorkflowIdFromWorkflow?.(workflow); if (serializedId) return serializedId; const activeWorkflow = app?.workflowManager?.activeWorkflow || app?.workflowManager?.workflow || app?.ui?.workflow?.activeWorkflow || app?.canvas?.workflow; return this.getWorkflowIdFromWorkflow?.(activeWorkflow) || ''; } catch (_error) { return ''; } }, getWorkflowLabelFromActiveTabElement() { return this.findActiveWorkflowTabElement?.()?.workflowLabel || ''; }, getActiveWorkflowDownloadLabel() { const stateLabel = this.getWorkflowLabelFromComfyState?.(); if (stateLabel) return stateLabel; const tabLabel = this.getWorkflowLabelFromActiveTabElement?.(); if (tabLabel) return tabLabel; const routeKey = this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || ''; const routeLabel = this.getWorkflowLabelFromRouteKey(routeKey); return routeLabel || 'Current workflow'; }, getDownloadWorkflowLabel(info = {}) { return info.workflowLabel || this.getWorkflowLabelFromRouteKey(info.workflowRouteKey || '') || this.getWorkflowLabelFromRouteKey(info.workflowKey || '') || this.getActiveWorkflowDownloadLabel?.() || 'Unknown workflow'; }, getWorkflowContextRouteKey(context = {}) { const direct = context.workflow_route_key || context.workflowRouteKey || ''; if (direct) return String(direct).split('\n')[0].trim(); const key = context.workflow_key || context.workflowKey || ''; return String(key || '').split('\n')[0].trim(); }, isUsableWorkflowRouteKey(routeKey = '') { const normalized = String(routeKey || '').trim(); return Boolean(normalized && normalized !== '#'); }, getWorkflowContextSignature(context = {}) { const direct = context.workflow_signature || context.workflowSignature || ''; if (direct) return String(direct).trim(); const key = context.workflow_key || context.workflowKey || ''; const parts = String(key || '').split('\n').map(part => part.trim()).filter(Boolean); return parts.length > 1 ? parts.slice(1).join('\n') : ''; }, getWorkflowContextId(context = {}) { const direct = context.workflow_id || context.workflowId || context.workflow_uuid || context.workflowUuid || ''; if (direct) return String(direct).trim(); const routeKey = this.getWorkflowContextRouteKey(context); if (routeKey) { try { const base = typeof window !== 'undefined' && window.location?.origin ? window.location.origin : 'http://localhost'; const url = new URL(String(routeKey || ''), base); const routeWorkflowId = url.searchParams.get('workflowId') || url.searchParams.get('workflow_id') || url.searchParams.get('id'); if (routeWorkflowId) return routeWorkflowId.trim(); } catch (_error) { /* fall through to uuid extraction */ } const routeUuid = String(routeKey || '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] || ''; if (routeUuid) return routeUuid; } const label = this.getWorkflowContextLabel(context); return this.isLikelyWorkflowId(label) ? label : ''; }, getWorkflowContextLabel(context = {}) { return this.cleanWorkflowLabel( context.workflow_label || context.workflowLabel || context.workflow_tab_name || context.workflowTabName || context.workflow_tab_text || context.workflowTabText || this.getWorkflowLabelFromRouteKey?.(this.getWorkflowContextRouteKey(context)) || '' ); }, getDownloadWorkflowRuntimeContext(context = {}) { const downloadId = context.download_id || context.downloadId || ''; const info = downloadId ? this.activeDownloads?.[downloadId] : null; if (!info) return context; return { ...context, workflowId: context.workflowId || context.workflow_id || info.workflowId || info.workflow_id || '', workflow_id: context.workflow_id || context.workflowId || info.workflow_id || info.workflowId || '', workflowRouteKey: context.workflowRouteKey || context.workflow_route_key || info.workflowRouteKey || '', workflow_route_key: context.workflow_route_key || context.workflowRouteKey || info.workflowRouteKey || '', workflowKey: context.workflowKey || info.workflowKey || '', workflowLabel: context.workflowLabel || context.workflow_label || info.workflowLabel || '', workflow_label: context.workflow_label || context.workflowLabel || info.workflowLabel || '', workflowSignature: context.workflowSignature || info.workflowSignature || '', workflowTabId: context.workflowTabId || context.workflow_tab_id || info.workflowTabId || '', workflow_tab_id: context.workflow_tab_id || context.workflowTabId || info.workflowTabId || '', workflowTabName: context.workflowTabName || context.workflow_tab_name || info.workflowTabName || '', workflow_tab_name: context.workflow_tab_name || context.workflowTabName || info.workflowTabName || '', workflowTabAriaControls: context.workflowTabAriaControls || context.workflow_tab_aria_controls || info.workflowTabAriaControls || '', workflow_tab_aria_controls: context.workflow_tab_aria_controls || context.workflowTabAriaControls || info.workflowTabAriaControls || '', workflowTabText: context.workflowTabText || context.workflow_tab_text || info.workflowTabText || '', workflow_tab_text: context.workflow_tab_text || context.workflowTabText || info.workflowTabText || '' }; }, canSwitchToDownloadWorkflow(context = {}) { context = this.getDownloadWorkflowRuntimeContext(context); const label = this.getWorkflowContextLabel(context); const hasSpecificLabel = Boolean(label && !/^(current workflow|unknown workflow|workflow)$/i.test(label)); return Boolean( this.isUsableWorkflowRouteKey(this.getWorkflowContextRouteKey(context)) || this.getWorkflowContextId(context) || this.getWorkflowContextSignature(context) || hasSpecificLabel || context.workflow_tab_id || context.workflowTabId || context.workflow_tab_aria_controls || context.workflowTabAriaControls ); }, isDownloadWorkflowActive(context = {}) { context = this.getDownloadWorkflowRuntimeContext(context); const workflowId = this.normalizeWorkflowId(this.getWorkflowContextId(context)); const activeWorkflowId = this.normalizeWorkflowId(this.getActiveWorkflowId?.()); if (workflowId && activeWorkflowId && workflowId === activeWorkflowId) return true; const routeKey = this.getWorkflowContextRouteKey(context); const currentRoute = this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || ''; if (this.isUsableWorkflowRouteKey(routeKey) && currentRoute && routeKey === currentRoute) return true; const signature = this.getWorkflowContextSignature(context); const currentSignature = this.activeWorkflowSignature || this.getWorkflowSignature?.(this.getCurrentWorkflow?.()) || ''; if (signature && currentSignature && signature === currentSignature) return true; const activeTab = this.findActiveWorkflowTabElement?.(); if (activeTab) { const targetTabId = String(context.workflow_tab_id || context.workflowTabId || '').trim(); const targetTabName = String(context.workflow_tab_name || context.workflowTabName || '').trim(); const targetAriaControls = String(context.workflow_tab_aria_controls || context.workflowTabAriaControls || '').trim(); if (targetTabId && targetTabId === activeTab.workflowTabId) return true; if (targetTabName && targetTabName === activeTab.workflowTabName) return true; if (targetAriaControls && targetAriaControls === activeTab.workflowTabAriaControls) return true; } const label = this.cleanWorkflowLabel(this.getWorkflowContextLabel(context)).toLowerCase(); const activeLabel = this.cleanWorkflowLabel(this.getActiveWorkflowDownloadLabel?.()).toLowerCase(); return Boolean(label && activeLabel && label === activeLabel); }, isDownloadWorkflowOpen(context = {}) { context = this.getDownloadWorkflowRuntimeContext(context); if (this.findWorkflowTabForDownload(context)) return true; const workflowId = this.normalizeWorkflowId(this.getWorkflowContextId(context)); const activeWorkflowId = this.normalizeWorkflowId(this.getActiveWorkflowId?.()); if (workflowId && activeWorkflowId && workflowId === activeWorkflowId) return true; const signature = this.getWorkflowContextSignature(context); const currentSignature = this.activeWorkflowSignature || this.getWorkflowSignature?.(this.getCurrentWorkflow?.()) || ''; if (signature && currentSignature && signature === currentSignature) return true; const activeTab = this.findActiveWorkflowTabElement?.(); if (activeTab) { const targetTabId = String(context.workflow_tab_id || context.workflowTabId || '').trim(); const targetTabName = String(context.workflow_tab_name || context.workflowTabName || '').trim(); const targetAriaControls = String(context.workflow_tab_aria_controls || context.workflowTabAriaControls || '').trim(); if (targetTabId && targetTabId === activeTab.workflowTabId) return true; if (targetTabName && targetTabName === activeTab.workflowTabName) return true; if (targetAriaControls && targetAriaControls === activeTab.workflowTabAriaControls) return true; const label = this.cleanWorkflowLabel(this.getWorkflowContextLabel(context)).toLowerCase(); const activeTabLabel = this.cleanWorkflowLabel(activeTab.workflowLabel).toLowerCase(); if (label && activeTabLabel && label === activeTabLabel) return true; } const label = this.cleanWorkflowLabel(this.getWorkflowContextLabel(context)).toLowerCase(); const stateLabel = this.cleanWorkflowLabel(this.getWorkflowLabelFromComfyState?.()).toLowerCase(); return Boolean(label && stateLabel && label === stateLabel); }, didActiveWorkflowTabChange(previousActiveTab = null) { const activeTab = this.findActiveWorkflowTabElement?.(); if (!activeTab) return false; if (!previousActiveTab) return true; const keys = [ 'workflowLabel', 'workflowTabId', 'workflowTabName', 'workflowTabAriaControls', 'workflowTabText' ]; return keys.some(key => String(activeTab[key] || '') !== String(previousActiveTab[key] || '')); }, isDownloadWorkflowOpenAfterRouteSwitch(context = {}, previousActiveTab = null) { if (this.isDownloadWorkflowOpen(context)) return true; const routeKey = this.getWorkflowContextRouteKey(context); const currentRoute = this.getActiveWorkflowRouteKey?.() || ''; return Boolean( this.isUsableWorkflowRouteKey(routeKey) && currentRoute && routeKey === currentRoute && this.didActiveWorkflowTabChange(previousActiveTab) ); }, verifyDownloadHistoryWorkflowSwitch(context = {}, label = 'workflow', previousActiveTab = null, attempt = 0) { const maxAttempts = 6; const delay = attempt === 0 ? 150 : 200; setTimeout(() => { window.dispatchEvent?.(new Event('model-resolver-active-workflowchange')); if (this.isDownloadWorkflowOpenAfterRouteSwitch(context, previousActiveTab)) { this.showNotification(`Switched to workflow: ${label}`, 'success'); return; } if (attempt < maxAttempts) { this.verifyDownloadHistoryWorkflowSwitch(context, label, previousActiveTab, attempt + 1); return; } this.showNotification(`Open workflow not found: ${label}. It may have been closed.`, 'error'); }, delay); }, getDownloadQueueContext(progress = {}, info = {}, workflowLabel = '', downloadId = '') { const folderContext = this.getDownloadFolderContext?.(progress, info) || null; const missing = info?.missing || {}; const label = workflowLabel || this.getDownloadWorkflowLabel?.(info) || ''; const context = { ...(folderContext || {}), context_scope: 'download_queue', download_id: downloadId, open_folder_label: folderContext ? 'Open Download Folder' : '', name: folderContext?.name || progress?.filename || info?.filename || missing?.original_path?.split(/[/\\]/).pop() || 'Download', workflow_id: info.workflowId || info.workflow_id || this.getWorkflowContextId(info) || '', workflow_label: label, workflow_route_key: info.workflowRouteKey || this.getWorkflowContextRouteKey(info) || '', workflow_tab_id: info.workflowTabId || '', workflow_tab_name: info.workflowTabName || '', workflow_tab_aria_controls: info.workflowTabAriaControls || '', workflow_tab_text: info.workflowTabText || '', node_id: missing.locate_node_id ?? missing.node_id ?? '', widget_index: missing.widget_index ?? '', subgraph_id: missing.locate_subgraph_id || missing.subgraph_id || '', is_top_level: missing.locate_is_top_level ?? missing.is_top_level ?? true }; return (folderContext || this.canSwitchToDownloadWorkflow(context)) ? context : null; }, getWorkflowTabComparisonValues(identity = {}, element = null) { if (!identity) return []; return [ identity.workflowLabel, identity.workflowTabId, identity.workflowTabName, identity.workflowTabAriaControls, identity.workflowTabText, identity.workflowTabTitle, identity.workflowTabAriaLabel, element?.getAttribute?.('href') || '', element?.getAttribute?.('data-route') || '', element?.getAttribute?.('data-path') || '' ].map(value => String(value || '').trim()).filter(Boolean); }, findWorkflowTabForDownload(context = {}) { if (typeof document === 'undefined') return null; context = this.getDownloadWorkflowRuntimeContext(context); const routeKey = this.getWorkflowContextRouteKey(context); const routeLabel = this.getWorkflowLabelFromRouteKey?.(routeKey) || ''; const labelTargets = new Set([ this.getWorkflowContextLabel(context), routeLabel, context.workflow_tab_text, context.workflowTabText, context.workflow_tab_name, context.workflowTabName ].map(value => this.cleanWorkflowLabel(value).toLowerCase()).filter(Boolean)); const idTargets = new Set([ context.workflow_tab_id, context.workflowTabId, context.workflow_tab_aria_controls, context.workflowTabAriaControls ].map(value => String(value || '').trim().toLowerCase()).filter(Boolean)); const routeTarget = String(routeKey || '').trim().toLowerCase(); const seen = new Set(); let bestMatch = null; for (const selector of this.getWorkflowTabSearchSelectors()) { let elements; try { elements = Array.from(document.querySelectorAll(selector)); } catch (_error) { continue; } for (const element of elements) { if (!element || seen.has(element)) continue; seen.add(element); const identity = this.getWorkflowTabElementIdentity(element); if (!identity || !this.isLikelyWorkflowTabElement(element, identity.workflowLabel)) continue; const values = this.getWorkflowTabComparisonValues(identity, element); const normalizedValues = values.map(value => this.cleanWorkflowLabel(value).toLowerCase()).filter(Boolean); const rawValues = values.map(value => String(value || '').trim().toLowerCase()).filter(Boolean); let score = 0; for (const value of rawValues) { if (idTargets.has(value)) score += 100; if (routeTarget && value.includes(routeTarget)) score += 70; } for (const value of normalizedValues) { if (labelTargets.has(value)) score += 80; for (const target of labelTargets) { if (target && value !== target && (value.includes(target) || target.includes(value))) score += 25; } } if (this.isWorkflowTabElementActive(element)) score += 5; if (score > (bestMatch?.score || 0)) { bestMatch = { element, score }; } } } return bestMatch?.score > 0 ? bestMatch.element : null; }, switchToDownloadWorkflow(context = {}) { context = this.getDownloadWorkflowRuntimeContext(context); if (!this.canSwitchToDownloadWorkflow(context)) { this.showNotification('No workflow information available for this download', 'error'); return false; } const label = this.getWorkflowContextLabel(context) || 'workflow'; const isDownloadHistoryContext = context?.context_scope === 'download_history'; if (this.isDownloadWorkflowActive(context)) { this.showNotification(`Already on workflow: ${label}`, 'info'); return true; } const tab = this.findWorkflowTabForDownload(context); if (tab) { try { tab.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); tab.click(); setTimeout(() => { window.dispatchEvent?.(new Event('model-resolver-active-workflowchange')); }, 30); this.showNotification(`Switched to workflow: ${label}`, 'success'); return true; } catch (error) { console.warn('Model Resolver: failed to switch workflow tab', error); } } const routeKey = this.getWorkflowContextRouteKey(context); const currentRoute = this.getActiveWorkflowRouteKey?.() || ''; if (this.isUsableWorkflowRouteKey(routeKey) && routeKey !== currentRoute && typeof window !== 'undefined') { try { const previousActiveTab = isDownloadHistoryContext ? this.findActiveWorkflowTabElement?.() : null; window.location.hash = routeKey.startsWith('#') ? routeKey : `#${routeKey.replace(/^#/, '')}`; if (isDownloadHistoryContext) { this.verifyDownloadHistoryWorkflowSwitch(context, label, previousActiveTab); } else { setTimeout(() => { window.dispatchEvent?.(new Event('model-resolver-active-workflowchange')); }, 30); this.showNotification(`Switched to workflow: ${label}`, 'success'); } return true; } catch (error) { console.warn('Model Resolver: failed to switch workflow route', error); } } this.showNotification( isDownloadHistoryContext ? `Open workflow not found: ${label}. It may have been closed.` : `Could not find workflow tab: ${label}. It may be closed.`, 'error' ); return false; }, getActiveQueuePanelDownloadIds() { return this.getActiveQueuePanelDownloads().map(({ downloadId }) => downloadId); }, renderDownloadsPanel(downloads = [], history = this.getDownloadHistory()) { const activeSubTab = this.queueDownloadsActiveTab === 'history' ? 'history' : 'active'; const activeSelected = activeSubTab === 'active'; let html = `
`; html += '
'; html += ``; html += ``; html += '
'; html += activeSelected ? this.renderQueueDownloadsHtml(downloads) : this.renderDownloadHistoryHtml(history); html += '
'; const currentPanel = this.queueList.querySelector?.(`.mr-downloads-panel[data-downloads-view="${activeSubTab}"]`); if (currentPanel && typeof document !== 'undefined') { const template = document.createElement('template'); template.innerHTML = html; const nextPanel = template.content.firstElementChild; if (nextPanel) { this.patchDownloadsPanelElement(currentPanel, nextPanel); this.wireDownloadsPanelControls(); return; } } this.queueList.innerHTML = html; this.wireDownloadsPanelControls(); }, patchDownloadsPanelElement(currentElement, nextElement) { const syncAttributes = (current, next) => { const nextAttributeNames = new Set(Array.from(next.attributes || [], attribute => attribute.name)); for (const attribute of Array.from(next.attributes || [])) { if (current.getAttribute(attribute.name) !== attribute.value) { current.setAttribute(attribute.name, attribute.value); } } for (const attribute of Array.from(current.attributes || [])) { if (!nextAttributeNames.has(attribute.name)) { current.removeAttribute(attribute.name); } } }; const getNodeKey = (node) => { if (node?.nodeType !== 1) return ''; if (!node.classList?.contains('mr-download-queue-item')) return ''; return node.getAttribute?.('data-download-id') || ''; }; const canPatchNode = (current, next) => { if (!current || current.nodeType !== next.nodeType) return false; if (current.nodeType !== 1) return true; if (current.tagName !== next.tagName) return false; if ((current.getAttribute('class') || '') !== (next.getAttribute('class') || '')) return false; const currentKey = getNodeKey(current); const nextKey = getNodeKey(next); return !currentKey && !nextKey ? true : currentKey === nextKey; }; const patchNode = (current, next) => { if (!canPatchNode(current, next)) { const replacement = next.cloneNode(true); current.replaceWith(replacement); return replacement; } if (current.nodeType !== 1) { if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue; return current; } syncAttributes(current, next); patchChildren(current, next); return current; }; const patchChildren = (current, next) => { const nextChildren = Array.from(next.childNodes || []); let cursor = current.firstChild; for (const nextChild of nextChildren) { const nextKey = getNodeKey(nextChild); let currentChild = cursor; if (nextKey) { currentChild = Array.from(current.childNodes || []).find(child => getNodeKey(child) === nextKey) || null; if (currentChild && currentChild !== cursor) { current.insertBefore(currentChild, cursor); } } if (!currentChild) { // Insert at the current position so the cleanup pass can // remove an obsolete single-card/group root after a mode switch. const insertedChild = nextChild.cloneNode(true); current.insertBefore(insertedChild, cursor); cursor = insertedChild.nextSibling; continue; } const patchedChild = patchNode(currentChild, nextChild); cursor = patchedChild.nextSibling; } while (cursor) { const nextSibling = cursor.nextSibling; cursor.remove(); cursor = nextSibling; } }; patchNode(currentElement, nextElement); }, wireDownloadsPanelControls() { const bindInstantAction = (button, handler) => { if (!button || button._hasListener) return; button._hasListener = true; const run = (event) => { event?.preventDefault?.(); event?.stopPropagation?.(); if (button.disabled) return; if (event?.type === 'click' && button._handledPointerAction) { button._handledPointerAction = false; return; } if (event?.type === 'pointerdown') { button._handledPointerAction = true; } handler(); }; button.addEventListener('pointerdown', run); button.addEventListener('click', run); }; const getDownloadContext = (button) => { const downloadId = button?.dataset?.downloadId || ''; const info = this.activeDownloads?.[downloadId]; if (!downloadId || !info) return null; const progress = typeof this.applyPendingDownloadStatus === 'function' ? this.applyPendingDownloadStatus(info, info.lastProgress || {}) : (info.lastProgress || {}); const workflowLabel = this.getDownloadWorkflowLabel?.(info) || 'Unknown workflow'; return this.getDownloadQueueContext?.(progress, info, workflowLabel, downloadId) || null; }; this.queueList.querySelectorAll('.mr-downloads-subtab').forEach(button => { button.addEventListener('click', () => { this.setQueueDownloadsTab(button.dataset.downloadsTab || 'active'); }); }); this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => { bindInstantAction(button, () => { const downloadId = button.dataset.downloadId; if (downloadId) this.cancelDownload(downloadId); }); }); this.queueList.querySelectorAll('.mr-download-queue-pause').forEach(button => { bindInstantAction(button, () => { const downloadId = button.dataset.downloadId; if (downloadId) this.pauseDownload(downloadId); }); }); this.queueList.querySelectorAll('.mr-download-queue-resume').forEach(button => { bindInstantAction(button, () => { const downloadId = button.dataset.downloadId; if (downloadId) this.resumeDownload(downloadId); }); }); this.queueList.querySelectorAll('.mr-download-queue-open-folder').forEach(button => { bindInstantAction(button, () => { const context = getDownloadContext(button); if (context) this.openContainingFolder?.(context); }); }); this.queueList.querySelectorAll('.mr-download-queue-switch-workflow').forEach(button => { bindInstantAction(button, () => { const context = getDownloadContext(button); if (context) this.switchToDownloadWorkflow?.(context); }); }); this.queueList.querySelectorAll('.mr-download-queue-more').forEach(button => { if (button._hasListener) return; button._hasListener = true; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); if (button.disabled) return; const item = button.closest('.mr-download-queue-item'); if (item) window.MLOpenContextMenu?.(event, item); }); }); const clearHistoryButton = this.queueList.querySelector('.mr-download-history-clear'); if (clearHistoryButton) { clearHistoryButton.addEventListener('click', () => this.clearDownloadHistory()); } this.queueList.querySelectorAll('.mr-download-history-remove').forEach(button => { button.addEventListener('click', (event) => { event.stopPropagation(); const historyId = button.dataset.historyId || ''; const historyIndex = Number.parseInt(button.dataset.historyIndex || '-1', 10); this.removeDownloadHistoryEntry(historyId, historyIndex); }); }); }, renderQueueDownloadsHtml( downloads, { wrap = true, showWorkflowAction = true, showWorkflowDetails = true } = {} ) { if (!downloads.length) { return '
No active downloads.
'; } let html = wrap ? '
' : ''; for (const { downloadId, info } of downloads) { const progress = typeof this.applyPendingDownloadStatus === 'function' ? this.applyPendingDownloadStatus(info, info.lastProgress || {}) : (info.lastProgress || {}); const presentation = this.getDownloadProgressPresentation(progress); const { percent, downloadedText: downloaded, totalText: total, percentLabel, logicalDownloadedText: logicalDownloaded, logicalTotalText: logicalTotal, } = presentation; const filename = progress.filename || info.filename || info.missing?.download_source?.filename || info.missing?.original_path?.split(/[/\\]/).pop() || 'model'; const category = this.getCategoryDisplayName?.(info.category || info.missing?.category || '') || info.category || ''; const nodeLabel = info.missing?.subgraph_name || info.missing?.node_type || (info.missing?.subgraph_id ? 'Subgraph' : 'Node'); const status = progress.status || info.lastStatus || 'starting'; const backend = String(progress.download_backend || info.downloadBackend || '').toLowerCase(); const isAria2 = backend === 'aria2'; const isPaused = status === 'paused'; const statusLabel = status === 'downloading' ? (presentation.isFinalizing ? 'Finalizing' : '') : (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : status.replace(/_/g, ' '))); const sizeTextBase = presentation.isFinalizing && logicalTotal ? `Finalizing file: ${logicalDownloaded} / ${logicalTotal}` : (presentation.totalSize ? `${downloaded} / ${total}` : downloaded); const sizeText = presentation.totalSize ? `${sizeTextBase} (${percentLabel}%)` : sizeTextBase; const speed = Number(progress.speed) || 0; const speedText = speed > 0 ? `${this.formatBytes(speed)}/s` : ''; const etaText = this.getDownloadEtaText?.(progress)?.replace(/^ETA\s+/i, 'Remaining ') || ''; const connections = Number(progress.connections) || 0; const targetPath = progress.directory || info.downloadDirectory || info.downloadPath || ''; const workflowLabel = this.getDownloadWorkflowLabel?.(info) || 'Unknown workflow'; const contextModel = this.getDownloadQueueContext?.(progress, info, workflowLabel, downloadId); const hasFolderAction = Boolean(contextModel?.folder_path || contextModel?.download_directory || contextModel?.directory || contextModel?.path || contextModel?.resolved_path); const hasWorkflowAction = Boolean(contextModel && this.canSwitchToDownloadWorkflow?.(contextModel)); const contextData = contextModel ? this.getContextMenuAttrs(contextModel) : ''; const safeDownloadId = this.escapeHtml(downloadId); const nodeText = `${nodeLabel} #${info.missing?.node_id ?? ''}`; const detailParts = [category, nodeText].filter(Boolean); const renderIcon = (iconName, className) => ( typeof getSvgIcon === 'function' ? getSvgIcon(iconName, 'currentColor', className) : '' ); const actionButton = (className, label, iconName, disabled = false) => ( `` ); html += `
`; html += `
`; html += `
`; html += `${this.escapeHtml(filename)}`; if (statusLabel) { html += `${this.escapeHtml(statusLabel)}`; } html += `
`; html += `
`; html += actionButton( isPaused ? 'mr-download-queue-resume' : 'mr-download-queue-pause', isPaused ? 'Resume download' : 'Pause download', isPaused ? 'play' : 'pause', !isAria2 ); html += actionButton('mr-download-queue-cancel', 'Cancel download', 'x'); html += actionButton('mr-download-queue-open-folder', 'Open download folder', 'folderOpen', !hasFolderAction); if (showWorkflowAction) { html += actionButton('mr-download-queue-switch-workflow', 'Switch to workflow', 'workflowBack', !hasWorkflowAction); } html += actionButton('mr-download-queue-more', 'More download actions', 'info', !contextModel); html += `
`; html += `
`; html += `
`; html += `
`; html += `
`; html += `${this.escapeHtml(sizeText)}`; html += ``; if (speedText) { html += `${renderIcon('download', 'mr-download-queue-stat-icon')}${this.escapeHtml(speedText)}`; } if (etaText) { html += `${this.escapeHtml(etaText)}`; } if (connections > 0) { html += `${renderIcon('network', 'mr-download-queue-stat-icon')}${connections}`; } if (!speedText && !etaText) { html += `${this.escapeHtml(statusLabel)}`; } html += ``; html += `
`; html += `
`; if (showWorkflowDetails) { html += `
Workflow${this.escapeHtml([workflowLabel, ...detailParts].filter(Boolean).join(' · '))}
`; } html += `
`; } if (wrap) html += '
'; return html; }, renderDownloadHistoryHtml(history = []) { if (!history.length) { return '
No downloaded models in history yet.
'; } let html = '
'; html += '
'; for (let index = 0; index < history.length; index++) { const entry = history[index]; const filename = entry.filename || 'model'; const workflowLabel = entry.workflowLabel || 'Unknown workflow'; const category = entry.categoryLabel || this.getCategoryDisplayName?.(entry.category || '') || entry.category || ''; const nodeText = entry.nodeId !== '' && entry.nodeId !== undefined ? `${entry.nodeLabel || 'Node'} #${entry.nodeId}` : (entry.nodeLabel || ''); const targetPath = entry.directory || entry.path || ''; const targetLabel = targetPath ? targetPath.split(/[/\\]/).filter(Boolean).pop() || targetPath : ''; const timeLabel = this.formatDownloadHistoryTime(entry.completedAt); const sizeLabel = entry.totalSize ? this.formatBytes(entry.totalSize) : ''; const statusLabel = entry.statusLabel || (entry.status === 'already_exists' ? 'Already downloaded' : 'Downloaded'); const contextModel = this.getDownloadHistoryFolderContext(entry); const hasFolderAction = Boolean(contextModel?.folder_path || contextModel?.download_directory || contextModel?.directory || contextModel?.path || contextModel?.resolved_path); const hasWorkflowAction = Boolean(contextModel && this.canSwitchToDownloadWorkflow?.(contextModel)); const contextTooltip = hasFolderAction && hasWorkflowAction ? 'Right-click for workflow and download folder actions' : (hasWorkflowAction ? 'Right-click to switch to workflow' : 'Right-click to open download folder'); const contextData = contextModel ? this.getContextMenuAttrs(contextModel, contextTooltip) : ''; const historyId = String(entry.id || ''); html += `
`; html += `
`; html += `${this.escapeHtml(filename)}`; html += `${this.escapeHtml(statusLabel)}`; html += `
`; if (nodeText) { html += `
Model${this.escapeHtml(nodeText)}
`; } html += `
Workflow${this.escapeHtml(workflowLabel)}
`; if (category) { html += `
Type${this.escapeHtml(category)}
`; } if (targetLabel) { html += `
Folder${this.escapeHtml(targetLabel)}
`; } if (sizeLabel || timeLabel) { html += `
Done${this.escapeHtml([timeLabel, sizeLabel].filter(Boolean).join(' | '))}
`; } html += `
`; html += `
`; } html += '
'; return html; }, // Remove queued by index removeQueuedByIndex(i) { const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; if (i < 0 || i >= list.length) return; const r = list[i]; // Remove this.pendingResolutions.splice(i, 1); this.rebuildPendingIndex(); this.savePendingQueueForActiveWorkflow(); // Update per-item selected bar this.updateSelectedBarForMissing?.(r); this.updateApplyPendingButton?.(); this.updateQueuePanel(); this.refreshMissingModelsBrowserFromCache?.(); }, // Clear all queued selections clearAllQueued() { this.pendingResolutions = []; this.pendingIndex = new Map(); this.savePendingQueueForActiveWorkflow(); this.updateApplyPendingButton?.(); this.updateQueuePanel(); this.refreshMissingModelsBrowserFromCache?.(); try { document.querySelectorAll('.model-resolver-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; }); } catch (_e) { /* ignore */ } }, // Update selected bar for a specific missing model slot updateSelectedBarForMissing(missing) { if (!missing) return; const key = this.getMissingModelKey(missing); const domKey = this.getMissingModelDomKey(missing); const selectedBar = document.getElementById(`selected-bar-${domKey}`); if (!selectedBar) return; // Find selection for this slot let selection = null; if (this.pendingIndex.has(key)) { const idx = this.pendingIndex.get(key); if (idx >= 0 && idx < this.pendingResolutions.length) { selection = this.pendingResolutions[idx]; } } if (!selection) { selectedBar.style.display = 'none'; selectedBar.innerHTML = ''; this.setContextMenuElement(selectedBar, null); return; } // Build selected bar content const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || ''; const selectedPath = selection.resolved_model?.path || selection.resolved_path || ''; const selectedContext = selectedPath ? { context_scope: 'download_folder', open_folder_label: 'Open Containing Folder', name: selection.resolved_model?.filename || label, path: selectedPath, resolved_path: selectedPath, category: selection.category, original_path: selection.original_path } : null; const selectedContextAttrs = selectedContext ? this.getContextMenuAttrs(selectedContext, 'Right-click to open containing folder') : ''; if (selectedContext) { this.setContextMenuElement(selectedBar, selectedContext, 'Right-click to open containing folder'); } else { this.setContextMenuElement(selectedBar, null); } const applyBtnId = `selected-apply-${domKey}`; const removeBtnId = `selected-remove-${domKey}`; selectedBar.innerHTML = `
Selected ${this.escapeHtml(label)}
`; selectedBar.style.display = 'block'; const applyBtn = selectedBar.querySelector(`#${applyBtnId}`); if (applyBtn) { applyBtn.addEventListener('click', () => { this.applyQueuedByKey(key, applyBtn); }); } // Wire remove button - use key-based removal const removeBtn = selectedBar.querySelector(`#${removeBtnId}`); if (removeBtn) { removeBtn.addEventListener('click', () => { this.removeQueuedByKey(key); }); } }, // Remove queued by key (more reliable than index) removeQueuedByKey(key) { if (!key || !this.pendingIndex.has(key)) return; // Find and remove the item with this key const idx = this.pendingIndex.get(key); if (idx >= 0 && idx < this.pendingResolutions.length) { const r = this.pendingResolutions[idx]; // Update the selected bar before removing const m = r; // Remove from array this.pendingResolutions.splice(idx, 1); this.rebuildPendingIndex(); this.savePendingQueueForActiveWorkflow(); this.updateSelectedBarForMissing(m); this.updateApplyPendingButton?.(); this.updateQueuePanel(); this.refreshMissingModelsBrowserFromCache?.(); } }, // Rebuild pending index after modification rebuildPendingIndex() { this.pendingIndex = new Map(); for (let i = 0; i < this.pendingResolutions.length; i++) { const r = this.pendingResolutions[i]; const key = this.getResolutionQueueKey(r); this.pendingIndex.set(key, i); } }, setQueueCollapsed(collapsed, { persist = true, updatePanel = true } = {}) { const nextCollapsed = !!collapsed; const wasCollapsed = !!this.queueCollapsed; const opening = wasCollapsed && !nextCollapsed; this.queueCollapsed = nextCollapsed; if (persist) { this.persistQueueCollapsedState(this.queueCollapsed, opening ? 500 : 0); } this.updateQueueVisibility(); if (!updatePanel) return; if (opening || this.isQueuePanelVisible()) { this.updateQueuePanel({ force: opening }); } else { this._queuePanelDirty = true; } }, getResolutionQueueKey(resolution = {}) { return resolution.missing_key || this.getMissingModelKey(resolution); }, getResolutionNodeRefs(missing = {}) { const refs = Array.isArray(missing.all_node_refs) && missing.all_node_refs.length ? missing.all_node_refs : [missing]; return refs.map(ref => ({ node_id: ref.node_id, node_type: ref.node_type, node_title: ref.node_title, widget_index: ref.widget_index, original_path: ref.original_path, name: ref.name, category: ref.category, subgraph_id: ref.subgraph_id, subgraph_name: ref.subgraph_name, is_top_level: ref.is_top_level, custom_node_adapter: ref.custom_node_adapter, custom_node_original_identity: ( ref.custom_node_original_identity || ref.name || ref.original_path ), nested_key: ref.nested_key, promoted_widget_name: ref.promoted_widget_name })); }, expandPendingResolutionsForApply(list = this.pendingResolutions || []) { const expanded = []; const seen = new Set(); for (const resolution of Array.isArray(list) ? list : []) { const { node_refs, all_node_refs, ...baseResolution } = resolution || {}; const refs = Array.isArray(node_refs) && node_refs.length ? node_refs : (Array.isArray(all_node_refs) && all_node_refs.length ? all_node_refs : [resolution]); for (const ref of refs.filter(Boolean)) { const item = { ...baseResolution, node_id: ref.node_id ?? baseResolution.node_id, widget_index: ref.widget_index ?? baseResolution.widget_index, category: ref.category || baseResolution.category, subgraph_id: ref.subgraph_id ?? baseResolution.subgraph_id, is_top_level: ref.is_top_level ?? baseResolution.is_top_level, custom_node_adapter: ( ref.custom_node_adapter || baseResolution.custom_node_adapter ), custom_node_original_identity: ( ref.custom_node_original_identity || ref.name || ref.original_path || baseResolution.custom_node_original_identity || baseResolution.name || baseResolution.original_path ), nested_key: ref.nested_key ?? baseResolution.nested_key, promoted_widget_name: ( ref.promoted_widget_name || baseResolution.promoted_widget_name ), original_path: ref.original_path || baseResolution.original_path, node_type: ref.node_type || baseResolution.node_type, node_label: ref.subgraph_name || ref.node_type || baseResolution.node_label }; const itemKey = [ item.node_id ?? '', item.widget_index ?? '', item.subgraph_id || '', item.is_top_level !== false ? 'T' : 'F', item.nested_key || '', item.custom_node_original_identity || item.original_path || '' ].join(':'); if (seen.has(itemKey)) continue; seen.add(itemKey); expanded.push(item); } } return expanded; }, updateQueueVisibility() { const isMissingTab = this.activeTab === 'missing'; const showQueuePanel = isMissingTab && !this.queueCollapsed; if (this.queueElement) { this.queueElement.style.display = showQueuePanel ? '' : 'none'; } if (!showQueuePanel) { this.clearQueueContentSplitFlex(); } else if (!this._splitDragging && !this._queuePanelDirty) { this.stabilizeQueueSplitLayout(); } if (this.splitterElement) { this.splitterElement.style.display = showQueuePanel ? '' : 'none'; } if (this.queueToggleIcon) { this.queueToggleIcon.style.display = isMissingTab ? '' : 'none'; } this.updateQueueToggleIcon(); if (showQueuePanel && this._queuePanelDirty) { this.updateQueuePanel({ force: true }); if (!this._splitDragging) { this.stabilizeQueueSplitLayout(); } } }, updateQueueToggleIcon() { if (!this.queueToggleIcon) return; this.queueToggleIcon.style.display = this.activeTab === 'missing' ? '' : 'none'; this.queueToggleIcon.textContent = ''; this.queueToggleIcon.removeAttribute('data-tooltip'); this.queueToggleIcon.removeAttribute('title'); if (this.queueCollapsed) { this.queueToggleIcon.classList.add('is-collapsed'); this.queueToggleIcon.setAttribute('aria-label', 'Show queue and downloads panel'); } else { this.queueToggleIcon.classList.remove('is-collapsed'); this.queueToggleIcon.setAttribute('aria-label', 'Hide queue and downloads panel'); } }, onQueueEdgeClick(event) { if (this._suppressQueueEdgeClick) { event?.preventDefault?.(); event?.stopPropagation?.(); this._suppressQueueEdgeClick = false; return; } event?.preventDefault?.(); event?.stopPropagation?.(); this.setQueueCollapsed(!this.queueCollapsed); }, startQueueEdgeDrag(event) { if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return; event.preventDefault?.(); event.stopPropagation?.(); this._suppressQueueEdgeClick = true; this.beginQueueEdgeDrag(event.clientX, event); }, beginQueueEdgeDrag(startX, sourceEvent = null) { const dragStartX = Number(startX); if (!Number.isFinite(dragStartX)) return; const usePointerEvents = String(sourceEvent?.type || '').startsWith('pointer'); this._suppressQueueEdgeClick = true; this.splitterElement.style.display = ''; document.getElementById('model-resolver-body')?.classList.add('mr-queue-preview-collapsed'); this.queueElement.style.display = 'none'; this.queueElement.style.width = '0px'; this.startSplitDrag( { clientX: dragStartX, type: usePointerEvents ? 'pointerdown' : 'mousedown', pointerId: sourceEvent?.pointerId, preventDefault: () => {}, stopPropagation: () => {} }, { startWidth: 0, edgeOpen: true, startCollapsedPreview: true } ); }, getQueueSplitBody() { return document.getElementById('model-resolver-body'); }, observeQueueSplitContainer(body = this.getQueueSplitBody()) { if (!(body instanceof HTMLElement)) return; const rememberWidth = (width) => { const nextWidth = Number(width); if (!Number.isFinite(nextWidth) || nextWidth <= 0) return; this._queueSplitLastContainerWidth = nextWidth; if (!this._splitDragging && this.isQueuePanelVisible?.() && !this._queuePanelDirty) { this.stabilizeQueueSplitLayout({ containerWidth: nextWidth }); } }; this._queueSplitFallbackContainerWidth = Number(window.innerWidth || 720); if (this._queueSplitResizeObserver) { try { this._queueSplitResizeObserver.disconnect(); } catch (_e) { } this._queueSplitResizeObserver = null; } if (typeof ResizeObserver === 'function') { this._queueSplitResizeObserver = new ResizeObserver((entries) => { rememberWidth(entries?.[0]?.contentRect?.width); }); this._queueSplitResizeObserver.observe(body); } requestAnimationFrame(() => { rememberWidth(body.clientWidth || body.getBoundingClientRect().width); }); }, getQueueSplitContainerWidth(body = null, { allowMeasure = false } = {}) { const cached = Number(this._queueSplitLastContainerWidth); if (Number.isFinite(cached) && cached > 0) return cached; if (allowMeasure && body instanceof HTMLElement) { const measured = Number(body.clientWidth || body.getBoundingClientRect().width); if (Number.isFinite(measured) && measured > 0) { this._queueSplitLastContainerWidth = measured; return measured; } } const width = Number(this._queueSplitFallbackContainerWidth || window.innerWidth || 0); const safeWidth = Number.isFinite(width) && width > 0 ? width : 720; this._queueSplitLastContainerWidth = safeWidth; return safeWidth; }, getQueueSplitterWidth() { const cached = Number(this._queueSplitterWidth); if (Number.isFinite(cached) && cached > 0) return cached; this._queueSplitterWidth = 10; return 10; }, getQueueSplitBounds(containerWidth, { edgeOpen = false } = {}) { const safeWidth = Math.max(1, Number(containerWidth) || 1); const splitterWidth = this.getQueueSplitterWidth(); const available = Math.max(1, safeWidth - splitterWidth); const min = edgeOpen ? 0 : 56; const max = Math.max(min, Math.floor(available - 360)); return { min, max, available }; }, getQueuePaneWidth(element, { allowMeasure = false } = {}) { if (!(element instanceof HTMLElement)) return null; const flexBasis = String(element.style.flexBasis || '').trim(); if (flexBasis.endsWith('px')) { const width = parseFloat(flexBasis); if (Number.isFinite(width) && width >= 0) return width; } const inlineWidth = String(element.style.width || '').trim(); if (inlineWidth.endsWith('px')) { const width = parseFloat(inlineWidth); if (Number.isFinite(width) && width >= 0) return width; } if (!allowMeasure) return null; const measured = Number(element.offsetWidth || element.getBoundingClientRect().width || 0); return Number.isFinite(measured) && measured >= 0 ? measured : null; }, applyQueueSplitWidth(width, { bounds = null, containerWidth = null, skipIfUnchanged = false } = {}) { if (!(this.queueElement instanceof HTMLElement) || !(this.contentElement instanceof HTMLElement)) return false; const resolvedBounds = bounds || this.getQueueSplitBounds(containerWidth || this.getQueueSplitContainerWidth(), { edgeOpen: this._splitEdgeOpen }); const nextWidth = Math.round(Math.max(resolvedBounds.min, Math.min(resolvedBounds.max, Number(width) || 0))); const contentWidth = Math.max(1, Math.round(resolvedBounds.available - nextWidth)); const queueValue = `${nextWidth}px`; const contentValue = `${contentWidth}px`; if ( skipIfUnchanged && this.queueElement.style.flexBasis === queueValue && this.contentElement.style.flexBasis === contentValue ) { return false; } if (this.contentElement.style.flexBasis !== contentValue) this.contentElement.style.flexBasis = contentValue; if (this.contentElement.style.flexGrow !== '0') this.contentElement.style.flexGrow = '0'; if (this.contentElement.style.flexShrink !== '0') this.contentElement.style.flexShrink = '0'; if (this.queueElement.style.width !== queueValue) this.queueElement.style.width = queueValue; if (this.queueElement.style.flexBasis !== queueValue) this.queueElement.style.flexBasis = queueValue; if (this.queueElement.style.flexGrow !== '0') this.queueElement.style.flexGrow = '0'; if (this.queueElement.style.flexShrink !== '0') this.queueElement.style.flexShrink = '0'; this._appliedSplitWidth = nextWidth; return true; }, stabilizeQueueSplitLayout({ containerWidth = null } = {}) { if (!this.isQueuePanelVisible() || this._splitDragging) return false; const measuredContainerWidth = Number(containerWidth); this._queueSplitLastContainerWidth = Number.isFinite(measuredContainerWidth) && measuredContainerWidth > 0 ? measuredContainerWidth : this.getQueueSplitContainerWidth(); const bounds = this.getQueueSplitBounds(this._queueSplitLastContainerWidth); const queueWidth = this.getQueuePaneWidth(this.queueElement) || 320; return this.applyQueueSplitWidth(queueWidth, { bounds, containerWidth: this._queueSplitLastContainerWidth, skipIfUnchanged: true }); }, deferQueueSplitReleaseUi(body) { if (this._queueSplitReleaseCleanupFrame) { cancelAnimationFrame(this._queueSplitReleaseCleanupFrame); this._queueSplitReleaseCleanupFrame = null; } const restore = () => { this._queueSplitReleaseCleanupFrame = null; body?.classList?.remove('mr-queue-preview-collapsed'); }; this._queueSplitReleaseCleanupFrame = requestAnimationFrame(() => { this._queueSplitReleaseCleanupFrame = requestAnimationFrame(restore); }); }, activateQueueSplitUi(body) { if (this._queueSplitUiActive) return; const targetBody = body || this.getQueueSplitBody(); this._queueSplitUiActive = true; this.hideTooltip?.(); targetBody?.classList?.add('is-resizing'); this.splitterElement?.classList?.add('is-resizing'); }, deactivateQueueSplitUi(body) { const targetBody = body || this.getQueueSplitBody(); this._queueSplitUiActive = false; targetBody?.classList?.remove('is-resizing'); this.splitterElement?.classList?.remove('is-resizing'); }, // Begin split drag for resizable panels startSplitDrag(e, { startWidth = null, edgeOpen = false, startCollapsedPreview = false } = {}) { try { if (e?.button !== undefined && e.button !== 0) return; e?.preventDefault?.(); e?.stopPropagation?.(); if (!this.queueElement || !this.contentElement) return; this._queueSplitLastContainerWidth = this.getQueueSplitContainerWidth(); const bounds = this.getQueueSplitBounds(this._queueSplitLastContainerWidth, { edgeOpen }); const currentWidth = this.getQueuePaneWidth(this.queueElement) || 320; const initialWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, Number.isFinite(startWidth) ? startWidth : currentWidth))); this._splitDragging = true; if (!startCollapsedPreview) { this.applyQueueSplitWidth(initialWidth, { bounds, containerWidth: this._queueSplitLastContainerWidth, skipIfUnchanged: true }); } this._splitStart = { x: e.clientX, startWidth: initialWidth, containerWidth: this._queueSplitLastContainerWidth, bounds }; this._pendingSplitWidth = initialWidth; this._appliedSplitWidth = initialWidth; this._splitPreviewCollapsed = !!startCollapsedPreview; this._splitEdgeOpen = !!edgeOpen; this._splitEdgeOpenedPastThreshold = false; const body = this.getQueueSplitBody(); this._splitInteraction = startSplitterDrag(e, { anchor: 'right', startWidth: initialWidth, bounds, onBeforeDrag: (newW) => { this.activateQueueSplitUi(body); if (newW < bounds.min) newW = bounds.min; if (newW > bounds.max) newW = bounds.max; return newW; }, onDrag: (width) => { this._pendingSplitWidth = width; if (this._splitEdgeOpen && this._pendingSplitWidth > 140) { this._splitEdgeOpenedPastThreshold = true; if (this.queueCollapsed) { this.queueCollapsed = false; this.persistQueueCollapsedState(false, 500); this.updateQueueToggleIcon(); } if (this._queuePanelDirty) { this.updateQueuePanel({ force: true }); } } const previewCollapsed = this._splitEdgeOpen ? (!this._splitEdgeOpenedPastThreshold || this._pendingSplitWidth <= 140) : this._pendingSplitWidth <= 140; if (previewCollapsed !== this._splitPreviewCollapsed) { this._splitPreviewCollapsed = previewCollapsed; const body = document.getElementById('model-resolver-body'); body?.classList.toggle('mr-queue-preview-collapsed', previewCollapsed); this.queueElement.style.display = previewCollapsed ? 'none' : ''; if (previewCollapsed) { const collapsedBounds = this.getQueueSplitBounds(this._splitStart.containerWidth, { edgeOpen: true }); this.applyQueueSplitWidth(0, { bounds: collapsedBounds, containerWidth: this._splitStart.containerWidth, skipIfUnchanged: true }); } } if (this._splitPreviewCollapsed) return; this.applyQueueSplitWidth(width, { bounds: this._splitStart?.bounds || bounds, containerWidth: this._splitStart?.containerWidth, skipIfUnchanged: true }); }, onEnd: (finalWidth) => { this._splitDragging = false; this.deactivateQueueSplitUi(body); if (this.queueElement && finalWidth && !this._splitPreviewCollapsed) { this.applyQueueSplitWidth(finalWidth, { bounds: this._splitStart?.bounds, containerWidth: this._splitStart?.containerWidth, skipIfUnchanged: true }); } if (this._splitEdgeOpen && !this._pendingSplitWidth) { try { const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); this.applyQueueSplitWidth(restoreWidth, { bounds: this._splitStart?.bounds, containerWidth: this._splitStart?.containerWidth }); this.queueElement.style.display = ''; if (this.splitterElement) this.splitterElement.style.display = ''; this.persistQueueSplitWidth(restoreWidth, 500); } catch (_err) { } this.queueCollapsed = false; this.persistQueueCollapsedState(false, 500); this.updateQueueVisibility(); this.updateQueuePanel({ force: true }); this._splitEdgeOpen = false; this._pendingSplitWidth = null; this._appliedSplitWidth = null; this._splitPreviewCollapsed = false; this.deferQueueSplitReleaseUi(body); this._splitEdgeOpenedPastThreshold = false; setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); this._splitInteraction = null; return; } const shouldCollapse = !!this._splitPreviewCollapsed; if (shouldCollapse) { try { const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); this.applyQueueSplitWidth(restoreWidth, { bounds: this._splitStart?.bounds, containerWidth: this._splitStart?.containerWidth }); this.queueElement.style.display = ''; this.persistQueueSplitWidth(restoreWidth); } catch (_err) { } this._pendingSplitWidth = null; this._appliedSplitWidth = null; this._splitPreviewCollapsed = false; this._splitEdgeOpen = false; this._splitEdgeOpenedPastThreshold = false; this.deferQueueSplitReleaseUi(body); this.setQueueCollapsed(true); setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); this._splitInteraction = null; return; } if (this.queueElement) this.queueElement.style.display = ''; try { const width = finalWidth || Number.parseInt(String(this.queueElement.style.width || ''), 10) || this.getQueuePaneWidth(this.queueElement); this.persistQueueSplitWidth(width); } catch (_err) { } this._pendingSplitWidth = null; this._appliedSplitWidth = null; this._splitPreviewCollapsed = false; this._splitEdgeOpen = false; this._splitEdgeOpenedPastThreshold = false; this.deferQueueSplitReleaseUi(body); setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); this._splitInteraction = null; } }); } catch (_err) { /* ignore */ } }, buildResolutionSelection(missing, resolvedModel) { const missingKey = this.getMissingModelKey(missing); return { missing_key: missingKey, missing_search_key: this.getMissingSearchKey?.(missing), node_id: missing.node_id, widget_index: missing.widget_index, resolved_path: resolvedModel.path, category: missing.category, resolved_model: resolvedModel, original_path: missing.original_path, expected_filename: missing.expected_filename || missing.civitai_info?.expected_filename || missing.download_source?.filename, filename: missing.filename, urn_string: missing.urn_string, subgraph_id: missing.subgraph_id, is_top_level: missing.is_top_level, custom_node_adapter: missing.custom_node_adapter, custom_node_original_identity: ( missing.custom_node_original_identity || missing.name || missing.original_path ), nested_key: missing.nested_key, promoted_widget_name: missing.promoted_widget_name, node_refs: this.getResolutionNodeRefs(missing), node_type: missing.node_type, node_label: missing.subgraph_name || missing.node_type }; }, /** * Queue a resolution for later batch apply */ queueResolution(missing, resolvedModel) { if (!resolvedModel) { this.showNotification('No model selected', 'error'); return; } const resolution = this.buildResolutionSelection(missing, resolvedModel); const key = this.getResolutionQueueKey(resolution); if (this.pendingIndex.has(key)) { // replace existing selection for this slot const idx = this.pendingIndex.get(key); this.pendingResolutions[idx] = resolution; } else { this.pendingIndex.set(key, this.pendingResolutions.length); this.pendingResolutions.push(resolution); } this.savePendingQueueForActiveWorkflow(); // Update selected bar UI this.updateSelectedBarForMissing?.(missing); this.updateQueuePanel(); this.updateApplyPendingButton(); this.refreshMissingModelsBrowserFromCache?.(); }, /** * Apply one queued resolution from the selected bar. */ async applyQueuedByKey(key, button = null) { if (!key || !this.pendingIndex.has(key)) { this.showNotification('Selected match is no longer queued.', 'error'); return; } const idx = this.pendingIndex.get(key); const selection = this.pendingResolutions?.[idx]; if (!selection) { this.showNotification('Selected match is no longer queued.', 'error'); return; } if (button) { button.disabled = true; button.classList.add('mr-btn-is-disabled'); button.textContent = 'Applying...'; } try { await this.applyPendingResolutionList([selection], { clearAll: false }); } finally { if (button?.isConnected) { button.disabled = false; button.classList.remove('mr-btn-is-disabled'); button.textContent = 'Apply'; } } }, async applyPendingResolutionList(list, { clearAll = false } = {}) { if (!list.length) { this.showNotification('No selections queued', 'error'); return null; } try { const appliedSelections = this.clonePendingResolutions?.(list) || JSON.parse(JSON.stringify(list)); const applyResolutions = this.expandPendingResolutionsForApply(appliedSelections); if (!applyResolutions.length) { this.showNotification('No valid selections to apply', 'error'); return null; } const workflow = this.getCurrentWorkflow(); if (!workflow) { this.showNotification('No workflow loaded', 'error'); return null; } const data = await this.fetchJson('/model_resolver/resolve', { method: 'POST', body: JSON.stringify({ workflow, resolutions: applyResolutions }) }, 'Apply resolutions'); if (data.success) { const optimisticData = this.getOptimisticAnalysisDataAfterApply(appliedSelections); await this.updateWorkflowInComfyUI(data.workflow, applyResolutions); this.rememberAppliedResolvedSelections(appliedSelections); const appliedKeys = new Set(appliedSelections.map(selection => this.getResolutionQueueKey(selection))); const remainingSelections = clearAll ? [] : (this.pendingResolutions || []).filter(selection => !appliedKeys.has(this.getResolutionQueueKey(selection))); this.pendingResolutions = remainingSelections; this.rebuildPendingIndex(); this.savePendingQueueForActiveWorkflow(); this.preserveSearchCacheAcrossNextWorkflowSync = true; this.syncWorkflowScopedQueue?.(data.workflow); this.pendingResolutions = remainingSelections; this.rebuildPendingIndex(); this.savePendingQueueForActiveWorkflow(); this.updateApplyPendingButton(); this.updateQueuePanel(); this.applyOptimisticAnalysisData(optimisticData, data.workflow); const selectionCount = appliedSelections.length; const refText = applyResolutions.length > selectionCount ? ` (${applyResolutions.length} references)` : ''; this.showNotification(`✓ Linked ${selectionCount} selection${selectionCount > 1 ? 's' : ''}${refText}`, 'success'); this.scheduleComfyModelCatalogRefreshAfterApply?.(data.workflow, applyResolutions); this.refreshAnalysisInBackground(data.workflow, this.getMissingWorkflowSignature(data.workflow)); return data.workflow || null; } else { this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error'); return null; } } catch (e) { console.error('Model Resolver: applyPendingResolutions error', e); this.showNotification('Error applying selections: ' + e.message, 'error'); return null; } }, /** * Apply all pending resolutions in batch */ async applyPendingResolutions() { const list = this.pendingResolutions || []; await this.applyPendingResolutionList(list, { clearAll: true }); }, updateApplyPendingButton() { if (!this.applyPendingBtn) return; const count = this.pendingResolutions?.length || 0; const isEmpty = count === 0; const tooltip = count > 0 ? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.` : 'Apply the model links you selected from local matches or search results.'; this.applyPendingBtn.textContent = `Apply Selected (${count})`; this.setTooltip(this.applyPendingBtn, tooltip); this.applyPendingBtn.disabled = isEmpty; this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty)); this.applyPendingBtn.classList.toggle('mr-btn-is-disabled', isEmpty); this.updateBatchFooterButtons(); }, updateBatchFooterButtons() { const missingModels = this.missingModels || []; const selectedCount = this.getSelectedMissingModels().length; const totalCount = missingModels.length; const pendingCount = this.pendingResolutions?.length || 0; const activeCount = this.getActiveQueuePanelDownloads().length; const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length; const downloadableAll = this.getMissingWithDownloadSources(missingModels).length; if (this.selectMenuButton) { this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`; this.selectMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0); } if (this.searchMenuButton) { this.searchMenuButton.textContent = this.batchSearchRunning ? (this.batchSearchCancelRequested ? 'Stopping...' : 'Searching...') : 'Search'; this.searchMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0 && !this.batchSearchRunning); } if (this.downloadMenuButton) { const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download'; this.downloadMenuButton.textContent = label; this.downloadMenuButton.classList.toggle('mr-btn-danger', activeCount > 0); this.downloadMenuButton.classList.toggle('mr-btn-download', activeCount === 0); this.downloadMenuButton.classList.toggle('mr-btn-is-disabled', activeCount === 0 && downloadableAll === 0); this.setTooltip( this.downloadMenuButton, activeCount > 0 ? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` : `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.` ); } if (this.applyPendingBtn) { this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`; } if (this.linkMenuButton) { this.linkMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0 && pendingCount === 0); } this.updateFooterMenuItemVisibility?.(); }, getMissingModelsForPendingResolutions() { const pending = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; if (!pending.length) return []; const pendingKeys = new Set(pending.map(resolution => this.getResolutionQueueKey(resolution))); return (this.missingModels || []).filter(missing => pendingKeys.has(this.getMissingModelKey(missing))); }, getQueueExactLocalMatchPreviewModels() { const selected = this.getSelectedMissingModels(); const targets = selected.length ? selected : (this.missingModels || []); return targets.filter(missing => this.getBestLocalMatch(missing, 100)?.model); }, getDownloadableBatchPreviewModels(mode = 'all') { const targets = mode === 'selected' ? this.getSelectedMissingModels() : (this.missingModels || []); return targets.filter(missing => { if (this.getBestLocalMatch(missing, 100)) return false; return Boolean(this.getBestDownloadSourceForMissing(missing)?.url); }); }, getActiveDownloadMissingModels() { const activeKeys = new Set( this.getActiveQueuePanelDownloads() .map(({ info }) => info?.missing) .filter(Boolean) .map(missing => this.getMissingModelKey(missing)) ); if (!activeKeys.size) return []; return (this.missingModels || []).filter(missing => activeKeys.has(this.getMissingModelKey(missing))); }, getResolvedSelectionPathCandidates(selection = {}) { const model = selection.resolved_model || {}; return [ model.relative_path, model.filename, model.name, model.path, model.resolved_path, selection.resolved_path ].filter(Boolean); }, normalizeResolvedSelectionToken(value = '', { basename = false, stem = false } = {}) { let text = this.normalizePathToForward(value).toLowerCase(); if (!text) return ''; if (basename) { text = text.split('/').pop() || text; } if (stem) { text = this.stripModelExtension(text); } return text; }, getResolvedSelectionTokenSet(values = []) { const tokens = new Set(); for (const value of values) { const normalized = this.normalizeResolvedSelectionToken(value); const basename = this.normalizeResolvedSelectionToken(value, { basename: true }); const stem = this.normalizeResolvedSelectionToken(value, { basename: true, stem: true }); if (normalized) tokens.add(normalized); if (basename) tokens.add(basename); if (stem) tokens.add(stem); } return tokens; }, getResolvedSelectionRefs(selection = {}) { const refs = Array.isArray(selection.node_refs) && selection.node_refs.length ? selection.node_refs : [selection]; return refs.filter(Boolean).map(ref => ({ node_id: ref.node_id ?? selection.node_id, widget_index: ref.widget_index ?? selection.widget_index, subgraph_id: ref.subgraph_id ?? selection.subgraph_id ?? '', is_top_level: ref.is_top_level ?? selection.is_top_level, nested_key: ref.nested_key ?? selection.nested_key ?? '' })); }, rememberAppliedResolvedSelections(appliedSelections = []) { if (!(this.appliedResolvedSelectionAliases instanceof Map)) { this.appliedResolvedSelectionAliases = new Map(); } for (const selection of Array.isArray(appliedSelections) ? appliedSelections : []) { const key = this.getResolutionQueueKey(selection); if (!key) continue; this.appliedResolvedSelectionAliases.set(key, { key, category: selection.category || '', searchKey: selection.missing_search_key || selection.search_key || '', refs: this.getResolvedSelectionRefs(selection), tokens: this.getResolvedSelectionTokenSet(this.getResolvedSelectionPathCandidates(selection)) }); } }, doesResolvedModelMatchAlias(model = {}, alias = {}) { const refs = Array.isArray(alias.refs) ? alias.refs : []; const modelNodeId = model.node_id ?? ''; const modelWidgetIndex = model.widget_index ?? ''; const modelSubgraphId = model.subgraph_id ?? ''; const modelScope = model.is_top_level !== false; const modelNestedKey = model.nested_key || ''; const refMatch = refs.some(ref => ( String(ref.node_id ?? '') === String(modelNodeId) && String(ref.widget_index ?? '') === String(modelWidgetIndex) && String(ref.subgraph_id ?? '') === String(modelSubgraphId) && (ref.is_top_level !== false) === modelScope && String(ref.nested_key || '') === String(modelNestedKey) )); if (!refMatch) return false; const aliasCategory = String(alias.category || '').toLowerCase(); const modelCategory = String(model.category || '').toLowerCase(); if (aliasCategory && modelCategory && aliasCategory !== modelCategory) return false; const modelTokens = this.getResolvedSelectionTokenSet([ model.original_path, model.name, model.filename, model.relative_path, model.full_path, model.path ]); for (const token of alias.tokens || []) { if (modelTokens.has(token)) return true; } return false; }, applyResolvedSelectionAliasesToAnalysisData(data = null) { if (!data || !(this.appliedResolvedSelectionAliases instanceof Map) || !this.appliedResolvedSelectionAliases.size) { return data; } const resolvedModels = Array.isArray(data.resolved_models) ? data.resolved_models : []; if (!resolvedModels.length) return data; const matchedKeys = new Set(); for (const model of resolvedModels) { if (model?.missing_key) continue; for (const [key, alias] of this.appliedResolvedSelectionAliases.entries()) { if (matchedKeys.has(key)) continue; if (!this.doesResolvedModelMatchAlias(model, alias)) continue; model.missing_key = key; if (alias.searchKey) { model.missing_search_key = alias.searchKey; } matchedKeys.add(key); break; } } for (const key of matchedKeys) { this.appliedResolvedSelectionAliases.delete(key); } return data; }, buildOptimisticResolvedModel(selection = {}, sourceMissing = null) { const resolvedModel = selection.resolved_model || {}; const resolvedRelativePath = resolvedModel.relative_path || resolvedModel.filename || selection.resolved_path || sourceMissing?.original_path || selection.original_path || ''; return { ...(sourceMissing || {}), missing_key: this.getResolutionQueueKey(selection), missing_search_key: selection.missing_search_key || sourceMissing?.missing_search_key || (sourceMissing ? this.getMissingSearchKey?.(sourceMissing) : ''), __isOptimisticResolved: true, node_id: selection.node_id ?? sourceMissing?.node_id, widget_index: selection.widget_index ?? sourceMissing?.widget_index, category: selection.category || sourceMissing?.category || resolvedModel.category || 'unknown', original_path: resolvedRelativePath, name: resolvedRelativePath, filename: resolvedModel.filename || this.getFilenameFromPath(resolvedRelativePath) || resolvedRelativePath, relative_path: resolvedRelativePath, full_path: resolvedModel.path || selection.resolved_path || sourceMissing?.full_path || '', path: resolvedModel.path || selection.resolved_path || '', matches: [{ confidence: 100, match_type: 'exact', filename: resolvedModel.filename || resolvedRelativePath, path: resolvedModel.path || selection.resolved_path || '', model: { ...resolvedModel, path: resolvedModel.path || selection.resolved_path || '', relative_path: resolvedRelativePath, filename: resolvedModel.filename || resolvedRelativePath } }] }; }, getOptimisticAnalysisDataAfterApply(appliedSelections = []) { const missingModels = Array.isArray(this.missingModels) ? this.missingModels : []; const appliedKeys = new Set( (Array.isArray(appliedSelections) ? appliedSelections : []) .map(selection => this.getResolutionQueueKey(selection)) ); if (!appliedKeys.size) return null; const sourceData = this.cachedAnalysisData || { missing_models: missingModels, total_missing: missingModels.length }; const sourceMissing = Array.isArray(sourceData.missing_models) ? sourceData.missing_models : missingModels; const nextMissing = sourceMissing.filter(missing => !appliedKeys.has(this.getMissingModelKey(missing))); const currentResolved = Array.isArray(sourceData.resolved_models) ? sourceData.resolved_models : []; const optimisticResolved = []; for (const selection of Array.isArray(appliedSelections) ? appliedSelections : []) { const key = this.getResolutionQueueKey(selection); const source = sourceMissing.find(missing => this.getMissingModelKey(missing) === key) || missingModels.find(missing => this.getMissingModelKey(missing) === key) || null; optimisticResolved.push(this.buildOptimisticResolvedModel(selection, source)); } const optimisticKeys = new Set(optimisticResolved.map(model => model.missing_key).filter(Boolean)); const nextResolved = [ ...currentResolved.filter(model => !optimisticKeys.has(this.getMissingModelKey(model))), ...optimisticResolved ]; return { ...sourceData, missing_models: nextMissing, resolved_models: nextResolved, total_missing: nextMissing.length }; }, applyOptimisticAnalysisData(data, workflow = null) { if (!data) return false; this.applyResolvedSelectionAliasesToAnalysisData(data); const workflowSignature = this.getMissingWorkflowSignature(workflow || this.getCurrentWorkflow()); if (workflowSignature) { this.cachedWorkflowSignature = workflowSignature; } this.cachedAnalysisData = this.cloneAnalysisData?.(data) || data; this.saveAnalysisCacheForActiveWorkflow?.(); if (this.activeTab === 'missing' && this.contentElement) { this.displayMissingModels( this.contentElement, data, { preserveBrowser: true } ); this.reconnectActiveDownloads?.(); } else { this.missingModels = Array.isArray(data.missing_models) ? data.missing_models : []; this.updateBatchFooterButtons?.(); } return true; }, async refreshAnalysisInBackground(workflow, expectedSignature = null) { if (!workflow) return; const signature = expectedSignature || this.getMissingWorkflowSignature(workflow); try { const data = await this.fetchJson('/model_resolver/analyze', { method: 'POST', body: JSON.stringify({ workflow }), silent: true }, 'Background analysis'); if (signature && this.activeMissingWorkflowSignature !== signature) return; this.applyResolvedSelectionAliasesToAnalysisData(data); this.cachedWorkflowSignature = signature || this.cachedWorkflowSignature; this.cachedAnalysisData = data; this.saveAnalysisCacheForActiveWorkflow?.(); if (this.activeTab === 'missing' && this.contentElement && !this._analysisProgressToken) { this.displayMissingModels( this.contentElement, data, { preserveBrowser: true } ); this.reconnectActiveDownloads?.(); } } catch (error) { console.warn('Model Resolver: background analysis refresh failed:', error); } }, previewFooterMenuModels(models = []) { const keys = new Set( (Array.isArray(models) ? models : []) .filter(Boolean) .map(missing => this.getMissingModelKey(missing)) ); this.contentElement?.querySelectorAll?.('.mr-missing-list-row')?.forEach(row => { const key = row.getAttribute('data-missing-key'); row.classList.toggle('is-menu-preview', Boolean(key && keys.has(key))); }); }, clearFooterMenuPreview() { this.contentElement?.querySelectorAll?.('.mr-missing-list-row.is-menu-preview')?.forEach(row => { row.classList.remove('is-menu-preview'); }); }, createFooterMenu(name, label, items = [], buttonClass = 'mr-btn-secondary') { const button = $el(`button.mr-btn.${buttonClass}.mr-footer-btn.mr-footer-menu-button`, { type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', onclick: (event) => { event.stopPropagation(); this.hideTooltip?.(); if (button.classList.contains('mr-btn-is-disabled')) { if (name === 'download') { this.showNotification('No missing models have downloadable sources yet.', 'info'); } else { this.showNotification('No missing models available.', 'info'); } return; } this.toggleFooterMenu(name); } }, [$el("span", { textContent: label })]); const menuChildren = items.map(item => { const isDivider = item === 'divider' || item?.type === 'divider'; let element; if (isDivider) { element = $el("div.mr-footer-menu-divider", {}); } else if (item?.type === 'checkbox') { const input = $el("input.mr-footer-menu-checkbox-input", { type: 'checkbox', checked: Boolean(item.checked?.()) }); element = $el("label.mr-footer-menu-item.mr-footer-menu-checkbox", { role: 'menuitemcheckbox', 'aria-checked': input.checked ? 'true' : 'false', onclick: (event) => { event.stopPropagation(); } }, [ input, $el("span", { textContent: item.label }) ]); input.addEventListener('change', () => { item.action?.(input.checked); element.setAttribute('aria-checked', input.checked ? 'true' : 'false'); }); } else { const attributes = { type: 'button', role: 'menuitem', onclick: (event) => { event.stopPropagation(); if (element.disabled || element.classList.contains('mr-btn-is-disabled')) return; item.action(); } }; if (item.id) attributes.id = item.id; if (item.ariaLabel) attributes['aria-label'] = item.ariaLabel; element = $el("button.mr-footer-menu-item", attributes, [$el("span", { textContent: item.label })]); } if (item?.className) { element.classList.add(...String(item.className).split(/\s+/).filter(Boolean)); } if (item?.refName) { this[item.refName] = element; } if (!isDivider && typeof item?.previewModels === 'function') { const showPreview = () => { if (element.disabled || element.classList.contains('mr-btn-is-disabled')) { this.clearFooterMenuPreview(); return; } try { this.previewFooterMenuModels(item.previewModels()); } catch (error) { console.warn('Model Resolver: footer menu preview failed:', error); this.clearFooterMenuPreview(); } }; element.addEventListener('mouseenter', showPreview); element.addEventListener('focus', showPreview); element.addEventListener('mouseleave', () => this.clearFooterMenuPreview()); element.addEventListener('blur', () => this.clearFooterMenuPreview()); } if (typeof item?.visibleWhen === 'function' || typeof item?.disabledWhen === 'function') { if (!this.footerMenuItems) this.footerMenuItems = new Map(); if (!this.footerMenuItems.has(name)) this.footerMenuItems.set(name, []); this.footerMenuItems.get(name).push({ element, visibleWhen: item.visibleWhen, disabledWhen: item.disabledWhen }); if (typeof item.visibleWhen === 'function') { element.style.display = item.visibleWhen() ? '' : 'none'; } if (typeof item.disabledWhen === 'function') { const disabled = item.disabledWhen(); element.disabled = disabled; element.setAttribute('aria-disabled', String(disabled)); element.classList.toggle('mr-btn-is-disabled', disabled); } } return element; }); const menu = $el("div.mr-footer-menu", { role: 'menu' }, menuChildren); this.footerMenuButtons.set(name, button); this.footerMenus.set(name, menu); return $el("div.mr-footer-menu-wrap", {}, [button, menu]); }, updateFooterMenuItemVisibility() { if (!this.footerMenuItems) return; for (const entries of this.footerMenuItems.values()) { for (const entry of entries) { if (typeof entry.visibleWhen === 'function') { entry.element.style.display = entry.visibleWhen() ? '' : 'none'; } if (typeof entry.disabledWhen === 'function') { const disabled = entry.disabledWhen(); entry.element.disabled = disabled; entry.element.setAttribute('aria-disabled', String(disabled)); entry.element.classList.toggle('mr-btn-is-disabled', disabled); } } } }, updateFooterStarLayout() { if (!this.footerElement || !this.footerStarLink || !this.footerActions) return; this.footerElement.classList.toggle('mr-footer-is-missing', this.activeTab === 'missing'); this.footerStarLink.classList.remove('mr-footer-star-hidden'); this.footerVersionElement?.classList.remove('mr-footer-version-hidden'); const footerStyle = getComputedStyle(this.footerElement); const footerWidth = (this.footerElement.clientWidth || 0) - (parseFloat(footerStyle.paddingLeft) || 0) - (parseFloat(footerStyle.paddingRight) || 0); if (footerWidth <= 0) return; const actionsVisible = getComputedStyle(this.footerActions).display !== 'none'; const actionItems = actionsVisible ? Array.from(this.footerActions.children || []).filter((element) => { const style = getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden'; }) : []; const gap = parseFloat(footerStyle.columnGap || footerStyle.gap || '0') || 0; const actionsStyle = getComputedStyle(this.footerActions); const actionGap = parseFloat(actionsStyle.columnGap || actionsStyle.gap || '0') || 0; const actionsWidth = actionItems.reduce((total, element) => { return total + element.getBoundingClientRect().width; }, 0) + (Math.max(0, actionItems.length - 1) * actionGap); const starWidth = this.footerStarLink.scrollWidth; const versionWidth = this.footerVersionElement?.scrollWidth || 0; const getRequiredWidth = ({ includeStar = true, includeVersion = true } = {}) => { const itemWidths = [ includeStar ? starWidth : 0, actionsWidth, includeVersion ? versionWidth : 0 ]; const visibleWidths = itemWidths.filter((width) => width > 0); return visibleWidths.reduce((total, width) => total + width, 0) + (Math.max(0, visibleWidths.length - 1) * gap); }; const shouldHideStar = getRequiredWidth() > footerWidth; const shouldHideVersion = versionWidth > 0 && getRequiredWidth({ includeStar: !shouldHideStar }) > footerWidth; this.footerVersionElement?.classList.toggle('mr-footer-version-hidden', shouldHideVersion); this.footerStarLink.classList.toggle('mr-footer-star-hidden', shouldHideStar); }, bindFooterStarLayoutObserver() { if (this._footerStarResizeObserver || !this.footerElement || typeof ResizeObserver === 'undefined') return; this._footerStarResizeObserver = new ResizeObserver(() => this.updateFooterStarLayout()); this._footerStarResizeObserver.observe(this.footerElement); this._footerStarResizeObserver.observe(this.footerActions); if (this.footerVersionElement) this._footerStarResizeObserver.observe(this.footerVersionElement); }, async loadFooterVersion() { if (!this.footerVersionCurrent || !this.footerVersionLatest) return; const now = Date.now(); const refreshInterval = this._footerVersionCheckFailed ? FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS : FOOTER_VERSION_REFRESH_INTERVAL_MS; if ( this._footerVersionLastCheckedAt && now - this._footerVersionLastCheckedAt < refreshInterval ) { if (this._footerVersionCheckFailed) { this.scheduleFooterVersionRetry( refreshInterval - (now - this._footerVersionLastCheckedAt) ); } return; } this._footerVersionLastCheckedAt = now; try { const data = await this.fetchJson( '/model_resolver/version', { silent: true }, 'Get Model Resolver version' ); const formatVersion = (value) => { const version = String(value || '').trim().replace(/^v/i, ''); return version ? `v${version}` : ''; }; const currentVersion = formatVersion(data?.current_version); const latestVersion = formatVersion(data?.latest_version); const versionCheckFailed = !latestVersion || data?.status === 'unavailable'; this._footerVersionCheckFailed = versionCheckFailed; if (!versionCheckFailed && this._footerVersionRetryTimer) { clearTimeout(this._footerVersionRetryTimer); this._footerVersionRetryTimer = null; } this.footerVersionCurrent.textContent = currentVersion || 'v—'; this.footerVersionLatest.textContent = latestVersion ? `(latest: ${latestVersion})` : '(latest: unavailable)'; const isUpToDate = data?.status === 'current'; const isOutdated = data?.status === 'update_available'; this.footerVersionElement?.classList.toggle('mr-footer-version-up-to-date', isUpToDate); this.footerVersionElement?.classList.toggle('mr-footer-version-outdated', isOutdated); this.footerVersionLatest.classList.toggle( 'mr-footer-version-update', isOutdated ); this.footerVersionLatest.title = data?.status === 'update_available' ? 'A newer Model Resolver version is available on GitHub.' : 'Model Resolver version status'; this.footerVersionElement?.setAttribute( 'aria-label', latestVersion ? `Model Resolver ${currentVersion}; latest GitHub version ${latestVersion}` : `Model Resolver ${currentVersion}; latest GitHub version unavailable` ); this.updateFooterStarLayout(); if (versionCheckFailed) this.scheduleFooterVersionRetry(); } catch (_) { this._footerVersionCheckFailed = true; this.footerVersionLatest.textContent = '(latest: unavailable)'; this.footerVersionElement?.classList.remove( 'mr-footer-version-up-to-date', 'mr-footer-version-outdated' ); this.footerVersionLatest.classList.remove('mr-footer-version-update'); this.updateFooterStarLayout(); this.scheduleFooterVersionRetry(); } }, scheduleFooterVersionRetry(delayMs = FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS) { if (this._footerVersionRetryTimer) return; const delay = Math.max(0, Number(delayMs) || FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS); this._footerVersionRetryTimer = setTimeout(() => { this._footerVersionRetryTimer = null; if (this.element?.style?.display === 'none') return; void this.loadFooterVersion(); }, delay); }, createFooter() { this.footerMenus = new Map(); this.footerMenuButtons = new Map(); this.footerMenuItems = new Map(); this.linkMenuButton = null; this.linkMenuWrap = null; this.queueExactButton = null; this.applyPendingBtn = null; this.clearSelectedButton = null; this.autoResolveButton = null; this.downloadMenuButton = null; this.downloadAllButton = null; const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [ { label: 'Select All', previewModels: () => this.missingModels || [], action: () => this.selectBatchMissingModels('all') }, { label: 'Select None', previewModels: () => this.getSelectedMissingModels(), action: () => this.selectBatchMissingModels('none') }, { label: 'Invert Selection', previewModels: () => { const selectedKeys = this.batchSelectedMissingKeys || new Set(); return (this.missingModels || []).filter(missing => !selectedKeys.has(this.getMissingModelKey(missing))); }, action: () => this.selectBatchMissingModels('invert') }, 'divider', { label: 'Select Exact Local', previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.selectBatchMissingModels('exact') }, { label: 'Select No Exact Local', previewModels: () => this.getMissingWithoutExactLocalMatches(), action: () => this.selectBatchMissingModels('no_exact') }, { label: 'Select Partial Local', previewModels: () => this.getMissingWithPartialLocalMatches(), action: () => this.selectBatchMissingModels('partial') }, { label: 'Select No Local Match', previewModels: () => this.getMissingWithoutLocalMatches(), action: () => this.selectBatchMissingModels('no_local') }, 'divider', { label: 'Select With Download Source', previewModels: () => this.getMissingWithDownloadSources(), action: () => this.selectBatchMissingModels('downloadable') }, { label: 'Select Without Download Source', previewModels: () => this.getMissingWithoutDownloadSources(), action: () => this.selectBatchMissingModels('no_download') }, 'divider', { label: 'Select Searched', previewModels: () => this.getSearchedMissingModels(), action: () => this.selectBatchMissingModels('searched') }, { label: 'Select Unsearched', previewModels: () => this.getUnsearchedMissingModels(), action: () => this.selectBatchMissingModels('unsearched') } ]); this.selectMenuButton = this.footerMenuButtons.get('select'); this.selectMenuWrap = selectMenu; const hasSelectedMissingModels = () => this.getSelectedMissingModels().length > 0; const searchBatchOptions = () => ({ forceSearch: Boolean(this.forceBatchSearch) }); const searchMenu = this.createFooterMenu('search', 'Search', [ { label: 'Stop Searching', className: 'mr-footer-menu-danger', visibleWhen: () => this.batchSearchRunning, action: () => this.stopBatchSearch() }, { type: 'divider', visibleWhen: () => this.batchSearchRunning }, { type: 'checkbox', label: 'Force search (ignore cache)', visibleWhen: () => !this.batchSearchRunning, checked: () => Boolean(this.forceBatchSearch), action: (checked) => { this.forceBatchSearch = Boolean(checked); } }, { type: 'divider', visibleWhen: () => !this.batchSearchRunning }, { label: 'Search Selected', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'all', searchBatchOptions()) }, { label: 'Search All Missing', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.missingModels || [], action: () => this.searchMissingBatch('all', 'all', searchBatchOptions()) }, { label: 'Search Unsearched', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.getUnsearchedMissingModels(), action: () => this.searchMissingBatch('unsearched', 'all', searchBatchOptions()) }, { type: 'divider', visibleWhen: () => !this.batchSearchRunning }, { label: 'Selected: Local Database', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'local', searchBatchOptions()) }, { label: 'Selected: CivitAI', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civitai', searchBatchOptions()) }, { label: 'Selected: HuggingFace', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'huggingface', searchBatchOptions()) }, { label: 'Selected: CivArchive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civarchive', searchBatchOptions()) }, { label: 'Selected: LoRA Manager Archive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'lora_manager_archive', searchBatchOptions()) } ]); this.searchMenuButton = this.footerMenuButtons.get('search'); this.searchMenuWrap = searchMenu; const hasExactLocalMatches = () => this.getMissingWithExactLocalMatches(this.missingModels || []).length > 0; const linkMenu = this.createFooterMenu('link', 'Link', [ { label: 'Queue Exact', refName: 'queueExactButton', ariaLabel: 'Queue exact local matches', previewModels: () => this.getQueueExactLocalMatchPreviewModels(), action: () => this.queueExactLocalMatchesBatch('selected') }, { label: 'Apply Selected (0)', id: 'apply-pending-resolutions', refName: 'applyPendingBtn', ariaLabel: 'Apply selected model links', disabledWhen: () => (this.pendingResolutions?.length || 0) === 0, previewModels: () => this.getMissingModelsForPendingResolutions(), action: () => { if ((this.pendingResolutions?.length || 0) === 0) return; this.applyPendingResolutions(); } }, { label: 'Clear All Selected', refName: 'clearSelectedButton', ariaLabel: 'Clear all queued selected model links', disabledWhen: () => (this.pendingResolutions?.length || 0) === 0, previewModels: () => this.getMissingModelsForPendingResolutions(), action: () => { this.clearAllQueued(); this.closeFooterMenus(); } }, { type: 'divider', visibleWhen: hasExactLocalMatches }, { label: 'Auto-Link 100%', refName: 'autoResolveButton', ariaLabel: 'Auto-link all 100 percent local matches', visibleWhen: hasExactLocalMatches, previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.autoResolve100Percent() } ]); this.linkMenuButton = this.footerMenuButtons.get('link'); this.linkMenuWrap = linkMenu; this.linkMenuButton.setAttribute('aria-label', 'Local link actions'); this.setTooltip(this.linkMenuButton, 'Queue exact local matches, apply queued links, or auto-link 100% matches.'); this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.'); this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.'); this.setTooltip(this.clearSelectedButton, 'Clear all queued model links without changing the workflow.'); this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.'); this.updateApplyPendingButton(); const downloadMenu = this.createFooterMenu('download', 'Download', [ { label: 'Download Selected', disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getDownloadableBatchPreviewModels('selected'), action: () => this.downloadMissingBatch('selected') }, { label: 'Download All With Sources', previewModels: () => this.getDownloadableBatchPreviewModels('all'), action: () => this.downloadMissingBatch('all') }, 'divider', { label: 'Cancel Downloads', previewModels: () => this.getActiveDownloadMissingModels(), action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } ], 'mr-btn-download'); this.downloadMenuButton = this.footerMenuButtons.get('download'); this.downloadMenuWrap = downloadMenu; this.downloadAllButton = this.downloadMenuButton; this.downloadAllButton.setAttribute('aria-label', 'Download missing models'); this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.'); const starLink = $el("a.mr-footer-star-link", { href: "https://github.com/Azornes/Comfyui-Model-Resolver", target: "_blank", rel: "noopener noreferrer", ariaLabel: "Star the Model Resolver repository on GitHub" }, [ $el("span.mr-footer-star-icon", { innerHTML: getSvgIcon('star', 'currentColor', 'mr-footer-star-svg') }), $el("span.mr-footer-star-text", { innerHTML: 'If this tool helps you, please consider starring the repository.' }) ]); const versionCurrent = $el("span.mr-footer-version-current", { textContent: "v—" }); const versionLatest = $el("a.mr-footer-version-latest", { href: "https://github.com/Azornes/Comfyui-Model-Resolver", target: "_blank", rel: "noopener noreferrer", textContent: "(latest: checking…)" }); const versionElement = $el("span.mr-footer-version", { title: "Model Resolver version" }, [versionCurrent, versionLatest]); this.footerStarLink = starLink; this.footerVersionElement = versionElement; this.footerVersionCurrent = versionCurrent; this.footerVersionLatest = versionLatest; this.footerActions = $el("div.mr-footer-actions", {}, [ selectMenu, searchMenu, linkMenu, downloadMenu ]); this.footerElement = $el("div.mr-footer", {}, [ starLink, this.footerActions, versionElement ]); this.bindFooterStarLayoutObserver(); queueMicrotask(() => this.updateFooterStarLayout()); return this.footerElement; }, animateTabContentTransition() { if (!this.contentElement?.animate) return; try { this.contentElement.animate( [ { opacity: 0.72, transform: 'translateY(7px)' }, { opacity: 1, transform: 'translateY(0)' } ], { duration: 180, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' } ); } catch (error) { console.warn('Model Resolver: tab content animation failed', error); } }, /** * Cancel all active downloads */ async cancelAllDownloads() { const downloadIds = this.getActiveQueuePanelDownloadIds(); if (downloadIds.length === 0) return; this.showNotification(`Cancelling ${downloadIds.length} download${downloadIds.length > 1 ? 's' : ''}...`, 'info'); for (const downloadId of downloadIds) { await this.cancelDownload(downloadId); } }, /** * Update the Download All button state based on active downloads */ updateDownloadAllButtonState() { if (!this.downloadAllButton) return; if (this.downloadMenuButton && this.downloadAllButton === this.downloadMenuButton) { this.updateBatchFooterButtons(); return; } const activeCount = this.getActiveQueuePanelDownloadIds().length; if (activeCount > 0) { this.downloadAllButton.innerHTML = `✕ Cancel All (${activeCount})`; this.downloadAllButton.setAttribute( 'data-tooltip', `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` ); this.downloadAllButton.setAttribute('aria-label', 'Cancel all active downloads'); this.downloadAllButton.classList.remove('mr-btn-download'); this.downloadAllButton.classList.add('mr-btn-danger'); } else { this.downloadAllButton.innerHTML = `☁ Download All Missing`; this.downloadAllButton.setAttribute( 'data-tooltip', 'Download every missing model that has a known download source.' ); this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); this.downloadAllButton.classList.remove('mr-btn-danger'); this.downloadAllButton.classList.add('mr-btn-download'); } } };