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'; }, getMissingNodeDisplay(missing = {}) { const isSubgraphNode = missing.node_type && missing.node_type.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 (missing.subgraph_name) { nodeLabel = missing.subgraph_name; } else if (isSubgraphNode) { nodeLabel = 'Subgraph'; } else { nodeLabel = missing.node_type || 'Node'; } const nodeId = missing.node_id ?? ''; const customNodeTitle = String(missing.node_title || '').trim(); const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel; const text = hasCustomNodeTitle ? `${nodeLabel} #${nodeId} · ${customNodeTitle}` : `${nodeLabel} #${nodeId}`; return { label: nodeLabel, text, canLocate: missing.is_top_level !== false && nodeId !== '' }; }, 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); } 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, 430), 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 = `
${totalMissing} missing model${totalMissing === 1 ? '' : 's'}
${this.escapeHtml(activeHint)}
${stats.exact} exact ${stats.partial} partial ${stats.none} no match
# 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 formattedFilename = this.formatFilename(filename, 46); 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 nodeId = missing.node_id ?? ''; const rowNodeHtml = nodeDisplay.canLocate ? `` : `${this.escapeHtml(nodeDisplay.text)}`; html += `
${index + 1} ${this.escapeHtml(formattedFilename.display)} ${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); }); }); 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}`); if (locateBtn && missing.is_top_level !== false) { locateBtn.addEventListener('click', () => { this.locateNodeInGraph(missing.node_id); }); } 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}`); if (comboList) { this.enableWheelScrollChaining(comboList); } const allModels = 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 || ''; const populateComboOptions = (filterText, highlightIdx = -1) => { if (!comboList) return; 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); } } }); }); }; if (comboList) { populateComboOptions(''); } if (comboInput) { const debouncedFilter = this.debounce(() => { populateComboOptions(comboInput.value); }, 200); comboInput.addEventListener('input', debouncedFilter); comboInput.addEventListener('focus', () => { if (comboList) { comboList.classList.remove('mr-is-hidden'); comboList.classList.add('mr-is-visible'); } populateComboOptions(comboInput.value); }); comboInput.addEventListener('blur', () => { setTimeout(() => { if (comboList) { comboList.classList.remove('mr-is-visible'); comboList.classList.add('mr-is-hidden'); } }, 200); }); } if (comboRefresh) { comboRefresh.addEventListener('click', async () => { this.allModels = null; await this.ensureAllModelsLoaded(); populateComboOptions(comboInput?.value || ''); }); } 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); } }, /** * Display missing models in the dialog */ displayMissingModels(container, data) { const missingModels = data.missing_models || []; 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') { container.innerHTML = ''; 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; try { if (button) { button.disabled = true; button.classList.add('mr-btn-is-disabled'); } this.showNotification('Refreshing missing models and local matches...', 'info'); this.allModels = null; this.invalidateLoadedModelsCacheForActiveWorkflow?.(); 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) { button.disabled = false; button.classList.remove('mr-btn-is-disabled'); } } }, 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); // Format the missing filename for display const missingFilename = this.formatFilename(missing.original_path, 60); // 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 += `
`; html += `
`; const titleMetaParts = []; let titlePrimaryHtml = `${missingFilename.display}`; let titleSecondaryHtml = ''; const modelId = missing.urn_model_id || missing.urn?.model_id; const versionId = missing.urn_version_id || missing.urn?.version_id; const modelUrl = missing.is_urn && modelId ? `https://civitai.com/models/${modelId}${versionId ? '?modelVersionId=' + versionId : ''}` : ''; const urnLoadingId = `urn-loading-${missing.node_id}-${missing.widget_index}`; if (missing.is_urn) { titleMetaParts.push(`${missingFilename.display}`); } if (missing.is_urn && !missing.civitai_info) { // URN without info - show Loading and fetch async in background titlePrimaryHtml = `Resolving CivitAI model...`; setTimeout(() => this.resolveUrnAsync(modelId, versionId, urnLoadingId, modelUrl), 10); } else if (missing.is_urn && missing.civitai_info) { // URN with resolved info - show model name/version const civitaiInfo = missing.civitai_info; const civitaiLabelHtml = this.renderVersionedModelNameHtml(civitaiInfo.model_name, civitaiInfo.version_name); if (civitaiLabelHtml) { const linkHtml = modelUrl ? `${civitaiLabelHtml}` : `${civitaiLabelHtml}`; titlePrimaryHtml = `${linkHtml}`; } if (civitaiInfo.expected_filename) { titleSecondaryHtml = `Expected file: ${civitaiInfo.expected_filename}`; } } html += `
`; html += titleMetaParts.join(''); html += `

${titlePrimaryHtml}

`; if (titleSecondaryHtml) { html += titleSecondaryHtml; } html += `
`; const locateId = `locate-${missing.node_id}-${missing.widget_index}`; const nodeChipClasses = missing.is_top_level !== false ? 'mr-node-chip is-locatable' : 'mr-node-chip'; const nodeChipTitle = missing.is_top_level !== false ? 'Center this node in the ComfyUI graph.' : ''; html += `
`; if (missing.category) { html += `${this.getCategoryDisplayName(missing.category)}`; } html += ``; if (missing.is_top_level !== false) { html += this.getLocateIconHtml(); } html += `${nodeChipText}`; html += `
`; html += `
`; 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 += ``; html += ``; html += ``; 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') { // Build children array, filtering out nulls const children = []; 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", { className: `mr-notification mr-notification--${type}` }, [ ...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); } };