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 missingBrowserMethods = {
getMissingFilename(missing = {}) {
return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model';
},
getMissingLocateTarget(missing = {}) {
const hasLocateNode = missing.locate_node_id !== undefined
&& missing.locate_node_id !== null
&& missing.locate_node_id !== '';
const nodeId = hasLocateNode ? missing.locate_node_id : missing.node_id;
return {
nodeId: nodeId ?? '',
nodeType: hasLocateNode ? (missing.locate_node_type || missing.node_type || '') : (missing.node_type || ''),
nodeTitle: hasLocateNode ? (missing.locate_node_title || '') : (missing.node_title || ''),
subgraphId: hasLocateNode ? (missing.locate_subgraph_id || '') : (missing.subgraph_id || ''),
subgraphName: hasLocateNode ? (missing.locate_subgraph_name || missing.subgraph_name || '') : (missing.subgraph_name || ''),
isTopLevel: hasLocateNode ? missing.locate_is_top_level !== false : missing.is_top_level !== false
};
},
getMissingNodeDisplay(missing = {}) {
const locateTarget = this.getMissingLocateTarget(missing);
const isSubgraphNode = locateTarget.nodeType && locateTarget.nodeType.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
let nodeLabel;
if (locateTarget.subgraphName) {
nodeLabel = locateTarget.subgraphName;
} else if (isSubgraphNode) {
nodeLabel = 'Subgraph';
} else {
nodeLabel = locateTarget.nodeType || 'Node';
}
const nodeId = locateTarget.nodeId ?? '';
const promotedDetail = missing.locate_via_promoted_widget
? (missing.node_title || missing.node_type || '')
: (missing.promoted_inner_node_title || missing.promoted_inner_node_type || '');
const customNodeTitle = String(promotedDetail || locateTarget.nodeTitle || missing.node_title || '').trim();
const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel;
const text = hasCustomNodeTitle
? `${nodeLabel} #${nodeId} · ${customNodeTitle}`
: `${nodeLabel} #${nodeId}`;
return {
label: nodeLabel,
text,
canLocate: nodeId !== '',
locateTarget,
locateTooltip: missing.locate_via_promoted_widget
? 'Center this subgraph node in the ComfyUI graph.'
: locateTarget.isTopLevel === false
? 'Open this subgraph and center the node in the ComfyUI graph.'
: 'Center this node in the ComfyUI graph.'
};
},
getBestLocalMatch(missing = {}, minConfidence = 0) {
const matches = Array.isArray(missing.matches) ? missing.matches : [];
return matches
.filter(match => Number(match.confidence) >= minConfidence)
.sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null;
},
getMissingModelSummaryStats(missingModels = []) {
return missingModels.reduce((stats, missing) => {
const best = this.getBestLocalMatch(missing, 70);
if (best?.confidence === 100) {
stats.exact += 1;
} else if (best) {
stats.partial += 1;
} else {
stats.none += 1;
}
return stats;
}, { exact: 0, partial: 0, none: 0 });
},
getMissingSourceStatus(missing = {}, source = '') {
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const progress = state?.sourceProgress?.[source];
const resultStatus = this.getMissingSourceResultStatus(missing, source, state);
if (progress?.status === 'found') return resultStatus || 'found';
if (progress?.status) return progress.status;
if (resultStatus) return resultStatus;
return 'idle';
},
getMissingSourceResultStatus(missing = {}, source = '', state = null) {
const results = state?.results || {};
const candidates = [];
if (source === 'local') {
candidates.push(results.model_list, results.popular);
const bestLocalMatch = this.getBestLocalMatch(missing, 70);
if (bestLocalMatch) {
candidates.push({
source: 'local_match',
confidence: Number(bestLocalMatch.confidence || 0),
match_type: Number(bestLocalMatch.confidence || 0) >= 100 ? 'exact' : 'similar'
});
}
} else if (source === 'huggingface') {
candidates.push(results.huggingface);
} else if (source === 'civitai') {
candidates.push(results.civitai);
} else if (source === 'civarchive') {
candidates.push(results.civarchive);
} else if (source === 'lora_manager_archive') {
candidates.push(results.lora_manager_archive);
}
const downloadSource = missing.download_source || {};
const mappedDownloadSource = {
model_list: 'local',
popular: 'local',
workflow: 'civitai'
}[downloadSource.source] || downloadSource.source;
if (mappedDownloadSource === source && downloadSource.url) {
candidates.push(downloadSource);
}
return this.getSearchResultStatusLevel(candidates);
},
getSearchResultStatusLevel(resultOrResults) {
const results = Array.isArray(resultOrResults)
? resultOrResults.flatMap(result => Array.isArray(result) ? result : [result])
: [resultOrResults];
let hasPartial = false;
let hasUnknownFound = false;
for (const result of results) {
if (!result) continue;
const confidence = Number(result.confidence);
const hasConfidence = Number.isFinite(confidence);
const matchType = String(result.match_type || '').toLowerCase();
if (hasConfidence) {
if (confidence >= 100) return 'exact';
if (confidence > 0) {
hasPartial = true;
continue;
}
}
if (matchType === 'exact' || result.source === 'popular') {
return 'exact';
}
if (matchType === 'partial' || matchType === 'fuzzy' || matchType === 'similar') {
hasPartial = true;
continue;
}
if (result.url || result.download_url) {
hasUnknownFound = true;
}
}
if (hasPartial) return 'partial';
if (hasUnknownFound) return 'found';
return '';
},
renderMissingSourcesSummary(missing = {}) {
const sourceItems = this.getEnabledSearchSources().map(source => ({ source }));
return sourceItems.map(item => {
const status = this.getMissingSourceStatus(missing, item.source);
const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(item.source);
const statusLabels = {
pending: 'Queued',
running: 'Searching',
exact: 'Exact match',
partial: 'Partial match',
found: 'Found',
none: 'No match',
error: 'Error',
idle: 'Not searched'
};
const title = `${label}: ${statusLabels[status] || status}`;
const iconName = this.getSearchSourceIconName(item.source);
const iconHtml = getSvgIcon(iconName, 'currentColor', 'mr-missing-source-icon');
return `${iconHtml} `;
}).join('');
},
hasRenderableSearchState(state = {}) {
return Boolean(
(state.lastAttemptSources || []).length
|| Object.keys(state.sourceProgress || {}).length
|| this.hasSearchResults(state.results || {})
|| state.lastAttemptError
);
},
getMissingModelsListLayout(missingModels = []) {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const textWidth = (value, charPx) => Math.ceil(String(value || '').length * charPx);
let modelPx = 180;
let typePx = 66;
for (const missing of missingModels) {
const filename = this.getMissingFilename(missing);
const nodeLabel = this.getMissingNodeDisplay(missing).text;
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
modelPx = Math.max(
modelPx,
textWidth(filename, 7.2) + 12,
textWidth(nodeLabel, 5.5) + 34
);
typePx = Math.max(typePx, textWidth(typeLabel, 5.8) + 18);
}
return {
modelPx: clamp(modelPx, 180, 820),
typePx: clamp(typePx, 66, 120)
};
},
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) {
const stats = this.getMissingModelSummaryStats(missingModels);
const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey);
const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null;
const activeHint = activeCount > 0
? `${activeCount} downloading`
: (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online');
const listLayout = this.getMissingModelsListLayout(missingModels);
let savedDetailWidth = null;
try {
savedDetailWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10);
} catch (e) {}
const splitStyle = Number.isFinite(savedDetailWidth) && savedDetailWidth > 0
? `--mr-missing-detail-track:${savedDetailWidth}px;`
: '';
const listStyle = `--mr-missing-model-col:${listLayout.modelPx}px;--mr-missing-type-col:${listLayout.typePx}px;`;
let html = `
#
Missing Model
Type
Best Local Match
Match
Sources
`;
missingModels.forEach((missing, index) => {
const key = this.getMissingModelKey(missing);
const isSelected = key === selectedKey;
const isBatchSelected = this.batchSelectedMissingKeys?.has(key);
const filename = this.getMissingFilename(missing);
const bestMatch = this.getBestLocalMatch(missing, 70);
const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0;
const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || '';
const matchDisplay = matchName || 'No local match';
const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none');
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel);
const nodeDisplay = this.getMissingNodeDisplay(missing);
const locateTarget = nodeDisplay.locateTarget || this.getMissingLocateTarget(missing);
const nodeId = locateTarget.nodeId ?? '';
const rowNodeHtml = nodeDisplay.canLocate
? `
${this.getLocateIconHtml()}${this.escapeHtml(nodeDisplay.text)} `
: `
${this.escapeHtml(nodeDisplay.text)} `;
html += `
${index + 1}
${this.escapeHtml(filename)}
${rowNodeHtml}
${this.escapeHtml(typeLabel)}
${bestMatch ? this.escapeHtml(matchDisplay) : '-- No local match '}
${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}
${this.renderMissingSourcesSummary(missing)}
`;
});
html += `
${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')}
`;
return html;
},
wireMissingModelsBrowser(container, data, sortedMissingModels) {
this.wireMissingBrowserSplitter(container);
const refreshBtn = container.querySelector('#mr-refresh-missing-analysis');
if (refreshBtn && refreshBtn.dataset.mlRefreshBound !== 'true') {
refreshBtn.dataset.mlRefreshBound = 'true';
refreshBtn.addEventListener('click', () => this.refreshMissingAnalysis(refreshBtn));
}
const selectRow = (row) => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
this.selectedMissingModelKey = key;
this.displayMissingModels(container, data);
};
const selectAllCheckbox = container.querySelector('.mr-missing-select-all-check');
if (selectAllCheckbox) {
this.updateBatchSelectAllCheckbox();
selectAllCheckbox.addEventListener('click', (event) => {
event.stopPropagation();
});
selectAllCheckbox.addEventListener('change', () => {
const shouldSelectAll = selectAllCheckbox.checked;
this.batchSelectedMissingKeys = shouldSelectAll
? new Set((sortedMissingModels || []).map(missing => this.getMissingModelKey(missing)))
: new Set();
this.lastBatchSelectedMissingKey = null;
this.refreshBatchSelectionUi();
this.updateBatchFooterButtons();
});
}
container.querySelectorAll('.mr-missing-list-row').forEach(row => {
const checkbox = row.querySelector('.mr-missing-row-check');
if (checkbox) {
checkbox.addEventListener('click', (event) => {
event.stopPropagation();
checkbox.dataset.shiftClick = event.shiftKey ? '1' : '0';
});
checkbox.addEventListener('change', (event) => {
const key = row.dataset.missingKey;
if (!key) return;
const selected = checkbox.checked;
const isShiftRange = event.shiftKey || checkbox.dataset.shiftClick === '1';
if (isShiftRange && this.lastBatchSelectedMissingKey) {
this.applyBatchSelectionRange(
sortedMissingModels,
this.lastBatchSelectedMissingKey,
key,
selected
);
} else {
this.setBatchSelectionForKey(key, selected);
}
this.lastBatchSelectedMissingKey = key;
this.refreshBatchSelectionUi();
this.updateBatchFooterButtons();
});
}
row.addEventListener('click', (event) => {
const clickedLocate = event.target instanceof Element && event.target.closest('.mr-missing-row-locate');
if (clickedLocate) return;
selectRow(row);
});
row.addEventListener('keydown', (event) => {
if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
selectRow(row);
});
});
container.querySelectorAll('.mr-missing-row-locate').forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const rawNodeId = button.dataset.nodeId;
const numericNodeId = Number(rawNodeId);
this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId, {
subgraphId: button.dataset.subgraphId || '',
isTopLevel: button.dataset.isTopLevel !== 'false'
});
});
});
const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey);
if (!selectedMissing) return;
const selectedIndex = sortedMissingModels.indexOf(selectedMissing);
this.wireMissingModelDetail(container, selectedMissing, selectedIndex);
},
wireMissingBrowserSplitter(container) {
const browser = container.querySelector('.mr-missing-browser');
const splitter = browser?.querySelector('.mr-missing-browser-splitter');
if (!(browser instanceof HTMLElement) || !(splitter instanceof HTMLElement)) return;
this.restoreMissingBrowserSplitWidth(browser);
splitter.addEventListener('mousedown', (event) => {
if (event.button !== 0) return;
this.startMissingBrowserSplitDrag(event, browser);
});
splitter.addEventListener('dblclick', () => {
browser.querySelector('.mr-missing-detail-pane')?.style.removeProperty('--mr-missing-detail-track');
try {
localStorage.removeItem(this.missingBrowserSplitStorageKey);
} catch (e) {}
});
splitter.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32);
});
this._missingBrowserResizeObserver?.disconnect?.();
if (typeof ResizeObserver === 'function') {
this._missingBrowserResizeObserver = new ResizeObserver(() => {
if (!this._missingBrowserSplitDragging) {
this.restoreMissingBrowserSplitWidth(browser);
}
});
this._missingBrowserResizeObserver.observe(browser);
}
},
restoreMissingBrowserSplitWidth(browser) {
try {
const savedWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10);
if (Number.isFinite(savedWidth) && savedWidth > 0) {
this.setMissingBrowserDetailWidth(browser, savedWidth, { persist: false });
}
} catch (e) {}
},
startMissingBrowserSplitDrag(event, browser) {
if (!(browser instanceof HTMLElement)) return;
const detailPane = browser.querySelector('.mr-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return;
event.preventDefault();
this._missingBrowserSplitDragging = true;
this._missingBrowserSplitStart = {
x: event.clientX,
width: detailPane.getBoundingClientRect().width,
bounds: this.getMissingBrowserSplitBounds(browser)
};
this._lastMissingBrowserSplitApply = 0;
this._missingBrowserSplitBrowser = browser;
this._missingBrowserSplitDetailPane = detailPane;
this._missingBrowserPrevUserSelect = document.body.style.userSelect;
this._missingBrowserPrevCursor = document.body.style.cursor;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
browser.classList.add('is-resizing');
this._onMissingBrowserSplitMove = (moveEvent) => this.onMissingBrowserSplitDrag(moveEvent);
this._onMissingBrowserSplitUp = () => this.endMissingBrowserSplitDrag();
document.addEventListener('mousemove', this._onMissingBrowserSplitMove);
document.addEventListener('mouseup', this._onMissingBrowserSplitUp, { once: true });
},
onMissingBrowserSplitDrag(event) {
if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitStart || !this._missingBrowserSplitBrowser) return;
const nextWidth = this._missingBrowserSplitStart.width - (event.clientX - this._missingBrowserSplitStart.x);
const bounds = this._missingBrowserSplitStart.bounds;
this._pendingMissingBrowserSplitWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, nextWidth)));
const now = performance.now();
if (this._lastMissingBrowserSplitApply && now - this._lastMissingBrowserSplitApply < 33) return;
this._lastMissingBrowserSplitApply = now;
if (this._missingBrowserSplitFrame) return;
this._missingBrowserSplitFrame = requestAnimationFrame(() => {
this._missingBrowserSplitFrame = null;
if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitDetailPane || !this._pendingMissingBrowserSplitWidth) return;
this._missingBrowserSplitDetailPane.style.setProperty('--mr-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`);
});
},
endMissingBrowserSplitDrag() {
if (!this._missingBrowserSplitDragging) return;
this._missingBrowserSplitDragging = false;
document.removeEventListener('mousemove', this._onMissingBrowserSplitMove);
if (this._missingBrowserSplitFrame) {
cancelAnimationFrame(this._missingBrowserSplitFrame);
this._missingBrowserSplitFrame = null;
}
if (this._missingBrowserSplitDetailPane && this._pendingMissingBrowserSplitWidth) {
this._missingBrowserSplitDetailPane.style.setProperty('--mr-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`);
try {
localStorage.setItem(this.missingBrowserSplitStorageKey, String(this._pendingMissingBrowserSplitWidth));
} catch (e) {}
}
this._missingBrowserSplitBrowser?.classList.remove('is-resizing');
this._missingBrowserSplitBrowser = null;
this._missingBrowserSplitDetailPane = null;
this._missingBrowserSplitStart = null;
this._pendingMissingBrowserSplitWidth = null;
this._lastMissingBrowserSplitApply = 0;
try {
document.body.style.userSelect = this._missingBrowserPrevUserSelect || '';
document.body.style.cursor = this._missingBrowserPrevCursor || '';
} catch (e) {}
},
resizeMissingBrowserDetailBy(browser, delta) {
const detailPane = browser.querySelector('.mr-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return;
this.setMissingBrowserDetailWidth(browser, detailPane.getBoundingClientRect().width + delta);
},
setMissingBrowserDetailWidth(browser, width, { persist = true } = {}) {
const bounds = this.getMissingBrowserSplitBounds(browser);
const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width)));
const detailPane = browser.querySelector('.mr-missing-detail-pane');
const target = detailPane instanceof HTMLElement ? detailPane : browser;
target.style.setProperty('--mr-missing-detail-track', `${nextWidth}px`);
if (persist) {
try {
localStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth));
} catch (e) {}
}
},
getMissingBrowserSplitBounds(browser) {
const browserRect = browser.getBoundingClientRect();
const splitter = browser.querySelector('.mr-missing-browser-splitter');
const splitterWidth = splitter instanceof HTMLElement
? splitter.getBoundingClientRect().width
: 10;
const available = Math.max(1, browserRect.width - splitterWidth);
const minListWidth = Math.min(360, Math.max(240, Math.floor(available * 0.28)));
const max = Math.max(1, available - minListWidth);
const min = Math.min(max, Math.min(420, Math.max(300, Math.floor(available * 0.3))));
return { min, max };
},
wireMissingModelDetail(container, missing, missingIndex) {
this.wireLocalMatchButtons(container, missing, missingIndex);
this.wireDownloadSearchPanel(container, missing);
this.updateSelectedBarForMissing(missing);
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
const locateBtn = container.querySelector(`#${locateId}`);
const locateTarget = this.getMissingLocateTarget(missing);
if (locateBtn && locateTarget.nodeId !== undefined && locateTarget.nodeId !== null && locateTarget.nodeId !== '') {
locateBtn.addEventListener('click', () => {
this.locateNodeInGraph(locateTarget.nodeId, {
subgraphId: locateTarget.subgraphId || '',
isTopLevel: locateTarget.isTopLevel
});
});
}
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
const comboInput = container.querySelector(`#combo-input-${comboId}`);
const comboList = container.querySelector(`#combo-list-${comboId}`);
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
const comboSection = comboInput?.closest('.mr-combo-section')
|| comboList?.closest('.mr-combo-section')
|| comboRefresh?.closest('.mr-combo-section');
if (comboList) {
this.enableWheelScrollChaining(comboList);
}
const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : [];
const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`;
const getFolder = (m) => m.path || m.base_directory || '';
let comboListPointerActive = false;
let outsideComboPointerHandler = null;
const isInsideCombo = (target) => (
Boolean(target) && (
Boolean(comboSection?.contains(target))
|| Boolean(comboInput?.contains(target))
|| Boolean(comboList?.contains(target))
|| Boolean(comboRefresh?.contains(target))
)
);
const stopOutsideComboPointerListener = () => {
if (!outsideComboPointerHandler) return;
window.removeEventListener('pointerdown', outsideComboPointerHandler, true);
outsideComboPointerHandler = null;
};
const startOutsideComboPointerListener = () => {
if (outsideComboPointerHandler) return;
outsideComboPointerHandler = (event) => {
if (!comboList?.isConnected) {
stopOutsideComboPointerListener();
return;
}
if (isInsideCombo(event.target)) {
return;
}
hideComboList();
};
window.addEventListener('pointerdown', outsideComboPointerHandler, true);
};
const showComboList = () => {
if (!comboList) return;
comboList.classList.remove('mr-is-hidden');
comboList.classList.add('mr-is-visible');
startOutsideComboPointerListener();
};
const hideComboList = () => {
if (!comboList) return;
comboList.classList.remove('mr-is-visible');
comboList.classList.add('mr-is-hidden');
stopOutsideComboPointerListener();
};
const populateComboOptions = (filterText, highlightIdx = -1) => {
if (!comboList) return;
const allModels = getAllModels();
const f = (filterText || '').toLowerCase();
const filtered = f
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
: allModels.slice();
let html = '';
for (let i = 0; i < filtered.length; i++) {
const m = filtered[i];
const label = buildLabel(m);
const folder = getFolder(m);
const isHighlighted = i === highlightIdx;
const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
html += ``;
html += `
`;
html += `${this.escapeHtml(label)}`;
html += `
`;
if (folderDisplay) {
html += `
Folder ${this.escapeHtml(folderDisplay)}
`;
}
html += `
`;
}
comboList.innerHTML = html;
comboList.querySelectorAll('div[data-idx]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) {
const chosenModel = allModels[idx];
if (chosenModel) {
this.queueResolution(missing, chosenModel);
hideComboList();
}
}
});
});
};
if (comboList) {
comboList.addEventListener('pointerdown', () => {
comboListPointerActive = true;
const releaseComboPointer = () => {
setTimeout(() => {
comboListPointerActive = false;
}, 0);
};
window.addEventListener('pointerup', releaseComboPointer, { once: true, capture: true });
window.addEventListener('pointercancel', releaseComboPointer, { once: true, capture: true });
}, { capture: true });
populateComboOptions('');
}
if (comboInput) {
const debouncedFilter = this.debounce(() => {
populateComboOptions(comboInput.value);
}, 200);
comboInput.addEventListener('input', debouncedFilter);
comboInput.addEventListener('focus', () => {
showComboList();
populateComboOptions(comboInput.value);
});
comboInput.addEventListener('blur', () => {
setTimeout(() => {
if (!comboList) return;
if (comboListPointerActive || comboList.matches(':hover')) {
return;
}
hideComboList();
}, 200);
});
}
if (comboRefresh) {
comboRefresh.addEventListener('click', async () => {
const minRefreshFeedback = new Promise(resolve => setTimeout(resolve, 420));
const refreshAnimation = this.startRefreshButtonAnimation(comboRefresh);
try {
comboRefresh.disabled = true;
comboRefresh.classList.add('mr-btn-is-disabled', 'mr-is-refreshing');
this.allModels = null;
await this.ensureAllModelsLoaded({ force: true });
await minRefreshFeedback;
populateComboOptions(comboInput?.value || '');
} catch (error) {
await minRefreshFeedback;
console.warn('Model Resolver: could not refresh local model list', error);
this.showNotification('Failed to refresh local model list', 'error');
} finally {
refreshAnimation?.cancel();
comboRefresh.disabled = false;
comboRefresh.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing');
}
});
}
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const searchResultsDiv = container.querySelector(`#search-results-${missing.node_id}-${missing.widget_index}`);
if (state && searchResultsDiv && this.hasRenderableSearchState(state)) {
searchResultsDiv.classList.remove('mr-is-hidden');
searchResultsDiv.classList.add('mr-is-visible');
this.displaySearchResults(missing, state, searchResultsDiv);
}
this.restoreDownloadProgressForMissing?.(missing);
},
startRefreshButtonAnimation(button) {
if (!button) return null;
const target = button.querySelector('.mr-refresh-spin-target') || button.querySelector('svg');
if (!target || typeof target.animate !== 'function') return null;
const existing = target.getAnimations?.() || [];
existing.forEach(animation => animation.cancel());
target.style.transformOrigin = 'center';
return target.animate(
[
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
],
{
duration: 620,
easing: 'linear',
iterations: Infinity
}
);
},
/**
* Display missing models in the dialog
*/
displayMissingModels(container, data) {
const missingModels = (data.missing_models || []).map(missing => (
this.restoreDownloadedLocalMatchesForMissing?.(missing) || missing
));
const totalMissing = data.total_missing || 0;
this.missingModels = missingModels;
this.syncBatchSelectionForMissingModels(missingModels);
// Check if there are active downloads
const activeCount = Object.keys(this.activeDownloads).length;
const hasAny100Match = missingModels.some(missing =>
(missing.matches || []).some(match => match.confidence === 100)
);
this.setMissingFooterControlsVisible(totalMissing > 0 || activeCount > 0);
// Hide download all button if no missing models
if (this.downloadAllButton) {
this.downloadAllButton.style.display = (totalMissing > 0 || activeCount > 0) ? 'inline-flex' : 'none';
}
if (totalMissing === 0 && activeCount === 0) {
container.innerHTML = this.renderStatusMessage('All models are available! No missing models found.', 'success');
return;
}
// If no missing models but downloads are active, show a waiting message
if (totalMissing === 0 && activeCount > 0) {
container.innerHTML = this.renderStatusMessage(
`${activeCount} download${activeCount > 1 ? 's' : ''} in progress. Local matches will refresh when complete.`,
'info'
);
return;
}
// Skip rendering if active tab is not "missing"
if (this.activeTab !== 'missing') {
return;
}
if (!this.downloadDirectories) {
const renderToken = `download-dirs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._downloadDirectoriesRenderToken = renderToken;
container.innerHTML = this.renderStatusMessage('Loading download folders...', 'info');
this.ensureDownloadDirectoriesLoaded?.().then(() => {
if (
this._downloadDirectoriesRenderToken === renderToken &&
this.activeTab === 'missing' &&
container.isConnected
) {
this.displayMissingModels(container, data);
}
});
return;
}
// Sort missing models: those with 100% confidence matches first, then others
const sortedMissingModels = [...missingModels].sort((a, b) => {
const aMatches = a.matches || [];
const bMatches = b.matches || [];
// Filter to 70%+ confidence
const aFiltered = aMatches.filter(m => m.confidence >= 70);
const bFiltered = bMatches.filter(m => m.confidence >= 70);
// Check if they have 100% matches
const aHas100 = aFiltered.some(m => m.confidence === 100);
const bHas100 = bFiltered.some(m => m.confidence === 100);
// If one has 100% and the other doesn't, prioritize the one with 100%
if (aHas100 && !bHas100) return -1;
if (!aHas100 && bHas100) return 1;
// If both have 100% or neither has 100%, sort by best confidence
const aBestConf = aFiltered.length > 0 ? Math.max(...aFiltered.map(m => m.confidence)) : 0;
const bBestConf = bFiltered.length > 0 ? Math.max(...bFiltered.map(m => m.confidence)) : 0;
return bBestConf - aBestConf; // Higher confidence first
});
for (let mi = 0; mi < sortedMissingModels.length; mi++) {
sortedMissingModels[mi].__displayIndex = mi;
}
const selectedStillExists = sortedMissingModels.some(missing => (
this.getMissingModelKey(missing) === this.selectedMissingModelKey
));
if (!selectedStillExists) {
this.selectedMissingModelKey = sortedMissingModels.length
? this.getMissingModelKey(sortedMissingModels[0])
: null;
}
this.missingModels = sortedMissingModels;
container.innerHTML = this.renderMissingModelsBrowser(
sortedMissingModels,
this.selectedMissingModelKey,
totalMissing,
activeCount,
hasAny100Match
);
this.wireMissingModelsBrowser(container, data, sortedMissingModels);
this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data);
this.reconnectActiveSearchProgress(sortedMissingModels);
this.updateBatchFooterButtons();
},
async refreshMissingAnalysis(button = null) {
if (button?.disabled) return;
const minRefreshFeedback = new Promise(resolve => setTimeout(resolve, 420));
const refreshAnimation = this.startRefreshButtonAnimation(button);
try {
if (button) {
button.disabled = true;
button.classList.add('mr-btn-is-disabled', 'mr-is-refreshing');
}
this.showNotification('Refreshing missing models and local matches...', 'info');
this.allModels = null;
this.invalidateLoadedModelsCacheForActiveWorkflow?.();
await minRefreshFeedback;
await this.loadWorkflowData(null, { force: true });
} catch (error) {
console.error('Model Resolver: missing analysis refresh failed:', error);
this.showNotification('Refresh failed: ' + error.message, 'error');
} finally {
if (button) {
refreshAnimation?.cancel();
button.disabled = false;
button.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing');
}
}
},
renderMissingModel(missing, missingIndex = 0) {
const allMatches = missing.matches || [];
// Filter out matches below 70% confidence threshold
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
const hasMatches = filteredMatches.length > 0;
// Calculate 100% matches upfront (needed for download section)
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
const missingFilename = this.getMissingFilename(missing);
// Determine node info for the chip
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeChipText = this.escapeHtml(nodeDisplay.text);
// Start card
let html = ``;
// Card Header: Filename as headline + node chip
html += ``;
// Selected bar - shows if this slot has a queued selection (BELOW card header)
const selectedBarId = `selected-bar-${missing.node_id}-${missing.widget_index}`;
html += `
`;
// Two-column layout
html += `
`;
// LEFT COLUMN: Local Matches
html += `
`;
html += `
Local Matches
`;
html += `
`;
html += this.renderLocalMatchesContent(missing, missingIndex);
html += `
`;
// Add all-models search picker - combo-style dropdown
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
html += `
`;
html += `
`;
html += `Model `;
html += ` `;
html += `${getSvgIcon('refreshCw', 'currentColor', 'mr-combo-refresh-icon')} `;
html += `
`;
html += `
`;
html += `
`;
html += `
`; // End left column
// RIGHT COLUMN: Download Option
html += `
`;
html += `
Download
`;
const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
const downloadSource = missing.download_source;
const urnDownloadId = `urn-download-${missing.node_id}-${missing.widget_index}`;
if (downloadSource && downloadSource.url) {
html += this.renderKnownDownloadPanel(missing, downloadSource);
} else if (perfectMatches.length > 0) {
// Has perfect local match - download not needed, but allow online re-check.
html += `
`;
html += this.renderSearchControls(missing, {
buttonText: this.hasSearchResultsForMissing(missing) ? 'Search Again' : 'Search Online'
});
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `
`;
html += `
`;
} else if (missing.is_urn) {
html += `
`;
html += `
Resolving CivitAI download for this URN...
`;
html += `
`;
} else {
// No known download - offer search
html += `
`;
html += this.renderSearchControls(missing);
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `
`;
html += `
`;
}
// Progress container (for downloads)
html += `
`;
html += `
`; // End right column
html += `
`; // End columns
html += `
`; // End card
return html;
},
/**
* Show a notification banner (similar to ComfyUI's "Reconnecting" banner)
*/
showNotification(message, type = 'success', options = {}) {
// Build children array, filtering out nulls
const children = [];
const contextMenuModel = options?.contextMenuModel || null;
const notificationProps = {
className: `mr-notification mr-notification--${type}${contextMenuModel ? ' mr-download-folder-context' : ''}`
};
if (contextMenuModel) {
notificationProps["data-model"] = encodeURIComponent(JSON.stringify(contextMenuModel));
notificationProps.oncontextmenu = (event) => {
window.MLOpenContextMenu?.(event, event.currentTarget);
};
}
if (type === 'success') {
children.push($el("span", {
textContent: "✓",
className: "mr-notification-icon"
}));
} else if (type === 'error') {
children.push($el("span", {
textContent: "×",
className: "mr-notification-icon"
}));
} else if (type === 'info') {
children.push($el("span", {
textContent: "ℹ",
className: "mr-notification-icon"
}));
}
// Create notification banner
const notification = $el("div", notificationProps, [
...children,
$el("span", {
textContent: message
}),
$el("button", {
className: "mr-notification-close",
textContent: "×",
onclick: () => {
if (notification.parentNode) {
notification.style.opacity = "0";
notification.style.transform = "translateX(-50%) translateY(-100%)";
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}
})
]);
document.body.appendChild(notification);
// Auto-dismiss after 4 seconds for success, 6 seconds for errors
const dismissTime = type === 'success' ? 4000 : 6000;
setTimeout(() => {
if (notification.parentNode) {
notification.style.opacity = "0";
notification.style.transform = "translateX(-50%) translateY(-100%)";
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}, dismissTime);
}
};