Files
DumiFlex-ComfyUI-Wildcard-P…/wp_nodes/types.py
T
Dumitru Picalau 26df88216f feat(ai): chat round, node negatives, pack builder quality fixes (#69)
Chat round (explain on selection, page starters, reactions, streamed notes, thinking), negatives on node fixed values and combines, Pack builder quality fixes from 31 real-model eval rounds, and small-model support (scoped tools, checked tool arguments on local servers, two-phase steps, fixed prefix shown in Test connection).
2026-10-17 01:23:58 +02:00

786 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Custom ComfyUI socket type ``PIPELINE_CONTEXT`` and widget-only types."""
import json
from dataclasses import dataclass, field
from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from comfy_api.latest._io import ( # pyright: ignore[reportMissingImports]
ComfyTypeIO,
comfytype,
)
from engine.context import strip_engine_internals
from engine.modules import Module, module_from_dict
@dataclass(frozen=True)
class ContextPayload:
"""Value carried across ``PIPELINE_CONTEXT`` sockets.
``context`` holds user-facing variables (internals stripped). ``debug``
carries run metadata (upstream snapshot, seed, trace) for the debug node.
``internals`` is a deliberate carve-out for engine-internal state that
MUST survive cross-node boundaries — currently `__wp_picks__` (so a
downstream constraint-aware wildcard can read its source's pick from
a wildcard that lived in an upstream Context) and `__wp_constraints__`
(so a constraint registered upstream still applies to a downstream
target wildcard). The standard `strip_internals` filter would drop
these alongside trace/rng/etc; this field lets them travel without
leaking into the user-facing `context` payload that the assembler
consumes.
"""
context: dict[str, Any] = field(default_factory=dict)
debug: dict[str, Any] = field(default_factory=dict)
internals: dict[str, Any] = field(default_factory=dict)
@comfytype(io_type="PIPELINE_CONTEXT")
class PipelineContext(ComfyTypeIO):
"""Socket type carrying ContextPayload across nodes."""
Type = ContextPayload
@comfytype(io_type="WP_CONTEXT_MODULES")
class ContextModulesInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CONTEXT_MODULES"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None, # widget_type
None, # force_input
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_DEBUG_VIEWER")
class DebugViewerInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_DEBUG_VIEWER"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_CLEANER")
class CleanerWidgetInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CLEANER"]``.
The widget payload is a JSON string holding the cleaner config:
``{mode, intensity, rules_override, blocklist, preset_id?}``. The
cleaner node parses it at execute time via ``json.loads``.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_INJECTOR_ROWS")
class InjectorRowsInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_INJECTOR_ROWS"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None, # widget_type
None, # force_input
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_MODEL_INFO")
class ModelInfoWidgetInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_MODEL_INFO"]``.
JSON ``{version, rules: [{variant, pattern}], family, variant, name}``;
the pins are empty when detection should decide. Parsed by
``engine/model_info.py::parse_config``.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None, # widget_type
None, # force_input
extra_dict,
raw_link,
advanced,
)
def deserialize_modules(payload: str) -> list[Module]:
"""Parse the widget JSON payload into a list of engine module dataclasses.
Raises ``ValueError`` on malformed JSON or a non-dict root.
"""
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid modules JSON: {exc.msg}") from exc
if not isinstance(data, dict):
raise ValueError(
f"Modules payload must be a JSON object, got {type(data).__name__}"
)
raw_modules = data.get("modules") or []
return [module_from_dict(entry) for entry in raw_modules]
def deserialize_node_input(
raw: "dict[str, Any] | str | None",
) -> "tuple[list[dict[str, Any]], dict[str, Any], list[str]]":
"""Parse a WP_Context node's ``modules`` widget value.
Returns ``(modules, catalog, pick_order)``:
- ``modules``: ordered list of every entry the user has in
the widget — wildcards, combines, derivations, constraints,
pipelines, fixed_values, all in one list. Same shape SPA
editors emit + ``PipelineEngine.run`` accepts.
- ``catalog``: the wildcard subset of ``modules``, keyed by
``id`` (= 8-hex uuid), each value mapped back to the
canonical ``SnapshotEntry`` shape the engine resolver
consumes via ``ctx["__wp_catalog__"]``.
- ``pick_order``: kept in the return signature for back-compat
with existing callers; always empty under the unified model
(the modules list itself preserves order).
Returns the modules portion as a ``list[dict]`` rather than the
typed ``list[Module]`` produced by ``deserialize_modules``. The
legacy ``module_from_dict`` requires fields the SPA shape doesn't
carry (and only knows how to type ``fixed_values``), so the raw-
dict path is the safe one for the unified shape — the engine
already coerces dicts at execute time.
Robust to malformed input: any failure path returns
``([], {}, [])`` so graph runs never crash on a broken widget
value.
"""
if raw is None:
return [], {}, []
if isinstance(raw, str):
try:
data = json.loads(raw)
except Exception:
return [], {}, []
else:
data = raw
if not isinstance(data, dict):
return [], {}, []
raw_modules = data.get("modules")
modules: list[dict[str, Any]] = (
list(raw_modules) if isinstance(raw_modules, list) else []
)
# Bundle enable-gate. `BundleInstance.enabled` used to cascade into
# every child's `module.enabled` at toggle time, which destroyed each
# child's individual on/off state every time the user disabled a
# bundle. Now the bundle gate is a non-destructive overlay applied at
# the engine boundary: child.instance.enabled stays whatever the user
# set, and we AND it with the bundle's gate here so the engine
# (which only knows about flat `modules[].enabled`) sees the
# effective view without needing bundle awareness.
#
# Effective enabled = bundle.enabled AND every ancestor bundle's
# enabled AND child.enabled. Tier-2 nesting walks one parent_uid
# hop; the API cap forbids deeper chains so the loop terminates
# in at most two steps.
raw_bundles = data.get("bundles")
bundle_enabled: dict[str, bool] = {}
bundle_parent: dict[str, str | None] = {}
if isinstance(raw_bundles, list):
for b in raw_bundles:
if not isinstance(b, dict):
continue
uid = b.get("_uid")
if isinstance(uid, str):
bundle_enabled[uid] = bool(b.get("enabled", True))
parent = b.get("parent_uid")
bundle_parent[uid] = parent if isinstance(parent, str) else None
if bundle_enabled:
def _bundle_chain_enabled(uid: str) -> bool:
# Walk up parent_uid, return false on the first disabled ancestor.
# Defensive depth cap (8) absorbs corrupt cycles even though
# the API tier-2 rule keeps real chains at 1–2 hops.
seen: set[str] = set()
cur: str | None = uid
depth = 0
while cur is not None and cur not in seen and depth < 8:
seen.add(cur)
if bundle_enabled.get(cur, True) is False:
return False
cur = bundle_parent.get(cur)
depth += 1
return True
gated: list[dict[str, Any]] = []
for m in modules:
if not isinstance(m, dict):
gated.append(m)
continue
origin = m.get("bundle_origin")
if isinstance(origin, str) and not _bundle_chain_enabled(origin):
# Shallow-clone so we don't mutate the workflow-state
# row the SPA holds onto for undo / restore.
gated.append({**m, "enabled": False})
else:
gated.append(m)
modules = gated
# Build the wildcard catalog from the modules list directly.
# Filter by `type == "wildcard"` and synthesise a SnapshotEntry
# per row keyed by `id`. Resolver looks up `ctx["__wp_catalog__"][uuid]`
# and reads `entry["payload"]["options"]` — that's the only
# contract this map needs to satisfy.
catalog: dict[str, dict[str, Any]] = {}
for m in modules:
if not isinstance(m, dict):
continue
if m.get("type") != "wildcard":
continue
mid = m.get("id")
if not isinstance(mid, str):
continue
payload = m.get("payload")
if not isinstance(payload, dict):
continue
catalog[mid] = {
"snapshot_version": 1,
"uuid": mid,
"type": "wildcard",
"name": m.get("meta", {}).get("name") if isinstance(m.get("meta"), dict) else "",
"payload": payload,
"payload_hash": m.get("payload_hash") or "",
"source": {"kind": "user"},
}
return modules, catalog, []
#: Internal ctx keys whose lifetime spans the full graph chain rather
#: than a single Context node. Pre-fix every `__`-prefixed key got
#: dropped at each socket boundary, so `__wp_picks__` (set when a
#: wildcard rolls) and `__wp_constraints__` (set when a constraint
#: module registers) didn't survive between nodes — a constraint in
#: Context B targeting a wildcard in Context C couldn't see the
#: source's pick recorded in Context A. Listed here explicitly so
#: future cross-node internals are an opt-in addition rather than a
#: default-leak.
_CROSS_NODE_INTERNAL_KEYS = (
"__wp_picks__",
# Rolled `accepts`-axis choices, keyed by variable binding. Crosses for the
# same reason picks do: a `$outfit.SHOES` read in Context C must see the
# roll that happened in Context A, and re-rolling on read is precisely the
# inconsistency the design forbids. A dropped key renders "" rather than
# erroring, so losing it looks like a content bug, not a plumbing one.
"__wp_axes__",
# Which accepts axes each binding declares. Rides with `__wp_axes__` so a
# `$outfit.SHOES` read in a later node can still tell "no SHOES tag on this
# pick" from "no SHOES axis"; losing it only makes that warning vaguer.
"__wp_axis_decl__",
"__wp_constraints__",
# `__wp_constraints__` propagates so a constraint registered in
# Context A can fire on a wildcard rolled in Context B. The matching
# SP3 reach-selector hit counter ALSO has to propagate — without it,
# Context B's first/next counters reset (a `first` constraint would
# re-fire on B's first target instance even though A already spent
# it), and B re-runs the never_applied end-of-run check against
# constraints A already applied, re-emitting the same warnings.
"__wp_constraint_hits__",
# User-marked-internal vars propagate across nodes as regular keys
# in `context`, but downstream PromptAssemblers need to know WHICH
# of those are internal so they can skip them at render time. Carry
# the flag map alongside picks/constraints so the next node's
# PromptAssembler can call `strip_internals` and re-apply the filter.
"__wp_internal_flags__",
# Send-to-negative: per-variable negatives (engine/negatives.py). A
# variable read in a later node's Assembler must bring the negatives it
# was rolled with, wherever it was rolled.
"__wp_negatives__",
# Loop bookkeeping from WP_ContextLoop. These reach the FIRST
# WP_Context (direct ContextLoop child) via the payload internals,
# but were dropped at that node's output boundary — so a SECOND,
# chained WP_Context defaulted loop_index=0 and seed_override=None,
# making its own seed (whatever drives it — widget OR an external
# node like rgthree Seed) identical across every iteration, and
# ignoring override entirely. Propagating them lets effective_chain_seed
# vary + override the seed for EVERY node in the chain, not just the
# first (2026-05-26).
# - __wp_loop_index__ : per-iteration index (drives the XOR shift)
# - __wp_seed_override__: derived base seed when override_seed=true
# - __wp_loop_seeds__ : the full derived series (debug display)
"__wp_loop_index__",
"__wp_seed_override__",
"__wp_loop_seeds__",
# Sweep mode (engine/sweep.py): this frame's {uid: option_id} pins and
# the "hold other picks" flag, carried so every Context in the chain
# pins its own swept wildcards and holds the rest.
"__wp_pin_overrides__",
"__wp_sweep_hold__",
# Hold across a loop: frame 0's payload ({"context", "internals"}) as the
# previous node emitted it, so a held module on any frame resolves
# against frame 0's inputs (see `frame0_payload`).
"__wp_frame0__",
)
#: Cross-node internal carrying frame 0's payload through a loop.
FRAME0_KEY = "__wp_frame0__"
def frame0_payload(ctx: dict[str, Any]) -> dict[str, Any]:
"""Frame 0's output of a node, from its frame-0 ctx (the hold base pass
or the Context Loop's frame 0), in the shape `__wp_frame0__` carries:
the socket context plus the cross-node internals (minus itself)."""
return {
"context": strip_engine_internals(ctx),
"internals": {
key: ctx[key]
for key in _CROSS_NODE_INTERNAL_KEYS
if key in ctx and key != FRAME0_KEY
},
}
def build_payload(
ctx: dict[str, Any],
upstream_debug: dict[str, Any],
seed: int,
node_id: str = "",
) -> ContextPayload:
"""Construct the socket-boundary payload. Strips internals from
`context`, but carries the cross-node-internal subset on the
dedicated `internals` field so the next node's execute can merge
them back into its ctx before running its own pipeline.
Trace + warnings accumulate across the chain in `debug.__wp_trace__` /
`debug.__wp_warnings__` (cumulative-merge pattern matching the
injector node) so a terminal WPDebug at the chain tail sees every
module + injector contribution, not just the immediate upstream's.
"""
internals = {
key: ctx[key]
for key in _CROSS_NODE_INTERNAL_KEYS
if key in ctx
}
upstream_trace = upstream_debug.get("__wp_trace__", [])
upstream_warnings = upstream_debug.get("__wp_warnings__", [])
this_trace = ctx.get("__wp_trace__", [])
this_warnings = ctx.get("__wp_warnings__", [])
this_refs = ctx.get("__wp_ref_log__")
# Tag this node's rows with its graph id so WP Debug can group a chain's
# trace by the Context that ran it. Injector rows carry the same key.
if node_id:
for rows in (this_trace, this_warnings, this_refs):
if isinstance(rows, list):
for row in rows:
if isinstance(row, dict):
row.setdefault("node_id", node_id)
# Dedup constraint finalisation warnings across the cross-node
# accumulator. Even with __wp_constraint_hits__ now propagating
# cross-node (so downstream knows what upstream applied), every
# Context still runs its own end-of-run check — if a constraint
# legitimately never fires anywhere (target wildcard absent from
# the whole chain) or under-reaches a `next N` / `pick` selector,
# every node along the way emits the same row and the user sees N
# copies. Keying on (type, module_id) here collapses them to one.
_DEDUP_CONSTRAINT_WARNINGS = frozenset({
"constraint_never_applied",
"constraint_partial_reach",
})
combined_warnings = list(upstream_warnings) + list(this_warnings)
deduped: list[dict[str, Any]] = []
seen_keys: set[tuple[str, str]] = set()
for w in combined_warnings:
if not isinstance(w, dict):
deduped.append(w)
continue
wtype = w.get("type")
if wtype in _DEDUP_CONSTRAINT_WARNINGS:
mid = w.get("module_id") or ""
key = (str(wtype), str(mid))
if key in seen_keys:
continue
seen_keys.add(key)
deduped.append(w)
return ContextPayload(
context=strip_engine_internals(ctx),
debug={
"upstream": upstream_debug,
"node_seed": seed,
"__wp_trace__": list(upstream_trace) + list(this_trace),
"__wp_warnings__": deduped,
# Nested @{} picks (engine `__wp_ref_log__`) and one row per
# Context node in the chain, accumulated like the trace.
"__wp_ref_log__": list(upstream_debug.get("__wp_ref_log__") or [])
+ (list(this_refs) if isinstance(this_refs, list) else []),
"__wp_nodes__": list(upstream_debug.get("__wp_nodes__") or [])
+ [{"node_id": node_id, "seed": seed}],
},
internals=internals,
)
@comfytype(io_type="WP_VAR_PICKER")
class VarPickerInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_VAR_PICKER"]``.
The widget renders a dropdown of upstream ``$var`` names + a live
parse preview strip. Stored as a plain string property
(``node.properties["var_name"]``) carrying the ``"$seed"`` form;
Python ``lookup_var`` strips the leading ``$`` on read.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_CONTEXT_LOOP_CONFIG")
class ContextLoopConfigInput(ComfyTypeIO):
"""Custom type — Input renders as a widget via
``getCustomWidgets["WP_CONTEXT_LOOP_CONFIG"]`` (JSON string config);
Output carries the parsed dict so sibling nodes like WP_SeedList can
mirror WP_ContextLoop's count / strategy / base_seed without the
user double-wiring widgets.
Input payload: JSON string ``{strategy, override_seed,
iteration_var_name, bypass}``. ``WPContextLoop.execute`` parses it
defensively (recovery path tolerates missing / malformed keys).
Output payload: dict ``{count, strategy, base_seed, override_seed}``
emitted by ``WPContextLoop.execute`` — the resolved view (post-
bypass count, validated strategy). Receivers should still defend
against unexpected shapes when reading (see
``wp_nodes/seed_list.py::_resolve_config`` for the pattern).
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_CONTEXT_LOOP_WIDGET")
class ContextLoopWidgetInput(ComfyTypeIO):
"""Widget-only twin of ``ContextLoopConfigInput``.
Earlier the Loop's DOM widget AND the wire payload both used
``WP_CONTEXT_LOOP_CONFIG``. The ``getCustomWidgets`` factory is keyed
by TYPE, so registering a factory for ``WP_CONTEXT_LOOP_CONFIG`` made
EVERY consumer of that type (including WP_SeedList's
``loop_config`` socket) try to mount the Loop's DOM widget. Gating
the factory on input-name kept the widget off SeedList but ComfyUI
still hid the socket because a (broken / undefined) widget was
registered for the input's type.
Split fix: this widget-only type is unique to WP_ContextLoop's
own ``wp_context_loop_config`` input. ``WP_CONTEXT_LOOP_CONFIG``
stays as the wire-payload type — Loop's OUTPUT and SeedList's INPUT
both use that, with NO factory registered → ComfyUI renders them as
plain sockets and the user can wire Loop → SeedList directly.
Payload format is identical to ``ContextLoopConfigInput.Input``'s
(JSON string with the same keys). Kept as a separate class so the
canvas typing can discriminate without runtime ambiguity.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_IMAGE_FILTER")
class ImageFilterWidgetInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_IMAGE_FILTER"]``.
JSON ``{mode, nothing_picked, send_as, timeout, on_timeout}`` for
WP_ImageFilter, parsed by ``engine/image_filter.py::parse_config``. The
same DOM widget shows the last run's picks.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None, # widget_type
None, # force_input
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_SEED_LIST_CONFIG")
class SeedListConfigInput(ComfyTypeIO):
"""Custom type — Input renders as a widget via
``getCustomWidgets["WP_SEED_LIST_CONFIG"]`` (JSON string config).
Holds WP_SeedList's strategy chip + two override toggles in a
single socketless DOM widget so the canvas isn't littered with
three separate stock widgets for what is conceptually one
configuration block.
Payload: JSON string ``{strategy, override_seed, override_config}``.
``WPSeedList.execute`` parses it defensively (unknown strategy →
"hash_index", non-bool → False) before resolving against the
optional ``loop_config`` socket.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)