Draw a picture, on a ComfyUI you are running
The last unbuilt capability, and built the way CLAUDE.md said it had to be: a
ToolDef reaching resolve_tools plus a permission and a capability flag, not a new
code path. The only genuinely new UI is one branch in the transcript.
services/images/ is three modules. comfy.py speaks HTTP -- submit, poll /history,
fetch the PNG, /free, and an /object_info discovery for the admin page only.
Polled and not socketed, because holding a connection open for the length of a
generation is the live-connection state the whole ssh.py design forbids, and the
thing being waited for takes tens of seconds anyway. The base URL is exempt from
the SSRF guard by construction, exactly as Connection.base_url and the audio
endpoints are -- said out loud in the docstring, because a default of
127.0.0.1:8188 is precisely the shape that guard exists to refuse and therefore
reads as a hole rather than a decision.
workflow.py fills a template, and the one thing that matters is that it walks the
parsed JSON rather than the text of it. A value that is exactly "{{steps}}"
becomes the number 20; ComfyUI validates types and refuses the string. A
placeholder inside a longer string is still text, which is what makes
"{{prompt}}, masterpiece" work -- and text substitution would additionally mean a
prompt containing a quotation mark produced a document that no longer parses, on
the one input guaranteed to hold arbitrary text. Which node holds the prompt is
the administrator's statement rather than a guess from node types: sniffing for
the first CLIPTextEncode works on the shipped workflow and on nothing else, and
swaps positive for negative the first time somebody reorders them. seed has no
fixed default, because one would make every unspecified generation identical and
make the retry loop redraw the same rejected picture four times.
tool.py is one call, one finished image. Returning every attempt to the
conversation would cost a round each, make the ceiling advisory rather than
enforced, and walk the reader past every reject -- so the reviewer lives inside
the tool and is asked about *bytes*: an attempt about to be discarded should not
leave an Attachment behind, so it sees a downscaled preview built in memory and
only the kept image is written. Anything that goes wrong in review is a keep;
losing a picture because a judging request timed out would be the check
destroying the thing it was checking. The last attempt is kept whatever the
verdict, so a request always produces something. Rejects are recorded, not
stored.
Preserve VRAM unloads the chat's own connection and nothing else, because the
memory being freed belongs to one machine: local llama-swap answers GET /unload,
and a box on the network has no reason to be unloaded when ComfyUI wants memory
here. The swap goes round the review rather than round the tool, which costs two
model loads per retry -- so the two settings are independent and the page warns
when both are on. Nothing loads the LLM back: the reply's next request does, and
that step exists in the description and not in the code, so the code says so.
Two rules elsewhere had to be drawn for the first time. message_payload sends
images only on user turns -- no assistant message had ever carried one, and the
moment one does the multimodal list form on an assistant turn is rejected by
OpenAI and most local runners, breaking every later turn in the chat. And
files.store gained keep_original, because _process_image turns anything without
alpha into JPEG q85 at 1400px: right for a phone photo, a visible loss on the one
output this feature exists to produce.
/image sends the ordinary message with force_tool, which becomes tool_choice for
the first round only -- left in place the reply would draw a picture, be asked
again, and draw another. FORCEABLE_TOOLS is an allow list because the name is
read off a form.
ToolContext gained chat_id, and that fixed a tool nobody had ever successfully
run: _run_scratch_write read context.chat_id on a dataclass with no such field,
so every call raised AttributeError, swallowed by run_tool's blanket except into
"the scratch_write tool failed" -- indistinguishable from a model calling it
wrongly. The test that existed asserted the family and the risk, which are
properties of the declaration rather than of the code.
Verified against the real ComfyUI 0.27.0 on this machine rather than against
documentation: every endpoint shape here was read off it, a generation ran end to
end through the client, the reviewer was shown a matching and a mismatched prompt
and answered KEEP and RETRY correctly, and the unload hook fired for the local
llama-swap and not for the remote box.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""Turning a stored template and a model's arguments into a ComfyUI workflow.
|
||||
|
||||
A template is an API-format workflow with `{{placeholders}}` where the values
|
||||
go. Which node holds the prompt is therefore the administrator's statement
|
||||
rather than something guessed from node types -- sniffing for the first
|
||||
`CLIPTextEncode` works on the shipped template and on nothing else, and gets
|
||||
positive and negative the wrong way round the first time somebody reorders them.
|
||||
|
||||
**Substitution walks the parsed JSON, not the text of it.** A value that is
|
||||
*exactly* `"{{steps}}"` is replaced by the number 20, not by the string "20";
|
||||
ComfyUI validates types and refuses the second. A placeholder inside a longer
|
||||
string still substitutes as text, which is what makes
|
||||
`"{{prompt}}, masterpiece"` work. Doing it textually would also mean a prompt
|
||||
containing a quotation mark produced a document that no longer parses, on the
|
||||
one input guaranteed to contain arbitrary text.
|
||||
|
||||
The names are the tool's parameter names, so there is one vocabulary: what a
|
||||
model may set, what the admin page documents and what a template may reference
|
||||
cannot drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
# Every hole a template may carry. A name outside this set is left alone, the
|
||||
# same rule `prompts.substitute` follows -- a literal `{{x}}` is not a feature,
|
||||
# but silently deleting one is worse than leaving it visible.
|
||||
PLACEHOLDERS = (
|
||||
"model",
|
||||
"prompt",
|
||||
"negative",
|
||||
"seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"width",
|
||||
"height",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"denoise",
|
||||
)
|
||||
|
||||
# The defaults, taken from the base template. `seed` is deliberately absent: it
|
||||
# has no fixed default, because one would make every generation that did not
|
||||
# name a seed identical -- and would make the retry loop produce the same
|
||||
# rejected image four times over.
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"negative": "text, watermark",
|
||||
"steps": 20,
|
||||
"cfg": 8.0,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"sampler": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
}
|
||||
|
||||
# ComfyUI's own ranges, read off `/object_info`. Clamped rather than refused: a
|
||||
# model that asks for 300 steps has misjudged rather than misbehaved, and one
|
||||
# clarifying round to say so is worse than doing the sensible thing.
|
||||
LIMITS: dict[str, tuple[float, float]] = {
|
||||
"steps": (1, 150),
|
||||
"cfg": (0.0, 30.0),
|
||||
"width": (64, 2048),
|
||||
"height": (64, 2048),
|
||||
"denoise": (0.0, 1.0),
|
||||
}
|
||||
|
||||
# ComfyUI's seed is a uint64. Generated here rather than left to the far side
|
||||
# so the value can be reported back -- "it looked like this and here is how to
|
||||
# get it again" is most of what a seed is for.
|
||||
MAX_SEED = 2**64 - 1
|
||||
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([a-z][a-z0-9_]*)\s*\}\}")
|
||||
|
||||
|
||||
def random_seed() -> int:
|
||||
return secrets.randbelow(MAX_SEED)
|
||||
|
||||
|
||||
def resolve(given: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The full parameter set: what was asked for, over the defaults.
|
||||
|
||||
Absent and null are both "no opinion". A model that emits `"seed": null`
|
||||
rather than omitting the key is common enough that treating it as a request
|
||||
for seed zero would be a bug nobody could see.
|
||||
"""
|
||||
values: dict[str, Any] = {**DEFAULTS}
|
||||
for name, value in (given or {}).items():
|
||||
if name in PLACEHOLDERS and value is not None and value != "":
|
||||
values[name] = value
|
||||
|
||||
values["seed"] = _whole(values.get("seed"), default=random_seed()) % (MAX_SEED + 1)
|
||||
for name in ("steps", "width", "height"):
|
||||
values[name] = _clamp(_whole(values.get(name), DEFAULTS[name]), name)
|
||||
for name in ("cfg", "denoise"):
|
||||
values[name] = _clamp(_decimal(values.get(name), DEFAULTS[name]), name)
|
||||
for name in ("prompt", "negative", "sampler", "scheduler", "model"):
|
||||
values[name] = str(values.get(name) or "")
|
||||
return values
|
||||
|
||||
|
||||
def _whole(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _decimal(value: Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _clamp(value: Any, name: str) -> Any:
|
||||
low, high = LIMITS.get(name, (None, None))
|
||||
if low is None:
|
||||
return value
|
||||
clamped = min(max(value, low), high)
|
||||
return int(clamped) if isinstance(value, int) else clamped
|
||||
|
||||
|
||||
def fill(template: Any, values: dict[str, Any]) -> Any:
|
||||
"""A copy of the template with its placeholders replaced.
|
||||
|
||||
Recursive over dicts and lists, because a workflow is nested and a
|
||||
placeholder can be anywhere in it -- including inside a node's `_meta`,
|
||||
which is harmless and should not be treated specially.
|
||||
"""
|
||||
if isinstance(template, dict):
|
||||
return {key: fill(value, values) for key, value in template.items()}
|
||||
if isinstance(template, list):
|
||||
return [fill(item, values) for item in template]
|
||||
if isinstance(template, str):
|
||||
return _fill_string(template, values)
|
||||
return template
|
||||
|
||||
|
||||
def _fill_string(text: str, values: dict[str, Any]) -> Any:
|
||||
"""One string, which may *become* a number.
|
||||
|
||||
The whole-value case is what keeps types right: `"{{steps}}"` is the number
|
||||
and not a string that looks like one. Anything else is ordinary text
|
||||
substitution, so `"{{prompt}}, masterpiece"` reads as a sentence.
|
||||
"""
|
||||
whole = _PLACEHOLDER.fullmatch(text.strip())
|
||||
if whole is not None:
|
||||
return values.get(whole.group(1), text)
|
||||
|
||||
def swap(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
return str(values[name]) if name in values else match.group(0)
|
||||
|
||||
return _PLACEHOLDER.sub(swap, text)
|
||||
|
||||
|
||||
def placeholders_in(template: Any) -> set[str]:
|
||||
"""Every `{{name}}` a template uses, for the admin page to report.
|
||||
|
||||
A template that mentions none of them is almost certainly a workflow pasted
|
||||
straight out of ComfyUI without being parameterised, which would generate
|
||||
the same picture whatever anybody typed. Worth saying at save time rather
|
||||
than leaving somebody to discover it.
|
||||
"""
|
||||
found: set[str] = set()
|
||||
if isinstance(template, dict):
|
||||
for value in template.values():
|
||||
found |= placeholders_in(value)
|
||||
elif isinstance(template, list):
|
||||
for item in template:
|
||||
found |= placeholders_in(item)
|
||||
elif isinstance(template, str):
|
||||
found |= {match.group(1) for match in _PLACEHOLDER.finditer(template)}
|
||||
return found
|
||||
Reference in New Issue
Block a user