From 413e7cc0ab83a75f3cf3175234f54195c38e27df Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 3 Aug 2026 04:17:49 +0200 Subject: [PATCH] Promote subgraph widget contexts to top-level model references When a subgraph exposes a promoted widget and the instance value or connected inner widget contains a usable model path, create a top-level model reference on the subgraph node instead of only pointing to the inner definition node. This lets resolution, location, and queue updates target the instance widget directly. Also handle serialized subgraph instances that omit proxyWidgets by reconstructing the same promotion from subgraph input/link metadata, and extend widget values for promoted inputs in the workflow updater. --- core/resolver.py | 1 + core/workflow/analysis.py | 71 +++++--- core/workflow/subgraphs.py | 233 +++++++++++++++++++++++--- core/workflow_updater.py | 15 ++ tests/test_workflow_components.py | 102 ++++++++++- web/resolver/actions/queue_methods.js | 8 +- 6 files changed, 381 insertions(+), 49 deletions(-) diff --git a/core/resolver.py b/core/resolver.py index ea056c9..26cbade 100644 --- a/core/resolver.py +++ b/core/resolver.py @@ -1253,6 +1253,7 @@ def apply_resolution( "nested_key": resolution.get( "nested_key" ), # For dict-type widget values + "promoted_widget_name": resolution.get("promoted_widget_name"), **get_custom_node_resolution_metadata(resolution), } diff --git a/core/workflow/analysis.py b/core/workflow/analysis.py index 12a01db..5668291 100644 --- a/core/workflow/analysis.py +++ b/core/workflow/analysis.py @@ -163,7 +163,7 @@ def _get_promoted_widget_context_cache_key( context_node = { **node, "widgets_values": ( - [None] * len(widgets_values) + list(widgets_values) if isinstance(widgets_values, list) else [] ), @@ -249,6 +249,7 @@ def analyze_workflow_models( promoted_widget_contexts = subgraph_utils._build_promoted_widget_contexts( workflow_json, subgraphs ) + promoted_instance_slots = set() # Analyze top-level nodes nodes = workflow_json.get("nodes", []) @@ -342,19 +343,25 @@ def analyze_workflow_models( # Mark with subgraph info if it's a subgraph node # For top-level subgraph instance nodes, subgraph_path is None # This distinguishes them from nodes within subgraph definitions + if subgraph_id: + for ref in model_refs: + context = promoted_widget_contexts.get( + "instance_widgets", {} + ).get((str(node.get("id")), ref.get("widget_index"))) + if context: + promoted_instance_slots.add( + (str(node.get("id")), ref.get("widget_index")) + ) + if not reused: + subgraph_utils._apply_instance_promoted_widget_context( + ref, context, available_models + ) if not reused: for ref in model_refs: ref["subgraph_id"] = subgraph_id ref["subgraph_name"] = subgraph_name ref["subgraph_path"] = None ref["is_top_level"] = True - if subgraph_id: - context = promoted_widget_contexts.get( - "instance_widgets", {} - ).get((str(node.get("id")), ref.get("widget_index"))) - subgraph_utils._apply_instance_promoted_widget_context( - ref, context, available_models - ) if node_cache_out is not None: node_cache_out[node_cache_key] = { "fingerprint": node_fingerprint, @@ -402,33 +409,47 @@ def analyze_workflow_models( and cached_node.get("fingerprint") == node_fingerprint ) if reused: - model_refs = cached_node.get("refs", []) + base_model_refs = cached_node.get("refs", []) reused_nodes += 1 else: - model_refs = references.get_node_model_info( + base_model_refs = references.get_node_model_info( node, available_models=available_models, ) analyzed_nodes += 1 analyzed_subgraph_nodes += 1 - for ref in model_refs: - ref["subgraph_id"] = subgraph_id - ref["subgraph_name"] = subgraph_name - ref["subgraph_path"] = [ - "definitions", - "subgraphs", - subgraph_id, - "nodes", - ] - ref["is_top_level"] = False - subgraph_utils._apply_promoted_widget_locator( - ref, - promoted_widget_contexts, - ) + model_refs = [] + for base_ref in base_model_refs: + scoped_ref = dict(base_ref) + scoped_ref["subgraph_id"] = subgraph_id + scoped_ref["subgraph_name"] = subgraph_name + scoped_ref["subgraph_path"] = [ + "definitions", + "subgraphs", + subgraph_id, + "nodes", + ] + scoped_ref["is_top_level"] = False + promoted_refs = subgraph_utils._promote_model_reference_to_instances( + scoped_ref, + promoted_widget_contexts, + available_models, + promoted_instance_slots, + ) + if promoted_refs is not None: + model_refs.extend(promoted_refs) + continue + + ref = scoped_ref + subgraph_utils._apply_promoted_widget_locator( + ref, + promoted_widget_contexts, + ) + model_refs.append(ref) if node_cache_out is not None: node_cache_out[node_cache_key] = { "fingerprint": node_fingerprint, - "refs": model_refs, + "refs": base_model_refs, } report_node_progress( node, diff --git a/core/workflow/subgraphs.py b/core/workflow/subgraphs.py index 10a9ec6..e2cabba 100644 --- a/core/workflow/subgraphs.py +++ b/core/workflow/subgraphs.py @@ -81,6 +81,44 @@ def _get_subgraph_input_link_ids(subgraph: Dict[str, Any], input_name: str) -> s return link_ids +def _get_node_input_by_name( + node: Dict[str, Any], input_name: str +) -> Optional[Dict[str, Any]]: + target_name = normalize_widget_name(input_name) + if not target_name: + return None + + for item in node.get("inputs", []): + candidates = _widget_item_name_candidates(item) + if any( + normalize_widget_name(candidate) == target_name for candidate in candidates + ): + return item + return None + + +def _get_node_widget_value(node: Dict[str, Any], widget_index: Any) -> Any: + try: + widget_index = int(widget_index) + except (TypeError, ValueError): + return None + + widgets_values = node.get("widgets_values", []) + if not isinstance(widgets_values, list) or widget_index < 0: + return None + if widget_index >= len(widgets_values): + return None + return widgets_values[widget_index] + + +def _has_promoted_value(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + return True + + def _promoted_widget_target_info( node: Dict[str, Any], widget_index: int, proxy_widget_name: str = "" ) -> Dict[str, Any]: @@ -92,6 +130,7 @@ def _promoted_widget_target_info( "widget_index": widget_index, "widget_name": widget_name, "category": references.get_effective_model_category_hint(node, widget_index), + "value": _get_node_widget_value(node, widget_index), } @@ -163,26 +202,33 @@ def _build_promoted_widget_contexts( proxy_widgets = instance_node.get("properties", {}).get("proxyWidgets", []) if not isinstance(proxy_widgets, list): - continue + proxy_widgets = [] - 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 + seen_proxy_indexes = set() + seen_proxy_names = set() - targets = _find_promoted_widget_targets( - subgraph, - _proxy_widget_node_id(proxy_entry), - proxy_widget_name, + def add_instance_context( + proxy_index: Optional[int], + proxy_widget_name: str, + targets: List[Dict[str, Any]], + ) -> None: + if proxy_index is None or not proxy_widget_name or not targets: + return + if proxy_index in seen_proxy_indexes: + return + + instance_input = _get_node_input_by_name( + instance_node, proxy_widget_name ) - if not targets: - continue - + input_connected = bool( + instance_input and instance_input.get("link") is not None + ) + instance_value = _get_node_widget_value(instance_node, proxy_index) + target_value = targets[0].get("value") promoted_value = ( - widgets_values[proxy_index] - if isinstance(widgets_values, list) and proxy_index < len(widgets_values) - else None + instance_value + if _has_promoted_value(instance_value) + else target_value ) instance_context = { **targets[0], @@ -191,10 +237,11 @@ def _build_promoted_widget_contexts( "proxy_widget_index": proxy_index, "proxy_widget_name": proxy_widget_name, "promoted_value": promoted_value, + "input_connected": input_connected, } - contexts["instance_widgets"][(str(instance_node.get("id")), proxy_index)] = ( - instance_context - ) + contexts["instance_widgets"][ + (str(instance_node.get("id")), proxy_index) + ] = instance_context locator = { "node_id": instance_node.get("id"), @@ -206,8 +253,11 @@ def _build_promoted_widget_contexts( "proxy_widget_index": proxy_index, "proxy_widget_name": proxy_widget_name, "promoted_value": promoted_value, + "input_connected": input_connected, } + seen_proxy_indexes.add(proxy_index) + seen_proxy_names.add(normalize_widget_name(proxy_widget_name)) for target in targets: exact_key = ( subgraph_id, @@ -227,6 +277,55 @@ def _build_promoted_widget_contexts( locator ) + 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, + ) + add_instance_context(proxy_index, proxy_widget_name, targets) + + # Some serialized workflows omit proxyWidgets even though a subgraph + # input is connected to a widget inside the definition. Reconstruct the + # same promotion from the subgraph input/link metadata in that case. + instance_inputs = instance_node.get("inputs", []) + if not isinstance(instance_inputs, list): + instance_inputs = [] + for subgraph_input in subgraph.get("inputs", []): + input_names = _widget_item_name_candidates(subgraph_input) + proxy_widget_name = next( + (name for name in input_names if str(name or "").strip()), + "", + ) + if not proxy_widget_name: + continue + if normalize_widget_name(proxy_widget_name) in seen_proxy_names: + continue + + instance_input_index = next( + ( + index + for index, item in enumerate(instance_inputs) + if _get_node_input_by_name( + {"inputs": [item]}, proxy_widget_name + ) + ), + None, + ) + if instance_input_index is None: + continue + proxy_index = _get_widget_index_for_input_index( + instance_node, instance_input_index + ) + targets = _find_promoted_widget_targets( + subgraph, "-1", proxy_widget_name + ) + add_instance_context(proxy_index, proxy_widget_name, targets) + return contexts @@ -277,12 +376,12 @@ def _select_promoted_locator( return locators[0] -def _apply_promoted_widget_locator( +def _get_promoted_widget_locators( ref: Dict[str, Any], contexts: Dict[str, Dict[Any, Any]] -) -> None: +) -> List[Dict[str, Any]]: subgraph_id = str(ref.get("subgraph_id") or "") if not subgraph_id: - return + return [] exact_key = (subgraph_id, str(ref.get("node_id")), ref.get("widget_index")) locators = contexts.get("inner_widgets", {}).get(exact_key, []) @@ -293,7 +392,97 @@ def _apply_promoted_widget_locator( normalize_widget_name(ref.get("widget_name")), ) locators = contexts.get("inner_widget_names", {}).get(name_key, []) + return locators + +def _promote_model_reference_to_instances( + ref: Dict[str, Any], + contexts: Dict[str, Dict[Any, Any]], + available_models: Optional[List[Dict[str, Any]]] = None, + existing_instance_slots: Optional[set] = None, +) -> Optional[List[Dict[str, Any]]]: + locators = _get_promoted_widget_locators(ref, contexts) + if not locators: + return None + + if existing_instance_slots is None: + existing_instance_slots = set() + promoted_refs = [] + for locator in locators: + instance_slot = ( + str(locator.get("node_id")), + locator.get("proxy_widget_index"), + ) + if instance_slot in existing_instance_slots: + continue + if locator.get("input_connected"): + continue + + value = locator.get("promoted_value") + nested_key = ref.get("nested_key") + if nested_key and isinstance(value, dict): + value = value.get(nested_key) + if not _has_promoted_value(value): + continue + + value_str = str(value).strip() + promoted = dict(ref) + promoted.update( + { + "node_id": locator.get("node_id"), + "node_type": locator.get("node_type", ""), + "node_title": locator.get("node_title", ""), + "widget_index": locator.get("proxy_widget_index"), + "widget_name": locator.get("proxy_widget_name", ""), + "original_path": value_str, + "subgraph_id": locator.get("subgraph_id", ""), + "subgraph_name": locator.get("subgraph_name", ""), + "subgraph_path": None, + "is_top_level": True, + "promoted_widget_name": locator.get("proxy_widget_name", ""), + "promoted_inner_node_id": ref.get("node_id"), + "promoted_inner_node_type": ref.get("node_type", ""), + "promoted_inner_node_title": ref.get("node_title", ""), + "promoted_inner_widget_index": ref.get("widget_index"), + "promoted_inner_widget_name": ref.get("widget_name", ""), + "locate_node_id": locator.get("node_id"), + "locate_node_type": locator.get("node_type", ""), + "locate_node_title": locator.get("node_title", ""), + "locate_subgraph_id": "", + "locate_subgraph_name": locator.get("subgraph_name", ""), + "locate_is_top_level": True, + "locate_via_promoted_widget": True, + "is_urn": bool(references.URN_REGEX.match(value_str)), + } + ) + + category_hints = promoted.get("category_hints") or [] + if not category_hints and promoted.get("category"): + category_hints = [promoted["category"]] + resolved = references.try_resolve_model_path( + value_str, + category_hints or None, + available_models=available_models, + ) + if resolved: + category, full_path = resolved + promoted["category"] = category + promoted["full_path"] = full_path + promoted["exists"] = os.path.exists(full_path) + else: + promoted["full_path"] = None + promoted["exists"] = False + + promoted_refs.append(promoted) + existing_instance_slots.add(instance_slot) + + return promoted_refs + + +def _apply_promoted_widget_locator( + ref: Dict[str, Any], contexts: Dict[str, Dict[Any, Any]] +) -> None: + locators = _get_promoted_widget_locators(ref, contexts) locator = _select_promoted_locator(locators, ref.get("original_path", "")) if not locator: return diff --git a/core/workflow_updater.py b/core/workflow_updater.py index 3252d90..80ee4dd 100644 --- a/core/workflow_updater.py +++ b/core/workflow_updater.py @@ -204,8 +204,23 @@ def update_model_path( return False widgets_values = node.get("widgets_values", []) + if not isinstance(widgets_values, list): + widgets_values = [] + node["widgets_values"] = widgets_values if widget_index >= len(widgets_values): + can_extend_promoted_input = bool( + is_top_level is True + and mapping + and mapping.get("promoted_widget_name") + ) + if can_extend_promoted_input: + widgets_values.extend([None] * (widget_index + 1 - len(widgets_values))) + else: + log.warning(f"Widget index {widget_index} out of range for node {node_id}") + return False + + if widget_index < 0: log.warning(f"Widget index {widget_index} out of range for node {node_id}") return False diff --git a/tests/test_workflow_components.py b/tests/test_workflow_components.py index 11837cc..c954db6 100644 --- a/tests/test_workflow_components.py +++ b/tests/test_workflow_components.py @@ -1,7 +1,8 @@ from types import SimpleNamespace from unittest.mock import patch -from core.workflow import references, subgraphs +from core.workflow import analysis, references, subgraphs +from core.workflow_updater import update_workflow_nodes def _loader_node(node_id=10, value="model.safetensors", link=None): @@ -161,6 +162,105 @@ def test_apply_promoted_locator_supports_name_fallback_and_value_selection(): assert reference["locate_via_promoted_widget"] is True +def test_subgraph_input_reference_targets_instance_for_update_and_location(): + workflow = { + "nodes": [ + { + "id": 1, + "type": "subgraph-1", + "title": "Model subgraph", + "inputs": [ + { + "name": "ckpt_name", + "type": "COMBO", + "widget": {"name": "ckpt_name"}, + "link": None, + } + ], + "outputs": [{"links": [20]}], + "properties": {"proxyWidgets": [["10", "ckpt_name"]]}, + # Some serialized subgraph instances have no values here; + # the connected inner widget still contains the old value. + "widgets_values": [], + } + ], + "definitions": { + "subgraphs": [ + { + "id": "subgraph-1", + "name": "Model subgraph", + "inputs": [ + {"name": "ckpt_name", "type": "COMBO", "linkIds": [7]} + ], + "nodes": [ + { + "id": 10, + "type": "CheckpointLoaderSimple", + "inputs": [ + { + "name": "ckpt_name", + "type": "COMBO", + "widget": {"name": "ckpt_name"}, + "link": 7, + } + ], + "outputs": [{"type": "MODEL", "links": [20]}], + "widgets_values": ["inner-stale.safetensors"], + } + ], + } + ] + }, + } + + refs = analysis.analyze_workflow_models(workflow, available_models=[]) + + assert len(refs) == 1 + assert refs[0]["node_id"] == 1 + assert refs[0]["widget_index"] == 0 + assert refs[0]["subgraph_id"] == "subgraph-1" + assert refs[0]["is_top_level"] is True + assert refs[0]["subgraph_path"] is None + assert refs[0]["locate_node_id"] == 1 + assert refs[0]["locate_subgraph_id"] == "" + assert refs[0]["promoted_inner_node_id"] == 10 + + no_proxy_workflow = { + **workflow, + "nodes": [ + { + **workflow["nodes"][0], + "properties": {}, + } + ], + } + no_proxy_refs = analysis.analyze_workflow_models( + no_proxy_workflow, available_models=[] + ) + assert no_proxy_refs[0]["node_id"] == 1 + assert no_proxy_refs[0]["promoted_inner_node_id"] == 10 + + update_workflow_nodes( + workflow, + [ + { + "node_id": refs[0]["node_id"], + "widget_index": refs[0]["widget_index"], + "resolved_path": "replacement.safetensors", + "category": "checkpoints", + "subgraph_id": refs[0]["subgraph_id"], + "is_top_level": refs[0]["is_top_level"], + "promoted_widget_name": refs[0]["promoted_widget_name"], + } + ], + ) + + assert workflow["nodes"][0]["widgets_values"] == ["replacement.safetensors"] + assert workflow["definitions"]["subgraphs"][0]["nodes"][0]["widgets_values"] == [ + "inner-stale.safetensors" + ] + + def test_reference_matching_handles_placeholders_paths_and_scanner_records(tmp_path): model_path = tmp_path / "folder" / "model.safetensors" model_path.parent.mkdir() diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index b7f2ae8..31b7dee 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -1458,7 +1458,8 @@ export const queueMethods = { || ref.name || ref.original_path ), - nested_key: ref.nested_key + nested_key: ref.nested_key, + promoted_widget_name: ref.promoted_widget_name })); }, @@ -1493,6 +1494,10 @@ export const queueMethods = { || baseResolution.original_path ), nested_key: ref.nested_key ?? baseResolution.nested_key, + promoted_widget_name: ( + ref.promoted_widget_name + || baseResolution.promoted_widget_name + ), original_path: ref.original_path || baseResolution.original_path, node_type: ref.node_type || baseResolution.node_type, node_label: ref.subgraph_name || ref.node_type || baseResolution.node_label @@ -1957,6 +1962,7 @@ export const queueMethods = { || missing.original_path ), nested_key: missing.nested_key, + promoted_widget_name: missing.promoted_widget_name, node_refs: this.getResolutionNodeRefs(missing), node_type: missing.node_type, node_label: missing.subgraph_name || missing.node_type