Handle LoraManager lora detection and updates
Filter out LoraManager (LoraLoaderV2) entries that already exist locally so they don't appear as missing, and skip matching for those entries. Integrate scanner data to detect local lora files (full_path/exists) in workflow analysis. Add special handling in update_model_path/update_workflow_nodes to update LoraManager lora lists and the formatted lora text widget (widgets_values[2] and [1]) when a new lora is resolved, using mapping fields is_lora_v2 and original_lora_name. Improve convert_to_relative_path and get_base_directory_for_model to prefer folder_paths lookup and preserve exact ComfyUI filename formats. In the web UI, make resolve button IDs unique by including a missingIndex, avoid duplicate listeners, forward is_lora_v2/original_lora_name to the resolve API, and add extra logging for debugging.
This commit is contained in:
+25
@@ -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", []):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -193,6 +193,23 @@ def get_node_model_info(node: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
# widgets_values[1] = "<lora:name1:strength> <lora:name2:strength>"
|
||||
# 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,
|
||||
|
||||
+185
-85
@@ -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"<lora:{original_lora_name}:", f"<lora:{new_lora_name}:"
|
||||
)
|
||||
# Also handle case without lora: prefix
|
||||
new_text = new_text.replace(
|
||||
f":{original_lora_name}:", f":{new_lora_name}:"
|
||||
)
|
||||
widgets_values[1] = new_text
|
||||
|
||||
logging.info(
|
||||
f"Updated node {node_id} LoraManager lora list - new text: {widgets_values[1]}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# Log the actual lora list for debugging
|
||||
logging.warning(
|
||||
f"Lora '{original_lora_name}' not found in lora list. Available: {[li.get('name') for li in lora_list if isinstance(li, dict)]}"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logging.warning(
|
||||
f"LoraManager widget_index == 2 but lora_list is not a list: {type(lora_list)}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Standard handling: 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)
|
||||
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]]
|
||||
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:
|
||||
@@ -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
|
||||
|
||||
|
||||
+30
-9
@@ -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 += '</div>';
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user