When a subgraph exposes a promoted widget and the instance value or connected inner widget contains a usable model path, create a top-level model reference on the subgraph node instead of only pointing to the inner definition node. This lets resolution, location, and queue updates target the instance widget directly. Also handle serialized subgraph instances that omit proxyWidgets by reconstructing the same promotion from subgraph input/link metadata, and extend widget values for promoted inputs in the workflow updater.
2998 lines
139 KiB
JavaScript
2998 lines
139 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";
|
|
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();
|
|
html += `<div class="mr-queue-item">`;
|
|
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.queueList.innerHTML = html;
|
|
},
|
|
|
|
getActiveQueuePanelDownloads() {
|
|
const entries = Object.entries(this.activeDownloads || {});
|
|
if (!entries.length) return [];
|
|
|
|
return entries
|
|
.filter(([, info]) => Boolean(info?.missing))
|
|
.map(([downloadId, info]) => ({ downloadId, info }));
|
|
},
|
|
|
|
getWorkflowLabelFromRouteKey(routeKey = '') {
|
|
const rawRoute = String(routeKey || '').split('\n')[0].trim();
|
|
if (!rawRoute) return '';
|
|
|
|
const decodeLabel = (value = '') => {
|
|
try {
|
|
return decodeURIComponent(String(value || '').replace(/\+/g, ' '));
|
|
} catch (_error) {
|
|
return String(value || '');
|
|
}
|
|
};
|
|
const cleanRoutePart = (value = '') => this.cleanWorkflowLabel?.(decodeLabel(value)) || decodeLabel(value).trim();
|
|
const commonRouteParts = new Set(['', '#', '/', 'workflow', 'workflows', 'view', 'tab', 'tabs', 'graph', 'graphs']);
|
|
|
|
try {
|
|
const base = typeof window !== 'undefined' && window.location?.origin ? window.location.origin : 'http://localhost';
|
|
const url = new URL(rawRoute, base);
|
|
const candidates = [
|
|
url.searchParams.get('workflow'),
|
|
url.searchParams.get('workflowName'),
|
|
url.searchParams.get('workflow_name'),
|
|
url.searchParams.get('name'),
|
|
url.searchParams.get('filename'),
|
|
url.searchParams.get('file'),
|
|
url.hash ? new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '').get('workflow') : '',
|
|
url.hash ? new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '').get('workflowName') : '',
|
|
url.hash ? new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '').get('name') : ''
|
|
];
|
|
const found = candidates.map(cleanRoutePart).find(Boolean);
|
|
if (found) return found;
|
|
} catch (_error) { /* fall through to manual route parsing */ }
|
|
|
|
try {
|
|
const hash = rawRoute.startsWith('#') ? rawRoute.slice(1) : rawRoute;
|
|
const paramsText = hash.includes('?') ? hash.slice(hash.indexOf('?') + 1) : hash.replace(/^\?/, '');
|
|
const params = new URLSearchParams(paramsText);
|
|
const candidates = [
|
|
params.get('workflow'),
|
|
params.get('workflowName'),
|
|
params.get('workflow_name'),
|
|
params.get('name'),
|
|
params.get('filename'),
|
|
params.get('file'),
|
|
params.get('workflowId'),
|
|
params.get('workflow_id'),
|
|
params.get('tab'),
|
|
params.get('id')
|
|
];
|
|
const found = candidates.map(cleanRoutePart).find(Boolean);
|
|
if (found) return found;
|
|
} catch (_error) { /* fall through to readable route */ }
|
|
|
|
const withoutQuery = rawRoute.split('?')[0].split('&')[0];
|
|
const parts = withoutQuery
|
|
.replace(/^#/, '')
|
|
.split(/[\\/]/)
|
|
.map(part => cleanRoutePart(part))
|
|
.filter(part => !commonRouteParts.has(part.toLowerCase()));
|
|
if (parts.length) return parts[parts.length - 1];
|
|
|
|
return cleanRoutePart(rawRoute
|
|
.replace(/^#\/?/, '')
|
|
.replace(/^\?/, '')
|
|
.replace(/[?&](workflow|workflowName|workflow_name|name|filename|file|workflowId|workflow_id|tab|id)=/i, ' ')
|
|
.replace(/[=&]+/g, ' '));
|
|
},
|
|
|
|
cleanWorkflowLabel(label = '') {
|
|
return String(label || '')
|
|
.replace(/[\r\n\t]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/^[*•\s]+|[×✕✖*•\s]+$/g, '')
|
|
.trim();
|
|
},
|
|
|
|
getWorkflowTabActiveSelectors() {
|
|
return [
|
|
'[data-workflow-name][aria-selected="true"]',
|
|
'[data-workflow-name].active',
|
|
'[data-workflow-name].selected',
|
|
'[data-workflow-id][aria-selected="true"]',
|
|
'[data-workflow-id][data-active="true"]',
|
|
'[data-workflow-id].active',
|
|
'[data-workflow-id].selected',
|
|
'[data-tab-id*="workflow" i][aria-selected="true"]',
|
|
'[data-tab-id*="workflow" i][data-active="true"]',
|
|
'[class*="workflow" i][class*="tab" i].active',
|
|
'[class*="workflow" i][class*="tab" i].selected',
|
|
'[class*="workflow" i][class*="tab" i].p-highlight',
|
|
'[class*="workflow" i][class*="tab" i].p-tab-active',
|
|
'[class*="tab" i][class*="workflow" i].active',
|
|
'[class*="tab" i][class*="workflow" i].selected',
|
|
'[class*="tab" i][class*="workflow" i].p-highlight',
|
|
'[class*="tab" i][class*="workflow" i].p-tab-active',
|
|
'[role="tab"][aria-selected="true"]',
|
|
'[role="tab"][data-active="true"]',
|
|
'[role="tab"].active',
|
|
'[role="tab"].selected',
|
|
'[role="tab"].p-highlight',
|
|
'[role="tab"].p-tab-active'
|
|
];
|
|
},
|
|
|
|
getWorkflowTabSearchSelectors() {
|
|
return [
|
|
'[data-workflow-name]',
|
|
'[data-workflow-id]',
|
|
'[data-tab-id*="workflow" i]',
|
|
'[aria-controls*="workflow" i]',
|
|
'[class*="workflow" i][class*="tab" i]',
|
|
'[class*="tab" i][class*="workflow" i]',
|
|
'[role="tab"]'
|
|
];
|
|
},
|
|
|
|
getWorkflowTabElementClassText(element) {
|
|
return String(element?.getAttribute?.('class') || '');
|
|
},
|
|
|
|
getWorkflowTabElementIdentity(element) {
|
|
if (!element) return null;
|
|
|
|
const workflowTabId = String(
|
|
element.getAttribute?.('data-workflow-id')
|
|
|| element.dataset?.workflowId
|
|
|| element.getAttribute?.('data-tab-id')
|
|
|| element.dataset?.tabId
|
|
|| ''
|
|
).trim();
|
|
const workflowTabName = String(
|
|
element.getAttribute?.('data-workflow-name')
|
|
|| element.dataset?.workflowName
|
|
|| element.getAttribute?.('data-tab-title')
|
|
|| element.dataset?.tabTitle
|
|
|| ''
|
|
).trim();
|
|
const workflowTabAriaControls = String(element.getAttribute?.('aria-controls') || '').trim();
|
|
const title = String(element.getAttribute?.('title') || '').trim();
|
|
const ariaLabel = String(element.getAttribute?.('aria-label') || '').trim();
|
|
const workflowTabText = this.cleanWorkflowLabel(element.textContent || '');
|
|
const workflowLabel = this.cleanWorkflowLabel(workflowTabName || title || ariaLabel || workflowTabText);
|
|
|
|
return {
|
|
element,
|
|
workflowLabel,
|
|
workflowTabId,
|
|
workflowTabName,
|
|
workflowTabAriaControls,
|
|
workflowTabText,
|
|
workflowTabTitle: title,
|
|
workflowTabAriaLabel: ariaLabel
|
|
};
|
|
},
|
|
|
|
isLikelyWorkflowTabElement(element, label = '') {
|
|
if (!element) return false;
|
|
if (element.closest?.('#model-resolver-modal, .model-resolver-backdrop')) return false;
|
|
|
|
const attrText = [
|
|
element.getAttribute?.('data-workflow-id') || '',
|
|
element.getAttribute?.('data-workflow-name') || '',
|
|
element.getAttribute?.('data-tab-id') || '',
|
|
element.getAttribute?.('aria-controls') || '',
|
|
element.getAttribute?.('title') || '',
|
|
element.getAttribute?.('aria-label') || '',
|
|
this.getWorkflowTabElementClassText(element)
|
|
].join(' ');
|
|
|
|
if (/workflow|untitled|unsaved|\.json/i.test(`${label} ${attrText}`)) return true;
|
|
if (element.hasAttribute?.('data-workflow-id') || element.hasAttribute?.('data-workflow-name')) return true;
|
|
if (element.matches?.('[role="tab"]')) {
|
|
const normalized = this.cleanWorkflowLabel(label).toLowerCase();
|
|
if (!normalized) return false;
|
|
if (/^(queued|downloads|missing|loaded|options|settings|queue|history|logs|console|manager)$/i.test(normalized)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
isWorkflowTabElementActive(element) {
|
|
if (!element) return false;
|
|
const classList = element.classList;
|
|
return element.getAttribute?.('aria-selected') === 'true'
|
|
|| element.getAttribute?.('data-active') === 'true'
|
|
|| classList?.contains('active')
|
|
|| classList?.contains('selected')
|
|
|| classList?.contains('p-highlight')
|
|
|| classList?.contains('p-tab-active');
|
|
},
|
|
|
|
findActiveWorkflowTabElement() {
|
|
if (typeof document === 'undefined') return null;
|
|
|
|
for (const selector of this.getWorkflowTabActiveSelectors()) {
|
|
let elements;
|
|
try {
|
|
elements = Array.from(document.querySelectorAll(selector));
|
|
} catch (_error) {
|
|
continue;
|
|
}
|
|
|
|
for (const element of elements) {
|
|
const identity = this.getWorkflowTabElementIdentity(element);
|
|
if (!identity?.workflowLabel && !identity?.workflowTabId && !identity?.workflowTabName) continue;
|
|
if (!this.isLikelyWorkflowTabElement(element, identity.workflowLabel)) continue;
|
|
return identity;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
getActiveWorkflowTabContext() {
|
|
const activeTab = this.findActiveWorkflowTabElement?.();
|
|
const routeKey = this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || '';
|
|
const workflow = this.getCurrentWorkflow?.();
|
|
const workflowSignature = this.activeWorkflowSignature || (workflow ? this.getWorkflowSignature?.(workflow) : '') || '';
|
|
const workflowId = this.getWorkflowIdFromWorkflow?.(workflow) || this.getActiveWorkflowId?.() || '';
|
|
const fallbackLabel = this.getWorkflowLabelFromComfyState?.()
|
|
|| activeTab?.workflowLabel
|
|
|| this.getWorkflowLabelFromRouteKey?.(routeKey)
|
|
|| 'Current workflow';
|
|
|
|
return {
|
|
workflowId,
|
|
workflowRouteKey: routeKey,
|
|
workflowSignature,
|
|
workflowLabel: activeTab?.workflowLabel || fallbackLabel,
|
|
workflowTabId: activeTab?.workflowTabId || '',
|
|
workflowTabName: activeTab?.workflowTabName || '',
|
|
workflowTabAriaControls: activeTab?.workflowTabAriaControls || '',
|
|
workflowTabText: activeTab?.workflowTabText || ''
|
|
};
|
|
},
|
|
|
|
getWorkflowLabelFromComfyState() {
|
|
const candidates = [];
|
|
try {
|
|
const activeWorkflow = app?.workflowManager?.activeWorkflow
|
|
|| app?.workflowManager?.workflow
|
|
|| app?.ui?.workflow?.activeWorkflow
|
|
|| app?.canvas?.workflow;
|
|
candidates.push(
|
|
activeWorkflow?.name,
|
|
activeWorkflow?.title,
|
|
activeWorkflow?.filename,
|
|
activeWorkflow?.path?.split?.(/[\\/]/)?.pop?.(),
|
|
activeWorkflow?.workflow?.name,
|
|
activeWorkflow?.workflow?.title
|
|
);
|
|
} catch (_error) { /* ignore unavailable Comfy internals */ }
|
|
|
|
for (const candidate of candidates) {
|
|
const label = this.cleanWorkflowLabel(candidate);
|
|
if (label) return label;
|
|
}
|
|
return '';
|
|
},
|
|
|
|
normalizeWorkflowId(value = '') {
|
|
return String(value || '').trim().toLowerCase();
|
|
},
|
|
|
|
isLikelyWorkflowId(value = '') {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim());
|
|
},
|
|
|
|
getWorkflowIdFromWorkflow(workflow = null) {
|
|
const candidates = [
|
|
workflow?.id,
|
|
workflow?.workflow_id,
|
|
workflow?.workflowId,
|
|
workflow?.workflow?.id,
|
|
workflow?.workflow?.workflow_id,
|
|
workflow?.workflow?.workflowId,
|
|
workflow?.extra?.workflow?.id,
|
|
workflow?.extra?.workflow_id,
|
|
workflow?.extra?.workflowId
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const workflowId = String(candidate || '').trim();
|
|
if (workflowId) return workflowId;
|
|
}
|
|
return '';
|
|
},
|
|
|
|
getActiveWorkflowId() {
|
|
try {
|
|
const workflow = this.getCurrentWorkflow?.();
|
|
const serializedId = this.getWorkflowIdFromWorkflow?.(workflow);
|
|
if (serializedId) return serializedId;
|
|
|
|
const activeWorkflow = app?.workflowManager?.activeWorkflow
|
|
|| app?.workflowManager?.workflow
|
|
|| app?.ui?.workflow?.activeWorkflow
|
|
|| app?.canvas?.workflow;
|
|
return this.getWorkflowIdFromWorkflow?.(activeWorkflow) || '';
|
|
} catch (_error) {
|
|
return '';
|
|
}
|
|
},
|
|
|
|
getWorkflowLabelFromActiveTabElement() {
|
|
return this.findActiveWorkflowTabElement?.()?.workflowLabel || '';
|
|
},
|
|
|
|
getActiveWorkflowDownloadLabel() {
|
|
const stateLabel = this.getWorkflowLabelFromComfyState?.();
|
|
if (stateLabel) return stateLabel;
|
|
|
|
const tabLabel = this.getWorkflowLabelFromActiveTabElement?.();
|
|
if (tabLabel) return tabLabel;
|
|
|
|
const routeKey = this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || '';
|
|
const routeLabel = this.getWorkflowLabelFromRouteKey(routeKey);
|
|
return routeLabel || 'Current workflow';
|
|
},
|
|
|
|
getDownloadWorkflowLabel(info = {}) {
|
|
return info.workflowLabel
|
|
|| this.getWorkflowLabelFromRouteKey(info.workflowRouteKey || '')
|
|
|| this.getWorkflowLabelFromRouteKey(info.workflowKey || '')
|
|
|| this.getActiveWorkflowDownloadLabel?.()
|
|
|| 'Unknown workflow';
|
|
},
|
|
|
|
getWorkflowContextRouteKey(context = {}) {
|
|
const direct = context.workflow_route_key || context.workflowRouteKey || '';
|
|
if (direct) return String(direct).split('\n')[0].trim();
|
|
|
|
const key = context.workflow_key || context.workflowKey || '';
|
|
return String(key || '').split('\n')[0].trim();
|
|
},
|
|
|
|
isUsableWorkflowRouteKey(routeKey = '') {
|
|
const normalized = String(routeKey || '').trim();
|
|
return Boolean(normalized && normalized !== '#');
|
|
},
|
|
|
|
getWorkflowContextSignature(context = {}) {
|
|
const direct = context.workflow_signature || context.workflowSignature || '';
|
|
if (direct) return String(direct).trim();
|
|
|
|
const key = context.workflow_key || context.workflowKey || '';
|
|
const parts = String(key || '').split('\n').map(part => part.trim()).filter(Boolean);
|
|
return parts.length > 1 ? parts.slice(1).join('\n') : '';
|
|
},
|
|
|
|
getWorkflowContextId(context = {}) {
|
|
const direct = context.workflow_id || context.workflowId || context.workflow_uuid || context.workflowUuid || '';
|
|
if (direct) return String(direct).trim();
|
|
|
|
const routeKey = this.getWorkflowContextRouteKey(context);
|
|
if (routeKey) {
|
|
try {
|
|
const base = typeof window !== 'undefined' && window.location?.origin ? window.location.origin : 'http://localhost';
|
|
const url = new URL(String(routeKey || ''), base);
|
|
const routeWorkflowId = url.searchParams.get('workflowId') || url.searchParams.get('workflow_id') || url.searchParams.get('id');
|
|
if (routeWorkflowId) return routeWorkflowId.trim();
|
|
} catch (_error) { /* fall through to uuid extraction */ }
|
|
|
|
const routeUuid = String(routeKey || '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] || '';
|
|
if (routeUuid) return routeUuid;
|
|
}
|
|
|
|
const label = this.getWorkflowContextLabel(context);
|
|
return this.isLikelyWorkflowId(label) ? label : '';
|
|
},
|
|
|
|
getWorkflowContextLabel(context = {}) {
|
|
return this.cleanWorkflowLabel(
|
|
context.workflow_label
|
|
|| context.workflowLabel
|
|
|| context.workflow_tab_name
|
|
|| context.workflowTabName
|
|
|| context.workflow_tab_text
|
|
|| context.workflowTabText
|
|
|| this.getWorkflowLabelFromRouteKey?.(this.getWorkflowContextRouteKey(context))
|
|
|| ''
|
|
);
|
|
},
|
|
|
|
getDownloadWorkflowRuntimeContext(context = {}) {
|
|
const downloadId = context.download_id || context.downloadId || '';
|
|
const info = downloadId ? this.activeDownloads?.[downloadId] : null;
|
|
if (!info) return context;
|
|
|
|
return {
|
|
...context,
|
|
workflowId: context.workflowId || context.workflow_id || info.workflowId || info.workflow_id || '',
|
|
workflow_id: context.workflow_id || context.workflowId || info.workflow_id || info.workflowId || '',
|
|
workflowRouteKey: context.workflowRouteKey || context.workflow_route_key || info.workflowRouteKey || '',
|
|
workflow_route_key: context.workflow_route_key || context.workflowRouteKey || info.workflowRouteKey || '',
|
|
workflowKey: context.workflowKey || info.workflowKey || '',
|
|
workflowLabel: context.workflowLabel || context.workflow_label || info.workflowLabel || '',
|
|
workflow_label: context.workflow_label || context.workflowLabel || info.workflowLabel || '',
|
|
workflowSignature: context.workflowSignature || info.workflowSignature || '',
|
|
workflowTabId: context.workflowTabId || context.workflow_tab_id || info.workflowTabId || '',
|
|
workflow_tab_id: context.workflow_tab_id || context.workflowTabId || info.workflowTabId || '',
|
|
workflowTabName: context.workflowTabName || context.workflow_tab_name || info.workflowTabName || '',
|
|
workflow_tab_name: context.workflow_tab_name || context.workflowTabName || info.workflowTabName || '',
|
|
workflowTabAriaControls: context.workflowTabAriaControls || context.workflow_tab_aria_controls || info.workflowTabAriaControls || '',
|
|
workflow_tab_aria_controls: context.workflow_tab_aria_controls || context.workflowTabAriaControls || info.workflowTabAriaControls || '',
|
|
workflowTabText: context.workflowTabText || context.workflow_tab_text || info.workflowTabText || '',
|
|
workflow_tab_text: context.workflow_tab_text || context.workflowTabText || info.workflowTabText || ''
|
|
};
|
|
},
|
|
|
|
canSwitchToDownloadWorkflow(context = {}) {
|
|
context = this.getDownloadWorkflowRuntimeContext(context);
|
|
const label = this.getWorkflowContextLabel(context);
|
|
const hasSpecificLabel = Boolean(label && !/^(current workflow|unknown workflow|workflow)$/i.test(label));
|
|
return Boolean(
|
|
this.isUsableWorkflowRouteKey(this.getWorkflowContextRouteKey(context))
|
|
|| this.getWorkflowContextId(context)
|
|
|| this.getWorkflowContextSignature(context)
|
|
|| hasSpecificLabel
|
|
|| context.workflow_tab_id
|
|
|| context.workflowTabId
|
|
|| context.workflow_tab_aria_controls
|
|
|| context.workflowTabAriaControls
|
|
);
|
|
},
|
|
|
|
isDownloadWorkflowActive(context = {}) {
|
|
context = this.getDownloadWorkflowRuntimeContext(context);
|
|
const workflowId = this.normalizeWorkflowId(this.getWorkflowContextId(context));
|
|
const activeWorkflowId = this.normalizeWorkflowId(this.getActiveWorkflowId?.());
|
|
if (workflowId && activeWorkflowId && workflowId === activeWorkflowId) return true;
|
|
|
|
const routeKey = this.getWorkflowContextRouteKey(context);
|
|
const currentRoute = this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || '';
|
|
if (this.isUsableWorkflowRouteKey(routeKey) && currentRoute && routeKey === currentRoute) return true;
|
|
|
|
const signature = this.getWorkflowContextSignature(context);
|
|
const currentSignature = this.activeWorkflowSignature || this.getWorkflowSignature?.(this.getCurrentWorkflow?.()) || '';
|
|
if (signature && currentSignature && signature === currentSignature) return true;
|
|
|
|
const activeTab = this.findActiveWorkflowTabElement?.();
|
|
if (activeTab) {
|
|
const targetTabId = String(context.workflow_tab_id || context.workflowTabId || '').trim();
|
|
const targetTabName = String(context.workflow_tab_name || context.workflowTabName || '').trim();
|
|
const targetAriaControls = String(context.workflow_tab_aria_controls || context.workflowTabAriaControls || '').trim();
|
|
if (targetTabId && targetTabId === activeTab.workflowTabId) return true;
|
|
if (targetTabName && targetTabName === activeTab.workflowTabName) return true;
|
|
if (targetAriaControls && targetAriaControls === activeTab.workflowTabAriaControls) return true;
|
|
}
|
|
|
|
const label = this.cleanWorkflowLabel(this.getWorkflowContextLabel(context)).toLowerCase();
|
|
const activeLabel = this.cleanWorkflowLabel(this.getActiveWorkflowDownloadLabel?.()).toLowerCase();
|
|
return Boolean(label && activeLabel && label === activeLabel);
|
|
},
|
|
|
|
isDownloadWorkflowOpen(context = {}) {
|
|
context = this.getDownloadWorkflowRuntimeContext(context);
|
|
if (this.findWorkflowTabForDownload(context)) return true;
|
|
|
|
const workflowId = this.normalizeWorkflowId(this.getWorkflowContextId(context));
|
|
const activeWorkflowId = this.normalizeWorkflowId(this.getActiveWorkflowId?.());
|
|
if (workflowId && activeWorkflowId && workflowId === activeWorkflowId) return true;
|
|
|
|
const signature = this.getWorkflowContextSignature(context);
|
|
const currentSignature = this.activeWorkflowSignature || this.getWorkflowSignature?.(this.getCurrentWorkflow?.()) || '';
|
|
if (signature && currentSignature && signature === currentSignature) return true;
|
|
|
|
const activeTab = this.findActiveWorkflowTabElement?.();
|
|
if (activeTab) {
|
|
const targetTabId = String(context.workflow_tab_id || context.workflowTabId || '').trim();
|
|
const targetTabName = String(context.workflow_tab_name || context.workflowTabName || '').trim();
|
|
const targetAriaControls = String(context.workflow_tab_aria_controls || context.workflowTabAriaControls || '').trim();
|
|
if (targetTabId && targetTabId === activeTab.workflowTabId) return true;
|
|
if (targetTabName && targetTabName === activeTab.workflowTabName) return true;
|
|
if (targetAriaControls && targetAriaControls === activeTab.workflowTabAriaControls) return true;
|
|
|
|
const label = this.cleanWorkflowLabel(this.getWorkflowContextLabel(context)).toLowerCase();
|
|
const activeTabLabel = this.cleanWorkflowLabel(activeTab.workflowLabel).toLowerCase();
|
|
if (label && activeTabLabel && label === activeTabLabel) return true;
|
|
}
|
|
|
|
const label = this.cleanWorkflowLabel(this.getWorkflowContextLabel(context)).toLowerCase();
|
|
const stateLabel = this.cleanWorkflowLabel(this.getWorkflowLabelFromComfyState?.()).toLowerCase();
|
|
return Boolean(label && stateLabel && label === stateLabel);
|
|
},
|
|
|
|
didActiveWorkflowTabChange(previousActiveTab = null) {
|
|
const activeTab = this.findActiveWorkflowTabElement?.();
|
|
if (!activeTab) return false;
|
|
if (!previousActiveTab) return true;
|
|
|
|
const keys = [
|
|
'workflowLabel',
|
|
'workflowTabId',
|
|
'workflowTabName',
|
|
'workflowTabAriaControls',
|
|
'workflowTabText'
|
|
];
|
|
return keys.some(key => String(activeTab[key] || '') !== String(previousActiveTab[key] || ''));
|
|
},
|
|
|
|
isDownloadWorkflowOpenAfterRouteSwitch(context = {}, previousActiveTab = null) {
|
|
if (this.isDownloadWorkflowOpen(context)) return true;
|
|
|
|
const routeKey = this.getWorkflowContextRouteKey(context);
|
|
const currentRoute = this.getActiveWorkflowRouteKey?.() || '';
|
|
return Boolean(
|
|
this.isUsableWorkflowRouteKey(routeKey)
|
|
&& currentRoute
|
|
&& routeKey === currentRoute
|
|
&& this.didActiveWorkflowTabChange(previousActiveTab)
|
|
);
|
|
},
|
|
|
|
verifyDownloadHistoryWorkflowSwitch(context = {}, label = 'workflow', previousActiveTab = null, attempt = 0) {
|
|
const maxAttempts = 6;
|
|
const delay = attempt === 0 ? 150 : 200;
|
|
setTimeout(() => {
|
|
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
|
|
if (this.isDownloadWorkflowOpenAfterRouteSwitch(context, previousActiveTab)) {
|
|
this.showNotification(`Switched to workflow: ${label}`, 'success');
|
|
return;
|
|
}
|
|
|
|
if (attempt < maxAttempts) {
|
|
this.verifyDownloadHistoryWorkflowSwitch(context, label, previousActiveTab, attempt + 1);
|
|
return;
|
|
}
|
|
|
|
this.showNotification(`Open workflow not found: ${label}. It may have been closed.`, 'error');
|
|
}, delay);
|
|
},
|
|
|
|
getDownloadQueueContext(progress = {}, info = {}, workflowLabel = '', downloadId = '') {
|
|
const folderContext = this.getDownloadFolderContext?.(progress, info) || null;
|
|
const missing = info?.missing || {};
|
|
const label = workflowLabel || this.getDownloadWorkflowLabel?.(info) || '';
|
|
const context = {
|
|
...(folderContext || {}),
|
|
context_scope: 'download_queue',
|
|
download_id: downloadId,
|
|
open_folder_label: folderContext ? 'Open Download Folder' : '',
|
|
name: folderContext?.name || progress?.filename || info?.filename || missing?.original_path?.split(/[/\\]/).pop() || 'Download',
|
|
workflow_id: info.workflowId || info.workflow_id || this.getWorkflowContextId(info) || '',
|
|
workflow_label: label,
|
|
workflow_route_key: info.workflowRouteKey || this.getWorkflowContextRouteKey(info) || '',
|
|
workflow_tab_id: info.workflowTabId || '',
|
|
workflow_tab_name: info.workflowTabName || '',
|
|
workflow_tab_aria_controls: info.workflowTabAriaControls || '',
|
|
workflow_tab_text: info.workflowTabText || '',
|
|
node_id: missing.locate_node_id ?? missing.node_id ?? '',
|
|
widget_index: missing.widget_index ?? '',
|
|
subgraph_id: missing.locate_subgraph_id || missing.subgraph_id || '',
|
|
is_top_level: missing.locate_is_top_level ?? missing.is_top_level ?? true
|
|
};
|
|
|
|
return (folderContext || this.canSwitchToDownloadWorkflow(context)) ? context : null;
|
|
},
|
|
|
|
getWorkflowTabComparisonValues(identity = {}, element = null) {
|
|
if (!identity) return [];
|
|
return [
|
|
identity.workflowLabel,
|
|
identity.workflowTabId,
|
|
identity.workflowTabName,
|
|
identity.workflowTabAriaControls,
|
|
identity.workflowTabText,
|
|
identity.workflowTabTitle,
|
|
identity.workflowTabAriaLabel,
|
|
element?.getAttribute?.('href') || '',
|
|
element?.getAttribute?.('data-route') || '',
|
|
element?.getAttribute?.('data-path') || ''
|
|
].map(value => String(value || '').trim()).filter(Boolean);
|
|
},
|
|
|
|
findWorkflowTabForDownload(context = {}) {
|
|
if (typeof document === 'undefined') return null;
|
|
|
|
context = this.getDownloadWorkflowRuntimeContext(context);
|
|
const routeKey = this.getWorkflowContextRouteKey(context);
|
|
const routeLabel = this.getWorkflowLabelFromRouteKey?.(routeKey) || '';
|
|
const labelTargets = new Set([
|
|
this.getWorkflowContextLabel(context),
|
|
routeLabel,
|
|
context.workflow_tab_text,
|
|
context.workflowTabText,
|
|
context.workflow_tab_name,
|
|
context.workflowTabName
|
|
].map(value => this.cleanWorkflowLabel(value).toLowerCase()).filter(Boolean));
|
|
const idTargets = new Set([
|
|
context.workflow_tab_id,
|
|
context.workflowTabId,
|
|
context.workflow_tab_aria_controls,
|
|
context.workflowTabAriaControls
|
|
].map(value => String(value || '').trim().toLowerCase()).filter(Boolean));
|
|
const routeTarget = String(routeKey || '').trim().toLowerCase();
|
|
const seen = new Set();
|
|
let bestMatch = null;
|
|
|
|
for (const selector of this.getWorkflowTabSearchSelectors()) {
|
|
let elements;
|
|
try {
|
|
elements = Array.from(document.querySelectorAll(selector));
|
|
} catch (_error) {
|
|
continue;
|
|
}
|
|
|
|
for (const element of elements) {
|
|
if (!element || seen.has(element)) continue;
|
|
seen.add(element);
|
|
|
|
const identity = this.getWorkflowTabElementIdentity(element);
|
|
if (!identity || !this.isLikelyWorkflowTabElement(element, identity.workflowLabel)) continue;
|
|
|
|
const values = this.getWorkflowTabComparisonValues(identity, element);
|
|
const normalizedValues = values.map(value => this.cleanWorkflowLabel(value).toLowerCase()).filter(Boolean);
|
|
const rawValues = values.map(value => String(value || '').trim().toLowerCase()).filter(Boolean);
|
|
let score = 0;
|
|
|
|
for (const value of rawValues) {
|
|
if (idTargets.has(value)) score += 100;
|
|
if (routeTarget && value.includes(routeTarget)) score += 70;
|
|
}
|
|
|
|
for (const value of normalizedValues) {
|
|
if (labelTargets.has(value)) score += 80;
|
|
for (const target of labelTargets) {
|
|
if (target && value !== target && (value.includes(target) || target.includes(value))) score += 25;
|
|
}
|
|
}
|
|
|
|
if (this.isWorkflowTabElementActive(element)) score += 5;
|
|
|
|
if (score > (bestMatch?.score || 0)) {
|
|
bestMatch = { element, score };
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestMatch?.score > 0 ? bestMatch.element : null;
|
|
},
|
|
|
|
switchToDownloadWorkflow(context = {}) {
|
|
context = this.getDownloadWorkflowRuntimeContext(context);
|
|
if (!this.canSwitchToDownloadWorkflow(context)) {
|
|
this.showNotification('No workflow information available for this download', 'error');
|
|
return false;
|
|
}
|
|
|
|
const label = this.getWorkflowContextLabel(context) || 'workflow';
|
|
const isDownloadHistoryContext = context?.context_scope === 'download_history';
|
|
if (this.isDownloadWorkflowActive(context)) {
|
|
this.showNotification(`Already on workflow: ${label}`, 'info');
|
|
return true;
|
|
}
|
|
|
|
const tab = this.findWorkflowTabForDownload(context);
|
|
if (tab) {
|
|
try {
|
|
tab.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
tab.click();
|
|
setTimeout(() => {
|
|
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
|
|
}, 30);
|
|
this.showNotification(`Switched to workflow: ${label}`, 'success');
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Model Resolver: failed to switch workflow tab', error);
|
|
}
|
|
}
|
|
|
|
const routeKey = this.getWorkflowContextRouteKey(context);
|
|
const currentRoute = this.getActiveWorkflowRouteKey?.() || '';
|
|
if (this.isUsableWorkflowRouteKey(routeKey) && routeKey !== currentRoute && typeof window !== 'undefined') {
|
|
try {
|
|
const previousActiveTab = isDownloadHistoryContext ? this.findActiveWorkflowTabElement?.() : null;
|
|
window.location.hash = routeKey.startsWith('#') ? routeKey : `#${routeKey.replace(/^#/, '')}`;
|
|
if (isDownloadHistoryContext) {
|
|
this.verifyDownloadHistoryWorkflowSwitch(context, label, previousActiveTab);
|
|
} else {
|
|
setTimeout(() => {
|
|
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
|
|
}, 30);
|
|
this.showNotification(`Switched to workflow: ${label}`, 'success');
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Model Resolver: failed to switch workflow route', error);
|
|
}
|
|
}
|
|
|
|
this.showNotification(
|
|
isDownloadHistoryContext
|
|
? `Open workflow not found: ${label}. It may have been closed.`
|
|
: `Could not find workflow tab: ${label}. It may be closed.`,
|
|
'error'
|
|
);
|
|
return false;
|
|
},
|
|
|
|
getActiveQueuePanelDownloadIds() {
|
|
return this.getActiveQueuePanelDownloads().map(({ downloadId }) => downloadId);
|
|
},
|
|
|
|
renderDownloadsPanel(downloads = [], history = this.getDownloadHistory()) {
|
|
const activeSubTab = this.queueDownloadsActiveTab === 'history' ? 'history' : 'active';
|
|
const activeSelected = activeSubTab === 'active';
|
|
let html = `<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 syncAttributes = (current, next) => {
|
|
const nextAttributeNames = new Set(Array.from(next.attributes || [], attribute => attribute.name));
|
|
for (const attribute of Array.from(next.attributes || [])) {
|
|
if (current.getAttribute(attribute.name) !== attribute.value) {
|
|
current.setAttribute(attribute.name, attribute.value);
|
|
}
|
|
}
|
|
for (const attribute of Array.from(current.attributes || [])) {
|
|
if (!nextAttributeNames.has(attribute.name)) {
|
|
current.removeAttribute(attribute.name);
|
|
}
|
|
}
|
|
};
|
|
const getNodeKey = (node) => {
|
|
if (node?.nodeType !== 1) return '';
|
|
return node.getAttribute?.('data-download-id') || '';
|
|
};
|
|
const canPatchNode = (current, next) => {
|
|
if (!current || current.nodeType !== next.nodeType) return false;
|
|
if (current.nodeType !== 1) return true;
|
|
if (current.tagName !== next.tagName) return false;
|
|
if ((current.getAttribute('class') || '') !== (next.getAttribute('class') || '')) return false;
|
|
const currentKey = getNodeKey(current);
|
|
const nextKey = getNodeKey(next);
|
|
return !currentKey && !nextKey ? true : currentKey === nextKey;
|
|
};
|
|
const patchNode = (current, next) => {
|
|
if (!canPatchNode(current, next)) {
|
|
const replacement = next.cloneNode(true);
|
|
current.replaceWith(replacement);
|
|
return replacement;
|
|
}
|
|
if (current.nodeType !== 1) {
|
|
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
|
return current;
|
|
}
|
|
|
|
syncAttributes(current, next);
|
|
patchChildren(current, next);
|
|
return current;
|
|
};
|
|
const patchChildren = (current, next) => {
|
|
const nextChildren = Array.from(next.childNodes || []);
|
|
let cursor = current.firstChild;
|
|
|
|
for (const nextChild of nextChildren) {
|
|
const nextKey = getNodeKey(nextChild);
|
|
let currentChild = cursor;
|
|
if (nextKey) {
|
|
currentChild = Array.from(current.childNodes || []).find(child => getNodeKey(child) === nextKey) || null;
|
|
if (currentChild && currentChild !== cursor) {
|
|
current.insertBefore(currentChild, cursor);
|
|
}
|
|
}
|
|
|
|
if (!currentChild) {
|
|
current.appendChild(nextChild.cloneNode(true));
|
|
cursor = null;
|
|
continue;
|
|
}
|
|
|
|
const patchedChild = patchNode(currentChild, nextChild);
|
|
cursor = patchedChild.nextSibling;
|
|
}
|
|
|
|
while (cursor) {
|
|
const nextSibling = cursor.nextSibling;
|
|
cursor.remove();
|
|
cursor = nextSibling;
|
|
}
|
|
};
|
|
|
|
patchNode(currentElement, nextElement);
|
|
},
|
|
|
|
wireDownloadsPanelControls() {
|
|
const bindInstantAction = (button, handler) => {
|
|
if (!button || button._hasListener) return;
|
|
button._hasListener = true;
|
|
const run = (event) => {
|
|
event?.preventDefault?.();
|
|
event?.stopPropagation?.();
|
|
if (button.disabled) return;
|
|
if (event?.type === 'click' && button._handledPointerAction) {
|
|
button._handledPointerAction = false;
|
|
return;
|
|
}
|
|
if (event?.type === 'pointerdown') {
|
|
button._handledPointerAction = true;
|
|
}
|
|
handler();
|
|
};
|
|
button.addEventListener('pointerdown', run);
|
|
button.addEventListener('click', run);
|
|
};
|
|
|
|
this.queueList.querySelectorAll('.mr-downloads-subtab').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
this.setQueueDownloadsTab(button.dataset.downloadsTab || 'active');
|
|
});
|
|
});
|
|
|
|
this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => {
|
|
bindInstantAction(button, () => {
|
|
const downloadId = button.dataset.downloadId;
|
|
if (downloadId) this.cancelDownload(downloadId);
|
|
});
|
|
});
|
|
|
|
this.queueList.querySelectorAll('.mr-download-queue-pause').forEach(button => {
|
|
bindInstantAction(button, () => {
|
|
const downloadId = button.dataset.downloadId;
|
|
if (downloadId) this.pauseDownload(downloadId);
|
|
});
|
|
});
|
|
|
|
this.queueList.querySelectorAll('.mr-download-queue-resume').forEach(button => {
|
|
bindInstantAction(button, () => {
|
|
const downloadId = button.dataset.downloadId;
|
|
if (downloadId) this.resumeDownload(downloadId);
|
|
});
|
|
});
|
|
|
|
const clearHistoryButton = this.queueList.querySelector('.mr-download-history-clear');
|
|
if (clearHistoryButton) {
|
|
clearHistoryButton.addEventListener('click', () => this.clearDownloadHistory());
|
|
}
|
|
|
|
this.queueList.querySelectorAll('.mr-download-history-remove').forEach(button => {
|
|
button.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
const historyId = button.dataset.historyId || '';
|
|
const historyIndex = Number.parseInt(button.dataset.historyIndex || '-1', 10);
|
|
this.removeDownloadHistoryEntry(historyId, historyIndex);
|
|
});
|
|
});
|
|
},
|
|
|
|
renderQueueDownloadsHtml(downloads) {
|
|
if (!downloads.length) {
|
|
return '<div class="mr-queue-empty">No active downloads.</div>';
|
|
}
|
|
|
|
let html = '<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 displayProgress = this.getDownloadDisplayProgress(progress);
|
|
const percent = displayProgress.percent;
|
|
const filename = progress.filename
|
|
|| info.filename
|
|
|| info.missing?.download_source?.filename
|
|
|| info.missing?.original_path?.split(/[/\\]/).pop()
|
|
|| 'model';
|
|
const category = this.getCategoryDisplayName?.(info.category || info.missing?.category || '') || info.category || '';
|
|
const nodeLabel = info.missing?.subgraph_name || info.missing?.node_type || (info.missing?.subgraph_id ? 'Subgraph' : 'Node');
|
|
const downloaded = this.formatBytes(displayProgress.downloaded);
|
|
const total = displayProgress.totalSize ? this.formatBytes(displayProgress.totalSize) : '';
|
|
const progressMeta = this.formatDownloadProgressMeta?.(progress) || '';
|
|
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 percentLabel = this.formatDownloadPercent?.(percent) ?? String(Math.round(percent));
|
|
const statusLabel = status === 'downloading'
|
|
? (displayProgress.isFinalizing ? 'Finalizing' : `${percentLabel}%`)
|
|
: (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : status.replace(/_/g, ' ')));
|
|
const logicalDownloaded = this.formatBytes(progress.downloaded || 0);
|
|
const logicalTotal = progress.total_size ? this.formatBytes(progress.total_size) : '';
|
|
const sizeText = displayProgress.isFinalizing && logicalTotal
|
|
? `Finalizing file: ${logicalDownloaded} / ${logicalTotal}`
|
|
: (total ? `${downloaded} / ${total}` : downloaded);
|
|
const targetPath = progress.directory || info.downloadDirectory || info.downloadPath || '';
|
|
const targetLabel = targetPath ? targetPath.split(/[/\\]/).filter(Boolean).pop() || targetPath : '';
|
|
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 contextTooltip = hasFolderAction
|
|
? 'Right-click for workflow and download folder actions'
|
|
: 'Right-click to switch to workflow';
|
|
const contextData = contextModel
|
|
? this.getContextMenuAttrs(contextModel, contextTooltip)
|
|
: '';
|
|
|
|
html += `<div class="mr-queue-item mr-download-queue-item" data-download-id="${this.escapeHtml(downloadId)}"${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-queue-status">${this.escapeHtml(statusLabel)}</span>`;
|
|
html += `</div>`;
|
|
html += `<div class="mr-queue-item-meta"><span>Model</span><code>${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(info.missing?.node_id ?? ''))}</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 (backend) {
|
|
html += `<div class="mr-queue-item-meta"><span>Backend</span><code>${this.escapeHtml(backend)}</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>`;
|
|
}
|
|
html += `<div class="mr-download-queue-progress">`;
|
|
html += `<div class="mr-progress-bar"><div class="mr-progress-fill" style="width: ${percent}%;"></div></div>`;
|
|
html += `<div class="mr-progress-text"><span>${this.escapeHtml(sizeText)}</span><span>${this.escapeHtml(progressMeta)}</span></div>`;
|
|
html += `</div>`;
|
|
html += `<div class="mr-queue-item-actions">`;
|
|
if (isAria2 && isPaused) {
|
|
html += `<button type="button" class="mr-btn mr-btn-primary mr-btn-sm mr-download-queue-resume" data-download-id="${this.escapeHtml(downloadId)}">Resume</button>`;
|
|
} else if (isAria2 && status === 'downloading') {
|
|
html += `<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-download-queue-pause" data-download-id="${this.escapeHtml(downloadId)}">Pause</button>`;
|
|
}
|
|
html += `<button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-download-queue-cancel" data-download-id="${this.escapeHtml(downloadId)}">Cancel</button>`;
|
|
html += `</div>`;
|
|
html += `</div>`;
|
|
}
|
|
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();
|
|
|
|
// Update selected bar UI
|
|
this.updateSelectedBarForMissing?.(missing);
|
|
this.updateQueuePanel();
|
|
this.updateApplyPendingButton();
|
|
this.refreshMissingModelsBrowserFromCache?.();
|
|
},
|
|
|
|
/**
|
|
* Apply one queued resolution from the selected bar.
|
|
*/
|
|
async applyQueuedByKey(key, button = null) {
|
|
if (!key || !this.pendingIndex.has(key)) {
|
|
this.showNotification('Selected match is no longer queued.', 'error');
|
|
return;
|
|
}
|
|
|
|
const idx = this.pendingIndex.get(key);
|
|
const selection = this.pendingResolutions?.[idx];
|
|
if (!selection) {
|
|
this.showNotification('Selected match is no longer queued.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.classList.add('mr-btn-is-disabled');
|
|
button.textContent = 'Applying...';
|
|
}
|
|
|
|
try {
|
|
await this.applyPendingResolutionList([selection], { clearAll: false });
|
|
} finally {
|
|
if (button?.isConnected) {
|
|
button.disabled = false;
|
|
button.classList.remove('mr-btn-is-disabled');
|
|
button.textContent = 'Apply';
|
|
}
|
|
}
|
|
},
|
|
|
|
async applyPendingResolutionList(list, { clearAll = false } = {}) {
|
|
if (!list.length) {
|
|
this.showNotification('No selections queued', 'error');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const appliedSelections = this.clonePendingResolutions?.(list) || JSON.parse(JSON.stringify(list));
|
|
const applyResolutions = this.expandPendingResolutionsForApply(appliedSelections);
|
|
if (!applyResolutions.length) {
|
|
this.showNotification('No valid selections to apply', 'error');
|
|
return null;
|
|
}
|
|
const workflow = this.getCurrentWorkflow();
|
|
if (!workflow) {
|
|
this.showNotification('No workflow loaded', 'error');
|
|
return null;
|
|
}
|
|
|
|
const data = await this.fetchJson('/model_resolver/resolve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ workflow, resolutions: applyResolutions })
|
|
}, 'Apply resolutions');
|
|
if (data.success) {
|
|
const optimisticData = this.getOptimisticAnalysisDataAfterApply(appliedSelections);
|
|
await this.updateWorkflowInComfyUI(data.workflow, applyResolutions);
|
|
this.rememberAppliedResolvedSelections(appliedSelections);
|
|
|
|
const appliedKeys = new Set(appliedSelections.map(selection => this.getResolutionQueueKey(selection)));
|
|
const remainingSelections = clearAll
|
|
? []
|
|
: (this.pendingResolutions || []).filter(selection => !appliedKeys.has(this.getResolutionQueueKey(selection)));
|
|
|
|
this.pendingResolutions = remainingSelections;
|
|
this.rebuildPendingIndex();
|
|
this.savePendingQueueForActiveWorkflow();
|
|
this.preserveSearchCacheAcrossNextWorkflowSync = true;
|
|
this.syncWorkflowScopedQueue?.(data.workflow);
|
|
|
|
this.pendingResolutions = remainingSelections;
|
|
this.rebuildPendingIndex();
|
|
this.savePendingQueueForActiveWorkflow();
|
|
this.updateApplyPendingButton();
|
|
this.updateQueuePanel();
|
|
|
|
this.applyOptimisticAnalysisData(optimisticData, data.workflow);
|
|
|
|
const selectionCount = appliedSelections.length;
|
|
const refText = applyResolutions.length > selectionCount
|
|
? ` (${applyResolutions.length} references)`
|
|
: '';
|
|
this.showNotification(`✓ Linked ${selectionCount} selection${selectionCount > 1 ? 's' : ''}${refText}`, 'success');
|
|
this.scheduleComfyModelCatalogRefreshAfterApply?.(data.workflow, applyResolutions);
|
|
this.refreshAnalysisInBackground(data.workflow, this.getMissingWorkflowSignature(data.workflow));
|
|
return data.workflow || null;
|
|
} else {
|
|
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
|
|
return null;
|
|
}
|
|
} catch (e) {
|
|
console.error('Model Resolver: applyPendingResolutions error', e);
|
|
this.showNotification('Error applying selections: ' + e.message, 'error');
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Apply all pending resolutions in batch
|
|
*/
|
|
async applyPendingResolutions() {
|
|
const list = this.pendingResolutions || [];
|
|
await this.applyPendingResolutionList(list, { clearAll: true });
|
|
},
|
|
|
|
updateApplyPendingButton() {
|
|
if (!this.applyPendingBtn) return;
|
|
const count = this.pendingResolutions?.length || 0;
|
|
const isEmpty = count === 0;
|
|
const tooltip = count > 0
|
|
? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.`
|
|
: 'Apply the model links you selected from local matches or search results.';
|
|
this.applyPendingBtn.textContent = `Apply Selected (${count})`;
|
|
this.setTooltip(this.applyPendingBtn, tooltip);
|
|
this.applyPendingBtn.disabled = isEmpty;
|
|
this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty));
|
|
this.applyPendingBtn.classList.toggle('mr-btn-is-disabled', isEmpty);
|
|
this.updateBatchFooterButtons();
|
|
},
|
|
|
|
updateBatchFooterButtons() {
|
|
const missingModels = this.missingModels || [];
|
|
const selectedCount = this.getSelectedMissingModels().length;
|
|
const totalCount = missingModels.length;
|
|
const pendingCount = this.pendingResolutions?.length || 0;
|
|
const activeCount = this.getActiveQueuePanelDownloads().length;
|
|
const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length;
|
|
const downloadableAll = this.getMissingWithDownloadSources(missingModels).length;
|
|
|
|
if (this.selectMenuButton) {
|
|
this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`;
|
|
this.selectMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0);
|
|
}
|
|
if (this.searchMenuButton) {
|
|
this.searchMenuButton.textContent = this.batchSearchRunning
|
|
? (this.batchSearchCancelRequested ? 'Stopping...' : 'Searching...')
|
|
: 'Search';
|
|
this.searchMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0 && !this.batchSearchRunning);
|
|
}
|
|
if (this.downloadMenuButton) {
|
|
const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download';
|
|
this.downloadMenuButton.textContent = label;
|
|
this.downloadMenuButton.classList.toggle('mr-btn-danger', activeCount > 0);
|
|
this.downloadMenuButton.classList.toggle('mr-btn-download', activeCount === 0);
|
|
this.downloadMenuButton.classList.toggle('mr-btn-is-disabled', activeCount === 0 && downloadableAll === 0);
|
|
this.setTooltip(
|
|
this.downloadMenuButton,
|
|
activeCount > 0
|
|
? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.`
|
|
: `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.`
|
|
);
|
|
}
|
|
if (this.applyPendingBtn) {
|
|
this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`;
|
|
}
|
|
if (this.linkMenuButton) {
|
|
this.linkMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0 && pendingCount === 0);
|
|
}
|
|
this.updateFooterMenuItemVisibility?.();
|
|
},
|
|
|
|
getMissingModelsForPendingResolutions() {
|
|
const pending = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
|
|
if (!pending.length) return [];
|
|
|
|
const pendingKeys = new Set(pending.map(resolution => this.getResolutionQueueKey(resolution)));
|
|
return (this.missingModels || []).filter(missing => pendingKeys.has(this.getMissingModelKey(missing)));
|
|
},
|
|
|
|
getQueueExactLocalMatchPreviewModels() {
|
|
const selected = this.getSelectedMissingModels();
|
|
const targets = selected.length ? selected : (this.missingModels || []);
|
|
return targets.filter(missing => this.getBestLocalMatch(missing, 100)?.model);
|
|
},
|
|
|
|
getDownloadableBatchPreviewModels(mode = 'all') {
|
|
const targets = mode === 'selected'
|
|
? this.getSelectedMissingModels()
|
|
: (this.missingModels || []);
|
|
|
|
return targets.filter(missing => {
|
|
if (this.getBestLocalMatch(missing, 100)) return false;
|
|
return Boolean(this.getBestDownloadSourceForMissing(missing)?.url);
|
|
});
|
|
},
|
|
|
|
getActiveDownloadMissingModels() {
|
|
const activeKeys = new Set(
|
|
this.getActiveQueuePanelDownloads()
|
|
.map(({ info }) => info?.missing)
|
|
.filter(Boolean)
|
|
.map(missing => this.getMissingModelKey(missing))
|
|
);
|
|
if (!activeKeys.size) return [];
|
|
|
|
return (this.missingModels || []).filter(missing => activeKeys.has(this.getMissingModelKey(missing)));
|
|
},
|
|
|
|
getResolvedSelectionPathCandidates(selection = {}) {
|
|
const model = selection.resolved_model || {};
|
|
return [
|
|
model.relative_path,
|
|
model.filename,
|
|
model.name,
|
|
model.path,
|
|
model.resolved_path,
|
|
selection.resolved_path
|
|
].filter(Boolean);
|
|
},
|
|
|
|
normalizeResolvedSelectionToken(value = '', { basename = false, stem = false } = {}) {
|
|
let text = this.normalizePathToForward(value).toLowerCase();
|
|
if (!text) return '';
|
|
if (basename) {
|
|
text = text.split('/').pop() || text;
|
|
}
|
|
if (stem) {
|
|
text = this.stripModelExtension(text);
|
|
}
|
|
return text;
|
|
},
|
|
|
|
getResolvedSelectionTokenSet(values = []) {
|
|
const tokens = new Set();
|
|
for (const value of values) {
|
|
const normalized = this.normalizeResolvedSelectionToken(value);
|
|
const basename = this.normalizeResolvedSelectionToken(value, { basename: true });
|
|
const stem = this.normalizeResolvedSelectionToken(value, { basename: true, stem: true });
|
|
if (normalized) tokens.add(normalized);
|
|
if (basename) tokens.add(basename);
|
|
if (stem) tokens.add(stem);
|
|
}
|
|
return tokens;
|
|
},
|
|
|
|
getResolvedSelectionRefs(selection = {}) {
|
|
const refs = Array.isArray(selection.node_refs) && selection.node_refs.length
|
|
? selection.node_refs
|
|
: [selection];
|
|
return refs.filter(Boolean).map(ref => ({
|
|
node_id: ref.node_id ?? selection.node_id,
|
|
widget_index: ref.widget_index ?? selection.widget_index,
|
|
subgraph_id: ref.subgraph_id ?? selection.subgraph_id ?? '',
|
|
is_top_level: ref.is_top_level ?? selection.is_top_level,
|
|
nested_key: ref.nested_key ?? selection.nested_key ?? ''
|
|
}));
|
|
},
|
|
|
|
rememberAppliedResolvedSelections(appliedSelections = []) {
|
|
if (!(this.appliedResolvedSelectionAliases instanceof Map)) {
|
|
this.appliedResolvedSelectionAliases = new Map();
|
|
}
|
|
|
|
for (const selection of Array.isArray(appliedSelections) ? appliedSelections : []) {
|
|
const key = this.getResolutionQueueKey(selection);
|
|
if (!key) continue;
|
|
this.appliedResolvedSelectionAliases.set(key, {
|
|
key,
|
|
category: selection.category || '',
|
|
searchKey: selection.missing_search_key || selection.search_key || '',
|
|
refs: this.getResolvedSelectionRefs(selection),
|
|
tokens: this.getResolvedSelectionTokenSet(this.getResolvedSelectionPathCandidates(selection))
|
|
});
|
|
}
|
|
},
|
|
|
|
doesResolvedModelMatchAlias(model = {}, alias = {}) {
|
|
const refs = Array.isArray(alias.refs) ? alias.refs : [];
|
|
const modelNodeId = model.node_id ?? '';
|
|
const modelWidgetIndex = model.widget_index ?? '';
|
|
const modelSubgraphId = model.subgraph_id ?? '';
|
|
const modelScope = model.is_top_level !== false;
|
|
const modelNestedKey = model.nested_key || '';
|
|
const refMatch = refs.some(ref => (
|
|
String(ref.node_id ?? '') === String(modelNodeId)
|
|
&& String(ref.widget_index ?? '') === String(modelWidgetIndex)
|
|
&& String(ref.subgraph_id ?? '') === String(modelSubgraphId)
|
|
&& (ref.is_top_level !== false) === modelScope
|
|
&& String(ref.nested_key || '') === String(modelNestedKey)
|
|
));
|
|
if (!refMatch) return false;
|
|
|
|
const aliasCategory = String(alias.category || '').toLowerCase();
|
|
const modelCategory = String(model.category || '').toLowerCase();
|
|
if (aliasCategory && modelCategory && aliasCategory !== modelCategory) return false;
|
|
|
|
const modelTokens = this.getResolvedSelectionTokenSet([
|
|
model.original_path,
|
|
model.name,
|
|
model.filename,
|
|
model.relative_path,
|
|
model.full_path,
|
|
model.path
|
|
]);
|
|
for (const token of alias.tokens || []) {
|
|
if (modelTokens.has(token)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
applyResolvedSelectionAliasesToAnalysisData(data = null) {
|
|
if (!data || !(this.appliedResolvedSelectionAliases instanceof Map) || !this.appliedResolvedSelectionAliases.size) {
|
|
return data;
|
|
}
|
|
const resolvedModels = Array.isArray(data.resolved_models) ? data.resolved_models : [];
|
|
if (!resolvedModels.length) return data;
|
|
|
|
const matchedKeys = new Set();
|
|
for (const model of resolvedModels) {
|
|
if (model?.missing_key) continue;
|
|
for (const [key, alias] of this.appliedResolvedSelectionAliases.entries()) {
|
|
if (matchedKeys.has(key)) continue;
|
|
if (!this.doesResolvedModelMatchAlias(model, alias)) continue;
|
|
model.missing_key = key;
|
|
if (alias.searchKey) {
|
|
model.missing_search_key = alias.searchKey;
|
|
}
|
|
matchedKeys.add(key);
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const key of matchedKeys) {
|
|
this.appliedResolvedSelectionAliases.delete(key);
|
|
}
|
|
return data;
|
|
},
|
|
|
|
buildOptimisticResolvedModel(selection = {}, sourceMissing = null) {
|
|
const resolvedModel = selection.resolved_model || {};
|
|
const resolvedRelativePath = resolvedModel.relative_path
|
|
|| resolvedModel.filename
|
|
|| selection.resolved_path
|
|
|| sourceMissing?.original_path
|
|
|| selection.original_path
|
|
|| '';
|
|
|
|
return {
|
|
...(sourceMissing || {}),
|
|
missing_key: this.getResolutionQueueKey(selection),
|
|
missing_search_key: selection.missing_search_key
|
|
|| sourceMissing?.missing_search_key
|
|
|| (sourceMissing ? this.getMissingSearchKey?.(sourceMissing) : ''),
|
|
__isOptimisticResolved: true,
|
|
node_id: selection.node_id ?? sourceMissing?.node_id,
|
|
widget_index: selection.widget_index ?? sourceMissing?.widget_index,
|
|
category: selection.category || sourceMissing?.category || resolvedModel.category || 'unknown',
|
|
original_path: resolvedRelativePath,
|
|
name: resolvedRelativePath,
|
|
filename: resolvedModel.filename || this.getFilenameFromPath(resolvedRelativePath) || resolvedRelativePath,
|
|
relative_path: resolvedRelativePath,
|
|
full_path: resolvedModel.path || selection.resolved_path || sourceMissing?.full_path || '',
|
|
path: resolvedModel.path || selection.resolved_path || '',
|
|
matches: [{
|
|
confidence: 100,
|
|
match_type: 'exact',
|
|
filename: resolvedModel.filename || resolvedRelativePath,
|
|
path: resolvedModel.path || selection.resolved_path || '',
|
|
model: {
|
|
...resolvedModel,
|
|
path: resolvedModel.path || selection.resolved_path || '',
|
|
relative_path: resolvedRelativePath,
|
|
filename: resolvedModel.filename || resolvedRelativePath
|
|
}
|
|
}]
|
|
};
|
|
},
|
|
|
|
getOptimisticAnalysisDataAfterApply(appliedSelections = []) {
|
|
const missingModels = Array.isArray(this.missingModels) ? this.missingModels : [];
|
|
const appliedKeys = new Set(
|
|
(Array.isArray(appliedSelections) ? appliedSelections : [])
|
|
.map(selection => this.getResolutionQueueKey(selection))
|
|
);
|
|
if (!appliedKeys.size) return null;
|
|
|
|
const sourceData = this.cachedAnalysisData || {
|
|
missing_models: missingModels,
|
|
total_missing: missingModels.length
|
|
};
|
|
const sourceMissing = Array.isArray(sourceData.missing_models)
|
|
? sourceData.missing_models
|
|
: missingModels;
|
|
const nextMissing = sourceMissing.filter(missing => !appliedKeys.has(this.getMissingModelKey(missing)));
|
|
const currentResolved = Array.isArray(sourceData.resolved_models) ? sourceData.resolved_models : [];
|
|
const optimisticResolved = [];
|
|
for (const selection of Array.isArray(appliedSelections) ? appliedSelections : []) {
|
|
const key = this.getResolutionQueueKey(selection);
|
|
const source = sourceMissing.find(missing => this.getMissingModelKey(missing) === key)
|
|
|| missingModels.find(missing => this.getMissingModelKey(missing) === key)
|
|
|| null;
|
|
optimisticResolved.push(this.buildOptimisticResolvedModel(selection, source));
|
|
}
|
|
const optimisticKeys = new Set(optimisticResolved.map(model => model.missing_key).filter(Boolean));
|
|
const nextResolved = [
|
|
...currentResolved.filter(model => !optimisticKeys.has(this.getMissingModelKey(model))),
|
|
...optimisticResolved
|
|
];
|
|
|
|
return {
|
|
...sourceData,
|
|
missing_models: nextMissing,
|
|
resolved_models: nextResolved,
|
|
total_missing: nextMissing.length
|
|
};
|
|
},
|
|
|
|
applyOptimisticAnalysisData(data, workflow = null) {
|
|
if (!data) return false;
|
|
|
|
this.applyResolvedSelectionAliasesToAnalysisData(data);
|
|
const workflowSignature = this.getMissingWorkflowSignature(workflow || this.getCurrentWorkflow());
|
|
if (workflowSignature) {
|
|
this.cachedWorkflowSignature = workflowSignature;
|
|
}
|
|
this.cachedAnalysisData = this.cloneAnalysisData?.(data) || data;
|
|
this.saveAnalysisCacheForActiveWorkflow?.();
|
|
|
|
if (this.activeTab === 'missing' && this.contentElement) {
|
|
this.displayMissingModels(
|
|
this.contentElement,
|
|
data,
|
|
{ preserveBrowser: true }
|
|
);
|
|
this.reconnectActiveDownloads?.();
|
|
} else {
|
|
this.missingModels = Array.isArray(data.missing_models) ? data.missing_models : [];
|
|
this.updateBatchFooterButtons?.();
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
async refreshAnalysisInBackground(workflow, expectedSignature = null) {
|
|
if (!workflow) return;
|
|
|
|
const signature = expectedSignature || this.getMissingWorkflowSignature(workflow);
|
|
try {
|
|
const data = await this.fetchJson('/model_resolver/analyze', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ workflow }),
|
|
silent: true
|
|
}, 'Background analysis');
|
|
|
|
if (signature && this.activeMissingWorkflowSignature !== signature) return;
|
|
this.applyResolvedSelectionAliasesToAnalysisData(data);
|
|
this.cachedWorkflowSignature = signature || this.cachedWorkflowSignature;
|
|
this.cachedAnalysisData = data;
|
|
this.saveAnalysisCacheForActiveWorkflow?.();
|
|
|
|
if (this.activeTab === 'missing' && this.contentElement && !this._analysisProgressToken) {
|
|
this.displayMissingModels(
|
|
this.contentElement,
|
|
data,
|
|
{ preserveBrowser: true }
|
|
);
|
|
this.reconnectActiveDownloads?.();
|
|
}
|
|
} catch (error) {
|
|
console.warn('Model Resolver: background analysis refresh failed:', error);
|
|
}
|
|
},
|
|
|
|
previewFooterMenuModels(models = []) {
|
|
const keys = new Set(
|
|
(Array.isArray(models) ? models : [])
|
|
.filter(Boolean)
|
|
.map(missing => this.getMissingModelKey(missing))
|
|
);
|
|
|
|
this.contentElement?.querySelectorAll?.('.mr-missing-list-row')?.forEach(row => {
|
|
const key = row.getAttribute('data-missing-key');
|
|
row.classList.toggle('is-menu-preview', Boolean(key && keys.has(key)));
|
|
});
|
|
},
|
|
|
|
clearFooterMenuPreview() {
|
|
this.contentElement?.querySelectorAll?.('.mr-missing-list-row.is-menu-preview')?.forEach(row => {
|
|
row.classList.remove('is-menu-preview');
|
|
});
|
|
},
|
|
|
|
createFooterMenu(name, label, items = [], buttonClass = 'mr-btn-secondary') {
|
|
const button = $el(`button.mr-btn.${buttonClass}.mr-footer-btn.mr-footer-menu-button`, {
|
|
type: 'button',
|
|
'aria-haspopup': 'menu',
|
|
'aria-expanded': 'false',
|
|
onclick: (event) => {
|
|
event.stopPropagation();
|
|
this.hideTooltip?.();
|
|
if (button.classList.contains('mr-btn-is-disabled')) {
|
|
if (name === 'download') {
|
|
this.showNotification('No missing models have downloadable sources yet.', 'info');
|
|
} else {
|
|
this.showNotification('No missing models available.', 'info');
|
|
}
|
|
return;
|
|
}
|
|
this.toggleFooterMenu(name);
|
|
}
|
|
}, [$el("span", { textContent: label })]);
|
|
|
|
const menuChildren = items.map(item => {
|
|
const isDivider = item === 'divider' || item?.type === 'divider';
|
|
let element;
|
|
if (isDivider) {
|
|
element = $el("div.mr-footer-menu-divider", {});
|
|
} else if (item?.type === 'checkbox') {
|
|
const input = $el("input.mr-footer-menu-checkbox-input", {
|
|
type: 'checkbox',
|
|
checked: Boolean(item.checked?.())
|
|
});
|
|
element = $el("label.mr-footer-menu-item.mr-footer-menu-checkbox", {
|
|
role: 'menuitemcheckbox',
|
|
'aria-checked': input.checked ? 'true' : 'false',
|
|
onclick: (event) => {
|
|
event.stopPropagation();
|
|
}
|
|
}, [
|
|
input,
|
|
$el("span", { textContent: item.label })
|
|
]);
|
|
input.addEventListener('change', () => {
|
|
item.action?.(input.checked);
|
|
element.setAttribute('aria-checked', input.checked ? 'true' : 'false');
|
|
});
|
|
} else {
|
|
const attributes = {
|
|
type: 'button',
|
|
role: 'menuitem',
|
|
onclick: (event) => {
|
|
event.stopPropagation();
|
|
if (element.disabled || element.classList.contains('mr-btn-is-disabled')) return;
|
|
item.action();
|
|
}
|
|
};
|
|
if (item.id) attributes.id = item.id;
|
|
if (item.ariaLabel) attributes['aria-label'] = item.ariaLabel;
|
|
element = $el("button.mr-footer-menu-item", attributes, [$el("span", { textContent: item.label })]);
|
|
}
|
|
|
|
if (item?.className) {
|
|
element.classList.add(...String(item.className).split(/\s+/).filter(Boolean));
|
|
}
|
|
if (item?.refName) {
|
|
this[item.refName] = element;
|
|
}
|
|
if (!isDivider && typeof item?.previewModels === 'function') {
|
|
const showPreview = () => {
|
|
if (element.disabled || element.classList.contains('mr-btn-is-disabled')) {
|
|
this.clearFooterMenuPreview();
|
|
return;
|
|
}
|
|
try {
|
|
this.previewFooterMenuModels(item.previewModels());
|
|
} catch (error) {
|
|
console.warn('Model Resolver: footer menu preview failed:', error);
|
|
this.clearFooterMenuPreview();
|
|
}
|
|
};
|
|
element.addEventListener('mouseenter', showPreview);
|
|
element.addEventListener('focus', showPreview);
|
|
element.addEventListener('mouseleave', () => this.clearFooterMenuPreview());
|
|
element.addEventListener('blur', () => this.clearFooterMenuPreview());
|
|
}
|
|
if (typeof item?.visibleWhen === 'function' || typeof item?.disabledWhen === 'function') {
|
|
if (!this.footerMenuItems) this.footerMenuItems = new Map();
|
|
if (!this.footerMenuItems.has(name)) this.footerMenuItems.set(name, []);
|
|
this.footerMenuItems.get(name).push({
|
|
element,
|
|
visibleWhen: item.visibleWhen,
|
|
disabledWhen: item.disabledWhen
|
|
});
|
|
if (typeof item.visibleWhen === 'function') {
|
|
element.style.display = item.visibleWhen() ? '' : 'none';
|
|
}
|
|
if (typeof item.disabledWhen === 'function') {
|
|
const disabled = item.disabledWhen();
|
|
element.disabled = disabled;
|
|
element.setAttribute('aria-disabled', String(disabled));
|
|
element.classList.toggle('mr-btn-is-disabled', disabled);
|
|
}
|
|
}
|
|
return element;
|
|
});
|
|
|
|
const menu = $el("div.mr-footer-menu", { role: 'menu' }, menuChildren);
|
|
|
|
this.footerMenuButtons.set(name, button);
|
|
this.footerMenus.set(name, menu);
|
|
|
|
return $el("div.mr-footer-menu-wrap", {}, [button, menu]);
|
|
},
|
|
|
|
updateFooterMenuItemVisibility() {
|
|
if (!this.footerMenuItems) return;
|
|
for (const entries of this.footerMenuItems.values()) {
|
|
for (const entry of entries) {
|
|
if (typeof entry.visibleWhen === 'function') {
|
|
entry.element.style.display = entry.visibleWhen() ? '' : 'none';
|
|
}
|
|
if (typeof entry.disabledWhen === 'function') {
|
|
const disabled = entry.disabledWhen();
|
|
entry.element.disabled = disabled;
|
|
entry.element.setAttribute('aria-disabled', String(disabled));
|
|
entry.element.classList.toggle('mr-btn-is-disabled', disabled);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
updateFooterStarLayout() {
|
|
if (!this.footerElement || !this.footerStarLink || !this.footerActions) return;
|
|
|
|
this.footerElement.classList.toggle('mr-footer-is-missing', this.activeTab === 'missing');
|
|
this.footerStarLink.classList.remove('mr-footer-star-hidden');
|
|
this.footerVersionElement?.classList.remove('mr-footer-version-hidden');
|
|
|
|
const footerStyle = getComputedStyle(this.footerElement);
|
|
const footerWidth = (this.footerElement.clientWidth || 0)
|
|
- (parseFloat(footerStyle.paddingLeft) || 0)
|
|
- (parseFloat(footerStyle.paddingRight) || 0);
|
|
if (footerWidth <= 0) return;
|
|
|
|
const actionsVisible = getComputedStyle(this.footerActions).display !== 'none';
|
|
const actionItems = actionsVisible
|
|
? Array.from(this.footerActions.children || []).filter((element) => {
|
|
const style = getComputedStyle(element);
|
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
})
|
|
: [];
|
|
|
|
const gap = parseFloat(footerStyle.columnGap || footerStyle.gap || '0') || 0;
|
|
const actionsStyle = getComputedStyle(this.footerActions);
|
|
const actionGap = parseFloat(actionsStyle.columnGap || actionsStyle.gap || '0') || 0;
|
|
const actionsWidth = actionItems.reduce((total, element) => {
|
|
return total + element.getBoundingClientRect().width;
|
|
}, 0) + (Math.max(0, actionItems.length - 1) * actionGap);
|
|
const starWidth = this.footerStarLink.scrollWidth;
|
|
const versionWidth = this.footerVersionElement?.scrollWidth || 0;
|
|
const getRequiredWidth = ({ includeStar = true, includeVersion = true } = {}) => {
|
|
const itemWidths = [
|
|
includeStar ? starWidth : 0,
|
|
actionsWidth,
|
|
includeVersion ? versionWidth : 0
|
|
];
|
|
const visibleWidths = itemWidths.filter((width) => width > 0);
|
|
return visibleWidths.reduce((total, width) => total + width, 0)
|
|
+ (Math.max(0, visibleWidths.length - 1) * gap);
|
|
};
|
|
|
|
const shouldHideStar = getRequiredWidth() > footerWidth;
|
|
const shouldHideVersion = versionWidth > 0
|
|
&& getRequiredWidth({ includeStar: !shouldHideStar }) > footerWidth;
|
|
this.footerVersionElement?.classList.toggle('mr-footer-version-hidden', shouldHideVersion);
|
|
this.footerStarLink.classList.toggle('mr-footer-star-hidden', shouldHideStar);
|
|
},
|
|
|
|
bindFooterStarLayoutObserver() {
|
|
if (this._footerStarResizeObserver || !this.footerElement || typeof ResizeObserver === 'undefined') return;
|
|
this._footerStarResizeObserver = new ResizeObserver(() => this.updateFooterStarLayout());
|
|
this._footerStarResizeObserver.observe(this.footerElement);
|
|
this._footerStarResizeObserver.observe(this.footerActions);
|
|
if (this.footerVersionElement) this._footerStarResizeObserver.observe(this.footerVersionElement);
|
|
},
|
|
|
|
async loadFooterVersion() {
|
|
if (!this.footerVersionCurrent || !this.footerVersionLatest) return;
|
|
|
|
const now = Date.now();
|
|
const refreshInterval = this._footerVersionCheckFailed
|
|
? FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS
|
|
: FOOTER_VERSION_REFRESH_INTERVAL_MS;
|
|
if (
|
|
this._footerVersionLastCheckedAt
|
|
&& now - this._footerVersionLastCheckedAt < refreshInterval
|
|
) {
|
|
if (this._footerVersionCheckFailed) {
|
|
this.scheduleFooterVersionRetry(
|
|
refreshInterval - (now - this._footerVersionLastCheckedAt)
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
this._footerVersionLastCheckedAt = now;
|
|
|
|
try {
|
|
const data = await this.fetchJson(
|
|
'/model_resolver/version',
|
|
{ silent: true },
|
|
'Get Model Resolver version'
|
|
);
|
|
const formatVersion = (value) => {
|
|
const version = String(value || '').trim().replace(/^v/i, '');
|
|
return version ? `v${version}` : '';
|
|
};
|
|
const currentVersion = formatVersion(data?.current_version);
|
|
const latestVersion = formatVersion(data?.latest_version);
|
|
const versionCheckFailed = !latestVersion || data?.status === 'unavailable';
|
|
this._footerVersionCheckFailed = versionCheckFailed;
|
|
if (!versionCheckFailed && this._footerVersionRetryTimer) {
|
|
clearTimeout(this._footerVersionRetryTimer);
|
|
this._footerVersionRetryTimer = null;
|
|
}
|
|
|
|
this.footerVersionCurrent.textContent = currentVersion || 'v—';
|
|
this.footerVersionLatest.textContent = latestVersion
|
|
? `(latest: ${latestVersion})`
|
|
: '(latest: unavailable)';
|
|
const isUpToDate = data?.status === 'current';
|
|
const isOutdated = data?.status === 'update_available';
|
|
this.footerVersionElement?.classList.toggle('mr-footer-version-up-to-date', isUpToDate);
|
|
this.footerVersionElement?.classList.toggle('mr-footer-version-outdated', isOutdated);
|
|
this.footerVersionLatest.classList.toggle(
|
|
'mr-footer-version-update',
|
|
isOutdated
|
|
);
|
|
this.footerVersionLatest.title = data?.status === 'update_available'
|
|
? 'A newer Model Resolver version is available on GitHub.'
|
|
: 'Model Resolver version status';
|
|
this.footerVersionElement?.setAttribute(
|
|
'aria-label',
|
|
latestVersion
|
|
? `Model Resolver ${currentVersion}; latest GitHub version ${latestVersion}`
|
|
: `Model Resolver ${currentVersion}; latest GitHub version unavailable`
|
|
);
|
|
this.updateFooterStarLayout();
|
|
if (versionCheckFailed) this.scheduleFooterVersionRetry();
|
|
} catch (_) {
|
|
this._footerVersionCheckFailed = true;
|
|
this.footerVersionLatest.textContent = '(latest: unavailable)';
|
|
this.footerVersionElement?.classList.remove(
|
|
'mr-footer-version-up-to-date',
|
|
'mr-footer-version-outdated'
|
|
);
|
|
this.footerVersionLatest.classList.remove('mr-footer-version-update');
|
|
this.updateFooterStarLayout();
|
|
this.scheduleFooterVersionRetry();
|
|
}
|
|
},
|
|
|
|
scheduleFooterVersionRetry(delayMs = FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS) {
|
|
if (this._footerVersionRetryTimer) return;
|
|
|
|
const delay = Math.max(0, Number(delayMs) || FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS);
|
|
this._footerVersionRetryTimer = setTimeout(() => {
|
|
this._footerVersionRetryTimer = null;
|
|
if (this.element?.style?.display === 'none') return;
|
|
void this.loadFooterVersion();
|
|
}, delay);
|
|
},
|
|
|
|
createFooter() {
|
|
this.footerMenus = new Map();
|
|
this.footerMenuButtons = new Map();
|
|
this.footerMenuItems = new Map();
|
|
this.linkMenuButton = null;
|
|
this.linkMenuWrap = null;
|
|
this.queueExactButton = null;
|
|
this.applyPendingBtn = null;
|
|
this.clearSelectedButton = null;
|
|
this.autoResolveButton = null;
|
|
this.downloadMenuButton = null;
|
|
this.downloadAllButton = null;
|
|
|
|
const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [
|
|
{ label: 'Select All', previewModels: () => this.missingModels || [], action: () => this.selectBatchMissingModels('all') },
|
|
{ label: 'Select None', previewModels: () => this.getSelectedMissingModels(), action: () => this.selectBatchMissingModels('none') },
|
|
{
|
|
label: 'Invert Selection',
|
|
previewModels: () => {
|
|
const selectedKeys = this.batchSelectedMissingKeys || new Set();
|
|
return (this.missingModels || []).filter(missing => !selectedKeys.has(this.getMissingModelKey(missing)));
|
|
},
|
|
action: () => this.selectBatchMissingModels('invert')
|
|
},
|
|
'divider',
|
|
{ label: 'Select Exact Local', previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.selectBatchMissingModels('exact') },
|
|
{ label: 'Select No Exact Local', previewModels: () => this.getMissingWithoutExactLocalMatches(), action: () => this.selectBatchMissingModels('no_exact') },
|
|
{ label: 'Select Partial Local', previewModels: () => this.getMissingWithPartialLocalMatches(), action: () => this.selectBatchMissingModels('partial') },
|
|
{ label: 'Select No Local Match', previewModels: () => this.getMissingWithoutLocalMatches(), action: () => this.selectBatchMissingModels('no_local') },
|
|
'divider',
|
|
{ label: 'Select With Download Source', previewModels: () => this.getMissingWithDownloadSources(), action: () => this.selectBatchMissingModels('downloadable') },
|
|
{ label: 'Select Without Download Source', previewModels: () => this.getMissingWithoutDownloadSources(), action: () => this.selectBatchMissingModels('no_download') },
|
|
'divider',
|
|
{ label: 'Select Searched', previewModels: () => this.getSearchedMissingModels(), action: () => this.selectBatchMissingModels('searched') },
|
|
{ label: 'Select Unsearched', previewModels: () => this.getUnsearchedMissingModels(), action: () => this.selectBatchMissingModels('unsearched') }
|
|
]);
|
|
this.selectMenuButton = this.footerMenuButtons.get('select');
|
|
this.selectMenuWrap = selectMenu;
|
|
|
|
const hasSelectedMissingModels = () => this.getSelectedMissingModels().length > 0;
|
|
const searchBatchOptions = () => ({ forceSearch: Boolean(this.forceBatchSearch) });
|
|
const searchMenu = this.createFooterMenu('search', 'Search', [
|
|
{ label: 'Stop Searching', className: 'mr-footer-menu-danger', visibleWhen: () => this.batchSearchRunning, action: () => this.stopBatchSearch() },
|
|
{ type: 'divider', visibleWhen: () => this.batchSearchRunning },
|
|
{
|
|
type: 'checkbox',
|
|
label: 'Force search (ignore cache)',
|
|
visibleWhen: () => !this.batchSearchRunning,
|
|
checked: () => Boolean(this.forceBatchSearch),
|
|
action: (checked) => {
|
|
this.forceBatchSearch = Boolean(checked);
|
|
}
|
|
},
|
|
{ type: 'divider', visibleWhen: () => !this.batchSearchRunning },
|
|
{ label: 'Search Selected', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'all', searchBatchOptions()) },
|
|
{ label: 'Search All Missing', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.missingModels || [], action: () => this.searchMissingBatch('all', 'all', searchBatchOptions()) },
|
|
{ label: 'Search Unsearched', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.getUnsearchedMissingModels(), action: () => this.searchMissingBatch('unsearched', 'all', searchBatchOptions()) },
|
|
{ type: 'divider', visibleWhen: () => !this.batchSearchRunning },
|
|
{ label: 'Selected: Local Database', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'local', searchBatchOptions()) },
|
|
{ label: 'Selected: CivitAI', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civitai', searchBatchOptions()) },
|
|
{ label: 'Selected: HuggingFace', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'huggingface', searchBatchOptions()) },
|
|
{ label: 'Selected: CivArchive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civarchive', searchBatchOptions()) },
|
|
{ label: 'Selected: LoRA Manager Archive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'lora_manager_archive', searchBatchOptions()) }
|
|
]);
|
|
this.searchMenuButton = this.footerMenuButtons.get('search');
|
|
this.searchMenuWrap = searchMenu;
|
|
|
|
const hasExactLocalMatches = () => this.getMissingWithExactLocalMatches(this.missingModels || []).length > 0;
|
|
const linkMenu = this.createFooterMenu('link', 'Link', [
|
|
{
|
|
label: 'Queue Exact',
|
|
refName: 'queueExactButton',
|
|
ariaLabel: 'Queue exact local matches',
|
|
previewModels: () => this.getQueueExactLocalMatchPreviewModels(),
|
|
action: () => this.queueExactLocalMatchesBatch('selected')
|
|
},
|
|
{
|
|
label: 'Apply Selected (0)',
|
|
id: 'apply-pending-resolutions',
|
|
refName: 'applyPendingBtn',
|
|
ariaLabel: 'Apply selected model links',
|
|
disabledWhen: () => (this.pendingResolutions?.length || 0) === 0,
|
|
previewModels: () => this.getMissingModelsForPendingResolutions(),
|
|
action: () => {
|
|
if ((this.pendingResolutions?.length || 0) === 0) return;
|
|
this.applyPendingResolutions();
|
|
}
|
|
},
|
|
{
|
|
label: 'Clear All Selected',
|
|
refName: 'clearSelectedButton',
|
|
ariaLabel: 'Clear all queued selected model links',
|
|
disabledWhen: () => (this.pendingResolutions?.length || 0) === 0,
|
|
previewModels: () => this.getMissingModelsForPendingResolutions(),
|
|
action: () => {
|
|
this.clearAllQueued();
|
|
this.closeFooterMenus();
|
|
}
|
|
},
|
|
{ type: 'divider', visibleWhen: hasExactLocalMatches },
|
|
{
|
|
label: 'Auto-Link 100%',
|
|
refName: 'autoResolveButton',
|
|
ariaLabel: 'Auto-link all 100 percent local matches',
|
|
visibleWhen: hasExactLocalMatches,
|
|
previewModels: () => this.getMissingWithExactLocalMatches(),
|
|
action: () => this.autoResolve100Percent()
|
|
}
|
|
]);
|
|
this.linkMenuButton = this.footerMenuButtons.get('link');
|
|
this.linkMenuWrap = linkMenu;
|
|
this.linkMenuButton.setAttribute('aria-label', 'Local link actions');
|
|
this.setTooltip(this.linkMenuButton, 'Queue exact local matches, apply queued links, or auto-link 100% matches.');
|
|
this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.');
|
|
this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.');
|
|
this.setTooltip(this.clearSelectedButton, 'Clear all queued model links without changing the workflow.');
|
|
this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.');
|
|
this.updateApplyPendingButton();
|
|
|
|
const downloadMenu = this.createFooterMenu('download', 'Download', [
|
|
{ label: 'Download Selected', disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getDownloadableBatchPreviewModels('selected'), action: () => this.downloadMissingBatch('selected') },
|
|
{ label: 'Download All With Sources', previewModels: () => this.getDownloadableBatchPreviewModels('all'), action: () => this.downloadMissingBatch('all') },
|
|
'divider',
|
|
{ label: 'Cancel Downloads', previewModels: () => this.getActiveDownloadMissingModels(), action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } }
|
|
], 'mr-btn-download');
|
|
this.downloadMenuButton = this.footerMenuButtons.get('download');
|
|
this.downloadMenuWrap = downloadMenu;
|
|
this.downloadAllButton = this.downloadMenuButton;
|
|
this.downloadAllButton.setAttribute('aria-label', 'Download missing models');
|
|
this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.');
|
|
|
|
const starLink = $el("a.mr-footer-star-link", {
|
|
href: "https://github.com/Azornes/Comfyui-Model-Resolver",
|
|
target: "_blank",
|
|
rel: "noopener noreferrer",
|
|
ariaLabel: "Star the Model Resolver repository on GitHub"
|
|
}, [
|
|
$el("span.mr-footer-star-icon", {
|
|
innerHTML: getSvgIcon('star', 'currentColor', 'mr-footer-star-svg')
|
|
}),
|
|
$el("span.mr-footer-star-text", {
|
|
innerHTML: 'If this tool helps you, please consider <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');
|
|
}
|
|
}
|
|
};
|