feat: support subgraph-aware node location and widget model context
- workflow_analyzer: refactor widget category hint resolution and add widget name candidate extraction across widgets, inputs, proxyWidgets, and localized names; expose new helpers get_widget_name_candidates/get_widget_name_hint - workflow_analyzer: add get_model_widget_category_hint and get_effective_model_category_hint to unify category fallback logic - workflow_analyzer: include widget_name on model refs emitted by get_node_model_info - workflow_analyzer: introduce promoted-widget context resolution (_build_promoted_widget_contexts, _find_promoted_widget_targets, etc.) and wire it into analyze_workflow_models so instance widgets and promoted widget locators are tracked with subgraph scope - lifecycle_graph_methods: refactor locateNodeInGraph into subgraph-aware resolver using findLocateTarget/getGraphNodeById/findSubgraphById/findNodeInGraphTree; support targeting nodes inside specific subgraphs with proper canvas focus and notifications - missing_browser_methods: use unified getMissingLocateTarget for locate button/display fields; pass subgraphId and isTopLevel to locateNodeInGraph; add promoted-widget-aware tooltips and canLocate handling
This commit is contained in:
+417
-36
@@ -309,6 +309,105 @@ def normalize_widget_name(value: Any) -> str:
|
||||
return re.sub(r"[_\s-]+", "_", str(value or "").strip().lower()).strip("_")
|
||||
|
||||
|
||||
def _unique_strings(values: List[Any]) -> List[str]:
|
||||
seen = set()
|
||||
unique = []
|
||||
for value in values:
|
||||
text = str(value or "").strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
unique.append(text)
|
||||
return unique
|
||||
|
||||
|
||||
def _widget_item_name_candidates(item: Any) -> List[str]:
|
||||
if not isinstance(item, dict):
|
||||
return []
|
||||
|
||||
candidates = []
|
||||
for key in ("name", "label", "localized_name"):
|
||||
if item.get(key):
|
||||
candidates.append(item.get(key))
|
||||
|
||||
widget = item.get("widget")
|
||||
if isinstance(widget, dict):
|
||||
for key in ("name", "label"):
|
||||
if widget.get(key):
|
||||
candidates.append(widget.get(key))
|
||||
elif widget:
|
||||
candidates.append(widget)
|
||||
|
||||
return _unique_strings(candidates)
|
||||
|
||||
|
||||
def _has_widget_input(item: Any) -> bool:
|
||||
return isinstance(item, dict) and item.get("widget") is not None
|
||||
|
||||
|
||||
def _get_widget_inputs(node: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
item
|
||||
for item in node.get("inputs", [])
|
||||
if _has_widget_input(item)
|
||||
]
|
||||
|
||||
|
||||
def _get_proxy_widget_entry(node: Dict[str, Any], widget_index: int) -> Any:
|
||||
properties = node.get("properties", {})
|
||||
proxy_widgets = properties.get("proxyWidgets", [])
|
||||
if isinstance(proxy_widgets, list) and widget_index < len(proxy_widgets):
|
||||
return proxy_widgets[widget_index]
|
||||
return None
|
||||
|
||||
|
||||
def _proxy_widget_name(proxy_entry: Any) -> str:
|
||||
if isinstance(proxy_entry, (list, tuple)) and len(proxy_entry) >= 2:
|
||||
return str(proxy_entry[1] or "").strip()
|
||||
if isinstance(proxy_entry, dict):
|
||||
for key in ("name", "widget_name", "widgetName", "targetWidgetName", "inputName"):
|
||||
if proxy_entry.get(key):
|
||||
return str(proxy_entry.get(key)).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _proxy_widget_node_id(proxy_entry: Any) -> str:
|
||||
if isinstance(proxy_entry, (list, tuple)) and proxy_entry:
|
||||
return str(proxy_entry[0] or "").strip()
|
||||
if isinstance(proxy_entry, dict):
|
||||
for key in ("node_id", "nodeId", "target_node_id", "targetNodeId"):
|
||||
if proxy_entry.get(key) is not None:
|
||||
return str(proxy_entry.get(key)).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def get_widget_name_candidates(node: Dict[str, Any], widget_index: int) -> List[str]:
|
||||
candidates = []
|
||||
|
||||
proxy_name = _proxy_widget_name(_get_proxy_widget_entry(node, widget_index))
|
||||
if proxy_name:
|
||||
candidates.append(proxy_name)
|
||||
|
||||
widgets = node.get("widgets", [])
|
||||
if isinstance(widgets, list) and widget_index < len(widgets):
|
||||
candidates.extend(_widget_item_name_candidates(widgets[widget_index]))
|
||||
|
||||
widget_inputs = _get_widget_inputs(node)
|
||||
if widget_index < len(widget_inputs):
|
||||
candidates.extend(_widget_item_name_candidates(widget_inputs[widget_index]))
|
||||
|
||||
inputs = node.get("inputs", [])
|
||||
if isinstance(inputs, list) and widget_index < len(inputs):
|
||||
candidates.extend(_widget_item_name_candidates(inputs[widget_index]))
|
||||
|
||||
return _unique_strings(candidates)
|
||||
|
||||
|
||||
def get_widget_name_hint(node: Dict[str, Any], widget_index: int) -> str:
|
||||
candidates = get_widget_name_candidates(node, widget_index)
|
||||
return candidates[0] if candidates else ""
|
||||
|
||||
|
||||
MODEL_WIDGET_PLACEHOLDERS = {
|
||||
"",
|
||||
"none",
|
||||
@@ -339,26 +438,7 @@ def get_widget_category_hint(node: Dict[str, Any], widget_index: int) -> Optiona
|
||||
include input/widget metadata. Use it when present; otherwise callers fall back to
|
||||
node-type hints and folder_paths resolution.
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
for key in ("widgets", "inputs"):
|
||||
items = node.get(key)
|
||||
if isinstance(items, list) and widget_index < len(items):
|
||||
item = items[widget_index]
|
||||
if isinstance(item, dict):
|
||||
for name in ("name", "label"):
|
||||
if item.get(name):
|
||||
candidates.append(str(item.get(name, "")).strip())
|
||||
|
||||
widget = item.get("widget")
|
||||
if isinstance(widget, dict):
|
||||
widget_name = widget.get("name")
|
||||
if widget_name:
|
||||
candidates.append(str(widget_name).strip())
|
||||
elif widget:
|
||||
candidates.append(str(widget).strip())
|
||||
|
||||
for candidate in candidates:
|
||||
for candidate in get_widget_name_candidates(node, widget_index):
|
||||
category = MODEL_WIDGET_NAME_TO_CATEGORY.get(normalize_widget_name(candidate))
|
||||
if category:
|
||||
return category
|
||||
@@ -390,6 +470,32 @@ def get_node_output_category_hint(node: Dict[str, Any]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def get_model_widget_category_hint(
|
||||
node: Dict[str, Any], widget_index: int
|
||||
) -> Optional[str]:
|
||||
node_type = node.get("type", "")
|
||||
indexed_category_hint = get_node_model_widget_category_hint(node_type, widget_index)
|
||||
widget_category_hint = get_widget_category_hint(node, widget_index)
|
||||
output_category_hint = get_node_output_category_hint(node)
|
||||
widgets_values = node.get("widgets_values", [])
|
||||
has_single_widget_value = len(widgets_values) == 1
|
||||
output_widget_category_hint = (
|
||||
output_category_hint
|
||||
if (indexed_category_hint or widget_category_hint or has_single_widget_value)
|
||||
else None
|
||||
)
|
||||
|
||||
return indexed_category_hint or output_widget_category_hint or widget_category_hint
|
||||
|
||||
|
||||
def get_effective_model_category_hint(
|
||||
node: Dict[str, Any], widget_index: int
|
||||
) -> Optional[str]:
|
||||
return get_model_widget_category_hint(
|
||||
node, widget_index
|
||||
) or NODE_TYPE_TO_CATEGORY_HINTS.get(node.get("type", ""))
|
||||
|
||||
|
||||
def is_placeholder_model_value(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
@@ -714,6 +820,7 @@ def get_node_model_info(
|
||||
"node_id": node_id,
|
||||
"node_type": node_type,
|
||||
"widget_index": 2, # Index in lora list
|
||||
"widget_name": get_widget_name_hint(node, 2),
|
||||
"original_path": name,
|
||||
"name": name,
|
||||
"strength": float(strength),
|
||||
@@ -729,24 +836,11 @@ def get_node_model_info(
|
||||
)
|
||||
return model_refs
|
||||
|
||||
# Get category hints for this node type
|
||||
category_hint = NODE_TYPE_TO_CATEGORY_HINTS.get(node_type)
|
||||
output_category_hint = get_node_output_category_hint(node)
|
||||
has_single_widget_value = len(widgets_values) == 1
|
||||
|
||||
# For each widget value, check if it looks like a model file or URN
|
||||
for idx, value in enumerate(widgets_values):
|
||||
widget_category_hint = get_widget_category_hint(node, idx)
|
||||
indexed_category_hint = get_node_model_widget_category_hint(node_type, idx)
|
||||
output_widget_category_hint = (
|
||||
output_category_hint
|
||||
if (indexed_category_hint or widget_category_hint or has_single_widget_value)
|
||||
else None
|
||||
)
|
||||
model_widget_category_hint = (
|
||||
indexed_category_hint or output_widget_category_hint or widget_category_hint
|
||||
)
|
||||
effective_category_hint = model_widget_category_hint or category_hint
|
||||
widget_name = get_widget_name_hint(node, idx)
|
||||
model_widget_category_hint = get_model_widget_category_hint(node, idx)
|
||||
effective_category_hint = get_effective_model_category_hint(node, idx)
|
||||
categories_to_try_for_widget = (
|
||||
[effective_category_hint] if effective_category_hint else None
|
||||
)
|
||||
@@ -790,6 +884,7 @@ def get_node_model_info(
|
||||
"node_id": node_id,
|
||||
"node_type": node_type,
|
||||
"widget_index": idx,
|
||||
"widget_name": widget_name,
|
||||
"original_path": value_str,
|
||||
"node_title": node_title,
|
||||
"category": category,
|
||||
@@ -830,6 +925,7 @@ def get_node_model_info(
|
||||
"node_id": node_id,
|
||||
"node_type": node_type,
|
||||
"widget_index": idx,
|
||||
"widget_name": widget_name,
|
||||
"original_path": value_str,
|
||||
"urn": {
|
||||
"full": value_str,
|
||||
@@ -869,6 +965,7 @@ def get_node_model_info(
|
||||
"node_id": node_id,
|
||||
"node_type": node_type,
|
||||
"widget_index": idx,
|
||||
"widget_name": widget_name,
|
||||
"original_path": value_str,
|
||||
"node_title": node_title,
|
||||
"category": category,
|
||||
@@ -882,6 +979,279 @@ def get_node_model_info(
|
||||
return model_refs
|
||||
|
||||
|
||||
def _get_node_by_id(nodes: List[Dict[str, Any]], node_id: Any) -> Optional[Dict[str, Any]]:
|
||||
node_id_text = str(node_id)
|
||||
for node in nodes:
|
||||
if str(node.get("id")) == node_id_text:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _get_widget_index_for_input_index(
|
||||
node: Dict[str, Any], input_index: int
|
||||
) -> Optional[int]:
|
||||
widget_index = -1
|
||||
for idx, item in enumerate(node.get("inputs", [])):
|
||||
if _has_widget_input(item):
|
||||
widget_index += 1
|
||||
if idx == input_index:
|
||||
return widget_index if _has_widget_input(item) else None
|
||||
return None
|
||||
|
||||
|
||||
def _get_widget_index_by_name(
|
||||
node: Dict[str, Any], widget_name: str
|
||||
) -> Optional[int]:
|
||||
target_name = normalize_widget_name(widget_name)
|
||||
if not target_name:
|
||||
return None
|
||||
|
||||
widgets_values = node.get("widgets_values", [])
|
||||
for widget_index in range(len(widgets_values)):
|
||||
candidates = get_widget_name_candidates(node, widget_index)
|
||||
if any(normalize_widget_name(candidate) == target_name for candidate in candidates):
|
||||
return widget_index
|
||||
|
||||
for input_index, item in enumerate(node.get("inputs", [])):
|
||||
if not _has_widget_input(item):
|
||||
continue
|
||||
candidates = _widget_item_name_candidates(item)
|
||||
if any(normalize_widget_name(candidate) == target_name for candidate in candidates):
|
||||
return _get_widget_index_for_input_index(node, input_index)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_subgraph_input_link_ids(subgraph: Dict[str, Any], input_name: str) -> set:
|
||||
target_name = normalize_widget_name(input_name)
|
||||
if not target_name:
|
||||
return set()
|
||||
|
||||
link_ids = set()
|
||||
for subgraph_input in subgraph.get("inputs", []):
|
||||
candidates = _widget_item_name_candidates(subgraph_input)
|
||||
if not any(normalize_widget_name(candidate) == target_name for candidate in candidates):
|
||||
continue
|
||||
for link_id in subgraph_input.get("linkIds", []) or []:
|
||||
link_ids.add(str(link_id))
|
||||
return link_ids
|
||||
|
||||
|
||||
def _promoted_widget_target_info(
|
||||
node: Dict[str, Any], widget_index: int, proxy_widget_name: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
widget_name = get_widget_name_hint(node, widget_index) or proxy_widget_name
|
||||
return {
|
||||
"node_id": node.get("id"),
|
||||
"node_type": node.get("type", ""),
|
||||
"node_title": str(node.get("title", "") or "").strip(),
|
||||
"widget_index": widget_index,
|
||||
"widget_name": widget_name,
|
||||
"category": get_effective_model_category_hint(node, widget_index),
|
||||
}
|
||||
|
||||
|
||||
def _find_promoted_widget_targets(
|
||||
subgraph: Dict[str, Any], proxy_node_id: str, proxy_widget_name: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
nodes = subgraph.get("nodes", [])
|
||||
targets: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
|
||||
def add_target(node: Optional[Dict[str, Any]], widget_index: Optional[int]):
|
||||
if node is None or widget_index is None:
|
||||
return
|
||||
key = (str(node.get("id")), int(widget_index))
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
targets.append(_promoted_widget_target_info(node, widget_index, proxy_widget_name))
|
||||
|
||||
if proxy_node_id and proxy_node_id != "-1":
|
||||
target_node = _get_node_by_id(nodes, proxy_node_id)
|
||||
add_target(target_node, _get_widget_index_by_name(target_node or {}, proxy_widget_name))
|
||||
return targets
|
||||
|
||||
link_ids = _get_subgraph_input_link_ids(subgraph, proxy_widget_name)
|
||||
if link_ids:
|
||||
for node in nodes:
|
||||
for input_index, item in enumerate(node.get("inputs", [])):
|
||||
if not _has_widget_input(item):
|
||||
continue
|
||||
if str(item.get("link")) not in link_ids:
|
||||
continue
|
||||
add_target(node, _get_widget_index_for_input_index(node, input_index))
|
||||
|
||||
if targets:
|
||||
return targets
|
||||
|
||||
for node in nodes:
|
||||
add_target(node, _get_widget_index_by_name(node, proxy_widget_name))
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _build_promoted_widget_contexts(
|
||||
workflow_json: Dict[str, Any], subgraphs: List[Dict[str, Any]]
|
||||
) -> Dict[str, Dict[Any, Any]]:
|
||||
subgraphs_by_id = {str(sg.get("id")): sg for sg in subgraphs if sg.get("id")}
|
||||
subgraph_names = {
|
||||
str(sg.get("id")): sg.get("name", sg.get("id"))
|
||||
for sg in subgraphs
|
||||
if sg.get("id")
|
||||
}
|
||||
contexts = {
|
||||
"instance_widgets": {},
|
||||
"inner_widgets": {},
|
||||
"inner_widget_names": {},
|
||||
}
|
||||
|
||||
for instance_node in workflow_json.get("nodes", []):
|
||||
subgraph_id = str(instance_node.get("type", ""))
|
||||
subgraph = subgraphs_by_id.get(subgraph_id)
|
||||
if not subgraph:
|
||||
continue
|
||||
|
||||
proxy_widgets = instance_node.get("properties", {}).get("proxyWidgets", [])
|
||||
if not isinstance(proxy_widgets, list):
|
||||
continue
|
||||
|
||||
widgets_values = instance_node.get("widgets_values", [])
|
||||
for proxy_index, proxy_entry in enumerate(proxy_widgets):
|
||||
proxy_widget_name = _proxy_widget_name(proxy_entry)
|
||||
if not proxy_widget_name:
|
||||
continue
|
||||
|
||||
targets = _find_promoted_widget_targets(
|
||||
subgraph,
|
||||
_proxy_widget_node_id(proxy_entry),
|
||||
proxy_widget_name,
|
||||
)
|
||||
if not targets:
|
||||
continue
|
||||
|
||||
promoted_value = (
|
||||
widgets_values[proxy_index]
|
||||
if isinstance(widgets_values, list) and proxy_index < len(widgets_values)
|
||||
else None
|
||||
)
|
||||
instance_context = {
|
||||
**targets[0],
|
||||
"subgraph_id": subgraph_id,
|
||||
"subgraph_name": subgraph_names.get(subgraph_id, subgraph_id),
|
||||
"proxy_widget_index": proxy_index,
|
||||
"proxy_widget_name": proxy_widget_name,
|
||||
"promoted_value": promoted_value,
|
||||
}
|
||||
contexts["instance_widgets"][(str(instance_node.get("id")), proxy_index)] = (
|
||||
instance_context
|
||||
)
|
||||
|
||||
locator = {
|
||||
"node_id": instance_node.get("id"),
|
||||
"node_type": instance_node.get("type", ""),
|
||||
"node_title": str(instance_node.get("title", "") or "").strip(),
|
||||
"subgraph_id": subgraph_id,
|
||||
"subgraph_name": subgraph_names.get(subgraph_id, subgraph_id),
|
||||
"is_top_level": True,
|
||||
"proxy_widget_index": proxy_index,
|
||||
"proxy_widget_name": proxy_widget_name,
|
||||
"promoted_value": promoted_value,
|
||||
}
|
||||
|
||||
for target in targets:
|
||||
exact_key = (
|
||||
subgraph_id,
|
||||
str(target.get("node_id")),
|
||||
target.get("widget_index"),
|
||||
)
|
||||
contexts["inner_widgets"].setdefault(exact_key, []).append(locator)
|
||||
|
||||
widget_name = normalize_widget_name(target.get("widget_name"))
|
||||
if widget_name:
|
||||
name_key = (subgraph_id, str(target.get("node_id")), widget_name)
|
||||
contexts["inner_widget_names"].setdefault(name_key, []).append(locator)
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _apply_instance_promoted_widget_context(
|
||||
ref: Dict[str, Any],
|
||||
context: Optional[Dict[str, Any]],
|
||||
available_models: Optional[List[Dict[str, Any]]],
|
||||
) -> None:
|
||||
if not context:
|
||||
return
|
||||
|
||||
ref["promoted_widget_name"] = context.get("proxy_widget_name", "")
|
||||
ref["promoted_inner_node_id"] = context.get("node_id")
|
||||
ref["promoted_inner_node_type"] = context.get("node_type", "")
|
||||
ref["promoted_inner_node_title"] = context.get("node_title", "")
|
||||
ref["promoted_inner_widget_index"] = context.get("widget_index")
|
||||
ref["promoted_inner_widget_name"] = context.get("widget_name", "")
|
||||
|
||||
category = context.get("category")
|
||||
original_path = ref.get("original_path", "")
|
||||
if not category or not original_path:
|
||||
return
|
||||
|
||||
resolved = try_resolve_model_path(
|
||||
original_path,
|
||||
[category],
|
||||
available_models=available_models,
|
||||
)
|
||||
ref["category"] = category
|
||||
if resolved:
|
||||
_, full_path = resolved
|
||||
ref["full_path"] = full_path
|
||||
ref["exists"] = os.path.exists(full_path)
|
||||
else:
|
||||
ref["full_path"] = None
|
||||
ref["exists"] = False
|
||||
|
||||
|
||||
def _select_promoted_locator(
|
||||
locators: List[Dict[str, Any]], original_path: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not locators:
|
||||
return None
|
||||
|
||||
for locator in locators:
|
||||
if locator.get("promoted_value") == original_path:
|
||||
return locator
|
||||
return locators[0]
|
||||
|
||||
|
||||
def _apply_promoted_widget_locator(
|
||||
ref: Dict[str, Any], contexts: Dict[str, Dict[Any, Any]]
|
||||
) -> None:
|
||||
subgraph_id = str(ref.get("subgraph_id") or "")
|
||||
if not subgraph_id:
|
||||
return
|
||||
|
||||
exact_key = (subgraph_id, str(ref.get("node_id")), ref.get("widget_index"))
|
||||
locators = contexts.get("inner_widgets", {}).get(exact_key, [])
|
||||
if not locators and ref.get("widget_name"):
|
||||
name_key = (
|
||||
subgraph_id,
|
||||
str(ref.get("node_id")),
|
||||
normalize_widget_name(ref.get("widget_name")),
|
||||
)
|
||||
locators = contexts.get("inner_widget_names", {}).get(name_key, [])
|
||||
|
||||
locator = _select_promoted_locator(locators, ref.get("original_path", ""))
|
||||
if not locator:
|
||||
return
|
||||
|
||||
ref["locate_node_id"] = locator.get("node_id")
|
||||
ref["locate_node_type"] = locator.get("node_type", "")
|
||||
ref["locate_node_title"] = locator.get("node_title", "")
|
||||
ref["locate_subgraph_id"] = ""
|
||||
ref["locate_subgraph_name"] = locator.get("subgraph_name", "")
|
||||
ref["locate_is_top_level"] = True
|
||||
ref["locate_via_promoted_widget"] = True
|
||||
|
||||
|
||||
def analyze_workflow_models(
|
||||
workflow_json: Dict[str, Any],
|
||||
available_models: Optional[List[Dict[str, Any]]] = None,
|
||||
@@ -902,6 +1272,9 @@ def analyze_workflow_models(
|
||||
definitions = workflow_json.get("definitions", {})
|
||||
subgraphs = definitions.get("subgraphs", [])
|
||||
subgraph_lookup = {sg.get("id"): sg.get("name", sg.get("id")) for sg in subgraphs}
|
||||
promoted_widget_contexts = _build_promoted_widget_contexts(
|
||||
workflow_json, subgraphs
|
||||
)
|
||||
|
||||
# Analyze top-level nodes
|
||||
nodes = workflow_json.get("nodes", [])
|
||||
@@ -925,6 +1298,13 @@ def analyze_workflow_models(
|
||||
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
|
||||
if subgraph_id:
|
||||
context = promoted_widget_contexts.get(
|
||||
"instance_widgets", {}
|
||||
).get((str(node.get("id")), ref.get("widget_index")))
|
||||
_apply_instance_promoted_widget_context(
|
||||
ref, context, available_models
|
||||
)
|
||||
all_model_refs.extend(model_refs)
|
||||
except Exception as e:
|
||||
log_warn(f"Error analyzing node {node.get('id', 'unknown')}: {e}")
|
||||
@@ -957,6 +1337,7 @@ def analyze_workflow_models(
|
||||
"nodes",
|
||||
]
|
||||
ref["is_top_level"] = False # This is inside a subgraph definition
|
||||
_apply_promoted_widget_locator(ref, promoted_widget_contexts)
|
||||
all_model_refs.extend(model_refs)
|
||||
except Exception as e:
|
||||
log_warn(
|
||||
|
||||
@@ -238,30 +238,44 @@ export const lifecycleGraphMethods = {
|
||||
/**
|
||||
* Locate and focus a node in the ComfyUI canvas
|
||||
*/
|
||||
locateNodeInGraph(nodeId) {
|
||||
locateNodeInGraph(nodeId, options = {}) {
|
||||
try {
|
||||
if (!app?.graph) {
|
||||
this.showNotification('Cannot locate node - graph not available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the node in the graph
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
this.showNotification(`Node #${nodeId} not found in graph`, 'error');
|
||||
const locateOptions = typeof options === 'string'
|
||||
? { subgraphId: options }
|
||||
: (options || {});
|
||||
const target = this.findLocateTarget(nodeId, locateOptions);
|
||||
if (!target?.node) {
|
||||
const subgraphText = locateOptions.subgraphId ? ` in subgraph ${locateOptions.subgraphId}` : '';
|
||||
this.showNotification(`Node #${nodeId}${subgraphText} not found in graph`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activateGraphForLocate(target);
|
||||
|
||||
const node = target.node;
|
||||
const graph = target.graph || node.graph || app.canvas?.graph || app.graph;
|
||||
let locatedWithAnimation = false;
|
||||
|
||||
// Focus on the node in the canvas with animated panning when possible
|
||||
if (app.canvas?.ds && app.canvas?.canvas) {
|
||||
locatedWithAnimation = this.animateCanvasToNode(node);
|
||||
locatedWithAnimation = this.animateCanvasToNode(node, graph);
|
||||
}
|
||||
|
||||
if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) {
|
||||
let canvasNode = null;
|
||||
if (!locatedWithAnimation && graph?._nodes && typeof graph._nodes.get === 'function') {
|
||||
const numericNodeId = Number(nodeId);
|
||||
canvasNode = graph._nodes.get(nodeId)
|
||||
|| graph._nodes.get(String(nodeId))
|
||||
|| (!Number.isNaN(numericNodeId) ? graph._nodes.get(numericNodeId) : null);
|
||||
}
|
||||
|
||||
if (!locatedWithAnimation && canvasNode) {
|
||||
// Alternative method for older versions
|
||||
const canvasNode = app.graph._nodes.get(nodeId);
|
||||
if (canvasNode && canvasNode.setSelected && canvasNode.graph) {
|
||||
canvasNode.setSelected(true);
|
||||
// Scroll to node
|
||||
@@ -277,23 +291,251 @@ export const lifecycleGraphMethods = {
|
||||
|
||||
// Also try to flash/select the node
|
||||
// Deselect all nodes first
|
||||
if (app.graph && app.graph.nodes) {
|
||||
app.graph.nodes.forEach(n => {
|
||||
if (graph?.nodes) {
|
||||
graph.nodes.forEach(n => {
|
||||
if (n.selected) n.selected = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Select and highlight our node
|
||||
node.selected = true;
|
||||
graph?.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
|
||||
this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info');
|
||||
const graphLabel = target.subgraphId || graph?.id || '';
|
||||
const scopeText = graphLabel && graph !== app.graph ? ` in subgraph ${target.subgraphName || graphLabel}` : '';
|
||||
this.showNotification(`Focused on Node #${nodeId} (${node.type})${scopeText}`, 'info');
|
||||
} catch (e) {
|
||||
console.error('Model Resolver: Error locating node:', e);
|
||||
this.showNotification('Error locating node: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
animateCanvasToNode(node) {
|
||||
findLocateTarget(nodeId, options = {}) {
|
||||
const rootGraph = app?.graph;
|
||||
if (!rootGraph) return null;
|
||||
|
||||
const isTopLevel = options.isTopLevel !== false;
|
||||
const subgraphId = String(options.subgraphId || '').trim();
|
||||
|
||||
if (isTopLevel) {
|
||||
const rootNode = this.getGraphNodeById(rootGraph, nodeId);
|
||||
if (rootNode) {
|
||||
return { node: rootNode, graph: rootGraph, path: [], subgraphId: '', subgraphName: '' };
|
||||
}
|
||||
}
|
||||
|
||||
if (subgraphId) {
|
||||
const graphMatch = this.findSubgraphById(rootGraph, subgraphId);
|
||||
const graphNode = graphMatch?.graph ? this.getGraphNodeById(graphMatch.graph, nodeId) : null;
|
||||
if (graphNode) {
|
||||
return {
|
||||
node: graphNode,
|
||||
graph: graphMatch.graph,
|
||||
path: graphMatch.path,
|
||||
subgraphId,
|
||||
subgraphName: graphMatch.name || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.findNodeInGraphTree(rootGraph, nodeId, {
|
||||
preferSubgraphId: subgraphId,
|
||||
includeRoot: true
|
||||
});
|
||||
},
|
||||
|
||||
getGraphNodeById(graph, nodeId) {
|
||||
if (!graph) return null;
|
||||
const idText = String(nodeId);
|
||||
const numericId = Number(nodeId);
|
||||
|
||||
if (typeof graph.getNodeById === 'function') {
|
||||
const direct = graph.getNodeById(nodeId);
|
||||
if (direct) return direct;
|
||||
if (!Number.isNaN(numericId)) {
|
||||
const numeric = graph.getNodeById(numericId);
|
||||
if (numeric) return numeric;
|
||||
}
|
||||
}
|
||||
|
||||
if (graph._nodes && typeof graph._nodes.get === 'function') {
|
||||
const direct = graph._nodes.get(nodeId) || graph._nodes.get(idText);
|
||||
if (direct) return direct;
|
||||
if (!Number.isNaN(numericId)) {
|
||||
const numeric = graph._nodes.get(numericId);
|
||||
if (numeric) return numeric;
|
||||
}
|
||||
}
|
||||
|
||||
return (graph.nodes || []).find(node => String(node?.id) === idText) || null;
|
||||
},
|
||||
|
||||
getGraphId(graph) {
|
||||
return graph?.id || graph?._id || graph?.config?.id || '';
|
||||
},
|
||||
|
||||
getSubgraphDisplayName(graph, ownerNode = null) {
|
||||
return graph?.name || graph?.title || ownerNode?.title || ownerNode?.type || this.getGraphId(graph);
|
||||
},
|
||||
|
||||
findSubgraphById(rootGraph, subgraphId) {
|
||||
const targetId = String(subgraphId || '').trim();
|
||||
if (!rootGraph || !targetId) return null;
|
||||
|
||||
const visited = new Set();
|
||||
const walk = (graph, path = []) => {
|
||||
if (!graph || visited.has(graph)) return null;
|
||||
visited.add(graph);
|
||||
|
||||
if (String(this.getGraphId(graph)) === targetId) {
|
||||
const lastStep = path[path.length - 1] || null;
|
||||
return {
|
||||
graph,
|
||||
path,
|
||||
name: this.getSubgraphDisplayName(graph, lastStep?.node)
|
||||
};
|
||||
}
|
||||
|
||||
for (const node of graph.nodes || []) {
|
||||
if (!node?.subgraph) continue;
|
||||
const result = walk(node.subgraph, [
|
||||
...path,
|
||||
{ parentGraph: graph, node, graph: node.subgraph }
|
||||
]);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return walk(rootGraph, []);
|
||||
},
|
||||
|
||||
findNodeInGraphTree(rootGraph, nodeId, options = {}) {
|
||||
if (!rootGraph) return null;
|
||||
const preferSubgraphId = String(options.preferSubgraphId || '').trim();
|
||||
const visited = new Set();
|
||||
let fallback = null;
|
||||
|
||||
const walk = (graph, path = []) => {
|
||||
if (!graph || visited.has(graph)) return null;
|
||||
visited.add(graph);
|
||||
|
||||
const graphId = String(this.getGraphId(graph) || '');
|
||||
const node = options.includeRoot !== false || path.length
|
||||
? this.getGraphNodeById(graph, nodeId)
|
||||
: null;
|
||||
if (node) {
|
||||
const target = {
|
||||
node,
|
||||
graph,
|
||||
path,
|
||||
subgraphId: graphId,
|
||||
subgraphName: this.getSubgraphDisplayName(graph, path[path.length - 1]?.node)
|
||||
};
|
||||
if (!preferSubgraphId || graphId === preferSubgraphId) return target;
|
||||
fallback = fallback || target;
|
||||
}
|
||||
|
||||
for (const childNode of graph.nodes || []) {
|
||||
if (!childNode?.subgraph) continue;
|
||||
const result = walk(childNode.subgraph, [
|
||||
...path,
|
||||
{ parentGraph: graph, node: childNode, graph: childNode.subgraph }
|
||||
]);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return walk(rootGraph, []) || fallback;
|
||||
},
|
||||
|
||||
activateGraphForLocate(target) {
|
||||
const canvas = app?.canvas;
|
||||
if (!canvas || !target?.graph) return false;
|
||||
|
||||
const path = Array.isArray(target.path) ? target.path : [];
|
||||
for (const step of path) {
|
||||
if (step.parentGraph && canvas.graph !== step.parentGraph) {
|
||||
this.setActiveCanvasGraph(step.parentGraph);
|
||||
}
|
||||
|
||||
if (step.node?.subgraph && canvas.graph !== step.node.subgraph) {
|
||||
const opened = this.openSubgraphNodeForLocate(step.node);
|
||||
if (!opened || canvas.graph !== step.node.subgraph) {
|
||||
this.setActiveCanvasGraph(step.node.subgraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canvas.graph !== target.graph) {
|
||||
this.setActiveCanvasGraph(target.graph);
|
||||
}
|
||||
|
||||
return canvas.graph === target.graph;
|
||||
},
|
||||
|
||||
openSubgraphNodeForLocate(node) {
|
||||
const canvas = app?.canvas;
|
||||
if (!canvas || !node?.subgraph) return false;
|
||||
|
||||
const attempts = [
|
||||
[canvas, 'openSubgraph', [node]],
|
||||
[canvas, 'openSubgraph', [node.subgraph, node]],
|
||||
[canvas, 'enterSubgraph', [node]],
|
||||
[canvas, 'openSubgraphNode', [node]],
|
||||
[canvas, 'showSubgraph', [node]],
|
||||
[node, 'openSubgraph', []],
|
||||
[node, 'enterSubgraph', []],
|
||||
[node, 'showSubgraph', []],
|
||||
[node, 'onOpenSubgraph', [canvas]]
|
||||
];
|
||||
|
||||
for (const [target, method, args] of attempts) {
|
||||
if (typeof target?.[method] !== 'function') continue;
|
||||
try {
|
||||
target[method](...args);
|
||||
if (canvas.graph === node.subgraph) return true;
|
||||
} catch (error) {
|
||||
console.debug?.(`Model Resolver: ${method} failed while opening subgraph`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
setActiveCanvasGraph(graph) {
|
||||
const canvas = app?.canvas;
|
||||
if (!canvas || !graph) return false;
|
||||
|
||||
const methods = ['setGraph', 'switchToGraph', 'changeGraph', 'showGraph'];
|
||||
for (const method of methods) {
|
||||
if (typeof canvas[method] !== 'function') continue;
|
||||
try {
|
||||
canvas[method](graph);
|
||||
break;
|
||||
} catch (error) {
|
||||
console.debug?.(`Model Resolver: canvas.${method} failed while switching graph`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (canvas.graph !== graph) {
|
||||
try {
|
||||
canvas.graph = graph;
|
||||
} catch (error) {
|
||||
console.debug?.('Model Resolver: assigning canvas.graph failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas.setDirty?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
return canvas.graph === graph;
|
||||
},
|
||||
|
||||
animateCanvasToNode(node, graph = null) {
|
||||
const canvas = app?.canvas;
|
||||
const ds = canvas?.ds;
|
||||
const htmlCanvas = canvas?.canvas;
|
||||
@@ -350,6 +592,7 @@ export const lifecycleGraphMethods = {
|
||||
ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased);
|
||||
ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased);
|
||||
canvas.setDirty?.(true, true);
|
||||
graph?.setDirtyCanvas?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
|
||||
if (progress < 1) {
|
||||
|
||||
@@ -7,19 +7,38 @@ export const missingBrowserMethods = {
|
||||
return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model';
|
||||
},
|
||||
|
||||
getMissingLocateTarget(missing = {}) {
|
||||
const hasLocateNode = missing.locate_node_id !== undefined
|
||||
&& missing.locate_node_id !== null
|
||||
&& missing.locate_node_id !== '';
|
||||
const nodeId = hasLocateNode ? missing.locate_node_id : missing.node_id;
|
||||
return {
|
||||
nodeId: nodeId ?? '',
|
||||
nodeType: hasLocateNode ? (missing.locate_node_type || missing.node_type || '') : (missing.node_type || ''),
|
||||
nodeTitle: hasLocateNode ? (missing.locate_node_title || '') : (missing.node_title || ''),
|
||||
subgraphId: hasLocateNode ? (missing.locate_subgraph_id || '') : (missing.subgraph_id || ''),
|
||||
subgraphName: hasLocateNode ? (missing.locate_subgraph_name || missing.subgraph_name || '') : (missing.subgraph_name || ''),
|
||||
isTopLevel: hasLocateNode ? missing.locate_is_top_level !== false : missing.is_top_level !== false
|
||||
};
|
||||
},
|
||||
|
||||
getMissingNodeDisplay(missing = {}) {
|
||||
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);
|
||||
const locateTarget = this.getMissingLocateTarget(missing);
|
||||
const isSubgraphNode = locateTarget.nodeType && locateTarget.nodeType.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
let nodeLabel;
|
||||
if (missing.subgraph_name) {
|
||||
nodeLabel = missing.subgraph_name;
|
||||
if (locateTarget.subgraphName) {
|
||||
nodeLabel = locateTarget.subgraphName;
|
||||
} else if (isSubgraphNode) {
|
||||
nodeLabel = 'Subgraph';
|
||||
} else {
|
||||
nodeLabel = missing.node_type || 'Node';
|
||||
nodeLabel = locateTarget.nodeType || 'Node';
|
||||
}
|
||||
|
||||
const nodeId = missing.node_id ?? '';
|
||||
const customNodeTitle = String(missing.node_title || '').trim();
|
||||
const nodeId = locateTarget.nodeId ?? '';
|
||||
const promotedDetail = missing.locate_via_promoted_widget
|
||||
? (missing.node_title || missing.node_type || '')
|
||||
: (missing.promoted_inner_node_title || missing.promoted_inner_node_type || '');
|
||||
const customNodeTitle = String(promotedDetail || locateTarget.nodeTitle || missing.node_title || '').trim();
|
||||
const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel;
|
||||
const text = hasCustomNodeTitle
|
||||
? `${nodeLabel} #${nodeId} · ${customNodeTitle}`
|
||||
@@ -28,7 +47,13 @@ export const missingBrowserMethods = {
|
||||
return {
|
||||
label: nodeLabel,
|
||||
text,
|
||||
canLocate: missing.is_top_level !== false && nodeId !== ''
|
||||
canLocate: nodeId !== '',
|
||||
locateTarget,
|
||||
locateTooltip: missing.locate_via_promoted_widget
|
||||
? 'Center this subgraph node in the ComfyUI graph.'
|
||||
: locateTarget.isTopLevel === false
|
||||
? 'Open this subgraph and center the node in the ComfyUI graph.'
|
||||
: 'Center this node in the ComfyUI graph.'
|
||||
};
|
||||
},
|
||||
|
||||
@@ -262,9 +287,10 @@ export const missingBrowserMethods = {
|
||||
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
|
||||
const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel);
|
||||
const nodeDisplay = this.getMissingNodeDisplay(missing);
|
||||
const nodeId = missing.node_id ?? '';
|
||||
const locateTarget = nodeDisplay.locateTarget || this.getMissingLocateTarget(missing);
|
||||
const nodeId = locateTarget.nodeId ?? '';
|
||||
const rowNodeHtml = nodeDisplay.canLocate
|
||||
? `<button type="button" class="mr-node-chip is-locatable mr-missing-row-node mr-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-tooltip="Center this node in the ComfyUI graph." aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="mr-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
|
||||
? `<button type="button" class="mr-node-chip is-locatable mr-missing-row-node mr-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-subgraph-id="${this.escapeHtml(String(locateTarget.subgraphId || ''))}" data-is-top-level="${locateTarget.isTopLevel ? 'true' : 'false'}" data-tooltip="${this.escapeHtml(nodeDisplay.locateTooltip)}" aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="mr-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
|
||||
: `<span class="mr-missing-row-node">${this.escapeHtml(nodeDisplay.text)}</span>`;
|
||||
|
||||
html += `
|
||||
@@ -385,7 +411,10 @@ export const missingBrowserMethods = {
|
||||
event.stopPropagation();
|
||||
const rawNodeId = button.dataset.nodeId;
|
||||
const numericNodeId = Number(rawNodeId);
|
||||
this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId);
|
||||
this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId, {
|
||||
subgraphId: button.dataset.subgraphId || '',
|
||||
isTopLevel: button.dataset.isTopLevel !== 'false'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -555,9 +584,13 @@ export const missingBrowserMethods = {
|
||||
|
||||
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
|
||||
const locateBtn = container.querySelector(`#${locateId}`);
|
||||
if (locateBtn && missing.is_top_level !== false) {
|
||||
const locateTarget = this.getMissingLocateTarget(missing);
|
||||
if (locateBtn && locateTarget.nodeId !== undefined && locateTarget.nodeId !== null && locateTarget.nodeId !== '') {
|
||||
locateBtn.addEventListener('click', () => {
|
||||
this.locateNodeInGraph(missing.node_id);
|
||||
this.locateNodeInGraph(locateTarget.nodeId, {
|
||||
subgraphId: locateTarget.subgraphId || '',
|
||||
isTopLevel: locateTarget.isTopLevel
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -854,15 +887,15 @@ export const missingBrowserMethods = {
|
||||
}
|
||||
html += `</div>`;
|
||||
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
|
||||
const nodeChipClasses = missing.is_top_level !== false ? 'mr-node-chip is-locatable' : 'mr-node-chip';
|
||||
const nodeChipTitle = missing.is_top_level !== false ? 'Center this node in the ComfyUI graph.' : '';
|
||||
const nodeChipClasses = nodeDisplay.canLocate ? 'mr-node-chip is-locatable' : 'mr-node-chip';
|
||||
const nodeChipTitle = nodeDisplay.canLocate ? nodeDisplay.locateTooltip : '';
|
||||
|
||||
html += `<div class="mr-card-subtitle">`;
|
||||
if (missing.category) {
|
||||
html += `<span class="mr-category-chip ${this.getModelTypeColorClass(missing.category)}" data-tooltip="${this.escapeHtml(missing.category)}">${this.getCategoryDisplayName(missing.category)}</span>`;
|
||||
}
|
||||
html += `<span id="${locateId}" class="${nodeChipClasses}"${nodeChipTitle ? ` data-tooltip="${this.escapeHtml(nodeChipTitle)}"` : ''}>`;
|
||||
if (missing.is_top_level !== false) {
|
||||
if (nodeDisplay.canLocate) {
|
||||
html += this.getLocateIconHtml();
|
||||
}
|
||||
html += `${nodeChipText}</span>`;
|
||||
|
||||
Reference in New Issue
Block a user