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,13 @@
|
||||
"""Making pictures, on a ComfyUI somebody else is running.
|
||||
|
||||
Three modules, split along the same seam the rest of the codebase uses:
|
||||
`comfy.py` speaks HTTP and knows nothing about chats, `workflow.py` turns a
|
||||
stored template plus a model's arguments into the document ComfyUI wants, and
|
||||
`tool.py` is the `ToolDef` that ties them to a conversation.
|
||||
|
||||
Nothing here executes anything locally. That is the same rule agent chats
|
||||
follow: the work happens on a service reached over HTTP, chosen and configured
|
||||
by an administrator, and the security of it is the security of that service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": "{{seed}}",
|
||||
"steps": "{{steps}}",
|
||||
"cfg": "{{cfg}}",
|
||||
"sampler_name": "{{sampler}}",
|
||||
"scheduler": "{{scheduler}}",
|
||||
"denoise": "{{denoise}}",
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"4": {
|
||||
"inputs": { "ckpt_name": "{{model}}" },
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": { "title": "Load Checkpoint" }
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": "{{width}}",
|
||||
"height": "{{height}}",
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": { "title": "Empty Latent Image" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": { "text": "{{prompt}}", "clip": ["4", 1] },
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Prompt)" }
|
||||
},
|
||||
"7": {
|
||||
"inputs": { "text": "{{negative}}", "clip": ["4", 1] },
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Negative)" }
|
||||
},
|
||||
"8": {
|
||||
"inputs": { "samples": ["3", 0], "vae": ["4", 2] },
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"9": {
|
||||
"inputs": { "filename_prefix": "LLeMbas", "images": ["8", 0] },
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Talking to ComfyUI.
|
||||
|
||||
Four calls and a discovery one, all plain httpx. `fetch.fetch` cannot be reused
|
||||
for the same reasons `custom_tools` gives -- it is GET-only, bodyless, and
|
||||
refuses every content type that is not HTML or text, which is both the JSON here
|
||||
and the PNG at the end of it.
|
||||
|
||||
**The base URL is exempt from the SSRF guard, and that is deliberate rather than
|
||||
forgotten.** `fetch.check_url` exists to stop a *model or a reader* pointing the
|
||||
application at something on the private network; this address was typed by an
|
||||
administrator into the admin page, exactly like `Connection.base_url` and the two
|
||||
audio endpoints, none of which are checked either. Saying so here because the
|
||||
default value is `127.0.0.1:8188`, which is precisely the shape the guard exists
|
||||
to refuse and therefore looks like a hole rather than a decision.
|
||||
|
||||
Progress is **polled, not streamed**. ComfyUI offers a WebSocket for it, and
|
||||
holding one open for the length of a generation is the live-connection state the
|
||||
whole `agent/ssh.py` design forbids; polling `/history` is self-healing across a
|
||||
restart of either side, and the thing being waited for takes tens of seconds, so
|
||||
a poll costs nothing anybody can measure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.services.llm.openai_client import (
|
||||
LLMError,
|
||||
describe_http_error,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What one generated image may weigh. A cap is required rather than tidy: this is
|
||||
# the only place in the codebase where an external service hands back raw bytes
|
||||
# that are then written to disk, and neither `audio.speak` nor `openai_client`
|
||||
# has one to copy. Generous, because a 2048px PNG is a legitimate several
|
||||
# megabytes and refusing it would be refusing the feature.
|
||||
MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
||||
|
||||
# How often to ask whether it has finished, and how long to keep asking. The
|
||||
# interval is not adaptive: unlike a background job, which may run for hours,
|
||||
# a generation is over in tens of seconds and the whole reply is parked on it.
|
||||
POLL_INTERVAL = 1.0
|
||||
# How long to wait for the queue *before* our own job starts running. A busy
|
||||
# ComfyUI with somebody else's batch in front of us is not an error.
|
||||
DEFAULT_TIMEOUT = 600.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Everything a call needs, lifted out of the settings group.
|
||||
|
||||
A snapshot rather than a session, for the reason `ToolContext` is one: a
|
||||
generation outlives the request that resolved it.
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
api_key: str = ""
|
||||
timeout: float = DEFAULT_TIMEOUT
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
# ComfyUI itself has no auth; a key is only ever for something in front
|
||||
# of it, so an empty one must not become `Authorization: Bearer `.
|
||||
return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Ref:
|
||||
"""Where a finished image lives on the far side."""
|
||||
|
||||
filename: str
|
||||
subfolder: str = ""
|
||||
kind: str = "output"
|
||||
|
||||
|
||||
class ComfyError(LLMError):
|
||||
"""Anything that stopped a generation, in words worth showing somebody."""
|
||||
|
||||
|
||||
def _transport_error(exc: httpx.RequestError, config: Config) -> ComfyError:
|
||||
"""The `wrap_transport_error` shape, said about ComfyUI rather than an LLM.
|
||||
|
||||
Not reused directly: that one names the request timeout from the deployment
|
||||
settings, which is not the timeout in force here.
|
||||
"""
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
return ComfyError(
|
||||
f"Could not reach ComfyUI at {config.base_url}. Is it running and the URL correct?"
|
||||
)
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return ComfyError(f"ComfyUI at {config.base_url} did not respond in time.")
|
||||
return ComfyError(f"Could not reach ComfyUI at {config.base_url}: {exc}")
|
||||
|
||||
|
||||
async def _get_json(config: Config, path: str, *, timeout: float = 30.0) -> Any:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(config.url(path), headers=config.headers())
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ComfyError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise _transport_error(exc, config) from exc
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise ComfyError(f"ComfyUI sent something that is not JSON: {exc}") from exc
|
||||
|
||||
|
||||
async def submit(config: Config, workflow: dict[str, Any]) -> str:
|
||||
"""Queue a workflow, and answer with the id it was given.
|
||||
|
||||
A `node_errors` block is a refusal rather than a failure: the workflow was
|
||||
accepted as JSON and rejected as a graph, usually because a checkpoint name
|
||||
does not exist on that machine. It is reported with the node named, because
|
||||
"invalid prompt" against a twelve-node document says nothing.
|
||||
"""
|
||||
body = {"prompt": workflow, "client_id": uuid.uuid4().hex}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
config.url("prompt"), headers=config.headers(), json=body
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise ComfyError(_refusal(response))
|
||||
data = response.json()
|
||||
except ComfyError:
|
||||
raise
|
||||
except httpx.RequestError as exc:
|
||||
raise _transport_error(exc, config) from exc
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise ComfyError(f"ComfyUI sent something that is not JSON: {exc}") from exc
|
||||
|
||||
if errors := (data.get("node_errors") or {}):
|
||||
raise ComfyError(_describe_nodes(errors))
|
||||
prompt_id = str(data.get("prompt_id") or "")
|
||||
if not prompt_id:
|
||||
raise ComfyError("ComfyUI accepted the workflow but did not say what to call it.")
|
||||
return prompt_id
|
||||
|
||||
|
||||
def _refusal(response: httpx.Response) -> str:
|
||||
"""Why ComfyUI would not take a workflow, in one sentence."""
|
||||
try:
|
||||
payload = response.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return f"ComfyUI refused the workflow (HTTP {response.status_code})."
|
||||
if isinstance(payload, dict):
|
||||
if errors := (payload.get("node_errors") or {}):
|
||||
return _describe_nodes(errors)
|
||||
if message := payload.get("error"):
|
||||
if isinstance(message, dict):
|
||||
message = message.get("message") or message.get("type") or ""
|
||||
return f"ComfyUI refused the workflow: {message}"
|
||||
return f"ComfyUI refused the workflow (HTTP {response.status_code})."
|
||||
|
||||
|
||||
def _describe_nodes(errors: dict[str, Any]) -> str:
|
||||
parts: list[str] = []
|
||||
for node, detail in list(errors.items())[:4]:
|
||||
messages = detail.get("errors") if isinstance(detail, dict) else None
|
||||
first = ""
|
||||
if isinstance(messages, list) and messages:
|
||||
entry = messages[0]
|
||||
first = entry.get("message", "") if isinstance(entry, dict) else str(entry)
|
||||
parts.append(f"node {node}: {first}" if first else f"node {node}")
|
||||
return "ComfyUI refused the workflow — " + "; ".join(parts)
|
||||
|
||||
|
||||
async def await_images(config: Config, prompt_id: str) -> list[Ref]:
|
||||
"""Wait for one queued workflow and answer with what it saved.
|
||||
|
||||
`/history/{id}` is empty while the job is queued or running and gains the
|
||||
whole record when it ends, so an empty answer is "not yet" rather than
|
||||
"nothing" -- which is why the deadline is the only thing that ends this.
|
||||
"""
|
||||
deadline = time.monotonic() + config.timeout
|
||||
while True:
|
||||
record = (await _get_json(config, f"history/{prompt_id}")).get(prompt_id)
|
||||
if isinstance(record, dict) and (record.get("status") or {}).get("completed"):
|
||||
status = record.get("status") or {}
|
||||
if status.get("status_str") not in (None, "success"):
|
||||
raise ComfyError(
|
||||
f"ComfyUI could not finish the workflow ({status.get('status_str')})."
|
||||
)
|
||||
return _refs_in(record.get("outputs") or {})
|
||||
if time.monotonic() > deadline:
|
||||
raise ComfyError(
|
||||
f"ComfyUI did not finish within {config.timeout:.0f}s. "
|
||||
"It may still be working; the queue is on its own page."
|
||||
)
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
def _refs_in(outputs: dict[str, Any]) -> list[Ref]:
|
||||
"""Every image any node saved, in node order.
|
||||
|
||||
Every node is read rather than a `SaveImage` being looked for by name: a
|
||||
template is somebody else's document and may save from a node called
|
||||
anything, or from two of them.
|
||||
"""
|
||||
refs: list[Ref] = []
|
||||
for node in outputs.values():
|
||||
for image in (node or {}).get("images") or []:
|
||||
if filename := str(image.get("filename") or ""):
|
||||
refs.append(
|
||||
Ref(
|
||||
filename=filename,
|
||||
subfolder=str(image.get("subfolder") or ""),
|
||||
kind=str(image.get("type") or "output"),
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
async def fetch_image(config: Config, ref: Ref) -> bytes:
|
||||
"""The bytes of one finished image."""
|
||||
params = {"filename": ref.filename, "subfolder": ref.subfolder, "type": ref.kind}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.get(
|
||||
config.url("view"), headers=config.headers(), params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.content
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ComfyError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise _transport_error(exc, config) from exc
|
||||
|
||||
if not payload:
|
||||
raise ComfyError(f"ComfyUI returned an empty file for {ref.filename}.")
|
||||
if len(payload) > MAX_IMAGE_BYTES:
|
||||
raise ComfyError(
|
||||
f"{ref.filename} is {len(payload) // (1024 * 1024)}MB, over the "
|
||||
f"{MAX_IMAGE_BYTES // (1024 * 1024)}MB limit."
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def free(config: Config) -> None:
|
||||
"""Ask ComfyUI to drop its models from memory.
|
||||
|
||||
Best-effort by design and never raised into the caller: this runs on the way
|
||||
out of a generation that has already produced its image, and failing the
|
||||
whole tool because a memory hint was refused would be turning a tidy-up into
|
||||
an error. The consequence of it silently not working is VRAM staying used,
|
||||
which is the state Preserve VRAM was already in before it was switched on.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
await client.post(
|
||||
config.url("free"),
|
||||
headers=config.headers(),
|
||||
json={"unload_models": True, "free_memory": True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a hint that failed is not a failed generation
|
||||
log.debug("could not free ComfyUI at %s", config.base_url, exc_info=True)
|
||||
|
||||
|
||||
async def discover(config: Config) -> tuple[list[str], list[str], list[str]]:
|
||||
"""What this ComfyUI can actually do: checkpoints, samplers, schedulers.
|
||||
|
||||
For the admin page only. Never called from the request path -- the tool
|
||||
reads the stored lists, exactly as the project listing is read from a cache
|
||||
rather than walked, because a keystroke must not wait on a machine.
|
||||
"""
|
||||
checkpoints = _options(
|
||||
await _get_json(config, "object_info/CheckpointLoaderSimple"),
|
||||
"CheckpointLoaderSimple",
|
||||
"ckpt_name",
|
||||
)
|
||||
sampler_info = await _get_json(config, "object_info/KSampler")
|
||||
samplers = _options(sampler_info, "KSampler", "sampler_name")
|
||||
schedulers = _options(sampler_info, "KSampler", "scheduler")
|
||||
return checkpoints, samplers, schedulers
|
||||
|
||||
|
||||
def _options(payload: Any, node: str, field: str) -> list[str]:
|
||||
"""The allowed values of one input, out of an `/object_info` document.
|
||||
|
||||
The shape is `{node: {input: {required: {field: [[...values], {...meta}]}}}}`
|
||||
-- a list whose first element is the list of options. Read defensively: this
|
||||
is somebody else's schema and a custom node pack can change it.
|
||||
"""
|
||||
try:
|
||||
spec = payload[node]["input"]["required"][field][0]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return []
|
||||
return [str(value) for value in spec] if isinstance(spec, list) else []
|
||||
@@ -0,0 +1,526 @@
|
||||
"""The tool that makes a picture, and the loop that decides to keep it.
|
||||
|
||||
One call is one finished image. The alternative -- return every attempt to the
|
||||
conversation and let the model decide whether to call again -- costs a full
|
||||
round per retry, makes the ceiling advisory rather than enforced, and shows the
|
||||
reader every reject on the way past. So the retrying happens here, and what
|
||||
comes back is the image that was kept.
|
||||
|
||||
**Three things are ordered rather than incidental.**
|
||||
|
||||
*The reviewer is asked about bytes, not about a row.* An attempt that is going
|
||||
to be thrown away should not leave an `Attachment` behind, so the judge is shown
|
||||
a downscaled preview built in memory and only the kept image is ever written.
|
||||
|
||||
*Preserve VRAM swaps around the review, not around the tool.* The sequence is
|
||||
unload the LLM, generate, free ComfyUI, ask the reviewer (which loads the LLM
|
||||
again), and round once more if it said no. Two model loads per retry, which is
|
||||
why the two settings are independent and the admin page says so.
|
||||
|
||||
*Nothing loads the LLM back at the end.* The reply's next request does it, and
|
||||
llama-swap -- or Ollama, or anything else worth pointing this at -- loads on
|
||||
demand. A step that exists in the description and not in the code looks like an
|
||||
omission, so it is said here instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.services.images import comfy, workflow
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||
from lembas.services.tools import RISK_WRITE, ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What the reviewer is allowed to write back. It is one verdict and one line of
|
||||
# reason, and a model that writes an essay about a picture is a model whose
|
||||
# answer nobody reads.
|
||||
MAX_VERDICT_TOKENS = 200
|
||||
|
||||
# How long to wait for a connection to admit it has unloaded. Short: this is a
|
||||
# hint before a slow operation, and a machine that will not answer it is one
|
||||
# where the generation should go ahead anyway rather than fail.
|
||||
UNLOAD_TIMEOUT = 30.0
|
||||
|
||||
SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
# First, and the only required one, because `tools.parse_arguments`
|
||||
# puts the whole raw string into the first required parameter when a
|
||||
# model emits arguments that are not valid JSON. That failure is common
|
||||
# with small models, and this way it degrades into a prompt rather than
|
||||
# into a seed.
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "What to draw. Describe the subject, the setting and the style.",
|
||||
},
|
||||
"negative": {
|
||||
"type": "string",
|
||||
"description": "What to keep out of the picture. Defaults to 'text, watermark'.",
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"description": "Which workflow to use. Omit to use this chat's usual one.",
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Which checkpoint to draw with. Omit to use this chat's usual one.",
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"description": "Omit for a new random image; repeat one to get the same image again.",
|
||||
},
|
||||
"steps": {"type": "integer", "description": "Sampling steps. Default 20."},
|
||||
"cfg": {"type": "number", "description": "Prompt adherence. Default 8."},
|
||||
"width": {"type": "integer", "description": "Pixels. Default 512."},
|
||||
"height": {"type": "integer", "description": "Pixels. Default 512."},
|
||||
"sampler": {"type": "string", "description": "Sampler name. Default euler."},
|
||||
"scheduler": {"type": "string", "description": "Scheduler name. Default normal."},
|
||||
"denoise": {"type": "number", "description": "0 to 1. Default 1."},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Attempt:
|
||||
"""One generated image and what was decided about it."""
|
||||
|
||||
number: int
|
||||
seed: int
|
||||
kept: bool
|
||||
verdict: str = ""
|
||||
|
||||
|
||||
def config_of(context: ToolContext) -> comfy.Config:
|
||||
"""The client snapshot, with the key decrypted at the last moment."""
|
||||
from lembas.services.crypto import decrypt
|
||||
|
||||
values = context.image_config or {}
|
||||
return comfy.Config(
|
||||
base_url=str(values.get("base_url") or ""),
|
||||
api_key=decrypt(str(values.get("api_key_encrypted") or "")),
|
||||
timeout=float(values.get("timeout") or comfy.DEFAULT_TIMEOUT),
|
||||
)
|
||||
|
||||
|
||||
def _choices(db, values: dict[str, Any]) -> tuple[list[Any], list[str]]:
|
||||
"""The templates and checkpoints on offer, for the schema and the harness."""
|
||||
from lembas.db.models import ImageWorkflow
|
||||
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ImageWorkflow)
|
||||
.where(ImageWorkflow.enabled.is_(True))
|
||||
.order_by(ImageWorkflow.position, ImageWorkflow.slug)
|
||||
)
|
||||
)
|
||||
return rows, [str(name) for name in (values.get("checkpoints") or [])]
|
||||
|
||||
|
||||
def schema_for(db, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The parameter schema, with this instance's own choices in it.
|
||||
|
||||
`template` and `model` become enums because a name that does not exist is a
|
||||
refusal from ComfyUI and a wasted round; `sampler` and `scheduler` stay
|
||||
plain strings because there are forty-four and nine of them, and an enum
|
||||
that size costs tokens on every request forever to prevent a mistake worth
|
||||
one sentence of correction.
|
||||
"""
|
||||
rows, checkpoints = _choices(db, values)
|
||||
schema = json.loads(json.dumps(SCHEMA))
|
||||
if rows:
|
||||
schema["properties"]["template"]["enum"] = [row.slug for row in rows]
|
||||
schema["properties"]["template"]["description"] = "Which workflow to use. " + "; ".join(
|
||||
f"{row.slug}: {row.description or row.name}" for row in rows[:12]
|
||||
)
|
||||
if checkpoints:
|
||||
schema["properties"]["model"]["enum"] = checkpoints
|
||||
return schema
|
||||
|
||||
|
||||
def tool_def(db, values: dict[str, Any]) -> ToolDef:
|
||||
return ToolDef(
|
||||
name="image_generate",
|
||||
family="image",
|
||||
description=(
|
||||
"Draw a picture from a description and show it to the person you are "
|
||||
"talking to. Returns once the image has been made and is on screen."
|
||||
),
|
||||
parameters=schema_for(db, values),
|
||||
run=run,
|
||||
# Not RISK_READ: it spends somebody's GPU for a minute and puts a new
|
||||
# artefact in the conversation. In an agent chat that means the mode
|
||||
# decides whether to ask first, which is the right answer for a call
|
||||
# that cannot be undone by reading something again.
|
||||
risk=RISK_WRITE,
|
||||
)
|
||||
|
||||
|
||||
# --- Preserve VRAM -------------------------------------------------------------
|
||||
async def _unload_llm(context: ToolContext) -> bool:
|
||||
"""Ask this chat's own endpoint to drop its model. Best-effort.
|
||||
|
||||
*This chat's own* is the whole of the design. The unload hook is a column on
|
||||
`Connection`, so a chat talking to a local llama-swap unloads that and a
|
||||
chat talking to a box on the network unloads nothing -- its VRAM is not the
|
||||
VRAM ComfyUI is about to want.
|
||||
"""
|
||||
from lembas.db.models import Connection
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
url = ""
|
||||
method = "POST"
|
||||
try:
|
||||
with session_scope() as db:
|
||||
connection = db.get(Connection, context.connection_id)
|
||||
if connection is not None:
|
||||
url = (connection.unload_url or "").strip()
|
||||
method = (connection.unload_method or "POST").upper()
|
||||
except Exception: # noqa: BLE001 - a hint that could not be looked up is not a failure
|
||||
log.debug("could not read the unload hook", exc_info=True)
|
||||
return False
|
||||
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=UNLOAD_TIMEOUT) as client:
|
||||
await client.request(method, url)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - see the module docstring: a hint, not a step
|
||||
log.info("could not unload the model at %s", url, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
# --- The reviewer --------------------------------------------------------------
|
||||
def _reviewer(context: ToolContext) -> tuple[Endpoint, str] | None:
|
||||
"""The model that judges an image, or None if there is nobody to ask.
|
||||
|
||||
The admin's choice first, then the chat's own model when it has vision. A
|
||||
chat on a text-only model with no reviewer configured simply keeps the first
|
||||
image, which is the behaviour with review switched off -- said here rather
|
||||
than failing, because "you asked for a picture and got an error about
|
||||
vision" is a worse answer than a picture.
|
||||
"""
|
||||
from lembas.db.models import Connection, Model
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
values = context.image_config or {}
|
||||
if not values.get("review_enabled"):
|
||||
return None
|
||||
|
||||
wanted = str(values.get("review_model_id") or "")
|
||||
try:
|
||||
with session_scope() as db:
|
||||
model = None
|
||||
if wanted:
|
||||
model = db.get(Model, wanted)
|
||||
if model is None and context.model_id:
|
||||
model = db.scalar(
|
||||
select(Model).where(
|
||||
Model.model_id == context.model_id,
|
||||
Model.connection_id == context.connection_id,
|
||||
)
|
||||
)
|
||||
if model is None or not (model.capabilities_json or {}).get("vision"):
|
||||
return None
|
||||
connection = db.get(Connection, model.connection_id)
|
||||
if connection is None or not connection.enabled:
|
||||
return None
|
||||
return Endpoint.from_connection(connection), model.model_id
|
||||
except Exception: # noqa: BLE001 - no reviewer is a degraded mode, not an error
|
||||
log.warning("could not resolve an image reviewer", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _review(
|
||||
context: ToolContext, endpoint: Endpoint, model_id: str, prompt: str, payload: bytes
|
||||
) -> tuple[bool, str]:
|
||||
"""Show the reviewer the image and ask whether to keep it.
|
||||
|
||||
Answers `(keep, reason)`. **Anything that goes wrong is a keep**: the
|
||||
reviewer is a second opinion on a picture that already exists, and losing an
|
||||
image because a judging request timed out would be the check destroying the
|
||||
thing it was checking.
|
||||
"""
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
preview = files_service.preview_data_uri(payload, max_edge=768)
|
||||
if preview is None:
|
||||
return True, ""
|
||||
|
||||
with session_scope() as db:
|
||||
instruction = prompts_service.resolve(db, "task.image_review")
|
||||
# An administrator who cleared the fragment has switched reviewing off, the
|
||||
# same way clearing `task.compact` switches compaction off. Nothing is asked
|
||||
# of anyone and the image is kept.
|
||||
if not instruction.strip():
|
||||
return True, ""
|
||||
|
||||
body = {
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
{"role": "system", "content": instruction},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"The request was: {prompt}"},
|
||||
{"type": "image_url", "image_url": {"url": preview}},
|
||||
],
|
||||
},
|
||||
],
|
||||
"max_tokens": MAX_VERDICT_TOKENS,
|
||||
"temperature": 0,
|
||||
}
|
||||
try:
|
||||
answer = (await complete(endpoint, body)).strip()
|
||||
except LLMError as exc:
|
||||
log.info("could not review a generated image: %s", exc.message)
|
||||
return True, ""
|
||||
|
||||
verdict, _, reason = answer.partition("\n")
|
||||
keep = not verdict.strip().upper().startswith("RETRY")
|
||||
return keep, (reason or verdict).strip()[:300]
|
||||
|
||||
|
||||
# --- The runner ----------------------------------------------------------------
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Generate one image, review it if there is anybody to ask, and keep one."""
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import files as files_service
|
||||
|
||||
event: dict[str, Any] = {
|
||||
"name": "image_generate",
|
||||
"kind": "image",
|
||||
"query": str(args.get("prompt") or "")[:200],
|
||||
"results": [],
|
||||
}
|
||||
|
||||
prompt = str(args.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return ToolOutcome(
|
||||
"No prompt was given, so nothing was drawn. Say what the picture should show.",
|
||||
{**event, "status": "error", "error": "No prompt."},
|
||||
)
|
||||
if not context.chat_id:
|
||||
return ToolOutcome(
|
||||
"Images can only be generated inside a chat.",
|
||||
{**event, "status": "error", "error": "No chat."},
|
||||
)
|
||||
|
||||
values = context.image_config or {}
|
||||
config = config_of(context)
|
||||
if not config.configured:
|
||||
return ToolOutcome(
|
||||
"No image generator is configured on this instance.",
|
||||
{**event, "status": "error", "error": "No ComfyUI configured."},
|
||||
)
|
||||
|
||||
# Resolve the template and the checkpoint: what the model asked for, then
|
||||
# this chat's usual, then the instance default. Every rung is a preference
|
||||
# and none of them is a constraint, which is what lets a model that only
|
||||
# wrote a prompt still get a picture.
|
||||
try:
|
||||
with session_scope() as db:
|
||||
rows, checkpoints = _choices(db, values)
|
||||
wanted = str(args.get("template") or "")
|
||||
chosen = _pick(rows, wanted, context.image_workflow_id, values)
|
||||
if chosen is None:
|
||||
return ToolOutcome(
|
||||
"No image workflow has been set up on this instance.",
|
||||
{**event, "status": "error", "error": "No workflow."},
|
||||
)
|
||||
template = json.loads(json.dumps(chosen.workflow_json or {}))
|
||||
template_slug, template_name = chosen.slug, chosen.name
|
||||
except ToolOutcome: # pragma: no cover - defensive
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("could not resolve an image workflow")
|
||||
return ToolOutcome(
|
||||
f"The image workflow could not be read: {exc}",
|
||||
{**event, "status": "error", "error": str(exc)},
|
||||
)
|
||||
|
||||
checkpoint = _checkpoint(str(args.get("model") or ""), context.image_checkpoint, checkpoints)
|
||||
if checkpoint is None:
|
||||
return ToolOutcome(
|
||||
"No checkpoint is available. An administrator has to list them on the "
|
||||
"image generation page.",
|
||||
{**event, "status": "error", "error": "No checkpoint."},
|
||||
)
|
||||
|
||||
given = {name: args.get(name) for name in workflow.PLACEHOLDERS if name in args}
|
||||
given["model"] = checkpoint
|
||||
given["prompt"] = prompt
|
||||
|
||||
reviewer = _reviewer(context)
|
||||
tries = int(values.get("max_tries") or 1) if reviewer else 1
|
||||
preserve = bool(values.get("preserve_vram"))
|
||||
|
||||
attempts: list[Attempt] = []
|
||||
kept: tuple[bytes, dict[str, Any]] | None = None
|
||||
|
||||
try:
|
||||
for number in range(1, tries + 1):
|
||||
if preserve:
|
||||
await _unload_llm(context)
|
||||
|
||||
params = workflow.resolve({**given, "seed": args.get("seed") if number == 1 else None})
|
||||
refs = await comfy.await_images(
|
||||
config, await comfy.submit(config, workflow.fill(template, params))
|
||||
)
|
||||
if not refs:
|
||||
raise comfy.ComfyError("ComfyUI finished but saved no image.")
|
||||
payload = await comfy.fetch_image(config, refs[0])
|
||||
|
||||
if preserve:
|
||||
await comfy.free(config)
|
||||
|
||||
if reviewer is None:
|
||||
attempts.append(Attempt(number, params["seed"], kept=True))
|
||||
kept = (payload, params)
|
||||
break
|
||||
|
||||
endpoint, model_id = reviewer
|
||||
keep, reason = await _review(context, endpoint, model_id, prompt, payload)
|
||||
last = number == tries
|
||||
attempts.append(Attempt(number, params["seed"], kept=keep or last, verdict=reason))
|
||||
if keep or last:
|
||||
kept = (payload, params)
|
||||
break
|
||||
except comfy.ComfyError as exc:
|
||||
if preserve:
|
||||
await comfy.free(config)
|
||||
return ToolOutcome(
|
||||
f"The image could not be generated: {exc.message}",
|
||||
{**event, "status": "error", "error": exc.message},
|
||||
)
|
||||
|
||||
if preserve:
|
||||
await comfy.free(config)
|
||||
if kept is None: # pragma: no cover - the loop always keeps its last attempt
|
||||
return ToolOutcome(
|
||||
"Nothing was generated.", {**event, "status": "error", "error": "No image."}
|
||||
)
|
||||
|
||||
payload, params = kept
|
||||
try:
|
||||
with session_scope() as db:
|
||||
attachment = files_service.store(
|
||||
db,
|
||||
user_id=context.owner_id,
|
||||
chat_id=context.chat_id,
|
||||
payload=payload,
|
||||
filename=f"{template_slug}-{params['seed']}.png",
|
||||
# What ComfyUI made, at the size it made it. See `_keep_image`.
|
||||
keep_original=True,
|
||||
source_label="Image generation",
|
||||
source_path=f"{checkpoint} · seed {params['seed']}",
|
||||
)
|
||||
attachment_id = attachment.id
|
||||
width, height = attachment.width, attachment.height
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("could not store a generated image")
|
||||
return ToolOutcome(
|
||||
f"The image was generated but could not be saved: {exc}",
|
||||
{**event, "status": "error", "error": str(exc)},
|
||||
)
|
||||
|
||||
return ToolOutcome(
|
||||
_describe(prompt, template_name, checkpoint, params, attempts),
|
||||
{
|
||||
**event,
|
||||
"status": "ok",
|
||||
"detail": f"{template_name} · {checkpoint}",
|
||||
"text": _transcript(params, attempts),
|
||||
# Bound to the reply by `generation._persist`, the single writer. A
|
||||
# runner may create the row; only the loop may say which turn owns
|
||||
# it.
|
||||
"attachment_id": attachment_id,
|
||||
"image": {"id": attachment_id, "width": width, "height": height},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _pick(rows: list[Any], wanted: str, chat_default: str, values: dict[str, Any]) -> Any:
|
||||
"""The workflow to use: asked for, then the chat's, then the instance's."""
|
||||
by_slug = {row.slug: row for row in rows}
|
||||
if wanted and wanted in by_slug:
|
||||
return by_slug[wanted]
|
||||
by_id = {row.id: row for row in rows}
|
||||
if chat_default and chat_default in by_id:
|
||||
return by_id[chat_default]
|
||||
fallback = str(values.get("default_workflow_id") or "")
|
||||
if fallback and fallback in by_id:
|
||||
return by_id[fallback]
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _checkpoint(wanted: str, chat_default: str, available: list[str]) -> str | None:
|
||||
"""The checkpoint to draw with, on the same ladder.
|
||||
|
||||
A name the instance does not have is ignored rather than passed through: it
|
||||
would reach ComfyUI, be refused, and cost a round to discover -- and the
|
||||
model was shown the list it may choose from.
|
||||
"""
|
||||
if wanted and wanted in available:
|
||||
return wanted
|
||||
if chat_default and chat_default in available:
|
||||
return chat_default
|
||||
return available[0] if available else None
|
||||
|
||||
|
||||
def _describe(
|
||||
prompt: str, template: str, checkpoint: str, params: dict[str, Any], attempts: list[Attempt]
|
||||
) -> str:
|
||||
"""What the model reads back.
|
||||
|
||||
It is told the image is already on screen, because otherwise the commonest
|
||||
next thing it does is offer to show it -- and there is nothing it could do
|
||||
to comply.
|
||||
"""
|
||||
lines = [
|
||||
"The image has been generated and is shown to them. It is not a link and "
|
||||
"needs no further action.",
|
||||
f"Prompt: {prompt}",
|
||||
f"Template {template}, checkpoint {checkpoint}, "
|
||||
f"{params['width']}x{params['height']}, seed {params['seed']}, "
|
||||
f"{params['steps']} steps, cfg {params['cfg']}.",
|
||||
]
|
||||
if len(attempts) > 1:
|
||||
rejected = [a for a in attempts if not a.kept]
|
||||
lines.append(
|
||||
f"It took {len(attempts)} attempts; the earlier ones were rejected on review "
|
||||
f"({'; '.join(a.verdict for a in rejected if a.verdict) or 'no reason given'})."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _transcript(params: dict[str, Any], attempts: list[Attempt]) -> str:
|
||||
"""What the reader sees when they open the tool block.
|
||||
|
||||
The rejected attempts are recorded here and their images are not kept. A
|
||||
transcript full of pictures somebody's model decided against is noise, and
|
||||
the disk they would occupy buys nothing -- what is worth knowing is that it
|
||||
took three goes and why the first two did not do.
|
||||
"""
|
||||
lines = [
|
||||
f"seed {params['seed']} · {params['steps']} steps · cfg {params['cfg']} · "
|
||||
f"{params['sampler']}/{params['scheduler']} · denoise {params['denoise']}"
|
||||
]
|
||||
if len(attempts) > 1:
|
||||
lines.append("")
|
||||
for attempt in attempts:
|
||||
state = "kept" if attempt.kept else "rejected"
|
||||
reason = f" — {attempt.verdict}" if attempt.verdict else ""
|
||||
lines.append(f"Attempt {attempt.number} (seed {attempt.seed}): {state}{reason}")
|
||||
return "\n".join(lines)
|
||||
@@ -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