chore: Rename comfyui-Model-Linker to comfyui-model-linker (kebab-case)

This commit is contained in:
unknown
2025-11-01 19:15:03 +01:00
commit 7b972d6dfa
18 changed files with 2140 additions and 0 deletions
+31
View File
@@ -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
+42
View File
@@ -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.
+152
View File
@@ -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)
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
"""
Core modules for Model Linker extension.
"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+197
View File
@@ -0,0 +1,197 @@
"""
Core Linker Module
Integrates all components to provide high-level API for model linking.
"""
import os
import logging
from typing import Dict, Any, List, Optional
from .scanner import get_model_files
from .workflow_analyzer import analyze_workflow_models, identify_missing_models
from .matcher import find_matches
from .workflow_updater import update_workflow_nodes
def analyze_and_find_matches(
workflow_json: Dict[str, Any],
similarity_threshold: float = 0.0,
max_matches_per_model: int = 10
) -> 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)
+167
View File
@@ -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
+148
View File
@@ -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()
+266
View File
@@ -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
+288
View File
@@ -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
+7
View File
@@ -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"
+838
View File
@@ -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 = '<p>Analyzing workflow...</p>';
try {
// Use provided workflow, or get current workflow from ComfyUI
if (!workflow) {
workflow = this.getCurrentWorkflow();
}
if (!workflow) {
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
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 = `<p style="color: red;">Error: ${error.message}</p>`;
}
}
}
/**
* 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 = '<p style="color: green;">✓ No missing models found. All models are available!</p>';
return;
}
let html = `<p><strong>Found ${totalMissing} missing model(s):</strong></p>`;
html += '<div style="display: flex; flex-direction: column; gap: 16px;">';
// 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 += '</div>';
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 = `<div style="border: 1px solid var(--border-color, #444); padding: 12px; border-radius: 4px;">`;
// 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 += `<div style="margin-bottom: 8px;"><strong>Subgraph:</strong> ${missing.subgraph_name} (ID: ${missing.node_id})</div>`;
} else if (isSubgraphNode) {
// Node type is a UUID (subgraph) but we don't have the name (shouldn't happen, but handle gracefully)
html += `<div style="margin-bottom: 8px;"><strong>Node:</strong> <em>Subgraph</em> (ID: ${missing.node_id})</div>`;
} else {
// Regular node
html += `<div style="margin-bottom: 8px;"><strong>Node:</strong> ${missing.node_type} (ID: ${missing.node_id})</div>`;
}
html += `<div style="margin-bottom: 8px;"><strong>Missing Model:</strong> <code>${missing.original_path}</code></div>`;
html += `<div style="margin-bottom: 8px;"><strong>Category:</strong> ${missing.category || 'unknown'}</div>`;
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 += `<div style="margin-top: 12px;"><strong>Suggested Matches:</strong></div>`;
html += '<ul style="margin: 8px 0; padding-left: 20px;">';
// 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 += `<li style="margin: 4px 0;">`;
html += `<code>${match.model?.relative_path || match.filename}</code> `;
html += `<span style="color: ${match.confidence === 100 ? 'green' : 'orange'};">
(${match.confidence}% confidence)
</span>`;
if (match.confidence === 100) {
html += ` <button id="${buttonId}"
class="model-linker-resolve-btn" style="margin-left: 8px; padding: 4px 8px;">
Resolve
</button>`;
}
html += `</li>`;
}
html += '</ul>';
// Add note if only showing 100% matches
if (perfectMatches.length > 0 && otherMatches.length > 0) {
html += `<div style="color: #888; font-size: 12px; margin-top: 8px; font-style: italic;">Showing only 100% confidence matches. ${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} available.</div>`;
}
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
// Had matches but all were below 70% threshold
html += `<div style="color: orange; margin-top: 8px;">No matches found above 70% confidence threshold.</div>`;
} else {
html += `<div style="color: orange; margin-top: 8px;">No matches found.</div>`;
}
html += '</div>';
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
});