diff --git a/__init__.py b/__init__.py index 6eedf45..021854b 100644 --- a/__init__.py +++ b/__init__.py @@ -115,6 +115,31 @@ class ModelLinkerExtension: # Analyze and find matches result = analyze_and_find_matches(workflow_json) + # Filter out LoraManager lorAs that already exist locally (exists=True) + # These should not appear in missing models at all + missing_models = result.get("missing_models", []) + filtered_missing = [] + for missing in missing_models: + is_lora = missing.get("is_lora_v2") + exists = missing.get("exists") + name = missing.get("name") or missing.get("original_path", "") + # Log for debugging + import logging + + logging.getLogger(__name__).debug( + f"Filtering: {name} is_lora_v2={is_lora} exists={exists}" + ) + + # Skip LoraManager lorAs that already exist locally + if is_lora and exists: + logging.getLogger(__name__).info( + f"Filtered out LoraManager lora: {name}" + ) + continue + filtered_missing.append(missing) + result["missing_models"] = filtered_missing + result["total_missing"] = len(filtered_missing) + # If download available, auto-search for download sources when no 100% local match if download_available: for missing in result.get("missing_models", []): diff --git a/core/linker.py b/core/linker.py index 1ec8504..fbe1f45 100644 --- a/core/linker.py +++ b/core/linker.py @@ -253,6 +253,16 @@ def analyze_and_find_matches( # Find matches for each missing model missing_with_matches = [] for missing in missing_models: + # Skip LoraManager lorAs that already exist locally (exists=True means no linking needed) + is_lora_v2 = missing.get("is_lora_v2") + exists = missing.get("exists") + name = missing.get("name") or missing.get("original_path", "") + logger.debug(f"Checking {name}: is_lora_v2={is_lora_v2}, exists={exists}") + + if is_lora_v2 and exists: + logger.info(f"Skipping LoraManager lora {name} - already exists locally") + continue + target_for_matching = missing.get("original_path", "") # For URNs, prefer expected_filename for matching @@ -345,6 +355,10 @@ def apply_resolution( "is_top_level": resolution.get( "is_top_level" ), # True for top-level nodes, False for nodes in subgraph definitions + "is_lora_v2": resolution.get("is_lora_v2"), # Flag for LoraManager nodes + "original_lora_name": resolution.get( + "original_lora_name" + ), # Original lora name for LoraManager replacement } # If resolved_model provided, extract path if needed diff --git a/core/workflow_analyzer.py b/core/workflow_analyzer.py index a238128..5e17cd4 100644 --- a/core/workflow_analyzer.py +++ b/core/workflow_analyzer.py @@ -193,6 +193,23 @@ def get_node_model_info(node: Dict[str, Any]) -> List[Dict[str, Any]]: # widgets_values[1] = " " # widgets_values[2] = [{"name": "...", "strength": 1, "active": true}, ...] + # Get all lora files using scanner for recursive search + from .scanner import get_model_files + + all_loras = get_model_files() + lora_files = [m for m in all_loras if m.get("category") == "loras"] + + # Build a lookup by filename (without extension) + lora_lookup = {} + for lf in lora_files: + fname = lf.get("filename", "") + if fname: + # Get name without extension for matching + base_name = os.path.splitext(fname)[0] + if base_name not in lora_lookup: + lora_lookup[base_name] = [] + lora_lookup[base_name].append(lf) + lora_list = widgets_values[2] if isinstance(lora_list, list): for lora_item in lora_list: @@ -202,6 +219,38 @@ def get_node_model_info(node: Dict[str, Any]) -> List[Dict[str, Any]]: active = lora_item.get("active", True) if name: + # Check if lora exists locally using scanner data (recursive search) + lora_exists = False + lora_full_path = None + + # Try exact name first (without extension) + if name in lora_lookup: + lora_full_path = lora_lookup[name][0].get("path") + lora_exists = ( + os.path.exists(lora_full_path) + if lora_full_path + else False + ) + else: + # Try with common extensions + for ext in [".safetensors", ".ckpt", ".pt", ".pth"]: + test_name = name + ext + if test_name in lora_lookup: + lora_full_path = lora_lookup[test_name][0].get( + "path" + ) + lora_exists = ( + os.path.exists(lora_full_path) + if lora_full_path + else False + ) + if lora_exists: + break + + logging.debug( + f"Lora {name}: exists={lora_exists}, path={lora_full_path}" + ) + model_refs.append( { "node_id": node_id, @@ -212,8 +261,8 @@ def get_node_model_info(node: Dict[str, Any]) -> List[Dict[str, Any]]: "strength": float(strength), "active": active, "category": "loras", - "full_path": None, - "exists": False, # Will be resolved later + "full_path": lora_full_path, + "exists": lora_exists, "is_urn": False, "is_lora_v2": is_lora_text, "connected": is_active, diff --git a/core/workflow_updater.py b/core/workflow_updater.py index 735f053..4b1e13f 100644 --- a/core/workflow_updater.py +++ b/core/workflow_updater.py @@ -9,18 +9,20 @@ import logging from typing import Dict, Any, List, Optional -def convert_to_relative_path(absolute_path: str, category: str, base_directory: str = None) -> str: +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 @@ -29,22 +31,25 @@ def convert_to_relative_path(absolute_path: str, category: str, base_directory: # 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): + 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 @@ -53,7 +58,7 @@ def convert_to_relative_path(absolute_path: str, category: str, base_directory: 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 @@ -67,32 +72,34 @@ def convert_to_relative_path(absolute_path: str, category: str, base_directory: # 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]: +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 "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'] + 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) @@ -103,7 +110,7 @@ def get_base_directory_for_model(model_dict: Dict[str, str], category: str) -> O return base_dir except (ValueError, FileNotFoundError): continue - + return None @@ -116,34 +123,35 @@ def update_model_path( base_directory: str = None, resolved_model: Dict[str, Any] = None, subgraph_id: str = None, - is_top_level: bool = None + is_top_level: bool = None, + mapping: Dict[str, Any] = 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 - + 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: Widget index to update + resolved_path: Absolute path to resolved model + category: Model category + base_directory: Base directory for category + resolved_model: Model dict from scanner + subgraph_id: Subgraph ID (if node is in subgraph) + is_top_level: Whether node is in top-level (not subgraph definition) + mapping: Full mapping dict with is_lora_v2 and original_lora_name for LoraManager + Returns: - True if update was successful, False otherwise + 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 @@ -153,9 +161,9 @@ def update_model_path( 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', []) + nodes = workflow.get("nodes", []) for n in nodes: - if n.get('id') == node_id: + if n.get("id") == node_id: # Found in top-level - this is a subgraph instance node search_in_subgraph = False break @@ -165,71 +173,157 @@ def update_model_path( 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', []) - + definitions = workflow.get("definitions", {}) + subgraphs = definitions.get("subgraphs", []) + for subgraph in subgraphs: - if subgraph.get('id') == subgraph_id: - subgraph_nodes = subgraph.get('nodes', []) + if subgraph.get("id") == subgraph_id: + subgraph_nodes = subgraph.get("nodes", []) for n in subgraph_nodes: - if n.get('id') == node_id: + if n.get("id") == node_id: node = n break break else: # Find in top-level nodes - nodes = workflow.get('nodes', []) + nodes = workflow.get("nodes", []) for n in nodes: - if n.get('id') == node_id: + 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', []) - + + 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 + category = resolved_model.get("category") + + # Check if this is a LoraManager (LoraLoaderV2) node with is_lora_v2 flag + is_lora_v2 = mapping.get("is_lora_v2") if mapping else False + original_lora_name = mapping.get("original_lora_name") if mapping else None + logging.info( + f"@@ update_model_path: node={node_id}, idx={widget_index}, is_lora_v2={is_lora_v2}, original={original_lora_name}" + ) + + # Special handling for LoraManager nodes: update lora name in lora list + if is_lora_v2 and original_lora_name and widget_index == 2: + # For LoraManager, we need to update the name in the lora list (widgets_values[2]) + lora_list = widgets_values[2] + logging.info( + f">>> LoraManager: original_lora={original_lora_name}, widget_index={widget_index}, list_type={type(lora_list)}" + ) + + if isinstance(lora_list, list): + # Get the new lora name from resolved_model + new_lora_name = None + if resolved_model: + # Use filename without extension + new_lora_name = resolved_model.get("filename") or resolved_model.get( + "name", "" + ) + if new_lora_name: + # Remove extension if present + if "." in new_lora_name: + new_lora_name = new_lora_name.rsplit(".", 1)[0] + + logging.debug( + f"LoraManager update: original={original_lora_name}, new={new_lora_name}" + ) + + if new_lora_name: + updated = False + original_stripped = original_lora_name.strip() + + for lora_item in lora_list: + if isinstance(lora_item, dict): + lora_name_in_list = lora_item.get("name", "").strip() + # Try exact match first, then case-insensitive + if lora_name_in_list == original_stripped: + lora_item["name"] = new_lora_name + updated = True + logging.info( + f"Updated LoraManager lora (exact): {original_lora_name} -> {new_lora_name}" + ) + break + elif lora_name_in_list.lower() == original_stripped.lower(): + lora_item["name"] = new_lora_name + updated = True + logging.info( + f"Updated LoraManager lora (case-insensitive): {original_lora_name} -> {new_lora_name}" + ) + break + + if updated: + # Also update the text widget (widgets_values[1]) which contains formatted lora string + if len(widgets_values) > 1 and isinstance(widgets_values[1], str): + old_text = widgets_values[1] + # Replace the lora name in the text format + new_text = old_text.replace( + f" 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: @@ -241,33 +335,39 @@ def update_workflow_nodes( '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') - + 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 = 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 - + + 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 + is_lora_v2 = mapping.get("is_lora_v2") # Flag for LoraManager nodes + original_lora_name = mapping.get( + "original_lora_name" + ) # Original lora name for replacement + success = update_model_path( workflow, node_id, @@ -277,12 +377,12 @@ def update_workflow_nodes( base_directory, resolved_model, subgraph_id, - is_top_level + is_top_level, + mapping, # Pass full mapping for LoraManager special handling ) - + if success: updated_count += 1 - + logging.info(f"Updated {updated_count} model paths in workflow") return workflow - diff --git a/web/linker.js b/web/linker.js index 798651c..2a1b223 100644 --- a/web/linker.js +++ b/web/linker.js @@ -1266,8 +1266,8 @@ class LinkerManagerDialog extends ComfyDialog { return bBestConf - aBestConf; // Higher confidence first }); - for (const missing of sortedMissingModels) { - html += this.renderMissingModel(missing); + for (let mi = 0; mi < sortedMissingModels.length; mi++) { + html += this.renderMissingModel(sortedMissingModels[mi], mi); } html += ''; @@ -1299,12 +1299,22 @@ class LinkerManagerDialog extends ComfyDialog { sortedMatches.forEach((match, matchIndex) => { // Attach listener to all matches (all now have resolve buttons) - const buttonId = `resolve-${missing.node_id}-${missing.widget_index}-${matchIndex}`; + // Use unique ID that includes missingIndex to avoid conflicts between different missing models with same node_id + const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`; const resolveButton = container.querySelector(`#${buttonId}`); + console.log('Attaching listener for', buttonId, 'missingIndex:', missingIndex); if (resolveButton) { + // Remove any existing listeners to prevent duplicates + resolveButton.onclick = null; resolveButton.addEventListener('click', () => { - this.resolveModel(missing, match.model); + console.log('>>> Link button clicked:', buttonId, missing.original_path, '->', match.model?.filename); + this.resolveModel(missing, match.model).catch(err => { + console.error('resolveModel error:', err); + this.showNotification('Link failed: ' + err.message, 'error'); + }); }); + } else { + console.warn('Button not found:', buttonId); } }); @@ -1331,7 +1341,7 @@ class LinkerManagerDialog extends ComfyDialog { /** * Render a single missing model entry */ - renderMissingModel(missing) { + renderMissingModel(missing, missingIndex = 0) { const allMatches = missing.matches || []; // Filter out matches below 70% confidence threshold @@ -1416,7 +1426,7 @@ class LinkerManagerDialog extends ComfyDialog { for (let matchIndex = 0; matchIndex < sortedMatches.length; matchIndex++) { const match = sortedMatches[matchIndex]; - const buttonId = `resolve-${missing.node_id}-${missing.widget_index}-${matchIndex}`; + const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`; const matchPath = match.model?.relative_path || match.filename || ''; const formattedPath = this.formatPath(matchPath, 45); const isBestMatch = matchIndex === 0 && match.confidence >= 95; @@ -1658,6 +1668,8 @@ class LinkerManagerDialog extends ComfyDialog { * Resolve a model - resolves ALL nodes that reference this model */ async resolveModel(missing, resolvedModel) { + console.log('resolveModel called:', missing?.original_path, '->', resolvedModel?.filename); + if (!resolvedModel) { this.showNotification('No resolved model selected', 'error'); return; @@ -1672,6 +1684,8 @@ class LinkerManagerDialog extends ComfyDialog { // Resolve ALL nodes that need this model (all_node_refs contains deduplicated refs) const nodeRefs = missing.all_node_refs || [missing]; + console.log('nodeRefs count:', nodeRefs?.length, 'is_lora_v2:', nodeRefs?.[0]?.is_lora_v2); + const resolutions = nodeRefs.map(ref => ({ node_id: ref.node_id, widget_index: ref.widget_index, @@ -1679,7 +1693,9 @@ class LinkerManagerDialog extends ComfyDialog { category: ref.category, resolved_model: resolvedModel, subgraph_id: ref.subgraph_id, - is_top_level: ref.is_top_level + is_top_level: ref.is_top_level, + is_lora_v2: ref.is_lora_v2, + original_lora_name: ref.name || ref.original_path })); const response = await api.fetchApi('/model_linker/resolve', { @@ -1696,6 +1712,7 @@ class LinkerManagerDialog extends ComfyDialog { } const data = await response.json(); + console.log('Resolve response: success=', data.success, ' missing count:', data.workflow?.nodes?.length); if (data.success) { // Update workflow in ComfyUI @@ -1760,7 +1777,9 @@ class LinkerManagerDialog extends ComfyDialog { category: missing.category, resolved_model: perfectMatch.model, subgraph_id: missing.subgraph_id, // Include subgraph_id for subgraph nodes - is_top_level: missing.is_top_level // True for top-level nodes, False for nodes in subgraph definitions + is_top_level: missing.is_top_level, // True for top-level nodes, False for nodes in subgraph definitions + is_lora_v2: missing.is_lora_v2, + original_lora_name: missing.name || missing.original_path }); } } @@ -1940,7 +1959,9 @@ class LinkerManagerDialog extends ComfyDialog { category: ref.category, resolved_model: perfectMatch.model, subgraph_id: ref.subgraph_id, - is_top_level: ref.is_top_level + is_top_level: ref.is_top_level, + is_lora_v2: ref.is_lora_v2, + original_lora_name: ref.name || ref.original_path })); const resolveResponse = await api.fetchApi('/model_linker/resolve', {