feat: improve category resolution and missing models browser

- Expand preferred categories to include clip_vision, ipadapter, sams, ultralytics
- Add clip_gguf alias for text_encoders in both backend and frontend
- Add explicit diffusion_models/text_encoders mappings in CATEGORY_MAP
- Refactor get_model_roots to skip custom_nodes/configs, use template key
  aliases, accept new folder keys from known categories, and normalize
  ultralytics paths (yolo/bbox, yolo/segm, yolo -> parent)
- Pass missing object through resolve_download_methods and search_panel
  so category selection can use widget hints
- Refactor frontend download target resolution:
  - getMissingSupportedDownloadCategories respects widget/type hints
  - getNodeTypeDownloadCategory splits exact-node map and adds
    normalizeNodeCategoryToken / getNodeTypeDownloadCategoryFromTokens
    for GGUF and future node families
  - getSourceResultDownloadCategory and _sourceSupportLevel accept
    missing for unified logic
- Prioritize dynamic widget category hints in workflow analyzer and
  include category_hints/folder_key_hints on all model refs
- Add folder count badges and path normalization in missing browser
  (normalizeMissingFolderRootPath handles category suffixes / bbox/segm)
This commit is contained in:
Dariusz L
2026-06-27 19:36:47 +02:00
parent e53d9f2a0b
commit b73cdb0f40
9 changed files with 524 additions and 40 deletions
+81 -10
View File
@@ -113,6 +113,7 @@ class ModelResolverExtension:
)
from .core.type_utils import first_non_empty, to_int, to_bool
from .core.settings import (
TEMPLATE_KEY_ALIASES,
bool_setting as resolver_bool_setting,
get_default_root_for_category,
load_settings as load_resolver_settings,
@@ -3542,7 +3543,7 @@ class ModelResolverExtension:
import os
import folder_paths
categories = [
preferred_categories = [
"loras",
"checkpoints",
"diffusion_models",
@@ -3550,24 +3551,81 @@ class ModelResolverExtension:
"text_encoders",
"vae",
"upscale_models",
"controlnet",
"clip_vision",
"ipadapter",
"sams",
"ultralytics",
]
skip_categories = {"custom_nodes", "configs"}
known_categories = set(folder_paths.folder_names_and_paths.keys())
categories = []
category_source_keys = {}
for cat in [
*preferred_categories,
*known_categories,
]:
folder_key = normalize_download_category(cat)
if (
not folder_key
or folder_key in skip_categories
or folder_key in categories
):
if folder_key and folder_key not in skip_categories:
category_source_keys.setdefault(folder_key, []).append(cat)
continue
categories.append(folder_key)
category_source_keys.setdefault(folder_key, []).append(cat)
roots = {}
settings = load_resolver_settings()
comfy_root = get_comfy_root_path(folder_paths)
for cat in categories:
folder_key = normalize_download_category(cat)
candidate_keys = [folder_key]
if folder_key == "diffusion_models":
candidate_keys.append("unet")
elif folder_key == "text_encoders":
candidate_keys.append("clip")
if folder_key in known_categories:
raw_candidate_keys = [folder_key]
else:
raw_candidate_keys = [
folder_key,
*TEMPLATE_KEY_ALIASES.get(folder_key, ()),
*category_source_keys.get(folder_key, []),
]
if folder_key == "ultralytics":
raw_candidate_keys = [
candidate_key
for candidate_key in raw_candidate_keys
if str(candidate_key or "").strip().lower() != "yolo"
]
candidate_keys = []
for candidate_key in raw_candidate_keys:
if (
candidate_key
and candidate_key in known_categories
and candidate_key not in candidate_keys
):
candidate_keys.append(candidate_key)
paths = []
for candidate_key in candidate_keys:
paths.extend(folder_paths.get_folder_paths(candidate_key) or [])
preferred_directory = (
get_default_root_for_category(folder_key, settings)
or get_download_directory(folder_key)
or ""
if folder_key == "ultralytics":
normalized_ultralytics_paths = []
for path in paths:
normalized_path = os.path.normpath(str(path or ""))
basename = os.path.basename(normalized_path).lower()
if basename == "yolo":
continue
parent_dir = os.path.dirname(normalized_path)
if (
basename in {"bbox", "segm"}
and os.path.basename(parent_dir).lower()
== "ultralytics"
):
normalized_path = parent_dir
normalized_ultralytics_paths.append(normalized_path)
paths = normalized_ultralytics_paths
preferred_directory = get_default_root_for_category(
folder_key, settings
)
normalized_paths = dedupe_local_base_directories(
paths,
@@ -3576,6 +3634,19 @@ class ModelResolverExtension:
)
roots[folder_key] = normalized_paths
for raw_key in known_categories:
if (
not raw_key
or raw_key in skip_categories
or raw_key in roots
):
continue
raw_paths = folder_paths.get_folder_paths(raw_key) or []
roots[raw_key] = dedupe_local_base_directories(
raw_paths,
comfy_root=comfy_root,
)
return web.json_response(roots)
@routes.get("/model_resolver/path-template-suggestions")
+1 -1
View File
@@ -43,7 +43,7 @@ TEMPLATE_KEY_ALIASES = {
"checkpoints": ("checkpoints", "checkpoint"),
"embeddings": ("embeddings", "embedding"),
"diffusion_models": ("diffusion_models", "diffusion_model", "unet"),
"text_encoders": ("text_encoders", "text_encoder", "clip", "clips"),
"text_encoders": ("text_encoders", "text_encoder", "clip", "clips", "clip_gguf"),
"controlnet": ("controlnet", "control_net"),
"vae": ("vae",),
"upscale_models": ("upscale_models", "upscale_model", "upscaler"),
+8
View File
@@ -141,6 +141,14 @@ CATEGORY_MAP = {
"style_models": "style_models",
"gligen": "gligen",
"diffusers": "diffusers",
"unet": "diffusion_models",
"diffusion_model": "diffusion_models",
"diffusion_models": "diffusion_models",
"clip": "text_encoders",
"clips": "text_encoders",
"clip_gguf": "text_encoders",
"text_encoder": "text_encoders",
"text_encoders": "text_encoders",
"vae_approx": "vae_approx",
"sam": "sams",
"sam_model": "sams",
+35 -10
View File
@@ -714,9 +714,7 @@ def get_model_widget_category_hints(
node_type = node.get("type", "")
indexed_category_hint = get_node_model_widget_category_hint(node_type, widget_index)
widget_category_hint = get_widget_category_hint(node, widget_index)
dynamic_category_hints: List[str] = []
if not indexed_category_hint and not widget_category_hint:
dynamic_category_hints = get_dynamic_widget_category_hints(node, widget_index)
dynamic_category_hints = get_dynamic_widget_category_hints(node, widget_index)
output_category_hint = get_node_output_category_hint(node)
widgets_values = node.get("widgets_values", [])
@@ -724,15 +722,24 @@ def get_model_widget_category_hints(
output_widget_category_hint = (
output_category_hint
if (
indexed_category_hint
or widget_category_hint
or dynamic_category_hints
or has_single_widget_value
not dynamic_category_hints
and (
indexed_category_hint
or widget_category_hint
or has_single_widget_value
)
)
else None
)
hints_list = []
for h in dynamic_category_hints:
if h:
hints_list.append(h)
if dynamic_category_hints:
return _ordered_unique_categories(hints_list)
if indexed_category_hint:
hints_list.append(indexed_category_hint)
@@ -744,9 +751,6 @@ def get_model_widget_category_hints(
else:
if widget_category_hint:
hints_list.append(widget_category_hint)
for h in dynamic_category_hints:
if h:
hints_list.append(h)
if output_widget_category_hint:
hints_list.append(output_widget_category_hint)
@@ -1092,6 +1096,8 @@ def get_node_model_info(
"active": active,
"node_title": node_title,
"category": "loras",
"category_hints": ["loras"],
"folder_key_hints": ["loras"],
"full_path": lora_full_path,
"exists": lora_exists,
"is_urn": False,
@@ -1104,6 +1110,7 @@ def get_node_model_info(
# For each widget value, check if it looks like a model file or URN
for idx, value in enumerate(widgets_values):
widget_name = get_widget_name_hint(node, idx)
model_widget_folder_key_hints = get_dynamic_widget_category_hints(node, idx)
model_widget_category_hints = get_model_widget_category_hints(node, idx)
model_widget_category_hint = (
model_widget_category_hints[0] if model_widget_category_hints else None
@@ -1161,6 +1168,8 @@ def get_node_model_info(
"original_path": value_str,
"node_title": node_title,
"category": category,
"category_hints": nested_categories or ([category] if category else []),
"folder_key_hints": nested_categories or ([category] if category else []),
"full_path": full_path,
"exists": exists,
"is_urn": False,
@@ -1192,6 +1201,16 @@ def get_node_model_info(
effective_category_hint
or URN_TYPE_MAP.get(typ.lower(), "unknown")
)
urn_category_hints = (
categories_to_try_for_widget
if categories_to_try_for_widget
else ([category] if category else [])
)
urn_folder_key_hints = (
model_widget_folder_key_hints
if model_widget_folder_key_hints
else urn_category_hints
)
model_refs.append(
{
@@ -1210,6 +1229,8 @@ def get_node_model_info(
},
"node_title": node_title,
"category": category,
"category_hints": urn_category_hints,
"folder_key_hints": urn_folder_key_hints,
"full_path": None,
"exists": False,
"is_urn": True,
@@ -1242,6 +1263,10 @@ def get_node_model_info(
"original_path": value_str,
"node_title": node_title,
"category": category,
"category_hints": categories_to_try_for_widget or ([category] if category else []),
"folder_key_hints": model_widget_folder_key_hints
or categories_to_try_for_widget
or ([category] if category else []),
"full_path": full_path,
"exists": exists,
"is_urn": False,
+28 -1
View File
@@ -1282,6 +1282,32 @@
transform: translateY(-1px);
}
.mr-node-folder-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
border: 1px solid rgba(114, 211, 138, 0.28);
background: rgba(114, 211, 138, 0.105);
color: #c9f6d0;
font-size: 10px;
font-weight: 800;
line-height: 1;
cursor: help;
flex-shrink: 0;
}
.mr-node-folder-count-badge:hover,
.mr-node-folder-count-badge:focus-visible {
border-color: rgba(114, 211, 138, 0.46);
background: rgba(114, 211, 138, 0.16);
color: #e6ffe9;
outline: none;
}
.mr-missing-browser .mr-node-chip.is-locatable:hover {
background: var(--mr-missing-chip-bg-hover, rgba(255, 255, 255, 0.06));
border-color: var(--mr-missing-chip-border-hover, rgba(255, 255, 255, 0.18));
@@ -6257,7 +6283,8 @@ a.mr-image-preview-resource-name:hover {
font-size: 12px;
font-weight: 400;
line-height: 1.5;
white-space: normal;
white-space: pre-line;
overflow-wrap: anywhere;
text-align: left;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
pointer-events: none;
@@ -2297,7 +2297,8 @@ export const resolveDownloadMethods = {
const archiveName = civarchiveResult.name || archiveFilename || 'Model';
const archiveCategory = this.getSourceResultDownloadCategory?.(
civarchiveResult,
this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints'
this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints',
missing
) || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints';
const archiveSecondary = [
archiveName && archiveName !== archiveFilename ? archiveFilename : '',
@@ -2332,7 +2333,8 @@ export const resolveDownloadMethods = {
const archiveName = loraManagerArchiveResult.name || archiveFilename;
const archiveCategory = this.getSourceResultDownloadCategory?.(
loraManagerArchiveResult,
this.getMissingDownloadCategory?.(missing, 'loras') || 'loras'
this.getMissingDownloadCategory?.(missing, 'loras') || 'loras',
missing
) || this.getMissingDownloadCategory?.(missing, 'loras') || 'loras';
addRow({
sourceKey: 'lora-archive',
@@ -2364,7 +2366,8 @@ export const resolveDownloadMethods = {
const modelName = civitaiResult.name || missing.civitai_info?.model_name || downloadFilename || 'Model';
const civitaiCategory = this.getSourceResultDownloadCategory?.(
civitaiResult,
this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints'
this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints',
missing
) || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints';
const civitaiSecondary = [
civitaiResult.type || '',
+188 -11
View File
@@ -106,6 +106,7 @@ export const downloadTargetMethods = {
diffusion_models: 'diffusion_models',
clip: 'text_encoders',
clips: 'text_encoders',
clip_gguf: 'text_encoders',
text_encoder: 'text_encoders',
text_encoders: 'text_encoders',
ip_adapter: 'ipadapter',
@@ -137,19 +138,127 @@ export const downloadTargetMethods = {
].map(key => this.normalizeDownloadCategory(key)).filter(Boolean));
},
getSourceResultDownloadCategory(source = {}, fallbackCategory = '') {
getMissingSupportedDownloadCategories(missing = {}) {
const knownCategories = this.getKnownDownloadCategorySet();
const collectCategories = (...values) => {
const categories = [];
const addCategory = (value) => {
if (value === undefined || value === null || String(value).trim() === '') return;
if (Array.isArray(value)) {
value.forEach(addCategory);
return;
}
String(value).split(/[,|;]/).forEach(part => {
const normalized = this.normalizeDownloadCategory(part);
if (normalized && knownCategories.has(normalized) && !categories.includes(normalized)) {
categories.push(normalized);
}
});
};
values.forEach(addCategory);
return categories;
};
const chooseSupported = (supported = []) => {
if (!supported.length) return [];
const nodeCategory = this.getMissingNodeTypeDownloadCategory(missing);
if (nodeCategory && supported.includes(nodeCategory)) {
return [nodeCategory];
}
return supported;
};
const widgetSupported = collectCategories(
missing.folder_key_hints,
missing.folderKeyHints,
missing.model_widget_folder_key_hints,
missing.modelWidgetFolderKeyHints,
missing.model_widget_category_hints,
missing.modelWidgetCategoryHints
);
if (widgetSupported.length) {
return chooseSupported(widgetSupported);
}
const supported = collectCategories(
missing.category_hints,
missing.categoryHints,
missing.supported_categories,
missing.supportedCategories
);
if (supported.length) {
return chooseSupported(supported);
}
const fallbackSupported = [];
const addCategory = (value) => {
if (value === undefined || value === null || String(value).trim() === '') return;
if (Array.isArray(value)) {
value.forEach(addCategory);
return;
}
String(value).split(/[,|;]/).forEach(part => {
const normalized = this.normalizeDownloadCategory(part);
if (normalized && knownCategories.has(normalized) && !fallbackSupported.includes(normalized)) {
fallbackSupported.push(normalized);
}
});
};
addCategory(this.getMissingNodeTypeDownloadCategory(missing));
if (fallbackSupported.length) {
return fallbackSupported;
}
addCategory(missing.category);
addCategory(missing.directory);
return fallbackSupported;
},
getSourceResultDownloadCategory(source = {}, fallbackCategory = '', missing = null) {
const sourceData = source && typeof source === 'object' ? source : {};
const knownCategories = this.getKnownDownloadCategorySet();
const candidates = [
const fallback = this.normalizeDownloadCategory(fallbackCategory || '');
const supportedCategories = missing
? this.getMissingSupportedDownloadCategories(missing)
: [];
const sourceCandidates = [
sourceData.model_type,
sourceData.modelType,
sourceData.type,
sourceData.category,
sourceData.directory,
fallbackCategory
sourceData.directory
];
for (const candidate of candidates) {
for (const candidate of sourceCandidates) {
if (candidate === undefined || candidate === null || String(candidate).trim() === '') continue;
const normalized = this.normalizeDownloadCategory(candidate);
if (
normalized
&& knownCategories.has(normalized)
&& supportedCategories.includes(normalized)
) {
return normalized;
}
}
if (
fallback
&& knownCategories.has(fallback)
&& (!supportedCategories.length || supportedCategories.includes(fallback))
) {
return fallback;
}
if (supportedCategories.length) {
return supportedCategories[0];
}
if (fallback && knownCategories.has(fallback)) {
return fallback;
}
for (const candidate of sourceCandidates) {
if (candidate === undefined || candidate === null || String(candidate).trim() === '') continue;
const normalized = this.normalizeDownloadCategory(candidate);
if (normalized && knownCategories.has(normalized)) {
@@ -163,7 +272,7 @@ export const downloadTargetMethods = {
: this.normalizeDownloadCategory(fallbackCategory || 'checkpoints');
},
getNodeTypeDownloadCategory(nodeType = '') {
getExactNodeTypeDownloadCategory(nodeType = '') {
const normalizedNodeType = String(nodeType || '').trim();
const exactMap = {
CheckpointLoaderSimple: 'checkpoints',
@@ -181,13 +290,30 @@ export const downloadTargetMethods = {
CLIPLoader: 'text_encoders',
DualCLIPLoader: 'text_encoders',
TripleCLIPLoader: 'text_encoders',
CLIPLoaderGGUF: 'text_encoders',
ClipLoaderGGUF: 'text_encoders',
DualCLIPLoaderGGUF: 'text_encoders',
DualClipLoaderGGUF: 'text_encoders',
TripleCLIPLoaderGGUF: 'text_encoders',
TripleClipLoaderGGUF: 'text_encoders',
QuadrupleCLIPLoader: 'text_encoders',
QuadrupleClipLoader: 'text_encoders',
QuadrupleCLIPLoaderGGUF: 'text_encoders',
QuadrupleClipLoaderGGUF: 'text_encoders',
UpscaleModelLoader: 'upscale_models',
LTXVAudioVAELoader: 'checkpoints',
LowVRAMAudioVAELoader: 'checkpoints'
};
if (exactMap[normalizedNodeType]) {
return exactMap[normalizedNodeType];
}
return exactMap[normalizedNodeType] || '';
},
getNodeTypeDownloadCategory(nodeType = '') {
const normalizedNodeType = String(nodeType || '').trim();
const exactCategory = this.getExactNodeTypeDownloadCategory(normalizedNodeType);
if (exactCategory) return exactCategory;
const tokenCategory = this.getNodeTypeDownloadCategoryFromTokens(normalizedNodeType);
if (tokenCategory) return tokenCategory;
const token = normalizedNodeType.toLowerCase();
if (!token) return '';
@@ -202,6 +328,53 @@ export const downloadTargetMethods = {
return '';
},
normalizeNodeCategoryToken(value = '') {
return String(value || '')
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
},
getNodeTypeDownloadCategoryFromTokens(nodeType = '') {
const nodeToken = this.normalizeNodeCategoryToken(nodeType);
if (!nodeToken) return '';
const ignoredTokens = new Set(['model', 'models', 'loader', 'provider']);
const candidates = [];
const seen = new Set();
const addCandidate = (category, tokenValue) => {
const token = this.normalizeNodeCategoryToken(tokenValue);
if (!token || token.length < 3 || ignoredTokens.has(token)) return;
const key = `${category}:${token}`;
if (seen.has(key)) return;
seen.add(key);
candidates.push({ category, token });
};
const addTokenParts = (category, tokenValue) => {
String(tokenValue || '').split(/[_\s-]+/).forEach(part => addCandidate(category, part));
};
this.getDefaultDownloadCategoryKeys().forEach(rawCategory => {
const category = this.normalizeDownloadCategory(rawCategory);
if (!category) return;
const tokenName = this.getCategoryTokenName(category);
addCandidate(category, category);
addCandidate(category, category.replace(/s$/, ''));
addCandidate(category, tokenName);
addTokenParts(category, category);
addTokenParts(category, tokenName);
});
candidates.sort((a, b) => b.token.length - a.token.length);
const match = candidates.find(candidate => (
candidate.token.length <= 3
? nodeToken.startsWith(candidate.token)
: nodeToken.includes(candidate.token)
));
return match?.category || '';
},
getMissingNodeTypeDownloadCategory(missing = {}) {
return this.getNodeTypeDownloadCategory(missing.locate_node_type)
|| this.getNodeTypeDownloadCategory(missing.promoted_inner_node_type)
@@ -233,7 +406,9 @@ export const downloadTargetMethods = {
getMissingDownloadCategory(missing = {}, fallbackCategory = 'checkpoints') {
const knownCategories = this.getKnownDownloadCategorySet();
const supportedCategories = this.getMissingSupportedDownloadCategories?.(missing) || [];
const candidates = [
...supportedCategories,
this.getMissingNodeTypeDownloadCategory(missing),
missing.category,
missing.directory,
@@ -1117,7 +1292,8 @@ export const downloadTargetMethods = {
});
merged.category = this.getSourceResultDownloadCategory(
result,
merged.category || missing.category || ''
merged.category || missing.category || '',
missing
);
}
}
@@ -1367,7 +1543,8 @@ export const downloadTargetMethods = {
const searchSuggestion = this.getCachedSearchSuggestionData(missing);
return this.getSourceResultDownloadCategory(
searchSuggestion || {},
this.getMissingDownloadCategory(missing, fallbackCategory || 'checkpoints')
this.getMissingDownloadCategory(missing, fallbackCategory || 'checkpoints'),
missing
);
},
+2 -1
View File
@@ -1671,7 +1671,8 @@ export const searchPanelMethods = {
const baseModel = downloadSource.base_model || missing.civitai_info?.base_model || '';
const rowCategory = this.getSourceResultDownloadCategory?.(
downloadSource,
downloadSource.directory || downloadSource.category || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints'
downloadSource.directory || downloadSource.category || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints',
missing
) || downloadSource.directory || downloadSource.category || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints';
const secondaryParts = [
sourceSecondary,
+175 -3
View File
@@ -58,6 +58,165 @@ export const missingBrowserMethods = {
};
},
getMissingSupportedFolderCategories(missing = {}) {
const categories = [];
const addCategory = (value) => {
if (value === undefined || value === null || String(value).trim() === '') return;
if (Array.isArray(value)) {
value.forEach(addCategory);
return;
}
String(value).split(/[,|;]/).forEach(part => {
const normalized = this.normalizeDownloadCategory?.(part) || String(part || '').trim();
if (
normalized
&& normalized !== 'unknown'
&& !categories.includes(normalized)
) {
categories.push(normalized);
}
});
};
addCategory(this.getMissingSupportedDownloadCategories?.(missing) || []);
if (!categories.length) {
addCategory(missing.category_hints);
addCategory(missing.categoryHints);
addCategory(missing.model_widget_category_hints);
addCategory(missing.modelWidgetCategoryHints);
addCategory(missing.supported_categories);
addCategory(missing.supportedCategories);
addCategory(missing.category);
}
return categories;
},
getMissingSupportedFolderKeys(missing = {}) {
const keys = [];
const addKey = (value) => {
if (value === undefined || value === null || String(value).trim() === '') return;
if (Array.isArray(value)) {
value.forEach(addKey);
return;
}
String(value).split(/[,|;]/).forEach(part => {
const key = String(part || '').trim();
if (key && key !== 'unknown' && !keys.includes(key)) {
keys.push(key);
}
});
};
addKey(missing.folder_key_hints);
addKey(missing.folderKeyHints);
addKey(missing.model_widget_folder_key_hints);
addKey(missing.modelWidgetFolderKeyHints);
if (keys.length) {
const nodeCategory = this.getMissingNodeTypeDownloadCategory?.(missing) || '';
if (nodeCategory) {
const filteredKeys = keys.filter(key => (
(this.normalizeDownloadCategory?.(key) || key) === nodeCategory
));
if (filteredKeys.length) return filteredKeys;
}
return keys;
}
return this.getMissingSupportedFolderCategories(missing);
},
normalizeMissingFolderRootPath(folderKey = '', path = '') {
const cleanPath = String(path || '').trim();
if (!cleanPath) return '';
const normalizedCategory = this.normalizeDownloadCategory?.(folderKey) || folderKey;
const rawKey = String(folderKey || '').trim();
if (!normalizedCategory || normalizedCategory === rawKey) {
return cleanPath;
}
const usesBackslash = cleanPath.includes('\\');
const normalizedPath = cleanPath.replace(/\\/g, '/').replace(/\/+$/g, '');
const parts = normalizedPath.split('/').filter(Boolean);
const leaf = (parts[parts.length - 1] || '').toLowerCase();
const parent = (parts[parts.length - 2] || '').toLowerCase();
const canonicalLeaf = String(normalizedCategory || '').toLowerCase();
const rawSuffix = rawKey.toLowerCase().startsWith(`${canonicalLeaf}_`)
? rawKey.slice(canonicalLeaf.length + 1).toLowerCase()
: '';
if (rawSuffix && leaf === rawSuffix && parent === canonicalLeaf) {
const parentPath = parts.slice(0, -1).join('/');
return usesBackslash ? parentPath.replace(/\//g, '\\') : parentPath;
}
return cleanPath;
},
getMissingSupportedFolderDetails(missing = {}) {
const folderKeys = this.getMissingSupportedFolderKeys(missing);
const seenPaths = new Set();
const details = [];
const normalizePathIdentity = (value = '') => String(value || '')
.replace(/[\\/]+/g, '/')
.replace(/\/+$/g, '')
.toLowerCase();
folderKeys.forEach(folderKey => {
const normalizedCategory = this.normalizeDownloadCategory?.(folderKey) || folderKey;
const label = this.getCategoryDisplayName?.(normalizedCategory) || folderKey;
const roots = this.downloadRootDirectories?.[folderKey]
|| this.downloadRootDirectories?.[normalizedCategory];
const paths = Array.isArray(roots)
? roots
: (roots ? [roots] : []);
if (!paths.length) {
details.push({
category: normalizedCategory,
folderKey,
label,
path: '',
display: label && label !== folderKey ? `${label} (${folderKey})` : folderKey
});
return;
}
paths.forEach(path => {
const cleanPath = this.normalizeMissingFolderRootPath(folderKey, path);
if (!cleanPath) return;
const identity = `${normalizedCategory}::${normalizePathIdentity(cleanPath)}`;
if (seenPaths.has(identity)) return;
seenPaths.add(identity);
details.push({
category: normalizedCategory,
folderKey,
label,
path: cleanPath,
display: `${label}: ${cleanPath}`
});
});
});
return details;
},
renderMissingNodeFolderBadge(missing = {}) {
const details = this.getMissingSupportedFolderDetails(missing);
if (!details.length) return '';
const folderCount = details.length;
const tooltip = folderCount === 1
? `This node reads models from 1 folder:\n${details[0].display}`
: `This node reads models from ${folderCount} folders:\n${details.map(detail => detail.display).join('\n')}`;
const ariaLabel = folderCount === 1
? `This node reads models from 1 folder`
: `This node reads models from ${folderCount} folders`;
return `<span class="mr-node-folder-count-badge" data-tooltip="${this.escapeHtml(tooltip)}" tabindex="0" aria-label="${this.escapeHtml(ariaLabel)}">${this.escapeHtml(String(folderCount))}</span>`;
},
getBestLocalMatch(missing = {}, minConfidence = 0) {
const matches = Array.isArray(missing.matches) ? missing.matches : [];
return matches
@@ -1297,11 +1456,23 @@ export const missingBrowserMethods = {
return;
}
if (!this.downloadDirectories) {
const needsDownloadDirectories = !this.downloadDirectories;
const needsDownloadRootDirectories = !this.downloadRootDirectories
&& typeof this.ensureDownloadRootDirectoriesLoaded === 'function';
if (needsDownloadDirectories || needsDownloadRootDirectories) {
const renderToken = `download-dirs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._downloadDirectoriesRenderToken = renderToken;
container.innerHTML = this.renderStatusMessage('Loading download folders...', 'info');
this.ensureDownloadDirectoriesLoaded?.().then(() => {
container.innerHTML = this.renderStatusMessage('Loading model folders...', 'info');
const loaders = [];
if (needsDownloadDirectories) {
loaders.push(this.ensureDownloadDirectoriesLoaded?.());
}
if (needsDownloadRootDirectories) {
loaders.push(this.ensureDownloadRootDirectoriesLoaded());
}
Promise.allSettled(loaders).then(() => {
if (
this._downloadDirectoriesRenderToken === renderToken &&
this.activeTab === 'missing' &&
@@ -1469,6 +1640,7 @@ export const missingBrowserMethods = {
html += this.getLocateIconHtml();
}
html += `${nodeChipText}</span>`;
html += this.renderMissingNodeFolderBadge(missing);
html += `</div>`;
html += `</div>`;
html += `</div>`;