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:
Dariusz L
2026-06-19 01:39:15 +02:00
parent 6de0668c4b
commit 5f1e814751
3 changed files with 720 additions and 63 deletions
+417 -36
View File
@@ -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(
+255 -12
View File
@@ -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) {
+48 -15
View File
@@ -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>`;