fix: deduplicate missing models by category+filename and expand per-node resolutions

Previously, identify_missing_models grouped missing entries only by filename,
which could merge unrelated models from different categories that happen to
share a filename. This change deduplicates by (category, filename) and tracks
all_node_refs with a reference_count so the frontend can preserve one
resolution per node reference instead of collapsing duplicates.

Frontend changes in resolve_download_methods.js iterate over all_node_refs,
and missing_browser_methods.js now shows '+ N refs' in the missing-model
label and clarifies the locate tooltip when multiple workflow references
share the same missing model.
This commit is contained in:
Dariusz L
2026-07-04 17:26:35 +02:00
parent d9dacc2016
commit 245bf79800
3 changed files with 70 additions and 28 deletions
+13 -8
View File
@@ -2044,7 +2044,7 @@ def identify_missing_models(
) -> List[Dict[str, Any]]:
"""
Identify which models from the workflow are missing.
Deduplicates by filename - same model file only appears once even if
Deduplicates by category and filename - same model file only appears once even if
referenced by multiple nodes.
Args:
@@ -2052,29 +2052,34 @@ def identify_missing_models(
available_models: Optional list of available models (if None, checks via folder_paths)
Returns:
List of missing model references (deduplicated by filename).
List of missing model references (deduplicated by category and filename).
Each entry has 'all_node_refs' containing all node references for that model.
"""
# Group missing models by filename to deduplicate
missing_by_filename: Dict[str, Dict[str, Any]] = {}
# Group missing models by category and filename to deduplicate workflow
# references without merging unrelated model folders that happen to share a name.
missing_by_model: Dict[tuple[str, str], Dict[str, Any]] = {}
for model_ref in workflow_models:
# If exists is False, it's missing
if not model_ref.get("exists", False):
filename = model_ref.get("original_path", "")
category = model_ref.get("category", "")
group_key = (str(category or ""), str(filename or ""))
if filename not in missing_by_filename:
if group_key not in missing_by_model:
# First occurrence - use this as the primary entry
missing_by_filename[filename] = {
missing_by_model[group_key] = {
**model_ref,
"reference_count": 1,
"all_node_refs": [
model_ref.copy()
], # Track all nodes needing this model
}
else:
# Duplicate - just add to the node refs list
existing = missing_by_filename[filename]
existing = missing_by_model[group_key]
existing["all_node_refs"].append(model_ref.copy())
existing["reference_count"] = len(existing["all_node_refs"])
if model_ref.get("auto_download_capable"):
existing["auto_download_capable"] = True
if model_ref.get("auto_download_candidate"):
@@ -2091,4 +2096,4 @@ def identify_missing_models(
existing["input_choice_source"] = model_source
# Return deduplicated list
return list(missing_by_filename.values())
return list(missing_by_model.values())
@@ -44,7 +44,8 @@ export const resolveDownloadMethods = {
subgraph_id: ref.subgraph_id,
is_top_level: ref.is_top_level,
is_lora_v2: ref.is_lora_v2,
original_lora_name: ref.name || ref.original_path
original_lora_name: ref.name || ref.original_path,
nested_key: ref.nested_key
}));
const data = await this.fetchJson('/model_resolver/resolve', {
@@ -104,24 +105,42 @@ export const resolveDownloadMethods = {
// Collect all 100% matches
const resolutions = [];
const seenResolutionKeys = new Set();
for (const missing of missingModels) {
const matches = missing.matches || [];
const perfectMatch = matches.find((m) => m.confidence === 100);
if (perfectMatch && perfectMatch.model) {
resolutions.push({
missing_key: this.getMissingModelKey?.(missing),
missing_search_key: this.getMissingSearchKey?.(missing),
node_id: missing.node_id,
widget_index: missing.widget_index,
resolved_path: perfectMatch.model.path,
category: missing.category,
resolved_model: perfectMatch.model,
subgraph_id: missing.subgraph_id, // Include subgraph_id for subgraph nodes
is_top_level: missing.is_top_level, // True for top-level nodes, False for nodes in subgraph definitions
is_lora_v2: missing.is_lora_v2,
original_lora_name: missing.name || missing.original_path
});
const nodeRefs = Array.isArray(missing.all_node_refs) && missing.all_node_refs.length
? missing.all_node_refs
: [missing];
for (const ref of nodeRefs) {
const resolutionKey = [
ref.node_id ?? '',
ref.widget_index ?? '',
ref.subgraph_id || '',
ref.is_top_level !== false ? 'T' : 'F',
ref.nested_key || '',
ref.name || ref.original_path || ''
].join(':');
if (seenResolutionKeys.has(resolutionKey)) continue;
seenResolutionKeys.add(resolutionKey);
resolutions.push({
missing_key: this.getMissingModelKey?.(missing),
missing_search_key: this.getMissingSearchKey?.(missing),
node_id: ref.node_id,
widget_index: ref.widget_index,
resolved_path: perfectMatch.model.path,
category: ref.category || missing.category,
resolved_model: perfectMatch.model,
subgraph_id: ref.subgraph_id, // Include subgraph_id for subgraph nodes
is_top_level: ref.is_top_level, // True for top-level nodes, False for nodes in subgraph definitions
is_lora_v2: ref.is_lora_v2,
original_lora_name: ref.name || ref.original_path,
nested_key: ref.nested_key
});
}
}
}
+24 -6
View File
@@ -27,6 +27,15 @@ export const missingBrowserMethods = {
};
},
getMissingReferenceCount(missing = {}) {
if (Array.isArray(missing.all_node_refs) && missing.all_node_refs.length > 0) {
return missing.all_node_refs.length;
}
const count = Number(missing.reference_count || 0);
return Number.isFinite(count) && count > 0 ? count : 1;
},
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);
@@ -45,20 +54,29 @@ export const missingBrowserMethods = {
: (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
const baseText = hasCustomNodeTitle
? `${nodeLabel} #${nodeId} · ${customNodeTitle}`
: `${nodeLabel} #${nodeId}`;
const referenceCount = this.getMissingReferenceCount(missing);
const extraReferenceCount = Math.max(0, referenceCount - 1);
const text = extraReferenceCount > 0
? `${baseText} + ${extraReferenceCount} ref${extraReferenceCount === 1 ? '' : 's'}`
: baseText;
const 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.';
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.'
referenceCount,
locateTooltip: extraReferenceCount > 0
? `${locateTooltip} This missing model is used by ${referenceCount} workflow references; this centers the first one.`
: locateTooltip
};
},