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.
This commit is contained in:
Dariusz L
2026-08-03 04:17:49 +02:00
parent 60e67fcb74
commit 413e7cc0ab
6 changed files with 381 additions and 49 deletions
+1
View File
@@ -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),
}
+46 -25
View File
@@ -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,
+211 -22
View File
@@ -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
+15
View File
@@ -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
+101 -1
View File
@@ -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()
+7 -1
View File
@@ -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