Adds a downloads panel inside the resolve queue UI with two tabs: - Active: live downloads - History: completed or already-downloaded entries History is persisted in localStorage (model_resolver_download_history, capped at 200 entries) via new helper methods on queueMethods. Downloads complete by calling rememberCompletedDownloadHistory(), which records filename, category, workflow, node, target path, size, status and timestamp. CSS additions cover the subtab bar, history item layout, and a small status badge.
2235 lines
103 KiB
JavaScript
2235 lines
103 KiB
JavaScript
import { app } from "../../../../../scripts/app.js";
|
|
import { api } from "../../../../../scripts/api.js";
|
|
import { $el } from "../../../../../scripts/ui.js";
|
|
import { getSvgIcon } from "../../utils/icon_utils.js";
|
|
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);
|
|
|
|
// 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`;
|
|
}
|
|
}
|
|
} catch (e) { }
|
|
|
|
try {
|
|
const onSplitMouseDown = (e) => this.startSplitDrag(e);
|
|
this.splitterElement.addEventListener('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),
|
|
onmousedown: (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);
|
|
} 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");
|
|
|
|
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() {
|
|
if (!this.queueList || !this.queueHeader) return;
|
|
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
|
|
const downloads = this.getActiveQueuePanelDownloads();
|
|
const history = this.getDownloadHistory();
|
|
const activeTab = this.queuePanelActiveTab === 'downloads' ? 'downloads' : 'queued';
|
|
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);
|
|
},
|
|
|
|
updateQueuePanelTabs(queueCount, downloadCount, activeTab) {
|
|
const queuedTab = this.queueQueuedTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="queued"]');
|
|
const downloadsTab = this.queueDownloadsTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="downloads"]');
|
|
const setTabLabel = (tab, label) => {
|
|
const labelEl = tab?.querySelector?.('.mr-tab-label');
|
|
if (labelEl) {
|
|
labelEl.textContent = label;
|
|
} else if (tab) {
|
|
tab.textContent = label;
|
|
}
|
|
};
|
|
if (queuedTab) {
|
|
setTabLabel(queuedTab, `Queued (${queueCount})`);
|
|
queuedTab.classList.toggle('mr-tab-active', activeTab === 'queued');
|
|
queuedTab.setAttribute('aria-selected', activeTab === 'queued' ? 'true' : 'false');
|
|
}
|
|
if (downloadsTab) {
|
|
setTabLabel(downloadsTab, `Downloads (${downloadCount})`);
|
|
downloadsTab.classList.toggle('mr-tab-active', activeTab === 'downloads');
|
|
downloadsTab.setAttribute('aria-selected', activeTab === 'downloads' ? 'true' : 'false');
|
|
}
|
|
},
|
|
|
|
renderQueuedSelections(list) {
|
|
if (!list.length) {
|
|
this.queueList.innerHTML = '<div class="mr-queue-empty">No selections queued.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="mr-queue-items">';
|
|
for (let i = 0; i < list.length; i++) {
|
|
const r = list[i];
|
|
const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString();
|
|
const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node');
|
|
const orig = (r.original_path || '').toString();
|
|
const rmId = `queue-remove-${i}`;
|
|
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 id="${rmId}" class="mr-btn mr-btn-danger mr-btn-sm">Remove</button></div>`;
|
|
html += `</div>`;
|
|
}
|
|
html += '</div>';
|
|
this.queueList.innerHTML = html;
|
|
|
|
// Wire remove buttons
|
|
for (let i = 0; i < list.length; i++) {
|
|
const rmId = `queue-remove-${i}`;
|
|
const btn = this.queueList.querySelector(`#${rmId}`);
|
|
if (btn) {
|
|
btn.addEventListener('click', () => this.removeQueuedByIndex(i));
|
|
}
|
|
}
|
|
},
|
|
|
|
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 fallbackLabel = this.getWorkflowLabelFromComfyState?.()
|
|
|| activeTab?.workflowLabel
|
|
|| this.getWorkflowLabelFromRouteKey?.(routeKey)
|
|
|| 'Current workflow';
|
|
|
|
return {
|
|
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 '';
|
|
},
|
|
|
|
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') : '';
|
|
},
|
|
|
|
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,
|
|
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.getWorkflowContextSignature(context)
|
|
|| hasSpecificLabel
|
|
|| context.workflow_tab_id
|
|
|| context.workflowTabId
|
|
|| context.workflow_tab_aria_controls
|
|
|| context.workflowTabAriaControls
|
|
);
|
|
},
|
|
|
|
isDownloadWorkflowActive(context = {}) {
|
|
context = this.getDownloadWorkflowRuntimeContext(context);
|
|
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);
|
|
},
|
|
|
|
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_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';
|
|
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 {
|
|
window.location.hash = routeKey.startsWith('#') ? routeKey : `#${routeKey.replace(/^#/, '')}`;
|
|
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(`Could not find workflow tab: ${label}. It may be closed.`, 'error');
|
|
return false;
|
|
},
|
|
|
|
getActiveQueuePanelDownloadIds() {
|
|
return this.getActiveQueuePanelDownloads().map(({ downloadId }) => downloadId);
|
|
},
|
|
|
|
loadDownloadHistory() {
|
|
if (this._downloadHistoryLoaded) return this.downloadHistory || [];
|
|
this._downloadHistoryLoaded = true;
|
|
try {
|
|
const raw = localStorage.getItem(this.downloadHistoryStorageKey || 'model_resolver_download_history');
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
this.downloadHistory = Array.isArray(parsed)
|
|
? parsed.filter(item => item && typeof item === 'object')
|
|
: [];
|
|
} catch (error) {
|
|
console.warn('Model Resolver: failed to load download history', error);
|
|
this.downloadHistory = [];
|
|
}
|
|
return this.downloadHistory;
|
|
},
|
|
|
|
getDownloadHistory() {
|
|
if (!this._downloadHistoryLoaded) {
|
|
this.loadDownloadHistory();
|
|
}
|
|
return Array.isArray(this.downloadHistory) ? this.downloadHistory : [];
|
|
},
|
|
|
|
saveDownloadHistory() {
|
|
const history = this.getDownloadHistory().slice(0, this.downloadHistoryLimit || 200);
|
|
this.downloadHistory = history;
|
|
try {
|
|
localStorage.setItem(
|
|
this.downloadHistoryStorageKey || 'model_resolver_download_history',
|
|
JSON.stringify(history)
|
|
);
|
|
} catch (error) {
|
|
console.warn('Model Resolver: failed to save download history', error);
|
|
}
|
|
},
|
|
|
|
getDownloadHistoryIdentity(entry = {}) {
|
|
return [
|
|
entry.path || '',
|
|
entry.filename || '',
|
|
entry.category || '',
|
|
entry.sourceUrl || ''
|
|
].map(value => String(value || '').trim().toLowerCase()).join('::');
|
|
},
|
|
|
|
addDownloadHistoryEntry(entry = {}) {
|
|
if (!entry || !entry.filename) return null;
|
|
|
|
const history = this.getDownloadHistory();
|
|
const identity = this.getDownloadHistoryIdentity(entry);
|
|
const filtered = identity
|
|
? history.filter(item => this.getDownloadHistoryIdentity(item) !== identity)
|
|
: history;
|
|
const nextEntry = {
|
|
...entry,
|
|
id: entry.id || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
completedAt: entry.completedAt || new Date().toISOString()
|
|
};
|
|
this.downloadHistory = [nextEntry, ...filtered].slice(0, this.downloadHistoryLimit || 200);
|
|
this.saveDownloadHistory();
|
|
this.updateQueuePanel?.();
|
|
return nextEntry;
|
|
},
|
|
|
|
rememberCompletedDownloadHistory(downloadId, info = {}, progress = {}) {
|
|
const missing = info?.missing || {};
|
|
const filename = progress.filename
|
|
|| info.filename
|
|
|| missing.download_source?.filename
|
|
|| missing.original_path?.split(/[\/\\]/).pop()
|
|
|| '';
|
|
if (!filename) return null;
|
|
|
|
const directory = progress.directory || info.downloadDirectory || '';
|
|
const path = progress.path || info.downloadPath || '';
|
|
const category = info.category || missing.category || progress.category || '';
|
|
const workflowLabel = this.getDownloadWorkflowLabel?.(info) || info.workflowLabel || '';
|
|
const nodeLabel = missing.subgraph_name || missing.node_type || (missing.subgraph_id ? 'Subgraph' : 'Node');
|
|
const status = progress.already_exists ? 'already_exists' : 'completed';
|
|
return this.addDownloadHistoryEntry({
|
|
downloadId,
|
|
filename,
|
|
category,
|
|
categoryLabel: this.getCategoryDisplayName?.(category) || category,
|
|
nodeLabel,
|
|
nodeId: missing.node_id ?? '',
|
|
widgetIndex: missing.widget_index ?? '',
|
|
workflowLabel,
|
|
workflowRouteKey: info.workflowRouteKey || '',
|
|
workflowTabId: info.workflowTabId || '',
|
|
workflowTabName: info.workflowTabName || '',
|
|
path,
|
|
directory,
|
|
sourceUrl: info.sourceUrl || missing.download_source?.url || '',
|
|
totalSize: progress.total_size || progress.size || 0,
|
|
status,
|
|
statusLabel: status === 'already_exists' ? 'Already downloaded' : 'Downloaded',
|
|
message: progress.message || '',
|
|
completedAt: new Date().toISOString()
|
|
});
|
|
},
|
|
|
|
formatDownloadHistoryTime(value = '') {
|
|
if (!value) return '';
|
|
try {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
return date.toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
},
|
|
|
|
getDownloadHistoryFolderContext(entry = {}) {
|
|
const targetPath = entry.directory || entry.path || '';
|
|
if (!targetPath) return null;
|
|
return {
|
|
context_scope: 'download_folder',
|
|
open_folder_label: 'Open Download Folder',
|
|
name: entry.filename || 'Download',
|
|
path: targetPath,
|
|
resolved_path: targetPath,
|
|
folder_path: entry.directory || targetPath,
|
|
download_directory: entry.directory || '',
|
|
download_path: entry.path || '',
|
|
category: entry.category || ''
|
|
};
|
|
},
|
|
|
|
renderDownloadsPanel(downloads = [], history = this.getDownloadHistory()) {
|
|
const activeSubTab = this.queueDownloadsActiveTab === 'history' ? 'history' : 'active';
|
|
const activeSelected = activeSubTab === 'active';
|
|
let html = '<div class="mr-downloads-panel">';
|
|
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>';
|
|
|
|
this.queueList.innerHTML = html;
|
|
this.wireDownloadsPanelControls();
|
|
},
|
|
|
|
wireDownloadsPanelControls() {
|
|
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 => {
|
|
button.addEventListener('click', () => {
|
|
const downloadId = button.dataset.downloadId;
|
|
if (downloadId) this.cancelDownload(downloadId);
|
|
});
|
|
});
|
|
|
|
const clearHistoryButton = this.queueList.querySelector('.mr-download-history-clear');
|
|
if (clearHistoryButton) {
|
|
clearHistoryButton.addEventListener('click', () => this.clearDownloadHistory());
|
|
}
|
|
},
|
|
|
|
renderQueueDownloads(downloads) {
|
|
this.queueList.innerHTML = this.renderQueueDownloadsHtml(downloads);
|
|
|
|
this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const downloadId = button.dataset.downloadId;
|
|
if (downloadId) this.cancelDownload(downloadId);
|
|
});
|
|
});
|
|
},
|
|
|
|
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 = info.lastProgress || {};
|
|
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
|
|
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(progress.downloaded || 0);
|
|
const total = progress.total_size ? this.formatBytes(progress.total_size) : '';
|
|
const progressMeta = this.formatDownloadProgressMeta?.(progress) || '';
|
|
const status = progress.status || info.lastStatus || 'starting';
|
|
const statusLabel = status === 'downloading'
|
|
? `${Math.round(percent)}%`
|
|
: (status === 'starting' ? 'Starting' : status.replace(/_/g, ' '));
|
|
const sizeText = 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
|
|
? ` data-model="${this.escapeHtml(encodeURIComponent(JSON.stringify(contextModel)))}" oncontextmenu="window.MLOpenContextMenu(event, this)" data-tooltip="${this.escapeHtml(contextTooltip)}"`
|
|
: '';
|
|
|
|
html += `<div class="mr-queue-item mr-download-queue-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-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 (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"><button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-download-queue-cancel" data-download-id="${this.escapeHtml(downloadId)}">Cancel</button></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 (const entry of history) {
|
|
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 contextData = contextModel
|
|
? ` data-model="${this.escapeHtml(encodeURIComponent(JSON.stringify(contextModel)))}" oncontextmenu="window.MLOpenContextMenu(event, this)" data-tooltip="Right-click to open download folder"`
|
|
: '';
|
|
|
|
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>`;
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
},
|
|
|
|
clearDownloadHistory() {
|
|
this.downloadHistory = [];
|
|
this.saveDownloadHistory();
|
|
this.updateQueuePanel();
|
|
},
|
|
|
|
// 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
|
|
const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
|
|
this.updateSelectedBarForMissing?.(m);
|
|
this.updateApplyPendingButton?.();
|
|
this.updateQueuePanel();
|
|
},
|
|
|
|
// Clear all queued selections
|
|
clearAllQueued() {
|
|
this.pendingResolutions = [];
|
|
this.pendingIndex = new Map();
|
|
this.savePendingQueueForActiveWorkflow();
|
|
this.updateApplyPendingButton?.();
|
|
this.updateQueuePanel();
|
|
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 nodeId = missing.node_id;
|
|
const widgetIndex = missing.widget_index;
|
|
const subgraphId = missing.subgraph_id || '';
|
|
const isTopLevel = missing.is_top_level !== false;
|
|
const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`;
|
|
|
|
const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`);
|
|
if (!selectedBar) return;
|
|
|
|
// Find selection for this slot
|
|
let selection = null;
|
|
let selectionIdx = -1;
|
|
if (this.pendingIndex.has(key)) {
|
|
const idx = this.pendingIndex.get(key);
|
|
if (idx >= 0 && idx < this.pendingResolutions.length) {
|
|
selection = this.pendingResolutions[idx];
|
|
selectionIdx = idx;
|
|
}
|
|
}
|
|
|
|
if (!selection) {
|
|
selectedBar.style.display = 'none';
|
|
selectedBar.innerHTML = '';
|
|
selectedBar.removeAttribute('data-model');
|
|
selectedBar.removeAttribute('data-tooltip');
|
|
selectedBar.oncontextmenu = 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
|
|
? ` data-model="${this.escapeHtml(encodeURIComponent(JSON.stringify(selectedContext)))}" oncontextmenu="window.MLOpenContextMenu(event, this)" data-tooltip="Right-click to open containing folder"`
|
|
: '';
|
|
if (selectedContext) {
|
|
selectedBar.dataset.model = encodeURIComponent(JSON.stringify(selectedContext));
|
|
selectedBar.dataset.tooltip = 'Right-click to open containing folder';
|
|
selectedBar.oncontextmenu = (event) => {
|
|
window.MLOpenContextMenu?.(event, selectedBar);
|
|
};
|
|
} else {
|
|
selectedBar.removeAttribute('data-model');
|
|
selectedBar.removeAttribute('data-tooltip');
|
|
selectedBar.oncontextmenu = null;
|
|
}
|
|
const applyBtnId = `selected-apply-${nodeId}-${widgetIndex}`;
|
|
const removeBtnId = `selected-remove-${nodeId}-${widgetIndex}`;
|
|
|
|
selectedBar.innerHTML = `<div class="mr-selected-bar-inner"${selectedContextAttrs}>`;
|
|
selectedBar.innerHTML += `<span class="mr-selected-label">✓ Selected:</span>`;
|
|
selectedBar.innerHTML += `<code class="mr-selected-code">${this.escapeHtml(label)}</code>`;
|
|
selectedBar.innerHTML += `<span class="mr-selected-actions">`;
|
|
selectedBar.innerHTML += `<button id="${applyBtnId}" class="mr-btn mr-btn-primary mr-btn-sm" data-tooltip="Apply this selected local match to the workflow.">Apply</button>`;
|
|
selectedBar.innerHTML += `<button id="${removeBtnId}" class="mr-btn mr-btn-danger mr-btn-sm">Remove</button>`;
|
|
selectedBar.innerHTML += `</span>`;
|
|
selectedBar.innerHTML += `</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 = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
|
|
|
|
// Remove from array
|
|
this.pendingResolutions.splice(idx, 1);
|
|
this.rebuildPendingIndex();
|
|
this.savePendingQueueForActiveWorkflow();
|
|
this.updateSelectedBarForMissing(m);
|
|
this.updateApplyPendingButton?.();
|
|
this.updateQueuePanel();
|
|
}
|
|
},
|
|
|
|
// 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);
|
|
}
|
|
},
|
|
|
|
// Collapse/expand queue panel
|
|
toggleQueueCollapsed() {
|
|
this.setQueueCollapsed(!this.queueCollapsed);
|
|
},
|
|
|
|
setQueueCollapsed(collapsed) {
|
|
this.queueCollapsed = !!collapsed;
|
|
if (this.queueCollapsed) {
|
|
try { localStorage.setItem('model_resolver_queue_collapsed', '1'); } catch (e) { }
|
|
} else {
|
|
try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (e) { }
|
|
}
|
|
this.updateQueueVisibility();
|
|
this.updateQueuePanel();
|
|
},
|
|
|
|
getResolutionQueueKey(resolution = {}) {
|
|
const nodeId = resolution.node_id ?? '';
|
|
const widgetIndex = resolution.widget_index ?? '';
|
|
const subgraphId = resolution.subgraph_id || '';
|
|
const scope = resolution.is_top_level !== false ? 'T' : 'F';
|
|
return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`;
|
|
},
|
|
|
|
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,
|
|
is_lora_v2: ref.is_lora_v2,
|
|
nested_key: ref.nested_key
|
|
}));
|
|
},
|
|
|
|
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,
|
|
is_lora_v2: ref.is_lora_v2 ?? baseResolution.is_lora_v2,
|
|
original_lora_name: ref.name || ref.original_path || baseResolution.original_lora_name || baseResolution.name || baseResolution.original_path,
|
|
nested_key: ref.nested_key ?? baseResolution.nested_key,
|
|
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.original_lora_name || 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 (this.splitterElement) {
|
|
this.splitterElement.style.display = showQueuePanel ? '' : 'none';
|
|
}
|
|
if (this.queueToggleIcon) {
|
|
this.queueToggleIcon.style.display = isMissingTab ? '' : 'none';
|
|
}
|
|
|
|
this.updateQueueToggleIcon();
|
|
},
|
|
|
|
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;
|
|
}
|
|
this.toggleQueueCollapsed();
|
|
},
|
|
|
|
startQueueEdgeDrag(event) {
|
|
if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return;
|
|
|
|
const startX = event.clientX;
|
|
const previousCursor = document.body.style.cursor;
|
|
event.preventDefault();
|
|
document.body.style.cursor = 'col-resize';
|
|
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: startX, preventDefault: () => {} },
|
|
{ startWidth: 0, edgeOpen: true, previousCursor, startCollapsedPreview: true }
|
|
);
|
|
},
|
|
|
|
// Begin split drag for resizable panels
|
|
startSplitDrag(e, { startWidth = null, edgeOpen = false, previousCursor = null, startCollapsedPreview = false } = {}) {
|
|
try {
|
|
e?.preventDefault?.();
|
|
if (!this.queueElement) return;
|
|
const rect = this.queueElement.getBoundingClientRect();
|
|
const body = document.getElementById('model-resolver-body');
|
|
const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth };
|
|
this._splitDragging = true;
|
|
body?.classList.add('mr-is-resizing-queue');
|
|
this._splitStart = {
|
|
x: e.clientX,
|
|
startWidth: Number.isFinite(startWidth) ? startWidth : rect.width,
|
|
containerWidth: bodyRect.width
|
|
};
|
|
this._lastSplitDragApply = 0;
|
|
this._splitPreviewCollapsed = !!startCollapsedPreview;
|
|
this._splitEdgeOpen = !!edgeOpen;
|
|
this._splitEdgeOpenedPastThreshold = false;
|
|
this._splitPreviousCursor = previousCursor;
|
|
this._prevUserSelect = document.body.style.userSelect;
|
|
document.body.style.userSelect = 'none';
|
|
this._onSplitMove = (ev) => this.onSplitDrag(ev);
|
|
this._onSplitUp = () => this.endSplitDrag();
|
|
document.addEventListener('mousemove', this._onSplitMove);
|
|
document.addEventListener('mouseup', this._onSplitUp, { once: true });
|
|
} catch (err) { /* ignore */ }
|
|
},
|
|
|
|
onSplitDrag(e) {
|
|
if (!this._splitDragging || !this._splitStart || !this.queueElement) return;
|
|
const dx = e.clientX - this._splitStart.x;
|
|
let newW = this._splitStart.startWidth - dx;
|
|
const minW = this._splitEdgeOpen ? 0 : 56;
|
|
const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360));
|
|
if (newW < minW) newW = minW;
|
|
if (newW > maxW) newW = maxW;
|
|
this._pendingSplitWidth = Math.round(newW);
|
|
if (this._splitEdgeOpen && this._pendingSplitWidth > 140) {
|
|
this._splitEdgeOpenedPastThreshold = true;
|
|
if (this.queueCollapsed) {
|
|
this.queueCollapsed = false;
|
|
try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (error) { }
|
|
this.updateQueueToggleIcon();
|
|
this.updateQueuePanel();
|
|
}
|
|
}
|
|
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' : '';
|
|
}
|
|
const now = performance.now();
|
|
if (this._lastSplitDragApply && now - this._lastSplitDragApply < 33) return;
|
|
this._lastSplitDragApply = now;
|
|
if (this._splitDragFrame) return;
|
|
this._splitDragFrame = requestAnimationFrame(() => {
|
|
this._splitDragFrame = null;
|
|
if (!this._splitDragging || !this.queueElement || !this._pendingSplitWidth || this._splitPreviewCollapsed) return;
|
|
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
|
|
});
|
|
},
|
|
|
|
endSplitDrag() {
|
|
if (!this._splitDragging) return;
|
|
this._splitDragging = false;
|
|
if (this._splitDragFrame) {
|
|
cancelAnimationFrame(this._splitDragFrame);
|
|
this._splitDragFrame = null;
|
|
}
|
|
if (this.queueElement && this._pendingSplitWidth && !this._splitPreviewCollapsed) {
|
|
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
|
|
}
|
|
const body = document.getElementById('model-resolver-body');
|
|
body?.classList.remove('mr-is-resizing-queue');
|
|
body?.classList.remove('mr-queue-preview-collapsed');
|
|
document.removeEventListener('mousemove', this._onSplitMove);
|
|
if (this._splitEdgeOpen && !this._pendingSplitWidth) {
|
|
try {
|
|
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
|
|
this.queueElement.style.width = `${restoreWidth}px`;
|
|
this.queueElement.style.display = '';
|
|
if (this.splitterElement) this.splitterElement.style.display = '';
|
|
localStorage.setItem('model_resolver_split_w', String(restoreWidth));
|
|
} catch (e) { }
|
|
this.queueCollapsed = false;
|
|
try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (e) { }
|
|
this.updateQueueVisibility();
|
|
this.updateQueuePanel();
|
|
this._splitEdgeOpen = false;
|
|
this._pendingSplitWidth = null;
|
|
this._lastSplitDragApply = 0;
|
|
this._splitPreviewCollapsed = false;
|
|
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
|
|
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
|
|
this._splitPreviousCursor = null;
|
|
this._splitEdgeOpenedPastThreshold = false;
|
|
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
|
|
return;
|
|
}
|
|
const shouldCollapse = !!this._splitPreviewCollapsed;
|
|
if (shouldCollapse) {
|
|
try {
|
|
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
|
|
this.queueElement.style.width = `${restoreWidth}px`;
|
|
this.queueElement.style.display = '';
|
|
localStorage.setItem('model_resolver_split_w', String(restoreWidth));
|
|
} catch (e) { }
|
|
this._pendingSplitWidth = null;
|
|
this._lastSplitDragApply = 0;
|
|
this._splitPreviewCollapsed = false;
|
|
this._splitEdgeOpen = false;
|
|
this._splitEdgeOpenedPastThreshold = false;
|
|
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
|
|
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
|
|
this._splitPreviousCursor = null;
|
|
this.setQueueCollapsed(true);
|
|
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
|
|
return;
|
|
}
|
|
if (this.queueElement) this.queueElement.style.display = '';
|
|
try {
|
|
const rect = this.queueElement.getBoundingClientRect();
|
|
localStorage.setItem('model_resolver_split_w', String(Math.round(rect.width)));
|
|
} catch (e) { }
|
|
this._pendingSplitWidth = null;
|
|
this._lastSplitDragApply = 0;
|
|
this._splitPreviewCollapsed = false;
|
|
this._splitEdgeOpen = false;
|
|
this._splitEdgeOpenedPastThreshold = false;
|
|
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
|
|
if (this._splitPreviousCursor !== null) {
|
|
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
|
|
this._splitPreviousCursor = null;
|
|
}
|
|
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
|
|
},
|
|
|
|
/**
|
|
* Queue a resolution for later batch apply
|
|
*/
|
|
queueResolution(missing, resolvedModel) {
|
|
if (!resolvedModel) {
|
|
this.showNotification('No model selected', 'error');
|
|
return;
|
|
}
|
|
|
|
const resolution = {
|
|
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,
|
|
subgraph_id: missing.subgraph_id,
|
|
is_top_level: missing.is_top_level,
|
|
is_lora_v2: missing.is_lora_v2,
|
|
original_lora_name: missing.name || missing.original_path,
|
|
nested_key: missing.nested_key,
|
|
node_refs: this.getResolutionNodeRefs(missing),
|
|
node_type: missing.node_type,
|
|
node_label: missing.subgraph_name || missing.node_type
|
|
};
|
|
|
|
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();
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
const workflow = this.getCurrentWorkflow();
|
|
if (!workflow) {
|
|
this.showNotification('No workflow loaded', 'error');
|
|
return;
|
|
}
|
|
|
|
const response = await api.fetchApi('/model_resolver/resolve', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workflow, resolutions: applyResolutions })
|
|
});
|
|
|
|
if (!response.ok) throw new Error(`API error: ${response.status}`);
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
const optimisticData = this.getOptimisticAnalysisDataAfterApply(appliedSelections);
|
|
await this.updateWorkflowInComfyUI(data.workflow);
|
|
|
|
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.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.refreshAnalysisInBackground(data.workflow, this.getWorkflowSignature(data.workflow));
|
|
} else {
|
|
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('Model Resolver: applyPendingResolutions error', e);
|
|
this.showNotification('Error applying selections: ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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)));
|
|
},
|
|
|
|
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)));
|
|
|
|
return {
|
|
...sourceData,
|
|
missing_models: nextMissing,
|
|
total_missing: nextMissing.length
|
|
};
|
|
},
|
|
|
|
applyOptimisticAnalysisData(data, workflow = null) {
|
|
if (!data) return false;
|
|
|
|
const workflowSignature = this.getWorkflowSignature(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);
|
|
this.reconnectActiveDownloads?.();
|
|
} else {
|
|
this.missingModels = Array.isArray(data.missing_models) ? data.missing_models : [];
|
|
this.updateBatchFooterButtons?.();
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
refreshAnalysisInBackground(workflow, expectedSignature = null) {
|
|
if (!workflow) return;
|
|
|
|
const signature = expectedSignature || this.getWorkflowSignature(workflow);
|
|
api.fetchApi('/model_resolver/analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workflow })
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) throw new Error(`API error: ${response.status}`);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (signature && this.activeWorkflowSignature !== signature) return;
|
|
this.cachedWorkflowSignature = signature || this.cachedWorkflowSignature;
|
|
this.cachedAnalysisData = data;
|
|
this.saveAnalysisCacheForActiveWorkflow?.();
|
|
|
|
if (this.activeTab === 'missing' && this.contentElement && !this._analysisProgressToken) {
|
|
this.displayMissingModels(this.contentElement, data);
|
|
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');
|
|
|
|
if (this.activeTab !== 'missing') return;
|
|
|
|
const footerStyle = getComputedStyle(this.footerElement);
|
|
const footerWidth = (this.footerElement.clientWidth || 0)
|
|
- (parseFloat(footerStyle.paddingLeft) || 0)
|
|
- (parseFloat(footerStyle.paddingRight) || 0);
|
|
if (footerWidth <= 0) return;
|
|
|
|
const actionItems = Array.from(this.footerActions.children || []).filter((element) => {
|
|
const style = getComputedStyle(element);
|
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
});
|
|
if (!actionItems.length) return;
|
|
|
|
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 requiredWidth = this.footerStarLink.scrollWidth + actionsWidth + gap;
|
|
this.footerStarLink.classList.toggle('mr-footer-star-hidden', requiredWidth > footerWidth);
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
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.'
|
|
})
|
|
]);
|
|
|
|
this.footerStarLink = starLink;
|
|
this.footerActions = $el("div.mr-footer-actions", {}, [
|
|
selectMenu,
|
|
searchMenu,
|
|
linkMenu,
|
|
downloadMenu
|
|
]);
|
|
this.footerElement = $el("div.mr-footer", {}, [
|
|
starLink,
|
|
this.footerActions
|
|
]);
|
|
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);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handle click on Download All / Cancel All button
|
|
*/
|
|
handleDownloadAllClick() {
|
|
if (this.getActiveQueuePanelDownloadIds().length > 0) {
|
|
// Cancel all active downloads
|
|
this.cancelAllDownloads();
|
|
} else {
|
|
// Start downloading all missing
|
|
this.downloadAllMissing();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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) {
|
|
try {
|
|
await api.fetchApi(`/model_resolver/cancel/${downloadId}`, {
|
|
method: 'POST'
|
|
});
|
|
} catch (error) {
|
|
console.error('Model Resolver: Error cancelling download:', error);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
}
|
|
};
|