diff --git a/__init__.py b/__init__.py index 3e3cd5a..d349e36 100644 --- a/__init__.py +++ b/__init__.py @@ -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") diff --git a/core/settings.py b/core/settings.py index 1579de1..188bc34 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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"), diff --git a/core/type_utils.py b/core/type_utils.py index 8071aa9..ce04f0f 100644 --- a/core/type_utils.py +++ b/core/type_utils.py @@ -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", diff --git a/core/workflow_analyzer.py b/core/workflow_analyzer.py index c036768..ec42576 100644 --- a/core/workflow_analyzer.py +++ b/core/workflow_analyzer.py @@ -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, diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 6cebeac..3d443e0 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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; diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index e484577..3b9ab18 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -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 || '', diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index db0d65e..f002400 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -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 ); }, diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index fa63393..a84fae2 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -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, diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 5a81dd0..b2961e9 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -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 `${this.escapeHtml(String(folderCount))}`; + }, + 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}`; + html += this.renderMissingNodeFolderBadge(missing); html += ``; html += ``; html += ``;