From 7b972d6dfa26dcd8fb18d01f88d1de86b1cea431 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 1 Nov 2025 19:15:03 +0100 Subject: [PATCH] chore: Rename comfyui-Model-Linker to comfyui-model-linker (kebab-case) --- .gitignore | 31 + README.md | 42 + __init__.py | 152 ++++ __pycache__/__init__.cpython-313.pyc | Bin 0 -> 7328 bytes core/__init__.py | 4 + core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 242 bytes core/__pycache__/linker.cpython-313.pyc | Bin 0 -> 6200 bytes core/__pycache__/matcher.cpython-313.pyc | Bin 0 -> 4790 bytes core/__pycache__/scanner.cpython-313.pyc | Bin 0 -> 5560 bytes .../workflow_analyzer.cpython-313.pyc | Bin 0 -> 8565 bytes .../workflow_updater.cpython-313.pyc | Bin 0 -> 8014 bytes core/linker.py | 197 ++++ core/matcher.py | 167 ++++ core/scanner.py | 148 ++++ core/workflow_analyzer.py | 266 ++++++ core/workflow_updater.py | 288 ++++++ pyproject.toml | 7 + web/linker.js | 838 ++++++++++++++++++ 18 files changed, 2140 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-313.pyc create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-313.pyc create mode 100644 core/__pycache__/linker.cpython-313.pyc create mode 100644 core/__pycache__/matcher.cpython-313.pyc create mode 100644 core/__pycache__/scanner.cpython-313.pyc create mode 100644 core/__pycache__/workflow_analyzer.cpython-313.pyc create mode 100644 core/__pycache__/workflow_updater.cpython-313.pyc create mode 100644 core/linker.py create mode 100644 core/matcher.py create mode 100644 core/scanner.py create mode 100644 core/workflow_analyzer.py create mode 100644 core/workflow_updater.py create mode 100644 pyproject.toml create mode 100644 web/linker.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c75cae6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temporary files +*.tmp +*.bak +*.cache + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b47b6aa --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# ComfyUI Model Linker Extension + +A ComfyUI extension that helps users relink missing models in workflows using fuzzy matching. + +## Features + +- Scans all nodes in workflows to find missing models +- Uses fuzzy matching to suggest similar model files +- Updates workflow JSON in UI/memory (user saves themselves) +- Supports all node types (not just specific ones) +- Optional auto-resolve for 100% confidence matches + +## Installation + +1. Clone or download this repository +2. Place it in your ComfyUI `custom_nodes/` directory +3. Restart ComfyUI + +## Usage + +1. Open a workflow with missing models +2. Click the "🔗 Model Linker" button in ComfyUI's top menu bar +3. Review missing models and their suggested matches (with confidence scores) +4. Click "Resolve" for individual models or "Auto-Resolve 100% Matches" for perfect matches +5. Save your workflow when ready + +## Features + +- **Subgraph Support**: Automatically detects and handles missing models inside subgraphs +- **Smart Matching**: Shows 100% confidence matches when available, otherwise shows best matches (≥70% confidence) +- **Fuzzy Matching**: Uses intelligent similarity scoring to find model files even with different naming +- **Auto-Resolve**: One-click resolution for all perfect matches + +## Requirements + +- Python 3.8+ +- ComfyUI + +## License + +See LICENSE file for details. + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..177db98 --- /dev/null +++ b/__init__.py @@ -0,0 +1,152 @@ +""" +@author: Model Linker Team +@title: ComfyUI Model Linker +@nickname: Model Linker +@version: 1.0.0 +@description: Extension for relinking missing models in ComfyUI workflows using fuzzy matching +""" + +import logging + +# Web directory for JavaScript interface +WEB_DIRECTORY = "./web" + +# Empty NODE_CLASS_MAPPINGS - we don't provide custom nodes, only web extension +# This prevents ComfyUI from showing "IMPORT FAILED" message +NODE_CLASS_MAPPINGS = {} + +__all__ = ["WEB_DIRECTORY"] + + +class ModelLinkerExtension: + """Main extension class for Model Linker.""" + + def __init__(self): + self.routes_setup = False + self.logger = logging.getLogger(__name__) + + def initialize(self): + """Initialize the extension and set up API routes.""" + try: + self.setup_routes() + self.logger.info("Model Linker: Extension initialized successfully") + except Exception as e: + self.logger.error(f"Model Linker: Extension initialization failed: {e}", exc_info=True) + + def setup_routes(self): + """Register API routes for the Model Linker extension.""" + if self.routes_setup: + return # Already set up + + try: + from aiohttp import web + + # Try to get routes from PromptServer + try: + from server import PromptServer + if not hasattr(PromptServer, 'instance') or PromptServer.instance is None: + self.logger.debug("Model Linker: PromptServer not available yet") + return False + + routes = PromptServer.instance.routes + except (ImportError, AttributeError) as e: + self.logger.debug(f"Model Linker: Could not access PromptServer: {e}") + return False + + # Import linker modules - use relative imports which should work for packages + try: + from .core.linker import analyze_and_find_matches, apply_resolution + from .core.scanner import get_model_files + except ImportError as e: + self.logger.error(f"Model Linker: Could not import core modules: {e}") + return False + + @routes.post("/model_linker/analyze") + async def analyze_workflow(request): + """Analyze workflow and return missing models with matches.""" + try: + data = await request.json() + workflow_json = data.get('workflow') + + if not workflow_json: + return web.json_response( + {'error': 'Workflow JSON is required'}, + status=400 + ) + + # Analyze and find matches + result = analyze_and_find_matches(workflow_json) + + return web.json_response(result) + except Exception as e: + self.logger.error(f"Model Linker analyze error: {e}", exc_info=True) + return web.json_response( + {'error': str(e)}, + status=500 + ) + + @routes.post("/model_linker/resolve") + async def resolve_models(request): + """Apply model resolution and return updated workflow.""" + try: + data = await request.json() + workflow_json = data.get('workflow') + resolutions = data.get('resolutions', []) + + if not workflow_json: + return web.json_response( + {'error': 'Workflow JSON is required'}, + status=400 + ) + + if not resolutions: + return web.json_response( + {'error': 'Resolutions array is required'}, + status=400 + ) + + # Apply resolutions + updated_workflow = apply_resolution(workflow_json, resolutions) + + return web.json_response({ + 'workflow': updated_workflow, + 'success': True + }) + except Exception as e: + self.logger.error(f"Model Linker resolve error: {e}", exc_info=True) + return web.json_response( + {'error': str(e), 'success': False}, + status=500 + ) + + @routes.get("/model_linker/models") + async def get_models(request): + """Get list of all available models (for debugging/UI display).""" + try: + models = get_model_files() + return web.json_response(models) + except Exception as e: + self.logger.error(f"Model Linker get_models error: {e}", exc_info=True) + return web.json_response( + {'error': str(e)}, + status=500 + ) + + self.routes_setup = True + self.logger.info("Model Linker: API routes registered successfully") + return True + + except ImportError as e: + self.logger.warning(f"Model Linker: Could not register routes (missing dependency): {e}") + return False + except Exception as e: + self.logger.error(f"Model Linker: Error setting up routes: {e}", exc_info=True) + return False + + +# Initialize the extension +try: + extension = ModelLinkerExtension() + extension.initialize() +except Exception as e: + logging.error(f"ComfyUI Model Linker extension initialization failed: {e}", exc_info=True) diff --git a/__pycache__/__init__.cpython-313.pyc b/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5a9320902991c222b00fbb7b1f6da9540e5d310 GIT binary patch literal 7328 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 ZOiRDjJxTuE9_?iRqa*5LH+?*i{|7*DqL}~y literal 0 HcmV?d00001 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..995e30b --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,4 @@ +""" +Core modules for Model Linker extension. +""" + diff --git a/core/__pycache__/__init__.cpython-313.pyc b/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b630ff6fb0fcdf5598639543f2654c6cec7dd4c GIT binary patch literal 242 zcmey&%ge<80$OaDW+FhdbTI-@32l@^zCeo?AIZhlH>PHM42 zT7Hp&Z+=Q@j)G5SUUq7cLTW`xYF=?>ex4qepC;oi_W1ae{N(ufl?h+oB7x1t?gQ v8WSI%nU`4-AFo$Xd5gma;v%~uHlQ^i#}|YA_JNs^k?|7~6C+Cz3y=!{G_OSZ literal 0 HcmV?d00001 diff --git a/core/__pycache__/linker.cpython-313.pyc b/core/__pycache__/linker.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85ef37909d04bf339e00cf018eb9a7ee66b8c0ab GIT binary patch literal 6200 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/matcher.cpython-313.pyc b/core/__pycache__/matcher.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..574f02763eea4e520083367cda66bb388deee3d5 GIT binary patch literal 4790 zcmbtYU2qfE6}~I2{&r={*kEdd%wjOMh^-LDCW%6z6elD$IIR~AZY@T$magO_(ynrM zg|X94)u)*B0s6qy^d-(y=^N80+UXCEd1=QQ&D748w#+osnY^@O@FcH2_wJ8m2b!65 zXSDb4+_UHByXTy%ryU&vf+uh~s3!L#^iMi)pH8Rq^mov?gJeXI%*noKj&Q8?5g)Yv zY5x>Y_^AL1Oa)0$qBQ(;Xevy?&<|uHaxfDa=jBj8y2cYh4#P}@&5GkeSzxo#U9%#a z?HE7<1EFAb84ZPgx}&Xs)lLtlWNYi0+*}6Gr?Ig8NXM>S`IrOwZa9pgy~i*NU&KY zT8^U(oQLYZtZ`{>HnfWfh7-<|#?6P_ z)ma7ucVxGNNcPG8G&ez|f`{?zu>0-r7QZxi6eakTe+x4@DJvy)1xpH;&sJJ0=tRot z<%&X7L)VgmFpJCjbqtmV3JKpZv1X{cHVQ=Abxgn|VEmH4gh@^@@TjDK8#I@(As84q zDW*;gX{4&96J>e2=&iOcs`pM%NSQ*usA6ZvV6K>@fF2fw8BQEq?(|f6=2F(K7uX3 zQC+YClwT#`Bh*P2zW`bql$Rt~>B&^)yk0IWr{B&l0sizQBU{mlsVtPRyO7CM4O1^? zHGrg%0m~MatLhja8<)l|s@l7lz$C#LhGk5Wm1V2jQ#9)ug6xGaQm-1{g;x!2A4Jj4 zTQA>yc~d-47Z0qx)ewi)mAW`mn`{PAwCh&tX6mP>Ym?hB=)866!&A3kSToiq8)9M? z!DBI2n|=yBK{ZhHCH6j4?oM%5If982oQEOW+o#8u z7znJWsgNQzvrIj!%hT7MFsz8V2*GzzFXb(r9-n3_w%h*Zox8~4v#ns`R^h09D2xuE@WTl zJo;|1|MPb6tsPn<9dY+4htN04>=949c6q^ym#zc9mKDMtoiKMTHHVM__Lc*h_zsyD zUH#|gTwOHrYwZ7wY~Ql99As5S^c(Q->6V^E5oN2l$1@KovRSenS?LylY8` z@`6|?QeX44G$QVLWGGkVsNC_Yk5b5;X+IChPV*4lV#+lTO?dMw6{I0m4O(8>MW1Y5?Cckq5x0}SsesJO{8AOS?a~2joRi}R<0Ib z_@MXGKY#h3v!CZ-{&Sk76TB5*Ca}vO81#ZtLu>D? zAN<9SSt7ce@L7JqqZJ|;E(r#nNr;4oHe2g3Eu!fai)B#D4=IZ!JVGOx$~0F#>@Z5sAJI)7K!ptdJv|6>|=w>ugJOCTME4A|5cTZm;VV*j8t2 zj$H`a#&kO~l*f?D-@b#5_q2Qg$vg{p34=yPirTivt^=a&Rwds7vT67k1#m$PHBX?} zp4x@&Fxubu^Wx8no4v>Cy~oyn*ytUvy#p0}&(M0d(LGU{{%qgSYUFYJ*hg3IUcHxU z#7}L6`@aaHuE8y5?$rI0FuNJgJV^X;>^Ebd#5TS^-$-RvXZ{|GKN=eT<&}@GYz|G- zhbHc8jiEO-x(5FhJN#*M?{+8ZAKe%|)95?9+WA=QZHOa}`wrfm0*U(1aSsnQ`ta%$ z2y!6)(eT~j&Hm%{{^R$~Hu_(CaIW5eW_9{e{Ln{N?q1o9kJaO28_91s;^!V7uE!@= zXC6rxJ`o#Iej^&E%;IZ5X!O0dI@J_VSN~eN5gS~8zcDHPiAd+A{Mq8KoR( zg0hhiJ`Kki{^dABC<5paG=2}Srk~@uCqACz;n;G*Us2>A=x7}s{Tn*c4EAwd>pe{b z&Apy&+B9G3;3n3LCW7YP5!T!v+NPtX6yuWjVG5cDGHV{Dx9O;P9DVD+*8aXHk$H~m z+3Mcc^wZAc-hQ}a_}At2@ZB@@-s8;xodpYa?Q4cuH;jOmZ34Fih1H|0#_jW~H*O~D Mfxa(9O2X9pFCQhed;kCd literal 0 HcmV?d00001 diff --git a/core/__pycache__/scanner.cpython-313.pyc b/core/__pycache__/scanner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a4a8c09d425180a3d8aea73c49e3ab4ed1619614 GIT binary patch literal 5560 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 Z6#7?XqO_kfosH{*{8cDVkGn6=#Xi-CD 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/workflow_updater.cpython-313.pyc b/core/__pycache__/workflow_updater.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfe9e9c02739f9c701be317a4e410c2e4d7d7c80 GIT binary patch literal 8014 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 Dict[str, Any]: + """ + Main entry point: analyze workflow and find matches for missing models. + + Args: + workflow_json: Complete workflow JSON dictionary + similarity_threshold: Minimum similarity score (0.0 to 1.0) for matches + max_matches_per_model: Maximum number of matches to return per missing model + + Returns: + Dictionary with analysis results: + { + 'missing_models': [ + { + 'node_id': node ID, + 'node_type': node type, + 'widget_index': widget index, + 'original_path': original path from workflow, + 'category': model category, + 'matches': [ + { + 'model': model dict from scanner, + 'filename': model filename, + 'similarity': similarity score (0.0-1.0), + 'confidence': confidence percentage (0-100) + }, + ... + ] + }, + ... + ], + 'total_missing': count of missing models, + 'total_models_analyzed': count of all models in workflow + } + """ + # Analyze workflow to find all model references + all_model_refs = analyze_workflow_models(workflow_json) + + # Get available models + available_models = get_model_files() + + # Identify missing models + missing_models = identify_missing_models(all_model_refs, available_models) + + # Find matches for each missing model + missing_with_matches = [] + for missing in missing_models: + original_path = missing.get('original_path', '') + + # Filter available models by category if known + # IMPORTANT: If category is 'unknown', we still try to find the right category + # by using node type hints + category = missing.get('category') + + # If category is unknown, try to use node type to infer category + if not category or category == 'unknown': + from .workflow_analyzer import NODE_TYPE_TO_CATEGORY_HINTS + node_type = missing.get('node_type', '') + category = NODE_TYPE_TO_CATEGORY_HINTS.get(node_type, 'unknown') + + candidates = available_models + if category and category != 'unknown': + # Prioritize models from the same category + candidates = [m for m in available_models if m.get('category') == category] + # Also include other categories as fallback + candidates.extend([m for m in available_models if m.get('category') != category]) + + # Find matches + matches = find_matches( + original_path, + candidates, + threshold=similarity_threshold, + max_results=max_matches_per_model + ) + + # Deduplicate matches by absolute path - same physical file should only appear once + # This handles cases where the same file exists in multiple base directories + # or has different relative_paths but is the same file + seen_absolute_paths = {} + deduplicated_matches = [] + for match in matches: + model_dict = match['model'] + absolute_path = model_dict.get('path', '') + + # Normalize absolute path for comparison + if absolute_path: + absolute_path = os.path.normpath(absolute_path) + + # If we haven't seen this absolute path, add it + if absolute_path not in seen_absolute_paths: + seen_absolute_paths[absolute_path] = match + deduplicated_matches.append(match) + else: + # If we've seen this absolute path before, replace with better match if confidence is higher + existing_match = seen_absolute_paths[absolute_path] + if match['confidence'] > existing_match['confidence']: + # Replace with better match + idx = deduplicated_matches.index(existing_match) + deduplicated_matches[idx] = match + seen_absolute_paths[absolute_path] = match + + missing_with_matches.append({ + **missing, + 'matches': deduplicated_matches + }) + + return { + 'missing_models': missing_with_matches, + 'total_missing': len(missing_with_matches), + 'total_models_analyzed': len(all_model_refs) + } + + +def apply_resolution( + workflow_json: Dict[str, Any], + resolutions: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + Apply model resolutions to workflow. + + Args: + workflow_json: Workflow JSON dictionary (will be modified) + resolutions: List of resolution dictionaries: + { + 'node_id': node ID, + 'widget_index': widget index, + 'resolved_path': absolute path to resolved model, + 'category': model category (optional), + 'resolved_model': model dict from scanner (optional) + } + + Returns: + Updated workflow JSON dictionary + """ + # Prepare mappings for workflow_updater + mappings = [] + for resolution in resolutions: + mapping = { + 'node_id': resolution.get('node_id'), + 'widget_index': resolution.get('widget_index'), + 'resolved_path': resolution.get('resolved_path'), + 'category': resolution.get('category'), + 'resolved_model': resolution.get('resolved_model'), + 'subgraph_id': resolution.get('subgraph_id'), # Include subgraph_id for subgraph nodes + 'is_top_level': resolution.get('is_top_level') # True for top-level nodes, False for nodes in subgraph definitions + } + + # If resolved_model provided, extract path if needed + if 'resolved_model' in resolution and resolution['resolved_model']: + resolved_model = resolution['resolved_model'] + if 'path' in resolved_model and not mapping.get('resolved_path'): + mapping['resolved_path'] = resolved_model['path'] + if 'base_directory' in resolved_model: + mapping['base_directory'] = resolved_model['base_directory'] + + mappings.append(mapping) + + # Update workflow + updated_workflow = update_workflow_nodes(workflow_json, mappings) + + return updated_workflow + + +def get_resolution_summary(workflow_json: Dict[str, Any]) -> Dict[str, Any]: + """ + Get summary of missing models and matches without applying resolutions. + + This is a convenience method that calls analyze_and_find_matches with defaults. + + Args: + workflow_json: Complete workflow JSON dictionary + + Returns: + Same format as analyze_and_find_matches + """ + return analyze_and_find_matches(workflow_json) + diff --git a/core/matcher.py b/core/matcher.py new file mode 100644 index 0000000..35deea7 --- /dev/null +++ b/core/matcher.py @@ -0,0 +1,167 @@ +""" +Fuzzy Matcher Module + +Implements fuzzy string matching to find similar model names. +""" + +import os +import re +from typing import List, Dict, Tuple +from difflib import SequenceMatcher + + +def normalize_filename(filename: str) -> str: + """ + Normalize a filename for comparison. + + Removes file extension, converts to lowercase, and normalizes + separators (underscores, hyphens, spaces). + + Args: + filename: Filename to normalize + + Returns: + Normalized string for comparison + """ + # Remove file extension + base = os.path.splitext(filename)[0] + + # Convert to lowercase + base = base.lower() + + # Normalize separators: replace underscores, hyphens, and spaces with a single space + base = re.sub(r'[_\-\s]+', ' ', base) + + # Strip whitespace + base = base.strip() + + return base + + +def calculate_similarity(str1: str, str2: str) -> float: + """ + Calculate similarity score between two strings (0.0 to 1.0). + + Uses SequenceMatcher to compute a ratio. + + Args: + str1: First string + str2: Second string + + Returns: + Similarity score from 0.0 (completely different) to 1.0 (identical) + """ + return SequenceMatcher(None, str1, str2).ratio() + + +def calculate_similarity_with_normalization(str1: str, str2: str) -> float: + """ + Calculate similarity score with filename normalization. + + Normalizes both strings before comparing. + + Args: + str1: First string (typically model filename) + str2: Second string (typically candidate model filename) + + Returns: + Similarity score from 0.0 to 1.0 + """ + norm1 = normalize_filename(str1) + norm2 = normalize_filename(str2) + return calculate_similarity(norm1, norm2) + + +def find_matches( + target_model: str, + candidate_models: List[Dict[str, str]], + threshold: float = 0.0, + max_results: int = 10 +) -> List[Dict[str, any]]: + """ + Find similar models using fuzzy matching. + + Args: + target_model: The target model filename/path to match + candidate_models: List of candidate model dictionaries with 'filename' or 'path' key + threshold: Minimum similarity score (0.0 to 1.0) to include in results + max_results: Maximum number of results to return + + Returns: + List of match dictionaries sorted by similarity (highest first): + { + 'model': original model dict from candidates, + 'filename': model filename, + 'similarity': similarity score (0.0 to 1.0), + 'confidence': confidence percentage (0 to 100) + } + """ + matches = [] + + # Extract just the filename from target_model (remove any subfolder paths) + # target_model might be just a filename or might include subfolder paths + target_filename = os.path.basename(target_model) + + # Normalize target filename once for exact match comparisons + target_norm = normalize_filename(target_filename) + + for candidate in candidate_models: + # Get filename from candidate (prefer 'filename' key, fallback to extracting from 'path' or 'relative_path') + candidate_filename = candidate.get('filename') + + # If no filename key, try to extract from path or relative_path + if not candidate_filename: + candidate_path = candidate.get('path', '') or candidate.get('relative_path', '') + if candidate_path: + candidate_filename = os.path.basename(candidate_path) + + if not candidate_filename: + continue + + # Calculate similarity comparing just filenames (not paths) + # This ensures we're comparing apples to apples + + # First check for exact match (after normalization) - should be 100% + # Only exact matches should get 100% confidence + candidate_norm = normalize_filename(candidate_filename) + + if target_norm == candidate_norm: + # Exact match after normalization = 100% confidence + similarity = 1.0 + else: + # Calculate similarity using SequenceMatcher + # This gives a ratio between 0.0 and 1.0 based on longest common subsequence + similarity = calculate_similarity_with_normalization(target_filename, candidate_filename) + + # Also try comparing without extensions for better matching + target_base = os.path.splitext(target_filename)[0] + candidate_base = os.path.splitext(candidate_filename)[0] + similarity_no_ext = calculate_similarity_with_normalization(target_base, candidate_base) + + # Use the higher of the two similarity scores + # But ensure we never get 1.0 unless it's an exact normalized match + similarity = max(similarity, similarity_no_ext) + + # Cap similarity at 0.999 for non-exact matches to prevent false 100% scores + # SequenceMatcher can sometimes give 1.0 for very similar but not identical strings + # due to normalization artifacts + if similarity >= 0.999 and target_norm != candidate_norm: + similarity = 0.999 + + # Only include if above threshold + if similarity >= threshold: + matches.append({ + 'model': candidate, + 'filename': candidate_filename, + 'similarity': similarity, + 'confidence': round(similarity * 100, 1) # Convert to percentage + }) + + # Sort by similarity (highest first) + matches.sort(key=lambda x: x['similarity'], reverse=True) + + # Limit to max_results + matches = matches[:max_results] + + return matches + diff --git a/core/scanner.py b/core/scanner.py new file mode 100644 index 0000000..8bd6ad6 --- /dev/null +++ b/core/scanner.py @@ -0,0 +1,148 @@ +""" +Directory Scanner Module + +Scans configured model directories and finds available model files. +""" + +import os +import logging +from typing import List, Dict, Tuple + +# Import folder_paths lazily - it may not be available until ComfyUI is initialized +try: + import folder_paths +except ImportError: + folder_paths = None + logging.warning("Model Linker: folder_paths not available yet - will retry later") + +# Model file extensions to look for +# This matches folder_paths.supported_pt_extensions +MODEL_EXTENSIONS = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.onnx'} + + +def get_model_directories() -> Dict[str, Tuple[List[str], set]]: + """ + Get all configured model directories from folder_paths. + + Returns: + Dictionary mapping category name to (list of paths, set of extensions) + """ + global folder_paths + + if folder_paths is None: + # Try to import again + try: + import folder_paths as fp + folder_paths = fp + except ImportError: + logging.error("Model Linker: folder_paths still not available") + return {} + + return folder_paths.folder_names_and_paths.copy() + + +def scan_directory(directory: str, extensions: set, category: str) -> List[Dict[str, str]]: + """ + Recursively scan a single directory for model files. + + Args: + directory: Absolute path to directory to scan + extensions: Set of file extensions to look for + category: Model category name (e.g., 'checkpoints', 'loras') + + Returns: + List of dictionaries with model information: + { + 'filename': 'model.safetensors', + 'path': 'absolute/path/to/model.safetensors', + 'relative_path': 'subfolder/model.safetensors' or 'model.safetensors', + 'category': 'checkpoints', + 'base_directory': 'absolute/path/to/base' + } + """ + models = [] + + if not os.path.exists(directory) or not os.path.isdir(directory): + logging.debug(f"Directory does not exist or is not accessible: {directory}") + return models + + try: + # Get absolute path and normalize + base_directory = os.path.abspath(directory) + + # Walk through directory recursively + for root, dirs, files in os.walk(base_directory, followlinks=True): + # Skip hidden directories + dirs[:] = [d for d in dirs if not d.startswith('.')] + + for filename in files: + # Check if file has a model extension + file_ext = os.path.splitext(filename)[1].lower() + + # For categories with empty extension set, accept all files + # Otherwise, check if extension matches + if len(extensions) == 0 or file_ext in extensions or file_ext in MODEL_EXTENSIONS: + full_path = os.path.join(root, filename) + + # Calculate relative path from base directory + # IMPORTANT: Use OS-native path separators (backslashes on Windows) + # This matches ComfyUI's recursive_search format for get_filename_list + try: + relative_path = os.path.relpath(full_path, base_directory) + # DO NOT normalize - keep OS-native separators to match ComfyUI + # ComfyUI's get_filename_list uses os.path.relpath which returns + # backslashes on Windows, forward slashes on Unix + except ValueError: + # If paths are on different drives (Windows), use filename only + relative_path = filename + + models.append({ + 'filename': filename, + 'path': full_path, + 'relative_path': relative_path, + 'category': category, + 'base_directory': base_directory + }) + except (OSError, PermissionError) as e: + logging.warning(f"Error scanning directory {directory}: {e}") + + return models + + +def scan_all_directories() -> List[Dict[str, str]]: + """ + Scan all configured model directories and return list of available models. + + Returns: + List of dictionaries with model information (same format as scan_directory) + """ + all_models = [] + directories = get_model_directories() + + for category, (paths, extensions) in directories.items(): + # Skip categories that aren't typically model directories + if category in ['custom_nodes', 'configs']: + continue + + for directory_path in paths: + try: + models = scan_directory(directory_path, extensions, category) + all_models.extend(models) + logging.debug(f"Found {len(models)} models in {category}/{directory_path}") + except Exception as e: + logging.warning(f"Error scanning {category} directory {directory_path}: {e}") + + return all_models + + +def get_model_files() -> List[Dict[str, str]]: + """ + Get list of all available model files with metadata. + + This is the main entry point for getting model files. + + Returns: + List of model dictionaries (same format as scan_directory) + """ + return scan_all_directories() + diff --git a/core/workflow_analyzer.py b/core/workflow_analyzer.py new file mode 100644 index 0000000..84d9c88 --- /dev/null +++ b/core/workflow_analyzer.py @@ -0,0 +1,266 @@ +""" +Workflow Analyzer Module + +Extracts model references from workflow JSON and identifies missing models. +""" + +import os +import logging +from typing import List, Dict, Any, Optional + +# Import folder_paths lazily - it may not be available until ComfyUI is initialized +try: + import folder_paths +except ImportError: + folder_paths = None + logging.warning("Model Linker: folder_paths not available yet - will retry later") + + +# Common model file extensions +MODEL_EXTENSIONS = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.onnx'} + +# Mapping of common node types to their expected model category +# This is used as hints but we don't rely solely on this +# UNETLoader uses 'diffusion_models' category (folder_paths maps 'unet' to 'diffusion_models') +NODE_TYPE_TO_CATEGORY_HINTS = { + 'CheckpointLoaderSimple': 'checkpoints', + 'CheckpointLoader': 'checkpoints', + 'unCLIPCheckpointLoader': 'checkpoints', + 'VAELoader': 'vae', + 'LoraLoader': 'loras', + 'LoraLoaderModelOnly': 'loras', + 'UNETLoader': 'diffusion_models', # UNETLoader uses diffusion_models category + 'ControlNetLoader': 'controlnet', + 'ControlNetLoaderAdvanced': 'controlnet', + 'CLIPVisionLoader': 'clip_vision', + 'UpscaleModelLoader': 'upscale_models', + 'HypernetworkLoader': 'hypernetworks', + 'EmbeddingLoader': 'embeddings', +} + + +def is_model_filename(value: Any) -> bool: + """ + Check if a value looks like a model filename. + + Args: + value: The value to check + + Returns: + True if it looks like a model filename + """ + if not isinstance(value, str): + return False + + # Check if it ends with a model extension + _, ext = os.path.splitext(value.lower()) + return ext in MODEL_EXTENSIONS + + +def try_resolve_model_path(value: str, categories: List[str] = None) -> Optional[tuple[str, str]]: + """ + Try to resolve a model path using folder_paths. + + Args: + value: The model filename/path to resolve + categories: Optional list of categories to try (if None, tries all) + + Returns: + Tuple of (category, full_path) if found, None otherwise + """ + if not isinstance(value, str) or not value.strip(): + return None + + # Remove any path separators that might indicate an absolute path prefix + # Workflows should store relative paths, but handle both cases + filename = value.strip() + + # Ensure folder_paths is available + global folder_paths + if folder_paths is None: + try: + import folder_paths as fp + folder_paths = fp + except ImportError: + logging.error("Model Linker: folder_paths not available") + return None + + # If categories not provided, try all categories + if categories is None: + categories = list(folder_paths.folder_names_and_paths.keys()) + + # Skip non-model categories + skip_categories = {'custom_nodes', 'configs'} + categories = [c for c in categories if c not in skip_categories] + + for category in categories: + try: + full_path = folder_paths.get_full_path(category, filename) + if full_path and os.path.exists(full_path): + return (category, full_path) + except Exception: + continue + + return None + + +def get_node_model_info(node: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Extract model references from a single node. + + This scans all widgets_values entries and tries to identify which ones + are model file references by attempting to resolve them. + + Args: + node: Node dictionary from workflow JSON + + Returns: + List of model reference dictionaries: + { + 'node_id': node id, + 'node_type': node type, + 'widget_index': index in widgets_values, + 'original_path': original path from workflow, + 'category': model category (if found), + 'exists': True if model exists + } + """ + model_refs = [] + node_id = node.get('id') + node_type = node.get('type', '') + widgets_values = node.get('widgets_values', []) + + if not widgets_values: + return model_refs + + # Get category hints for this node type + category_hint = NODE_TYPE_TO_CATEGORY_HINTS.get(node_type) + categories_to_try = [category_hint] if category_hint else None + + # For each widget value, check if it looks like a model file + for idx, value in enumerate(widgets_values): + if not is_model_filename(value): + continue + + # Try to resolve the model path + resolved = try_resolve_model_path(value, categories_to_try) + + if resolved: + category, full_path = resolved + exists = os.path.exists(full_path) + else: + # If we can't resolve it, check if it at least looks like a model filename + # This might be a missing model or a custom node's model + category = category_hint or 'unknown' + full_path = None + exists = False + + model_refs.append({ + 'node_id': node_id, + 'node_type': node_type, + 'widget_index': idx, + 'original_path': value, + 'category': category, + 'full_path': full_path, + 'exists': exists + }) + + return model_refs + + +def analyze_workflow_models(workflow_json: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Extract all model references from a workflow, including nested subgraphs. + + Args: + workflow_json: Complete workflow JSON dictionary + + Returns: + List of model reference dictionaries (same format as get_node_model_info) + Each dict includes 'subgraph_id' if the model is in a subgraph + """ + all_model_refs = [] + + # Get subgraph definitions first to check if node types are subgraph UUIDs + definitions = workflow_json.get('definitions', {}) + subgraphs = definitions.get('subgraphs', []) + subgraph_lookup = {sg.get('id'): sg.get('name', sg.get('id')) for sg in subgraphs} + + # Analyze top-level nodes + nodes = workflow_json.get('nodes', []) + for node in nodes: + try: + model_refs = get_node_model_info(node) + node_type = node.get('type', '') + + # Check if node type is a subgraph UUID + subgraph_name = None + subgraph_id = None + if node_type in subgraph_lookup: + subgraph_name = subgraph_lookup[node_type] + subgraph_id = node_type + + # Mark with subgraph info if it's a subgraph node + # For top-level subgraph instance nodes, subgraph_path is None + # This distinguishes them from nodes within subgraph definitions + for ref in model_refs: + ref['subgraph_id'] = subgraph_id + ref['subgraph_name'] = subgraph_name + ref['subgraph_path'] = None # Top-level, not in definitions.subgraphs + ref['is_top_level'] = True # Flag to indicate this is a top-level node + all_model_refs.extend(model_refs) + except Exception as e: + logging.warning(f"Error analyzing node {node.get('id', 'unknown')}: {e}") + continue + + # Recursively analyze subgraphs (definitions already loaded above) + if not subgraphs: # Re-get if not loaded above + subgraphs = definitions.get('subgraphs', []) + + for subgraph in subgraphs: + subgraph_id = subgraph.get('id') + subgraph_name = subgraph.get('name', subgraph_id) + subgraph_nodes = subgraph.get('nodes', []) + + logging.debug(f"Analyzing subgraph: {subgraph_name} (ID: {subgraph_id}) with {len(subgraph_nodes)} nodes") + + for node in subgraph_nodes: + try: + model_refs = get_node_model_info(node) + # Mark as belonging to this subgraph definition + for ref in model_refs: + ref['subgraph_id'] = subgraph_id + ref['subgraph_name'] = subgraph_name + ref['subgraph_path'] = ['definitions', 'subgraphs', subgraph_id, 'nodes'] + ref['is_top_level'] = False # This is inside a subgraph definition + all_model_refs.extend(model_refs) + except Exception as e: + logging.warning(f"Error analyzing subgraph node {node.get('id', 'unknown')}: {e}") + continue + + return all_model_refs + + +def identify_missing_models( + workflow_models: List[Dict[str, Any]], + available_models: List[Dict[str, str]] = None +) -> List[Dict[str, Any]]: + """ + Identify which models from the workflow are missing. + + Args: + workflow_models: List of model references from analyze_workflow_models + available_models: Optional list of available models (if None, checks via folder_paths) + + Returns: + List of missing model references (filtered to only missing ones) + """ + missing = [] + + for model_ref in workflow_models: + # If exists is False, it's missing + if not model_ref.get('exists', False): + missing.append(model_ref) + + return missing + diff --git a/core/workflow_updater.py b/core/workflow_updater.py new file mode 100644 index 0000000..735f053 --- /dev/null +++ b/core/workflow_updater.py @@ -0,0 +1,288 @@ +""" +Workflow Updater Module + +Updates workflow JSON by replacing model paths in nodes. +""" + +import os +import logging +from typing import Dict, Any, List, Optional + + +def convert_to_relative_path(absolute_path: str, category: str, base_directory: str = None) -> str: + """ + Convert an absolute path to a relative path for workflow storage. + + This should match the format that ComfyUI's get_filename_list() returns, + which uses relative paths from the category base directory with forward slashes. + + Args: + absolute_path: Full absolute path to the model file + category: Model category (e.g., 'checkpoints', 'loras') + base_directory: Optional base directory for the category + + Returns: + Relative path (filename or subfolder/filename) suitable for workflow storage + This MUST match the format ComfyUI uses for validation + """ + if not absolute_path or not os.path.isabs(absolute_path): + # Already relative or empty - return as-is (keep OS-native separators) + # Don't normalize path separators - must match ComfyUI's format exactly + return absolute_path + + # Use folder_paths.get_filename_list to find the exact format ComfyUI expects + # CRITICAL: ComfyUI uses OS-native path separators (backslashes on Windows, forward slashes on Unix) + # We must return the EXACT format from get_filename_list, not a normalized version + try: + import folder_paths + # Get all available filenames for this category + # This returns paths with OS-native separators (backslashes on Windows) + available_filenames = folder_paths.get_filename_list(category) + + # Try to find a matching entry in ComfyUI's list + # Compare by finding the file that resolves to our absolute path + for filename in available_filenames: + try: + full_path = folder_paths.get_full_path(category, filename) + if full_path and os.path.normpath(full_path) == os.path.normpath(absolute_path): + # Found exact match - return ComfyUI's format EXACTLY as-is + # This includes OS-native path separators + return filename + except Exception: + continue + except Exception: + # Fall back to manual calculation if folder_paths not available + pass + + # If base_directory is provided, calculate relative to it + # IMPORTANT: Use OS-native path separators (don't normalize to forward slashes) + # ComfyUI expects paths with backslashes on Windows, forward slashes on Unix + if base_directory: + try: + relative_path = os.path.relpath(absolute_path, base_directory) + # DO NOT normalize path separators - use OS-native format + # This matches what ComfyUI's recursive_search returns + return relative_path + except ValueError: + # Paths are on different drives (Windows) or can't be relativized + # Fall back to just filename + pass + + # Fallback: return just the filename + return os.path.basename(absolute_path) + + +def get_base_directory_for_model(model_dict: Dict[str, str], category: str) -> Optional[str]: + """ + Get the base directory for a model based on its metadata. + + Args: + model_dict: Model dictionary with 'base_directory' or 'path' key + category: Model category + + Returns: + Base directory path if found, None otherwise + """ + # Try to get base_directory from model dict + if 'base_directory' in model_dict: + return model_dict['base_directory'] + + # If we have the full path, try to find the category base directory + if 'path' in model_dict: + full_path = model_dict['path'] + # Import here to avoid circular dependency + import folder_paths + + # Try to get category directories + if category in folder_paths.folder_names_and_paths: + category_paths = folder_paths.get_folder_paths(category) + # Find which base directory this path belongs to + for base_dir in category_paths: + try: + if os.path.commonpath([full_path, base_dir]) == base_dir: + return base_dir + except (ValueError, FileNotFoundError): + continue + + return None + + +def update_model_path( + workflow: Dict[str, Any], + node_id: int, + widget_index: int, + resolved_path: str, + category: str = None, + base_directory: str = None, + resolved_model: Dict[str, Any] = None, + subgraph_id: str = None, + is_top_level: bool = None +) -> bool: + """ + Update a single model path in a workflow node, supporting both top-level and subgraph nodes. + + Args: + workflow: Workflow JSON dictionary + node_id: ID of the node to update + widget_index: Index in widgets_values array to update + resolved_path: Absolute path to the resolved model + category: Model category (optional, for calculating relative path) + base_directory: Base directory for the category (optional) + resolved_model: Model dict from scanner (optional) + subgraph_id: ID of the subgraph (UUID for subgraph type, or None) + is_top_level: True if this is a top-level node (even if it's a subgraph instance), + False if it's inside a subgraph definition, None to auto-detect + + Returns: + True if update was successful, False otherwise + """ + node = None + + # Determine if this is a top-level node or inside a subgraph definition + # - If is_top_level is True, it's a top-level node (even if it's a subgraph instance) + # - If is_top_level is False, it's inside a subgraph definition + # - If is_top_level is None and subgraph_id is set, check if node exists in top-level first + search_in_subgraph = False + + if is_top_level is False: + # Explicitly inside a subgraph definition + search_in_subgraph = True + elif is_top_level is True: + # Explicitly a top-level node + search_in_subgraph = False + elif subgraph_id: + # Auto-detect: Check if node exists in top-level nodes first + # (Top-level subgraph instances have subgraph_id set but are in workflow.nodes) + nodes = workflow.get('nodes', []) + for n in nodes: + if n.get('id') == node_id: + # Found in top-level - this is a subgraph instance node + search_in_subgraph = False + break + else: + # Not found in top-level - must be inside subgraph definition + search_in_subgraph = True + else: + # No subgraph_id - definitely top-level + search_in_subgraph = False + + # Search for the node + if search_in_subgraph: + # Find in subgraph definition + definitions = workflow.get('definitions', {}) + subgraphs = definitions.get('subgraphs', []) + + for subgraph in subgraphs: + if subgraph.get('id') == subgraph_id: + subgraph_nodes = subgraph.get('nodes', []) + for n in subgraph_nodes: + if n.get('id') == node_id: + node = n + break + break + else: + # Find in top-level nodes + nodes = workflow.get('nodes', []) + for n in nodes: + if n.get('id') == node_id: + node = n + break + + if not node: + location = f"subgraph {subgraph_id}" if subgraph_id else "top-level" + logging.warning(f"Node {node_id} not found in {location}") + return False + + widgets_values = node.get('widgets_values', []) + + if widget_index >= len(widgets_values): + logging.warning(f"Widget index {widget_index} out of range for node {node_id}") + return False + + # Get category from resolved_model if not provided + if not category and resolved_model: + category = resolved_model.get('category') + + # Convert absolute path to relative path for workflow storage + # IMPORTANT: Use the category from resolved_model, not the original missing model category + # This ensures we use the correct category for validation + if os.path.isabs(resolved_path): + # Use category from resolved_model for path conversion + effective_category = category + if resolved_model: + effective_category = resolved_model.get('category', category) + + relative_path = convert_to_relative_path(resolved_path, effective_category, base_directory) + else: + relative_path = resolved_path + + # Update the widget value + widgets_values[widget_index] = relative_path + + logging.debug(f"Updated node {node_id}, widget {widget_index} to: {relative_path}") + return True + + +def update_workflow_nodes( + workflow: Dict[str, Any], + mappings: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + Apply multiple model path changes to a workflow. + + Args: + workflow: Workflow JSON dictionary (will be modified in place) + mappings: List of mapping dictionaries: + { + 'node_id': node ID, + 'widget_index': widget index, + 'resolved_path': absolute path to resolved model, + 'category': model category (optional), + 'base_directory': base directory for category (optional), + 'resolved_model': model dict from scanner (optional, for base_directory) + } + + Returns: + Updated workflow dictionary (same reference, modified in place) + """ + updated_count = 0 + + for mapping in mappings: + node_id = mapping.get('node_id') + widget_index = mapping.get('widget_index') + resolved_path = mapping.get('resolved_path') + + if not all([node_id is not None, widget_index is not None, resolved_path]): + logging.warning(f"Invalid mapping: {mapping}") + continue + + # Try to get base_directory from resolved_model if provided + base_directory = mapping.get('base_directory') + if not base_directory and 'resolved_model' in mapping: + resolved_model = mapping['resolved_model'] + category = mapping.get('category', '') + base_directory = get_base_directory_for_model(resolved_model, category) + + category = mapping.get('category') + resolved_model = mapping.get('resolved_model') + subgraph_id = mapping.get('subgraph_id') + is_top_level = mapping.get('is_top_level') # True for top-level nodes, False for nodes in subgraph definitions + + success = update_model_path( + workflow, + node_id, + widget_index, + resolved_path, + category, + base_directory, + resolved_model, + subgraph_id, + is_top_level + ) + + if success: + updated_count += 1 + + logging.info(f"Updated {updated_count} model paths in workflow") + return workflow + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d89908b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "comfyui-model-linker" +version = "0.1.0" +description = "A ComfyUI extension that helps users relink missing models in workflows" +readme = "README.md" +requires-python = ">=3.8" + diff --git a/web/linker.js b/web/linker.js new file mode 100644 index 0000000..e4d0e01 --- /dev/null +++ b/web/linker.js @@ -0,0 +1,838 @@ +/** + * ComfyUI Model Linker Extension - Frontend + * + * Provides a menu button and dialog interface for relinking missing models in workflows. + */ + +// Import ComfyUI APIs +// These paths are relative to the ComfyUI web directory +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; +import { $el, ComfyDialog } from "../../../scripts/ui.js"; + +// Check if ComfyButtonGroup is available (from newer ComfyUI versions) +let ComfyButtonGroup = null; +try { + // Try to import from scripts if available + if (typeof window !== 'undefined') { + try { + // Some ComfyUI versions expose this globally + if (window.ComfyButtonGroup) { + ComfyButtonGroup = window.ComfyButtonGroup; + } + } catch (e) { + // Ignore + } + } +} catch (e) { + // Fallback if ComfyButtonGroup not available +} + +class LinkerManagerDialog extends ComfyDialog { + constructor() { + super(); + this.currentWorkflow = null; + this.missingModels = []; + + // Create dialog element using $el + this.element = $el("div.comfy-modal", { + parent: document.body, + style: { + position: "fixed", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "900px", + height: "700px", + maxWidth: "95vw", + maxHeight: "95vh", + backgroundColor: "var(--comfy-menu-bg, #202020)", + color: "var(--input-text, #ffffff)", + border: "2px solid var(--border-color, #555555)", + borderRadius: "8px", + padding: "0", + zIndex: "99999", + boxShadow: "0 4px 20px rgba(0,0,0,0.8)", + display: "none", + flexDirection: "column" + } + }, [ + this.createHeader(), + this.createContent(), + this.createFooter() + ]); + } + + createHeader() { + return $el("div", { + style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "20px 20px 10px 20px", + borderBottom: "1px solid var(--border-color)", + backgroundColor: "var(--comfy-menu-bg, #202020)" + } + }, [ + $el("h2", { + textContent: "🔗 Model Linker", + style: { + margin: "0", + color: "var(--input-text)", + fontSize: "18px", + fontWeight: "600" + } + }), + $el("button", { + textContent: "×", + onclick: () => this.close(), + style: { + background: "none", + border: "none", + fontSize: "24px", + cursor: "pointer", + color: "var(--input-text)", + padding: "0", + width: "30px", + height: "30px", + borderRadius: "4px", + display: "flex", + alignItems: "center", + justifyContent: "center" + } + }) + ]); + } + + createContent() { + this.contentElement = $el("div", { + id: "model-linker-content", + style: { + padding: "16px", + overflowY: "auto", + flex: "1", + minHeight: "0" + } + }); + return this.contentElement; + } + + createFooter() { + return $el("div", { + style: { + padding: "16px", + borderTop: "1px solid var(--border-color)", + display: "flex", + justifyContent: "flex-end", + gap: "8px" + } + }, [ + $el("button", { + textContent: "Auto-Resolve 100% Matches", + onclick: () => this.autoResolve100Percent(), + className: "comfy-button", + style: { + padding: "8px 16px" + } + }) + ]); + } + + async show() { + this.element.style.display = "flex"; + await this.loadWorkflowData(); + } + + close() { + this.element.style.display = "none"; + } + + /** + * Load workflow data and display missing models + */ + async loadWorkflowData(workflow = null) { + if (!this.contentElement) return; + + // Show loading state + this.contentElement.innerHTML = '

Analyzing workflow...

'; + + try { + // Use provided workflow, or get current workflow from ComfyUI + if (!workflow) { + workflow = this.getCurrentWorkflow(); + } + + if (!workflow) { + this.contentElement.innerHTML = '

No workflow loaded. Please load a workflow first.

'; + return; + } + + // Call analyze endpoint + const response = await api.fetchApi('/model_linker/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow }) + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json(); + this.displayMissingModels(this.contentElement, data); + + } catch (error) { + console.error('Model Linker: Error loading workflow data:', error); + if (this.contentElement) { + this.contentElement.innerHTML = `

Error: ${error.message}

`; + } + } + } + + /** + * Get current workflow from ComfyUI + */ + getCurrentWorkflow() { + // Try to get workflow from app + if (app?.graph) { + try { + // Use ComfyUI's workflow serialization + const workflow = app.graph.serialize(); + return workflow; + } catch (e) { + console.warn('Model Linker: Could not serialize workflow from graph:', e); + } + } + return null; + } + + /** + * Display missing models in the dialog + */ + displayMissingModels(container, data) { + const missingModels = data.missing_models || []; + const totalMissing = data.total_missing || 0; + + if (totalMissing === 0) { + container.innerHTML = '

✓ No missing models found. All models are available!

'; + return; + } + + let html = `

Found ${totalMissing} missing model(s):

`; + html += '
'; + + // Sort missing models: those with 100% confidence matches first, then others + const sortedMissingModels = missingModels.sort((a, b) => { + const aMatches = a.matches || []; + const bMatches = b.matches || []; + + // Filter to 70%+ confidence + const aFiltered = aMatches.filter(m => m.confidence >= 70); + const bFiltered = bMatches.filter(m => m.confidence >= 70); + + // Check if they have 100% matches + const aHas100 = aFiltered.some(m => m.confidence === 100); + const bHas100 = bFiltered.some(m => m.confidence === 100); + + // If one has 100% and the other doesn't, prioritize the one with 100% + if (aHas100 && !bHas100) return -1; + if (!aHas100 && bHas100) return 1; + + // If both have 100% or neither has 100%, sort by best confidence + const aBestConf = aFiltered.length > 0 ? Math.max(...aFiltered.map(m => m.confidence)) : 0; + const bBestConf = bFiltered.length > 0 ? Math.max(...bFiltered.map(m => m.confidence)) : 0; + + return bBestConf - aBestConf; // Higher confidence first + }); + + for (const missing of sortedMissingModels) { + html += this.renderMissingModel(missing); + } + + html += '
'; + container.innerHTML = html; + + // Attach event listeners for resolve buttons (use sorted order) + sortedMissingModels.forEach((missing, missingIndex) => { + const allMatches = missing.matches || []; + + // Filter out matches below 70% confidence threshold + const filteredMatches = allMatches.filter(m => m.confidence >= 70); + + // Filter to only 100% matches if available, otherwise use filtered matches (>=70%) + const perfectMatches = filteredMatches.filter(m => m.confidence === 100); + const matchesToUse = perfectMatches.length > 0 ? perfectMatches : filteredMatches; + + matchesToUse.forEach((match, matchIndex) => { + const buttonId = `resolve-${missing.node_id}-${missing.widget_index}-${matchIndex}`; + const resolveButton = container.querySelector(`#${buttonId}`); + if (resolveButton) { + resolveButton.addEventListener('click', () => { + this.resolveModel(missing, match.model); + }); + } + }); + }); + } + + /** + * Render a single missing model entry + */ + renderMissingModel(missing) { + const allMatches = missing.matches || []; + + // Filter out matches below 70% confidence threshold + const filteredMatches = allMatches.filter(m => m.confidence >= 70); + const hasMatches = filteredMatches.length > 0; + + let html = `
`; + + // Display subgraph name as primary identifier if available, otherwise show node type + // A node type that's a UUID indicates it's a subgraph instance + const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + + if (missing.subgraph_name) { + // Show subgraph name as primary identifier + html += `
Subgraph: ${missing.subgraph_name} (ID: ${missing.node_id})
`; + } else if (isSubgraphNode) { + // Node type is a UUID (subgraph) but we don't have the name (shouldn't happen, but handle gracefully) + html += `
Node: Subgraph (ID: ${missing.node_id})
`; + } else { + // Regular node + html += `
Node: ${missing.node_type} (ID: ${missing.node_id})
`; + } + html += `
Missing Model: ${missing.original_path}
`; + html += `
Category: ${missing.category || 'unknown'}
`; + + if (hasMatches) { + // Filter out matches below 70% confidence threshold + const filteredMatches = allMatches.filter(m => m.confidence >= 70); + + // Separate 100% matches from others (from filtered list) + const perfectMatches = filteredMatches.filter(m => m.confidence === 100); + const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70); + + // If we have 100% matches, only show those. Otherwise, show other matches sorted by confidence + const matchesToShow = perfectMatches.length > 0 + ? perfectMatches + : otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5); + + html += `
Suggested Matches:
`; + html += '
    '; + + // Sort: 100% matches first, then by confidence descending + const sortedMatches = matchesToShow.sort((a, b) => { + if (a.confidence === 100 && b.confidence !== 100) return -1; + if (a.confidence !== 100 && b.confidence === 100) return 1; + return b.confidence - a.confidence; + }); + + for (let matchIndex = 0; matchIndex < sortedMatches.length; matchIndex++) { + const match = sortedMatches[matchIndex]; + const buttonId = `resolve-${missing.node_id}-${missing.widget_index}-${matchIndex}`; + html += `
  • `; + html += `${match.model?.relative_path || match.filename} `; + html += ` + (${match.confidence}% confidence) + `; + if (match.confidence === 100) { + html += ` `; + } + html += `
  • `; + } + + html += '
'; + + // Add note if only showing 100% matches + if (perfectMatches.length > 0 && otherMatches.length > 0) { + html += `
Showing only 100% confidence matches. ${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} available.
`; + } + } else if (allMatches.length > 0 && filteredMatches.length === 0) { + // Had matches but all were below 70% threshold + html += `
No matches found above 70% confidence threshold.
`; + } else { + html += `
No matches found.
`; + } + + html += '
'; + return html; + } + + /** + * Show a notification banner (similar to ComfyUI's "Reconnecting" banner) + */ + showNotification(message, type = 'success') { + // Create notification banner + const notification = $el("div", { + style: { + position: "fixed", + top: "0", + left: "50%", + transform: "translateX(-50%)", + backgroundColor: type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : '#007acc', + color: "#ffffff", + padding: "12px 24px", + borderRadius: "0 0 8px 8px", + fontSize: "14px", + fontWeight: "500", + zIndex: "100000", + boxShadow: "0 4px 12px rgba(0,0,0,0.4)", + display: "flex", + alignItems: "center", + gap: "12px", + minWidth: "300px", + maxWidth: "600px", + textAlign: "center", + animation: "slideDown 0.3s ease" + } + }, [ + type === 'success' ? $el("span", { + textContent: "✓", + style: { + fontSize: "18px", + fontWeight: "bold" + } + }) : type === 'error' ? $el("span", { + textContent: "×", + style: { + fontSize: "18px", + fontWeight: "bold" + } + }) : null, + $el("span", { + textContent: message + }), + $el("button", { + textContent: "×", + onclick: () => { + if (notification.parentNode) { + notification.style.opacity = "0"; + notification.style.transform = "translateX(-50%) translateY(-100%)"; + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + } + }, + style: { + background: "none", + border: "none", + color: "#ffffff", + fontSize: "20px", + cursor: "pointer", + padding: "0", + marginLeft: "auto", + opacity: "0.8", + width: "24px", + height: "24px", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "4px" + } + }) + ]); + + // Add CSS animation if not already added + if (!document.getElementById('model-linker-notification-style')) { + const style = $el("style", { + id: 'model-linker-notification-style', + textContent: ` + @keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-100%); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + ` + }); + document.head.appendChild(style); + } + + document.body.appendChild(notification); + + // Auto-dismiss after 4 seconds for success, 6 seconds for errors + const dismissTime = type === 'success' ? 4000 : 6000; + setTimeout(() => { + if (notification.parentNode) { + notification.style.opacity = "0"; + notification.style.transform = "translateX(-50%) translateY(-100%)"; + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + } + }, dismissTime); + } + + /** + * Resolve a single model + */ + async resolveModel(missing, resolvedModel) { + if (!resolvedModel) { + this.showNotification('No resolved model selected', 'error'); + return; + } + + try { + const workflow = this.getCurrentWorkflow(); + if (!workflow) { + this.showNotification('No workflow loaded', 'error'); + return; + } + + const resolution = { + node_id: missing.node_id, + widget_index: missing.widget_index, + resolved_path: resolvedModel.path, + category: missing.category, + resolved_model: resolvedModel, + 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 + }; + + const response = await api.fetchApi('/model_linker/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflow, + resolutions: [resolution] + }) + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json(); + + if (data.success) { + // Update workflow in ComfyUI + await this.updateWorkflowInComfyUI(data.workflow); + + // Show success notification + const modelName = resolvedModel.relative_path || resolvedModel.filename || 'model'; + this.showNotification(`✓ Model linked successfully: ${modelName}`, 'success'); + + // Reload dialog using the updated workflow from API response + // This ensures we're analyzing the correct updated workflow + await this.loadWorkflowData(data.workflow); + } else { + this.showNotification('Failed to resolve model: ' + (data.error || 'Unknown error'), 'error'); + } + + } catch (error) { + console.error('Model Linker: Error resolving model:', error); + this.showNotification('Error resolving model: ' + error.message, 'error'); + } + } + + /** + * Auto-resolve all 100% confidence matches + */ + async autoResolve100Percent() { + if (!this.contentElement) return; + + try { + const workflow = this.getCurrentWorkflow(); + if (!workflow) { + this.showNotification('No workflow loaded', 'error'); + return; + } + + // Analyze workflow first + const analyzeResponse = await api.fetchApi('/model_linker/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow }) + }); + + if (!analyzeResponse.ok) { + throw new Error(`API error: ${analyzeResponse.status}`); + } + + const analyzeData = await analyzeResponse.json(); + const missingModels = analyzeData.missing_models || []; + + // Collect all 100% matches + const resolutions = []; + for (const missing of missingModels) { + const matches = missing.matches || []; + const perfectMatch = matches.find((m) => m.confidence === 100); + + if (perfectMatch && perfectMatch.model) { + resolutions.push({ + 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 + }); + } + } + + if (resolutions.length === 0) { + this.showNotification('No 100% confidence matches found to auto-resolve.', 'error'); + return; + } + + // Apply resolutions + const resolveResponse = await api.fetchApi('/model_linker/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflow, + resolutions + }) + }); + + if (!resolveResponse.ok) { + throw new Error(`API error: ${resolveResponse.status}`); + } + + const resolveData = await resolveResponse.json(); + + if (resolveData.success) { + // Update workflow in ComfyUI + await this.updateWorkflowInComfyUI(resolveData.workflow); + + // Show success notification + this.showNotification( + `✓ Successfully linked ${resolutions.length} model${resolutions.length > 1 ? 's' : ''}!`, + 'success' + ); + + // Reload dialog using the updated workflow from API response + // This ensures we're analyzing the correct updated workflow + await this.loadWorkflowData(resolveData.workflow); + } else { + this.showNotification('Failed to resolve models: ' + (resolveData.error || 'Unknown error'), 'error'); + } + + } catch (error) { + console.error('Model Linker: Error auto-resolving:', error); + this.showNotification('Error auto-resolving: ' + error.message, 'error'); + } + } + + /** + * Update workflow in ComfyUI's UI/memory + */ + async updateWorkflowInComfyUI(workflow) { + if (app && app.loadGraphData) { + try { + await app.loadGraphData(workflow, false, false); + } catch (error) { + console.error('Model Linker: Error updating workflow in ComfyUI:', error); + throw error; + } + } else { + console.warn('Model Linker: Could not update workflow - app.loadGraphData not available'); + } + } +} + +// Main extension class +class ModelLinker { + constructor() { + this.linkerButton = null; + this.buttonId = "model-linker-button"; + this.dialog = null; + } + + setup = () => { + // Remove any existing button + this.removeExistingButton(); + + // Find a visible menu element + const allMenus = document.querySelectorAll("[class*='menu']"); + + // Try to find a visible menu + let visibleMenu = null; + for (const menu of allMenus) { + const style = window.getComputedStyle(menu); + if (style.display !== 'none' && style.visibility !== 'hidden') { + visibleMenu = menu; + break; + } + } + + // Try alternative: app.menu.settingsGroup + if (!visibleMenu && app.menu?.settingsGroup?.element) { + visibleMenu = app.menu.settingsGroup.element.parentElement; + } + + // Try alternative selectors for the top bar + if (!visibleMenu) { + const alternatives = [ + 'header', + '.header', + '.top-bar', + '.toolbar', + '.nav', + '.navigation', + '[role="toolbar"]', + '[role="menubar"]' + ]; + + for (const selector of alternatives) { + const element = document.querySelector(selector); + if (element) { + const style = window.getComputedStyle(element); + if (style.display !== 'none' && style.visibility !== 'hidden') { + visibleMenu = element; + break; + } + } + } + } + + if (visibleMenu) { + this.createLinkerButton(visibleMenu); + } else { + this.createFloatingButton(); + } + + // Create dialog instance (will be created on demand) + if (!this.dialog) { + this.dialog = new LinkerManagerDialog(); + } + } + + removeExistingButton() { + // Remove any existing button by ID + const existingButton = document.getElementById(this.buttonId); + if (existingButton) { + existingButton.remove(); + } + + // Also remove the stored reference if it exists + if (this.linkerButton && this.linkerButton.parentNode) { + this.linkerButton.remove(); + this.linkerButton = null; + } + } + + createLinkerButton(menu) { + this.linkerButton = $el("button", { + id: this.buttonId, + textContent: "🔗 Model Linker", + title: "Open Model Linker to resolve missing models in workflow", + onclick: () => { + this.openLinkerManager(); + }, + style: { + backgroundColor: "var(--comfy-input-bg, #353535)", + color: "var(--input-text, #ffffff)", + border: "2px solid var(--border-color, #555555)", + padding: "8px 16px", + margin: "4px", + borderRadius: "6px", + cursor: "pointer", + fontSize: "14px", + fontWeight: "600", + display: "inline-block", + minWidth: "80px", + textAlign: "center", + zIndex: "1000", + position: "relative", + transition: "all 0.2s ease" + } + }); + + // Add hover effects + this.linkerButton.addEventListener("mouseenter", () => { + this.linkerButton.style.backgroundColor = "var(--comfy-input-bg-hover, #4a4a4a)"; + this.linkerButton.style.borderColor = "var(--primary-color, #007acc)"; + this.linkerButton.style.transform = "translateY(-1px)"; + this.linkerButton.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)"; + }); + + this.linkerButton.addEventListener("mouseleave", () => { + this.linkerButton.style.backgroundColor = "var(--comfy-input-bg, #353535)"; + this.linkerButton.style.borderColor = "var(--border-color, #555555)"; + this.linkerButton.style.transform = "translateY(0)"; + this.linkerButton.style.boxShadow = "none"; + }); + + // Try to insert before settings group if using app.menu + if (app.menu?.settingsGroup?.element && menu === app.menu.settingsGroup.element.parentElement) { + app.menu.settingsGroup.element.before(this.linkerButton); + } else { + menu.appendChild(this.linkerButton); + } + } + + createFloatingButton() { + // Create a floating button as fallback + this.linkerButton = $el("button", { + id: this.buttonId, + textContent: "🔗 Model Linker", + title: "Open Model Linker to resolve missing models in workflow", + onclick: () => { + this.openLinkerManager(); + }, + style: { + position: "fixed", + top: "10px", + right: "10px", + zIndex: "10000", + backgroundColor: "var(--comfy-input-bg, #353535)", + color: "var(--input-text, #ffffff)", + border: "2px solid var(--primary-color, #007acc)", + padding: "8px 16px", + borderRadius: "6px", + cursor: "pointer", + fontSize: "14px", + fontWeight: "600", + boxShadow: "0 4px 12px rgba(0,0,0,0.3)", + transition: "all 0.2s ease" + } + }); + + // Add hover effects + this.linkerButton.addEventListener("mouseenter", () => { + this.linkerButton.style.backgroundColor = "var(--primary-color, #007acc)"; + this.linkerButton.style.transform = "scale(1.05)"; + }); + + this.linkerButton.addEventListener("mouseleave", () => { + this.linkerButton.style.backgroundColor = "var(--comfy-input-bg, #353535)"; + this.linkerButton.style.transform = "scale(1)"; + }); + + document.body.appendChild(this.linkerButton); + } + + openLinkerManager() { + try { + if (!this.dialog) { + this.dialog = new LinkerManagerDialog(); + } + this.dialog.show(); + } catch (error) { + console.error("🔗 Model Linker: Error creating/showing dialog:", error); + alert("Error opening Model Linker: " + error.message); + } + } +} + +const modelLinker = new ModelLinker(); + +// Register the extension +app.registerExtension({ + name: "Model Linker", + setup: modelLinker.setup +}); +