feat(missing-browser): add toggle to show resolved models

Report resolved (already-existing) models alongside missing ones in the
missing-browser view. Resolved models are hidden by default; a new
'Show resolved' toggle in the toolbar reveals them with a badge and
reduced-opacity row styling. The toggle state is persisted in
localStorage.

Backend: include resolved_models and total_resolved in the analysis
payload so the frontend can merge them into the display list.

Frontend: normalize resolved workflow models into the missing-models
display shape, compute visibility-aware counts, and refresh the
missing-browser view on queue mutating actions.
This commit is contained in:
Dariusz L
2026-06-22 01:34:55 +02:00
parent ff95a2bd1e
commit 590b4a4969
5 changed files with 185 additions and 14 deletions
+7
View File
@@ -428,6 +428,11 @@ def analyze_and_find_matches(
# Identify missing models
missing_models = identify_missing_models(all_model_refs, available_models)
resolved_models = [
{**model_ref, "matches": []}
for model_ref in all_model_refs
if model_ref.get("exists", False)
]
# Enrich missing models with workflow URLs
for missing in missing_models:
@@ -557,6 +562,8 @@ def analyze_and_find_matches(
result = {
"missing_models": missing_with_matches,
"resolved_models": resolved_models,
"total_resolved": len(resolved_models),
"total_missing": len(missing_with_matches),
"total_models_analyzed": len(all_model_refs),
}
+59
View File
@@ -558,6 +558,48 @@
gap: 8px;
flex-wrap: wrap;
}
.mr-missing-resolved-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 26px;
padding: 0 8px;
color: var(--mr-text-muted);
background: var(--mr-missing-control-bg, rgba(255,255,255,0.035));
border: 1px solid var(--mr-missing-control-border, rgba(255,255,255,0.105));
border-radius: var(--mr-missing-control-radius, 7px);
cursor: pointer;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
transition: var(--mr-missing-control-transition, background 0.15s ease, border-color 0.15s ease, color 0.15s ease);
}
.mr-missing-resolved-toggle:hover {
color: var(--mr-text);
background: var(--mr-missing-control-bg-hover, rgba(255,255,255,0.06));
border-color: var(--mr-missing-control-border-hover, rgba(255,255,255,0.17));
}
.mr-missing-resolved-toggle input {
width: 13px;
height: 13px;
margin: 0;
accent-color: #72d38a;
}
.mr-missing-resolved-toggle em {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
color: #c9f6d0;
background: rgba(114,211,138,0.12);
font-style: normal;
font-size: 10px;
}
.mr-missing-refresh-btn,
#model-resolver-modal .mr-missing-refresh-btn {
min-height: 26px;
@@ -648,6 +690,9 @@
background: linear-gradient(90deg, rgba(114,211,138,0.12) 0%, rgba(114,211,138,0.055) 42%, rgba(114,211,138,0.018) 100%);
box-shadow: inset 3px 0 0 rgba(114,211,138,0.92), inset 0 0 0 1px rgba(114,211,138,0.12);
}
.mr-missing-list-row.is-resolved {
opacity: 0.72;
}
.mr-missing-list-row.is-batch-selected {
background: rgba(78,161,255,0.055);
}
@@ -715,6 +760,20 @@
font-size: 13px;
font-weight: 700;
}
.mr-missing-row-resolved-pill {
display: inline-flex;
align-items: center;
justify-self: start;
min-height: 17px;
padding: 1px 6px;
border-radius: 999px;
color: #c9f6d0;
background: rgba(114,211,138,0.10);
border: 1px solid rgba(114,211,138,0.20);
font-size: 9px;
font-weight: 750;
line-height: 1;
}
.mr-missing-row-node {
grid-column: 1;
color: var(--mr-text-muted);
+4
View File
@@ -1284,6 +1284,7 @@ export const queueMethods = {
this.updateSelectedBarForMissing?.(m);
this.updateApplyPendingButton?.();
this.updateQueuePanel();
this.refreshMissingModelsBrowserFromCache?.();
},
// Clear all queued selections
@@ -1293,6 +1294,7 @@ export const queueMethods = {
this.savePendingQueueForActiveWorkflow();
this.updateApplyPendingButton?.();
this.updateQueuePanel();
this.refreshMissingModelsBrowserFromCache?.();
try {
document.querySelectorAll('.model-resolver-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; });
} catch (e) { /* ignore */ }
@@ -1405,6 +1407,7 @@ export const queueMethods = {
this.updateSelectedBarForMissing(m);
this.updateApplyPendingButton?.();
this.updateQueuePanel();
this.refreshMissingModelsBrowserFromCache?.();
}
},
@@ -1748,6 +1751,7 @@ export const queueMethods = {
this.updateSelectedBarForMissing?.(missing);
this.updateQueuePanel();
this.updateApplyPendingButton();
this.refreshMissingModelsBrowserFromCache?.();
},
/**
+6
View File
@@ -68,6 +68,12 @@ export class ResolverManagerDialog extends ComfyDialog {
this.sidebarTabId = "comfyui-model-resolver";
this.sidebarOpenModeStorageKey = "model_resolver_sidebar_open_mode";
this.missingBrowserSplitStorageKey = "model_resolver_missing_browser_detail_w";
this.showResolvedModelsStorageKey = "model_resolver_show_resolved_models";
try {
this.showResolvedModels = localStorage.getItem(this.showResolvedModelsStorageKey) === '1';
} catch (e) {
this.showResolvedModels = false;
}
this.dockButton = null;
this.undockButton = null;
this._floatingRectBeforeDock = null;
+109 -14
View File
@@ -223,12 +223,76 @@ export const missingBrowserMethods = {
};
},
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) {
isMissingModelResolved(missing = {}) {
if (!missing) return false;
if (missing.__isExistingResolved) return true;
const key = this.getMissingModelKey(missing);
return Boolean(key && this.pendingIndex?.has?.(key));
},
getResolvedWorkflowModels(data = {}) {
return (data.resolved_models || []).map(model => this.normalizeResolvedWorkflowModel(model));
},
normalizeResolvedWorkflowModel(model = {}) {
const originalPath = model.original_path || model.name || model.filename || '';
const filename = originalPath.split('/').pop()?.split('\\').pop() || originalPath || 'Resolved model';
const fullPath = model.full_path || model.path || '';
const relativePath = model.relative_path || originalPath || filename;
const category = model.category || 'unknown';
const resolvedModel = {
path: fullPath,
relative_path: relativePath,
filename,
category,
resolved_path: fullPath
};
return {
...model,
__isExistingResolved: true,
name: model.name || filename,
original_path: originalPath || filename,
category,
matches: [{
confidence: 100,
match_type: 'exact',
filename,
path: fullPath,
model: resolvedModel
}]
};
},
getVisibleMissingModels(missingModels = []) {
if (this.showResolvedModels) return missingModels;
return missingModels.filter(missing => !this.isMissingModelResolved(missing));
},
getResolvedMissingCount(missingModels = []) {
return missingModels.reduce((count, missing) => (
count + (this.isMissingModelResolved(missing) ? 1 : 0)
), 0);
},
refreshMissingModelsBrowserFromCache() {
if (this.activeTab !== 'missing' || !this.contentElement || !this.cachedAnalysisData) return;
this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
},
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match, options = {}) {
const hiddenResolvedCount = Number(options.hiddenResolvedCount || 0);
const resolvedCount = Number(options.resolvedCount || 0);
const rawMissingCount = Number(options.rawMissingCount ?? totalMissing);
const missingCount = Number(options.missingCount ?? rawMissingCount);
const resolvedToggleCount = this.showResolvedModels ? resolvedCount : hiddenResolvedCount;
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`
: hiddenResolvedCount > 0
? `${hiddenResolvedCount} resolved hidden`
: (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online');
const listLayout = this.getMissingModelsListLayout(missingModels);
let savedDetailWidth = null;
@@ -239,13 +303,16 @@ export const missingBrowserMethods = {
? `--mr-missing-detail-track:${savedDetailWidth}px;`
: '';
const listStyle = `--mr-missing-model-col:${listLayout.modelPx}px;--mr-missing-type-col:${listLayout.typePx}px;`;
const titleText = this.showResolvedModels && resolvedCount > 0
? `${totalMissing} shown (${missingCount} missing / ${resolvedCount} resolved)`
: `${totalMissing} missing model${totalMissing === 1 ? '' : 's'}`;
let html = `
<div class="mr-missing-browser" style="${listStyle}">
<section class="mr-missing-list-pane" aria-label="Missing model list">
<div class="mr-missing-list-toolbar">
<div>
<div class="mr-missing-list-title">${totalMissing} missing model${totalMissing === 1 ? '' : 's'}</div>
<div class="mr-missing-list-title">${this.escapeHtml(titleText)}</div>
<div class="mr-missing-list-meta">${this.escapeHtml(activeHint)}</div>
</div>
<div class="mr-missing-list-tools">
@@ -254,6 +321,11 @@ export const missingBrowserMethods = {
<span class="mr-missing-stat mr-missing-stat-partial">${stats.partial} partial</span>
<span class="mr-missing-stat mr-missing-stat-none">${stats.none} no match</span>
</div>
<label class="mr-missing-resolved-toggle" data-tooltip="Show resolved models, including models that were already available when the workflow loaded.">
<input id="mr-show-resolved-models" type="checkbox" ${this.showResolvedModels ? 'checked' : ''}>
<span>Show resolved</span>
${resolvedToggleCount > 0 ? `<em>${resolvedToggleCount}</em>` : ''}
</label>
<button id="mr-refresh-missing-analysis" type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-missing-refresh-btn" data-tooltip="Re-analyze workflow and refresh local matches">
<span class="mr-refresh-spin-target">${getSvgIcon('refreshCw')}</span> Refresh
</button>
@@ -277,6 +349,7 @@ export const missingBrowserMethods = {
const key = this.getMissingModelKey(missing);
const isSelected = key === selectedKey;
const isBatchSelected = this.batchSelectedMissingKeys?.has(key);
const isResolved = this.isMissingModelResolved(missing);
const filename = this.getMissingFilename(missing);
const bestMatch = this.getBestLocalMatch(missing, 70);
const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0;
@@ -294,7 +367,7 @@ export const missingBrowserMethods = {
html += `
<div role="button" tabindex="0"
class="mr-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''}"
class="mr-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''} ${isResolved ? 'is-resolved' : ''}"
data-missing-key="${this.escapeHtml(key)}">
<span class="mr-missing-row-select">
<input type="checkbox" class="mr-missing-row-check" data-ml-no-drag="1" aria-label="Select ${this.escapeHtml(filename)}" ${isBatchSelected ? 'checked' : ''}>
@@ -302,6 +375,7 @@ export const missingBrowserMethods = {
<span class="mr-missing-row-index">${index + 1}</span>
<span class="mr-missing-row-model">
<span class="mr-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>
${isResolved ? '<span class="mr-missing-row-resolved-pill">Resolved</span>' : ''}
${rowNodeHtml}
</span>
<span class="mr-missing-row-type ${typeColorClass}">${this.escapeHtml(typeLabel)}</span>
@@ -337,6 +411,18 @@ export const missingBrowserMethods = {
refreshBtn.addEventListener('click', () => this.refreshMissingAnalysis(refreshBtn));
}
const showResolvedToggle = container.querySelector('#mr-show-resolved-models');
if (showResolvedToggle && showResolvedToggle.dataset.mlResolvedBound !== 'true') {
showResolvedToggle.dataset.mlResolvedBound = 'true';
showResolvedToggle.addEventListener('change', () => {
this.showResolvedModels = Boolean(showResolvedToggle.checked);
try {
localStorage.setItem(this.showResolvedModelsStorageKey, this.showResolvedModels ? '1' : '0');
} catch (e) {}
this.displayMissingModels(container, data);
});
}
const selectRow = (row) => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
@@ -797,30 +883,38 @@ export const missingBrowserMethods = {
const missingModels = (data.missing_models || []).map(missing => (
this.restoreDownloadedLocalMatchesForMissing?.(missing) || missing
));
const totalMissing = data.total_missing || 0;
this.missingModels = missingModels;
this.syncBatchSelectionForMissingModels(missingModels);
const resolvedModels = this.getResolvedWorkflowModels(data);
const allModelsForDisplay = [...missingModels, ...resolvedModels];
const rawMissingCount = data.total_missing ?? missingModels.length;
const resolvedMissingCount = this.getResolvedMissingCount(missingModels);
const resolvedCount = resolvedMissingCount + resolvedModels.length;
const unresolvedMissingCount = Math.max(0, rawMissingCount - resolvedMissingCount);
const hiddenResolvedCount = this.showResolvedModels ? 0 : resolvedCount;
const visibleMissingModels = this.getVisibleMissingModels(allModelsForDisplay);
this.missingModels = visibleMissingModels;
this.syncBatchSelectionForMissingModels(visibleMissingModels);
// Check if there are active downloads
const activeCount = Object.keys(this.activeDownloads).length;
const hasAny100Match = missingModels.some(missing =>
const hasAny100Match = visibleMissingModels.some(missing =>
(missing.matches || []).some(match => match.confidence === 100)
);
const hasAnyModelsToDisplay = rawMissingCount > 0 || resolvedModels.length > 0;
this.setMissingFooterControlsVisible(totalMissing > 0 || activeCount > 0);
this.setMissingFooterControlsVisible(hasAnyModelsToDisplay || activeCount > 0);
// Hide download all button if no missing models
if (this.downloadAllButton) {
this.downloadAllButton.style.display = (totalMissing > 0 || activeCount > 0) ? 'inline-flex' : 'none';
this.downloadAllButton.style.display = (rawMissingCount > 0 || activeCount > 0) ? 'inline-flex' : 'none';
}
if (totalMissing === 0 && activeCount === 0) {
if (!hasAnyModelsToDisplay && 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) {
if (rawMissingCount === 0 && activeCount > 0 && !resolvedModels.length) {
container.innerHTML = this.renderStatusMessage(
`${activeCount} download${activeCount > 1 ? 's' : ''} in progress. Local matches will refresh when complete.`,
'info'
@@ -850,7 +944,7 @@ export const missingBrowserMethods = {
}
// Sort missing models: those with 100% confidence matches first, then others
const sortedMissingModels = [...missingModels].sort((a, b) => {
const sortedMissingModels = [...visibleMissingModels].sort((a, b) => {
const aMatches = a.matches || [];
const bMatches = b.matches || [];
@@ -890,9 +984,10 @@ export const missingBrowserMethods = {
container.innerHTML = this.renderMissingModelsBrowser(
sortedMissingModels,
this.selectedMissingModelKey,
totalMissing,
sortedMissingModels.length,
activeCount,
hasAny100Match
hasAny100Match,
{ hiddenResolvedCount, resolvedCount, rawMissingCount, missingCount: unresolvedMissingCount }
);
this.wireMissingModelsBrowser(container, data, sortedMissingModels);
this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data);