Files
Azornes-Comfyui-Model-Resolver/web/resolver/actions/queue_methods.js
T

2920 lines
136 KiB
JavaScript

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";
import { syncElementAttributes } from "../utils/dom_patch_utils.js";
import { bindDownloadActionHandlers } from "../utils/download_action_handlers.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 = '<div class="mr-queue-empty">No selections queued.</div>';
return;
}
let html = '<div class="mr-queue-items">';
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();
const queueKey = `${r.node_id ?? ''}:${r.widget_index ?? ''}:${orig}`;
html += `<div class="mr-queue-item" data-queue-key="${this.escapeHtml(encodeURIComponent(queueKey))}">`;
html += `<div class="mr-queue-item-title">${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}</div>`;
html += `<div class="mr-queue-item-meta"><span>Original</span><code>${this.escapeHtml(orig)}</code></div>`;
html += `<div class="mr-queue-item-selection"><span>Selected</span><code>${this.escapeHtml(label)}</code></div>`;
html += `<div class="mr-queue-item-actions"><button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-queue-remove" data-queue-index="${this.escapeHtml(String(i))}">Remove</button></div>`;
html += `</div>`;
}
html += '</div>';
this.patchQueuedSelections(html);
},
patchQueuedSelections(html) {
if (!this.queueList) return;
if (typeof document === 'undefined') {
this.queueList.innerHTML = html;
return;
}
const template = document.createElement('template');
template.innerHTML = html;
const nextList = template.content.firstElementChild;
const currentList = this.queueList.querySelector('.mr-queue-items');
if (!nextList || !currentList || nextList.className !== currentList.className) {
this.queueList.innerHTML = html;
return;
}
const nextItems = Array.from(nextList.children)
.filter(item => item.dataset.queueKey);
const currentItems = Array.from(currentList.children)
.filter(item => item.dataset.queueKey);
if (
nextItems.length !== nextList.children.length
|| currentItems.length !== currentList.children.length
) {
this.queueList.innerHTML = html;
return;
}
const currentByKey = new Map(
currentItems.map(item => [item.dataset.queueKey, item])
);
const retainedItems = new Set();
const nextDomItems = [];
for (const nextItem of nextItems) {
let currentItem = currentByKey.get(nextItem.dataset.queueKey);
if (!currentItem) {
currentItem = nextItem.cloneNode(true);
} else if (currentItem.innerHTML !== nextItem.innerHTML) {
currentItem.innerHTML = nextItem.innerHTML;
}
retainedItems.add(currentItem);
nextDomItems.push(currentItem);
}
currentItems.forEach(item => {
if (!retainedItems.has(item)) item.remove();
});
nextDomItems.forEach(item => currentList.appendChild(item));
},
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 || this.getFilenameFromPath(missing?.original_path) || '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 = `<div class="mr-downloads-panel" data-downloads-view="${activeSubTab}">`;
html += '<div class="mr-tabs mr-queue-tabs mr-downloads-subtabs" role="tablist" aria-label="Downloads views">';
html += `<button type="button" class="mr-tab mr-queue-tab mr-downloads-subtab${activeSelected ? ' mr-tab-active' : ''}" data-downloads-tab="active" aria-selected="${activeSelected ? 'true' : 'false'}"><span class="mr-tab-label">Active (${downloads.length})</span></button>`;
html += `<button type="button" class="mr-tab mr-queue-tab mr-downloads-subtab${!activeSelected ? ' mr-tab-active' : ''}" data-downloads-tab="history" aria-selected="${!activeSelected ? 'true' : 'false'}"><span class="mr-tab-label">History (${history.length})</span></button>`;
html += '</div>';
html += activeSelected
? this.renderQueueDownloadsHtml(downloads)
: this.renderDownloadHistoryHtml(history);
html += '</div>';
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 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;
}
syncElementAttributes(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 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 => {
if (button._hasListener) return;
button._hasListener = true;
button.addEventListener('click', () => {
this.setQueueDownloadsTab(button.dataset.downloadsTab || 'active');
});
});
bindDownloadActionHandlers(this.queueList, {
selectors: {
cancel: '.mr-download-queue-cancel',
pause: '.mr-download-queue-pause',
resume: '.mr-download-queue-resume',
openFolder: '.mr-download-queue-open-folder',
switchWorkflow: '.mr-download-queue-switch-workflow',
more: '.mr-download-queue-more',
},
getDownloadId: button => button?.dataset?.downloadId || '',
getContext: getDownloadContext,
onCancel: downloadId => this.cancelDownload(downloadId),
onPause: downloadId => this.pauseDownload(downloadId),
onResume: downloadId => this.resumeDownload(downloadId),
onOpenFolder: context => this.openContainingFolder?.(context),
onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context),
moreBinding: 'click',
onMore: (event, button) => {
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._hasListener) {
clearHistoryButton._hasListener = true;
clearHistoryButton.addEventListener('click', () => this.clearDownloadHistory());
}
this.queueList.querySelectorAll('.mr-download-history-remove').forEach(button => {
if (button._hasListener) return;
button._hasListener = true;
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 '<div class="mr-queue-empty">No active downloads.</div>';
}
let html = wrap ? '<div class="mr-queue-items mr-download-queue-items">' : '';
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
|| this.getFilenameFromPath(info.missing?.original_path)
|| '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) => (
`<button type="button" class="mr-download-queue-action ${className}" data-download-id="${safeDownloadId}" data-tooltip="${label}" aria-label="${label}"${disabled ? ' disabled' : ''}>${renderIcon(iconName, 'mr-download-queue-action-icon')}</button>`
);
html += `<div class="mr-queue-item mr-download-queue-item" data-download-id="${this.escapeHtml(downloadId)}"${contextData}>`;
html += `<div class="mr-download-queue-header">`;
html += `<div class="mr-download-queue-heading">`;
html += `<span class="mr-download-queue-filename" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>`;
if (statusLabel) {
html += `<span class="mr-download-queue-status">${this.escapeHtml(statusLabel)}</span>`;
}
html += `</div>`;
html += `<div class="mr-download-queue-actions" role="group" aria-label="Download actions">`;
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 += `</div>`;
html += `</div>`;
html += `<div class="mr-download-queue-progress">`;
html += `<div class="mr-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percent}" aria-label="Download progress"><div class="mr-progress-fill" style="width: ${percent}%;"></div></div>`;
html += `<div class="mr-download-queue-stats">`;
html += `<span class="mr-download-queue-size">${this.escapeHtml(sizeText)}</span>`;
html += `<span class="mr-download-queue-stat-group">`;
if (speedText) {
html += `<span class="mr-download-queue-stat">${renderIcon('download', 'mr-download-queue-stat-icon')}<span>${this.escapeHtml(speedText)}</span></span>`;
}
if (etaText) {
html += `<span class="mr-download-queue-stat"><span>${this.escapeHtml(etaText)}</span></span>`;
}
if (connections > 0) {
html += `<span class="mr-download-queue-stat" data-tooltip="Active download connections">${renderIcon('network', 'mr-download-queue-stat-icon')}<span>${connections}</span></span>`;
}
if (!speedText && !etaText) {
html += `<span class="mr-download-queue-stat mr-download-queue-stat-muted">${this.escapeHtml(statusLabel)}</span>`;
}
html += `</span>`;
html += `</div>`;
html += `</div>`;
if (showWorkflowDetails) {
html += `<div class="mr-download-queue-details" data-tooltip="${this.escapeHtml([targetPath, backend].filter(Boolean).join(' · '))}"><span>Workflow</span><span>${this.escapeHtml([workflowLabel, ...detailParts].filter(Boolean).join(' · '))}</span></div>`;
}
html += `</div>`;
}
if (wrap) html += '</div>';
return html;
},
renderDownloadHistoryHtml(history = []) {
if (!history.length) {
return '<div class="mr-queue-empty">No downloaded models in history yet.</div>';
}
let html = '<div class="mr-download-history-toolbar"><button type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-download-history-clear">Clear History</button></div>';
html += '<div class="mr-queue-items mr-download-history-items">';
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 += `<div class="mr-queue-item mr-download-history-item"${contextData}>`;
html += `<div class="mr-queue-item-title mr-download-queue-title">`;
html += `<span data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>`;
html += `<span class="mr-download-history-status">${this.escapeHtml(statusLabel)}</span>`;
html += `</div>`;
if (nodeText) {
html += `<div class="mr-queue-item-meta"><span>Model</span><code>${this.escapeHtml(nodeText)}</code></div>`;
}
html += `<div class="mr-queue-item-meta"><span>Workflow</span><code data-tooltip="${this.escapeHtml(workflowLabel)}">${this.escapeHtml(workflowLabel)}</code></div>`;
if (category) {
html += `<div class="mr-queue-item-meta"><span>Type</span><code>${this.escapeHtml(category)}</code></div>`;
}
if (targetLabel) {
html += `<div class="mr-queue-item-meta"><span>Folder</span><code data-tooltip="${this.escapeHtml(targetPath)}">${this.escapeHtml(targetLabel)}</code></div>`;
}
if (sizeLabel || timeLabel) {
html += `<div class="mr-queue-item-meta"><span>Done</span><code>${this.escapeHtml([timeLabel, sizeLabel].filter(Boolean).join(' | '))}</code></div>`;
}
html += `<div class="mr-queue-item-actions"><button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-download-history-remove" data-history-id="${this.escapeHtml(historyId)}" data-history-index="${this.escapeHtml(String(index))}">Remove</button></div>`;
html += `</div>`;
}
html += '</div>';
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 = `
<div class="mr-selected-bar-inner"${selectedContextAttrs}>
<span class="mr-selected-summary">
<span class="mr-selected-label">
<span class="mr-selected-status-icon" aria-hidden="true">${getSvgIcon('circleCheckBig')}</span>
<span>Selected</span>
</span>
<code class="mr-selected-code" data-tooltip="${this.escapeHtml(label)}">${this.escapeHtml(label)}</code>
</span>
<span class="mr-selected-actions">
<button type="button" id="${applyBtnId}" class="mr-btn mr-btn-primary mr-btn-sm mr-selected-apply" data-tooltip="Apply this selected local match to the workflow.">Apply</button>
<button type="button" id="${removeBtnId}" class="mr-btn mr-btn-danger mr-btn-sm mr-selected-remove" data-tooltip="Remove this selection.">Remove</button>
</span>
</div>
`;
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();
this.updateQueuePanel();
this.updateApplyPendingButton();
this.refreshMissingModelsBrowserFromCache?.();
// Refreshing the Missing Models browser can replace the detail pane,
// so render the per-model selected bar after that refresh has finished.
const currentMissing = (this.missingModels || [])
.find(item => this.getMissingModelKey(item) === resolution.missing_key)
|| missing;
this.updateSelectedBarForMissing?.(currentMissing);
},
/**
* 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;
}
let applyRefreshSuppressionStarted = false;
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) {
this.beginApplyWorkflowRefreshSuppression?.();
applyRefreshSuppressionStarted = true;
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();
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);
const releaseApplyRefreshSuppression = () => {
if (!applyRefreshSuppressionStarted) return;
applyRefreshSuppressionStarted = false;
this.endApplyWorkflowRefreshSuppression?.();
};
const workflowRefresh = this.loadWorkflowData?.(data.workflow, {
preserveContent: true
});
Promise.resolve(workflowRefresh).then(
releaseApplyRefreshSuppression,
releaseApplyRefreshSuppression
);
return data.workflow || null;
} else {
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
return null;
}
} catch (e) {
if (applyRefreshSuppressionStarted) {
applyRefreshSuppressionStarted = false;
this.endApplyWorkflowRefreshSuppression?.();
}
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)));
},
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)
});
}
},
doesResolvedModelMatchAlias(model = {}, alias = {}) {
const refs = Array.isArray(alias.refs) ? alias.refs : [];
const modelRefs = [
model,
...(Array.isArray(model.all_node_refs) ? model.all_node_refs : [])
];
const refMatch = modelRefs.some(modelRef => {
const modelNodeId = modelRef?.node_id ?? '';
const modelWidgetIndex = modelRef?.widget_index ?? '';
const modelSubgraphId = modelRef?.subgraph_id ?? '';
const modelScope = modelRef?.is_top_level !== false;
const modelNestedKey = modelRef?.nested_key || '';
return 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;
// Workflow position is the stable identity after Apply. The backend may
// return a resolved model with a different path representation, so the
// matching slot is sufficient to restore the optimistic missing_key.
return true;
},
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) {
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;
},
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 <span>starring</span> 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 = `<span class="mr-btn-icon">✕</span> 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 = `<span class="mr-btn-icon">☁</span> 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');
}
}
};