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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user