From 18b72316be71b63c6144635566ded02b24b7925c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 26 May 2026 23:53:32 +0200 Subject: [PATCH] feat: add missing models browser UI and logic - Implement CSS styles for missing models browser interface (.ml-missing-browser and related classes) - Add JavaScript methods for handling missing models: * getMissingModelKey, getMissingFilename, getBestLocalMatch * getMissingModelSummaryStats, getMissingSourceStatus * renderMissingSourcesSummary, hasRenderableSearchState - Replace missing models rendering logic with new implementation - Support for exact/partial match statistics and source status indicators --- web/css/linker-main.css | 301 +++++++++++++++++++++++++++ web/linker.js | 438 ++++++++++++++++++++++++++++------------ 2 files changed, 612 insertions(+), 127 deletions(-) diff --git a/web/css/linker-main.css b/web/css/linker-main.css index 4f87227..1b2355d 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -222,6 +222,307 @@ color: var(--ml-text-muted); } +.ml-missing-browser { + display: grid; + grid-template-columns: minmax(360px, 1.1fr) minmax(320px, 0.9fr); + gap: 10px; + height: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.015); +} +.ml-missing-list-pane, +.ml-missing-detail-pane { + min-width: 0; + min-height: 0; + overflow: hidden; +} +.ml-missing-list-pane { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + border-right: 1px solid rgba(255,255,255,0.08); + background: rgba(20,20,20,0.92); +} +.ml-missing-list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid rgba(255,255,255,0.07); +} +.ml-missing-list-title { + font-size: 14px; + font-weight: 750; + color: var(--ml-text); +} +.ml-missing-list-meta { + margin-top: 3px; + font-size: 11px; + color: var(--ml-text-muted); +} +.ml-missing-list-stats { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; +} +.ml-missing-stat { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(255,255,255,0.055); + border: 1px solid rgba(255,255,255,0.08); + color: var(--ml-text-muted); + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} +.ml-missing-stat-exact { + color: #72d38a; +} +.ml-missing-stat-partial { + color: #f1cf64; +} +.ml-missing-list-head, +#model-linker-modal .ml-missing-list-row { + display: grid; + grid-template-columns: 18px minmax(220px, 1.7fr) minmax(66px, 112px) minmax(150px, 1fr) 106px; + align-items: center; + gap: 8px; +} +.ml-missing-list-head { + padding: 8px 10px 8px 6px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.035); + color: var(--ml-text-muted); + font-size: 10px; + font-weight: 700; +} +.ml-missing-list { + min-height: 0; + overflow-y: auto; + scrollbar-width: auto; +} +#model-linker-modal .ml-missing-list-row { + width: 100%; + min-height: 50px; + padding: 7px 10px 7px 6px; + border: 0; + border-bottom: 1px solid rgba(255,255,255,0.055); + border-radius: 0; + background: transparent; + color: var(--ml-text); + cursor: pointer; + font-family: inherit; + font-size: 11px; + text-align: left; + transition: background 0.14s ease, box-shadow 0.14s ease; +} +#model-linker-modal .ml-missing-list-row:hover { + background: rgba(255,255,255,0.04); +} +#model-linker-modal .ml-missing-list-row.is-selected { + background: rgba(114,211,138,0.09); + box-shadow: inset 3px 0 0 rgba(114,211,138,0.92); +} +.ml-missing-row-index { + display: block; + width: 100%; + color: rgba(255,255,255,0.55); + font-size: 10px; + font-weight: 700; + text-align: center; + justify-self: stretch; +} +.ml-missing-row-model, +.ml-missing-row-match { + min-width: 0; + display: grid; + gap: 2px; +} +.ml-missing-row-model { + grid-template-columns: minmax(0, 1fr); + align-items: center; +} +.ml-missing-row-name, +.ml-missing-row-node, +.ml-missing-row-match span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ml-missing-row-name { + color: var(--ml-text); + font-size: 12px; + font-weight: 700; +} +.ml-missing-row-node { + grid-column: 1; + color: var(--ml-text-muted); + font-size: 10px; +} +.ml-missing-row-type { + justify-self: start; + max-width: 100%; + min-width: 0; + padding: 2px 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 999px; + background: rgba(255,255,255,0.065); + color: #d4d4d4; + font-size: 9px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.02em; +} +.ml-missing-row-match { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + column-gap: 6px; + row-gap: 2px; + color: var(--ml-text-muted); +} +.ml-missing-row-match strong { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(255,255,255,0.065); + font-size: 10px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} +.ml-missing-row-match span { + white-space: normal; + overflow: visible; + text-overflow: clip; + overflow-wrap: anywhere; + word-break: normal; + line-height: 1.25; +} +.ml-missing-row-match-exact strong { + color: #72d38a; + background: rgba(114,211,138,0.12); +} +.ml-missing-row-match-partial strong { + color: #f1cf64; + background: rgba(241,207,100,0.13); +} +.ml-missing-row-match-none strong { + color: rgba(255,255,255,0.42); +} +.ml-missing-row-sources { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 5px; + min-width: 0; +} +.ml-missing-source-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 999px; + background: rgba(255,255,255,0.035); + border: 1px solid rgba(255,255,255,0.12); + color: rgba(255,255,255,0.42); + font-size: 9px; + font-weight: 800; +} +.ml-missing-source-icon { + display: block; + width: 12px; + height: 12px; + flex: 0 0 12px; +} +.ml-missing-source-found { + color: #72d38a; + border-color: rgba(114,211,138,0.55); + background: rgba(114,211,138,0.11); +} +.ml-missing-source-none { + color: rgba(255,255,255,0.48); +} +.ml-missing-source-running, +.ml-missing-source-pending { + color: #8fd0ff; + border-color: rgba(143,208,255,0.55); + background: rgba(143,208,255,0.1); +} +.ml-missing-source-error { + color: #ff8a80; + border-color: rgba(255,138,128,0.55); + background: rgba(255,138,128,0.1); +} +.ml-missing-detail-pane { + overflow-y: auto; + background: rgba(18,18,18,0.9); +} +.ml-missing-detail-pane .ml-card { + margin: 0; + padding: 14px; + background: transparent; + box-shadow: none; +} +.ml-missing-detail-pane .ml-card:hover { + box-shadow: none; +} +.ml-missing-detail-pane .ml-columns { + grid-template-columns: minmax(0, 1fr); +} +.ml-missing-detail-pane .ml-column { + border-radius: 8px; +} + +@media (max-width: 980px) { + .ml-missing-browser { + grid-template-columns: 1fr; + overflow-y: auto; + } + .ml-missing-list-pane { + max-height: 46vh; + border-right: 0; + border-bottom: 1px solid rgba(255,255,255,0.08); + } + .ml-missing-detail-pane { + overflow: visible; + } +} + +@media (max-width: 760px) { + .ml-missing-list-head { + display: none; + } + #model-linker-modal .ml-missing-list-row { + grid-template-columns: 18px minmax(0, 1fr); + gap: 7px; + } + .ml-missing-row-index { + grid-row: 1 / span 4; + align-self: start; + margin-top: 1px; + text-align: center; + } + .ml-missing-row-model, + .ml-missing-row-type, + .ml-missing-row-match, + .ml-missing-row-sources { + grid-column: 2; + } +} + /* Card Styles */ .ml-card { background: var(--ml-card-bg); diff --git a/web/linker.js b/web/linker.js index b6fba0f..b7fc985 100644 --- a/web/linker.js +++ b/web/linker.js @@ -28,6 +28,7 @@ class LinkerManagerDialog extends ComfyDialog { this.searchProgressTimers = new Map(); this.cachedAnalysisData = null; this.cachedWorkflowSignature = null; + this.selectedMissingModelKey = null; this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); this.activeTabStorageKey = 'model_linker_active_tab'; this.activeTab = this.restoreActiveTab(); // Default tab @@ -471,6 +472,7 @@ class LinkerManagerDialog extends ComfyDialog { huggingface: 'huggingface', civitai: 'civitai', 'lora-archive': 'archive', + lora_manager_archive: 'archive', 'lora-manager-archive': 'archive', local: 'database', 'workflow-url': 'link', @@ -3924,7 +3926,297 @@ class LinkerManagerDialog extends ComfyDialog { } } } - + + getMissingModelKey(missing = {}) { + const nodeId = missing.node_id ?? ''; + const widgetIndex = missing.widget_index ?? ''; + const subgraphId = missing.subgraph_id || ''; + const scope = missing.is_top_level !== false ? 'T' : 'F'; + return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`; + } + + getMissingFilename(missing = {}) { + return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model'; + } + + 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]; + if (progress?.status) return progress.status; + + const results = state?.results || {}; + const resultKeys = { + local: 'model_list', + huggingface: 'huggingface', + civitai: 'civitai', + lora_manager_archive: 'lora_manager_archive' + }; + if (resultKeys[source] && results[resultKeys[source]]) { + return 'found'; + } + + const downloadSource = missing.download_source || {}; + const mappedDownloadSource = { + model_list: 'local', + popular: 'local', + workflow: 'civitai' + }[downloadSource.source] || downloadSource.source; + if (mappedDownloadSource === source && downloadSource.url) { + return 'found'; + } + + return 'idle'; + } + + renderMissingSourcesSummary(missing = {}) { + const sourceItems = [ + { source: 'local' }, + { source: 'huggingface' }, + { source: 'civitai' } + ]; + if (this.isSourceAvailable('lora_manager_archive')) { + sourceItems.push({ source: 'lora_manager_archive' }); + } + + 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', + 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', 'ml-missing-source-icon'); + return `${iconHtml}`; + }).join(''); + } + + hasRenderableSearchState(state = {}) { + return Boolean( + (state.lastAttemptSources || []).length + || Object.keys(state.sourceProgress || {}).length + || this.hasSearchResults(state.results || {}) + || state.lastAttemptError + ); + } + + 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'); + + let html = ` +
+
+
+
+
${totalMissing} missing model${totalMissing === 1 ? '' : 's'}
+
${this.escapeHtml(activeHint)}
+
+
+ ${stats.exact} exact + ${stats.partial} partial + ${stats.none} no match +
+
+
+ ID + Missing Model + Type + Best Local Match + Sources +
+
+ `; + + missingModels.forEach((missing, index) => { + const key = this.getMissingModelKey(missing); + const isSelected = key === selectedKey; + 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 || 'Local match'; + const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none'); + const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; + const nodeLabel = missing.subgraph_name || missing.node_type || 'Node'; + + html += ` + + `; + }); + + html += ` +
+
+
+ ${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')} +
+
+ `; + return html; + } + + wireMissingModelsBrowser(container, data, sortedMissingModels) { + container.querySelectorAll('.ml-missing-list-row').forEach(row => { + row.addEventListener('click', () => { + const key = row.dataset.missingKey; + if (!key || key === this.selectedMissingModelKey) return; + this.selectedMissingModelKey = key; + this.displayMissingModels(container, data); + }); + }); + + const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey); + if (!selectedMissing) return; + + const selectedIndex = sortedMissingModels.indexOf(selectedMissing); + this.wireMissingModelDetail(container, selectedMissing, selectedIndex); + } + + wireMissingModelDetail(container, missing, missingIndex) { + this.wireLocalMatchButtons(container, missing, missingIndex); + this.wireDownloadSearchPanel(container, 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.style.display = 'block'; + populateComboOptions(comboInput.value); + }); + comboInput.addEventListener('blur', () => { + setTimeout(() => { + if (comboList) comboList.style.display = 'none'; + }, 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.style.display = 'block'; + this.displaySearchResults(missing, state, searchResultsDiv); + } + } + /** * Display missing models in the dialog */ @@ -3965,20 +4257,6 @@ class LinkerManagerDialog extends ComfyDialog { return; } - // Summary header with count - let html = ` -
-
- ${totalMissing} Missing Model${totalMissing > 1 ? 's' : ''} - Compact relinking and download view -
-
- ${activeCount > 0 ? `${activeCount} downloading` : (hasAny100Match ? 'Auto-Link ready for exact matches' : 'Review matches or search online')} -
-
- `; - html += '
'; - // Skip rendering if active tab is not "missing" if (this.activeTab !== 'missing') { container.innerHTML = ''; @@ -3986,7 +4264,7 @@ class LinkerManagerDialog extends ComfyDialog { } // Sort missing models: those with 100% confidence matches first, then others - const sortedMissingModels = missingModels.sort((a, b) => { + const sortedMissingModels = [...missingModels].sort((a, b) => { const aMatches = a.matches || []; const bMatches = b.matches || []; @@ -4011,122 +4289,28 @@ class LinkerManagerDialog extends ComfyDialog { for (let mi = 0; mi < sortedMissingModels.length; mi++) { sortedMissingModels[mi].__displayIndex = mi; - html += this.renderMissingModel(sortedMissingModels[mi], mi); } - html += '
'; - container.innerHTML = html; + const selectedStillExists = sortedMissingModels.some(missing => ( + this.getMissingModelKey(missing) === this.selectedMissingModelKey + )); + if (!selectedStillExists) { + this.selectedMissingModelKey = sortedMissingModels.length + ? this.getMissingModelKey(sortedMissingModels[0]) + : null; + } - // Attach event listeners for resolve buttons (use sorted order) - // Note: We need to match the exact same logic as renderMissingModel to find which buttons were rendered - sortedMissingModels.forEach((missing, missingIndex) => { - this.wireLocalMatchButtons(container, missing, missingIndex); - - this.wireDownloadSearchPanel(container, missing); - - // Wire locate chip (only for top-level nodes) - 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); - }); - } - - // Wire up all-models search + dropdown (combo-style) - 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 || ''; - - // Populate dropdown with filtered models - 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(); // Copy to avoid mutation - - 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 += `${label}`; - html += `
`; - if (folderDisplay) { - html += `
📁 ${folderDisplay}
`; - } - html += `
`; - } - comboList.innerHTML = html; - - // Add click listeners to options - comboList.querySelectorAll('div[data-idx]').forEach(el => { - el.addEventListener('click', () => { - const idx = parseInt(el.dataset.idx, 10); - if (!isNaN(idx) && idx >= 0 && idx < allModels.length) { - const chosenModel = allModels[idx]; - if (chosenModel) { - this.queueResolution(missing, chosenModel); - } - } - }); - }); - }; - - // Initial populate - if (comboList) { - populateComboOptions(''); - } - - // Filter input with debounce - if (comboInput) { - const debouncedFilter = this.debounce(() => { - populateComboOptions(comboInput.value); - }, 200); - comboInput.addEventListener('input', debouncedFilter); - - // Show dropdown on focus - comboInput.addEventListener('focus', () => { - if (comboList) comboList.style.display = 'block'; - populateComboOptions(comboInput.value); - }); - - // Close on blur (with delay to allow click) - comboInput.addEventListener('blur', () => { - setTimeout(() => { - if (comboList) comboList.style.display = 'none'; - }, 200); - }); - } - - // Refresh button - reload all models - if (comboRefresh) { - comboRefresh.addEventListener('click', async () => { - this.allModels = null; // Force reload - await this.ensureAllModelsLoaded(); - populateComboOptions(comboInput?.value || ''); - }); - } - }); + this.missingModels = sortedMissingModels; + container.innerHTML = this.renderMissingModelsBrowser( + sortedMissingModels, + this.selectedMissingModelKey, + totalMissing, + activeCount, + hasAny100Match + ); + this.wireMissingModelsBrowser(container, data, sortedMissingModels); } - /** - * Render a single missing model entry - */ renderMissingModel(missing, missingIndex = 0) { const allMatches = missing.matches || [];