Files
ComfyUI_Skills_OpenClaw/ui/workflow_format.py
科林 KELINandGitHub cbee24f850 fix: duplicate nodes losing parameters due to schema name collision (#87) (#88)
Root cause: when a workflow has multiple nodes of the same type (e.g. two
GeminiNanoBanana2), extract_schema_params gave them identical parameter
names (e.g. both "seed"). build_final_schema deduped them, but the
unique names were never synced back to ui_parameters. When the frontend
saved using ui_parameters, the duplicate names caused one parameter to
silently overwrite the other.

Changes:
- Refactor _get_auto_mapping to only decide exposure/required, not naming
- Add _assign_parameter_names for global context-aware naming:
  - Single node of a type → simple name (seed, prompt)
  - Multiple nodes → disambiguate with node title or node_id
- Add sync_names_back option to build_final_schema so ui_parameters
  and parameters stay consistent
- Enrich descriptions with node context for duplicate types

Closes #87
2026-03-30 16:01:31 +08:00

532 lines
20 KiB
Python

from __future__ import annotations
import re
from typing import Any
def normalize_string(value: Any, default: str = "") -> str:
if value is None:
return default
return str(value).strip()
def is_editor_workflow(workflow_data: Any) -> bool:
return (
isinstance(workflow_data, dict)
and isinstance(workflow_data.get("nodes"), list)
and isinstance(workflow_data.get("links"), list)
)
def is_api_workflow(workflow_data: Any) -> bool:
if not isinstance(workflow_data, dict) or not workflow_data:
return False
if is_editor_workflow(workflow_data):
return False
for key, value in workflow_data.items():
if not isinstance(key, str) or not key.strip():
return False
if not isinstance(value, dict) or "class_type" not in value:
return False
return True
def _get_type_guess(value: Any) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int) and not isinstance(value, bool):
return "int"
if isinstance(value, float):
return "float"
return "string"
def _get_auto_mapping(node_class: str, field: str) -> dict[str, Any]:
"""Decide whether a field should be exposed and whether it's required.
Returns exposure/required/description only — naming is handled separately
by ``_assign_parameter_names`` after all parameters have been collected.
"""
if "KSampler" in node_class:
if field == "seed":
return {"exposed": True, "required": False, "description": "Random seed (for reproducibility)"}
if field == "steps":
return {"exposed": True, "required": False, "description": "Generation steps"}
if "CLIPTextEncode" in node_class or "Text" in node_class or "Prompt" in node_class:
if field in {"text", "prompt"}:
return {"exposed": True, "required": True, "description": "Text prompt description"}
if node_class == "EmptyLatentImage" and field in {"width", "height", "batch_size"}:
return {"exposed": True, "required": False, "description": f"Image {field}"}
if node_class == "SaveImage" and field == "filename_prefix":
return {"exposed": True, "required": False, "description": "Output file prefix"}
if node_class == "LoadImage" and field == "image":
return {"exposed": True, "required": True, "description": "Upload an image"}
if node_class == "LightCCDoubaoImageNode":
if field == "prompt":
return {"exposed": True, "required": True, "description": "Positive image prompt"}
if field == "size":
return {"exposed": True, "required": False, "description": "e.g., 1:1,2048x2048"}
if field == "seed":
return {"exposed": True, "required": False, "description": "Random seed"}
if field == "num":
return {"exposed": True, "required": False, "description": "Number of images to generate"}
if field in {"text", "prompt"}:
return {"exposed": True, "required": True, "description": "Text prompt"}
if field == "seed":
return {"exposed": True, "required": False, "description": "Random seed"}
if field in {"width", "height", "batch_size", "size", "num", "num_images", "max_images"}:
return {"exposed": True, "required": False, "description": f"Workflow parameter: {field}"}
if field == "filename_prefix":
return {"exposed": True, "required": False, "description": "Output file prefix"}
return {"exposed": False, "required": False, "description": ""}
def _normalize_title(title: str) -> str:
"""Normalize a node title into a safe parameter name suffix."""
normalized = re.sub(r"[^\w]+", "_", title.strip(), flags=re.UNICODE)
normalized = re.sub(r"_+", "_", normalized)
return normalized.strip("_").lower()
def _get_node_title(node_object: dict[str, Any]) -> str:
"""Extract the user-defined title from a node's _meta."""
meta = node_object.get("_meta")
if isinstance(meta, dict):
return normalize_string(meta.get("title"))
return ""
def _assign_parameter_names(
schema_params: dict[str, dict[str, Any]],
workflow_data: dict[str, Any],
) -> None:
"""Assign unique, human-readable names to all parameters.
- If a (class_type, field) pair appears only once → use ``field`` directly
(e.g. ``seed``, ``prompt``).
- If it appears multiple times → append the node's title or node_id to
distinguish (e.g. ``seed_portrait_style`` or ``seed_23``).
This is a **global** decision — we look at the entire workflow first,
then name parameters, instead of naming them one-by-one.
"""
# Count how many times each (class_type, field) pair appears among exposed params.
pair_counts: dict[tuple[str, str], int] = {}
for param in schema_params.values():
if not param.get("exposed"):
continue
key = (param.get("nodeClass", ""), param.get("field", ""))
pair_counts[key] = pair_counts.get(key, 0) + 1
# Track used names to guarantee uniqueness.
used_names: set[str] = set()
for param in schema_params.values():
field = param.get("field", "")
node_class = param.get("nodeClass", "")
node_id = str(param.get("node_id", ""))
pair_key = (node_class, field)
if pair_counts.get(pair_key, 0) <= 1:
# Only one node with this (class_type, field) — use simple name.
base_name = _friendly_field_name(field)
else:
# Multiple nodes — disambiguate with title or node_id.
node_object = workflow_data.get(node_id, {})
title = _get_node_title(node_object) if isinstance(node_object, dict) else ""
# Skip title if it's just the class_type (default, not user-defined)
normalized_title = _normalize_title(title) if title and title != node_class else ""
suffix = normalized_title or node_id
base_name = f"{_friendly_field_name(field)}_{suffix}"
# Ensure uniqueness.
name = base_name
if name in used_names:
name = f"{base_name}_{node_id}"
counter = 2
while name in used_names:
name = f"{base_name}_{node_id}_{counter}"
counter += 1
used_names.add(name)
param["name"] = name
# Enrich description with node context for duplicate types.
if pair_counts.get(pair_key, 0) > 1:
node_object = workflow_data.get(node_id, {})
title = _get_node_title(node_object) if isinstance(node_object, dict) else ""
node_label = title or node_class or "Unknown"
orig_desc = param.get("description", "")
param["description"] = f"{orig_desc} ({node_label} #{node_id})"
def _friendly_field_name(field: str) -> str:
"""Map common field names to friendlier parameter names."""
mapping = {
"text": "prompt",
}
return mapping.get(field, field)
def extract_schema_params(workflow_data: dict[str, Any]) -> dict[str, dict[str, Any]]:
schema_params: dict[str, dict[str, Any]] = {}
for node_id, node_object in workflow_data.items():
if not isinstance(node_object, dict):
continue
inputs = node_object.get("inputs")
if not isinstance(inputs, dict):
continue
node_class = normalize_string(node_object.get("class_type"))
for field, value in inputs.items():
if isinstance(value, list):
continue
auto_mapping = _get_auto_mapping(node_class, field)
schema_params[f"{node_id}_{field}"] = {
"exposed": auto_mapping["exposed"],
"node_id": node_id,
"field": field,
"name": "", # assigned later by _assign_parameter_names
"type": "image" if (node_class == "LoadImage" and field == "image") else _get_type_guess(value),
"required": auto_mapping["required"],
"description": auto_mapping["description"],
"default": value,
"example": value,
"choices": [],
"currentVal": value,
"nodeClass": node_class or "UnknownNode",
}
# Assign names globally — this is where disambiguation happens.
_assign_parameter_names(schema_params, workflow_data)
return schema_params
def build_final_schema(
schema_params: dict[str, dict[str, Any]],
*,
sync_names_back: bool = False,
) -> dict[str, dict[str, Any]]:
"""Build the run-time schema from UI parameters.
When *sync_names_back* is True, the resolved (unique) alias is written
back into each source ``schema_params`` entry so that ``ui_parameters``
and ``parameters`` stay consistent. This prevents the front-end from
seeing duplicate names and silently dropping parameters on save.
"""
final_schema: dict[str, dict[str, Any]] = {}
for param_key, parameter in schema_params.items():
if not bool(parameter.get("exposed")):
continue
alias = normalize_string(parameter.get("name"))
if not alias:
continue
alias = _ensure_unique_alias(final_schema, alias, str(parameter["node_id"]))
if sync_names_back:
parameter["name"] = alias
target: dict[str, Any] = {
"node_id": str(parameter["node_id"]),
"field": normalize_string(parameter.get("field")),
"required": bool(parameter.get("required", False)),
"type": normalize_string(parameter.get("type"), "string") or "string",
"description": normalize_string(parameter.get("description")),
}
if "default" in parameter:
target["default"] = parameter["default"]
if "example" in parameter:
target["example"] = parameter["example"]
choices = parameter.get("choices")
if isinstance(choices, list) and choices:
target["choices"] = choices
final_schema[alias] = target
return final_schema
def _ensure_unique_alias(final_schema: dict[str, dict[str, Any]], alias: str, node_id: str) -> str:
if alias not in final_schema:
return alias
node_scoped_alias = f"{alias}_{node_id}"
if node_scoped_alias not in final_schema:
return node_scoped_alias
index = 2
while f"{node_scoped_alias}_{index}" in final_schema:
index += 1
return f"{node_scoped_alias}_{index}"
def suggest_workflow_id(workflow_data: dict[str, Any], file_name: str = "") -> str:
def normalize_candidate(value: Any) -> str:
if not isinstance(value, str):
return ""
normalized = re.sub(r"[./\\]+", "-", value.strip())
normalized = re.sub(r"[^\w-]+", "-", normalized, flags=re.UNICODE)
normalized = re.sub(r"-+", "-", normalized)
return normalized.strip("-_")
def first_node_title() -> str:
for node_object in workflow_data.values():
if not isinstance(node_object, dict):
continue
meta = node_object.get("_meta")
if isinstance(meta, dict):
title = normalize_string(meta.get("title"))
if title:
return title
return ""
base_file_name = re.sub(r"\.[^.]+$", "", file_name).strip() if file_name else ""
candidates = [
workflow_data.get("workflow_name"),
workflow_data.get("name"),
workflow_data.get("title"),
workflow_data.get("_meta", {}).get("title") if isinstance(workflow_data.get("_meta"), dict) else "",
workflow_data.get("extra", {}).get("workflow_name") if isinstance(workflow_data.get("extra"), dict) else "",
workflow_data.get("extra", {}).get("name") if isinstance(workflow_data.get("extra"), dict) else "",
workflow_data.get("extra", {}).get("title") if isinstance(workflow_data.get("extra"), dict) else "",
workflow_data.get("metadata", {}).get("workflow_name") if isinstance(workflow_data.get("metadata"), dict) else "",
workflow_data.get("metadata", {}).get("name") if isinstance(workflow_data.get("metadata"), dict) else "",
workflow_data.get("metadata", {}).get("title") if isinstance(workflow_data.get("metadata"), dict) else "",
base_file_name,
first_node_title(),
]
for candidate in candidates:
normalized = normalize_candidate(candidate)
if normalized:
return normalized
return "workflow"
def _list_value(value: Any) -> list[Any]:
if isinstance(value, list):
return value
return []
class WorkflowImportError(ValueError):
pass
class EditorWorkflowConverter:
def __init__(self, object_info: dict[str, Any]):
self.object_info = object_info
def convert(self, workflow_data: dict[str, Any]) -> dict[str, Any]:
nodes = workflow_data.get("nodes")
links = workflow_data.get("links")
if not isinstance(nodes, list) or not isinstance(links, list):
raise WorkflowImportError("Unsupported ComfyUI editor workflow format.")
node_by_id: dict[int, dict[str, Any]] = {}
for node in nodes:
if isinstance(node, dict) and node.get("id") is not None:
node_by_id[int(node["id"])] = node
link_map: dict[tuple[int, int], tuple[str, int]] = {}
for link in links:
if not isinstance(link, list) or len(link) < 5:
continue
source_link = self._resolve_link_source(int(link[1]), int(link[2]), node_by_id, links)
target_node_id = int(link[3])
target_slot = int(link[4])
if source_link is None:
continue
link_map[(target_node_id, target_slot)] = source_link
api_workflow: dict[str, Any] = {}
for node in nodes:
if not isinstance(node, dict):
continue
node_id = node.get("id")
class_type = normalize_string(node.get("type"))
if node_id is None or not class_type:
continue
if class_type in {"Reroute", "Note"}:
continue
node_object_info = self.object_info.get(class_type)
if not isinstance(node_object_info, dict):
raise WorkflowImportError(f"ComfyUI object_info is missing node type '{class_type}'.")
title = normalize_string(node.get("title"))
api_workflow[str(node_id)] = {
"inputs": self._convert_node_inputs(node, class_type, node_object_info, link_map),
"class_type": class_type,
"_meta": {"title": title or class_type},
}
if not api_workflow:
raise WorkflowImportError("No supported nodes were found in the editor workflow.")
return api_workflow
def _resolve_link_source(
self,
source_node_id: int,
source_slot: int,
node_by_id: dict[int, dict[str, Any]],
links: list[Any],
) -> tuple[str, int] | None:
node = node_by_id.get(source_node_id)
if not isinstance(node, dict):
return (str(source_node_id), source_slot)
class_type = normalize_string(node.get("type"))
if class_type != "Reroute":
return (str(source_node_id), source_slot)
input_slots = _list_value(node.get("inputs"))
if not input_slots:
return None
incoming_link_id = input_slots[0].get("link") if isinstance(input_slots[0], dict) else None
if incoming_link_id is None:
return None
for link in links:
if not isinstance(link, list) or len(link) < 5:
continue
if int(link[0]) != int(incoming_link_id):
continue
return self._resolve_link_source(int(link[1]), int(link[2]), node_by_id, links)
return None
def _convert_node_inputs(
self,
node: dict[str, Any],
class_type: str,
node_object_info: dict[str, Any],
link_map: dict[tuple[int, int], tuple[str, int]],
) -> dict[str, Any]:
node_id = int(node["id"])
input_defs = self._ordered_input_defs(node_object_info)
input_slots = _list_value(node.get("inputs"))
widget_values = _list_value(node.get("widgets_values"))
converted: dict[str, Any] = {}
connected_names: set[str] = set()
for slot_index, slot in enumerate(input_slots):
if not isinstance(slot, dict):
continue
slot_name = normalize_string(slot.get("name"))
if not slot_name:
continue
link_tuple = link_map.get((node_id, slot_index))
if link_tuple is None:
continue
converted[slot_name] = [link_tuple[0], link_tuple[1]]
connected_names.add(slot_name)
widget_field_names = [
normalize_string(slot.get("name"))
for slot in input_slots
if isinstance(slot, dict)
and normalize_string(slot.get("name"))
and normalize_string(slot.get("name")) not in connected_names
and isinstance(slot.get("widget"), dict)
]
if not widget_field_names:
widget_field_names = [name for name in input_defs if name not in connected_names]
control_after_generate_fields = self._control_after_generate_fields(node_object_info)
widget_value_index = 0
for field_name in widget_field_names:
if widget_value_index >= len(widget_values):
break
converted[field_name] = widget_values[widget_value_index]
widget_value_index += 1
if field_name not in control_after_generate_fields:
continue
if widget_value_index >= len(widget_values):
continue
control_value = widget_values[widget_value_index]
if isinstance(control_value, str) and control_value.lower() in {
"fixed",
"increment",
"decrement",
"randomize",
}:
widget_value_index += 1
if not converted and widget_values and not widget_field_names:
raise WorkflowImportError(f"Unable to map widget values for node type '{class_type}'.")
return converted
@staticmethod
def _control_after_generate_fields(node_object_info: dict[str, Any]) -> set[str]:
fields: set[str] = set()
def visit_section(section: Any) -> None:
if not isinstance(section, dict):
return
for field_name, definition in section.items():
if (
isinstance(definition, list)
and len(definition) >= 2
and isinstance(definition[1], dict)
and definition[1].get("control_after_generate")
):
fields.add(field_name)
input_section = node_object_info.get("input")
if isinstance(input_section, dict):
visit_section(input_section.get("required"))
visit_section(input_section.get("optional"))
visit_section(node_object_info.get("required"))
visit_section(node_object_info.get("optional"))
return fields
@staticmethod
def _ordered_input_defs(node_object_info: dict[str, Any]) -> list[str]:
ordered: list[str] = []
input_order = node_object_info.get("input_order")
if isinstance(input_order, dict):
for section_name in ("required", "optional"):
section = input_order.get(section_name)
if isinstance(section, list):
ordered.extend(normalize_string(item) for item in section if normalize_string(item))
if ordered:
return ordered
input_section = node_object_info.get("input")
if isinstance(input_section, dict):
for section_name in ("required", "optional"):
section = input_section.get(section_name)
if isinstance(section, dict):
ordered.extend(section.keys())
if ordered:
return ordered
for section_name in ("required", "optional"):
section = node_object_info.get(section_name)
if not isinstance(section, dict):
continue
ordered.extend(section.keys())
return ordered