diff --git a/CLAUDE.md b/CLAUDE.md
index 0a2a259..6c3296b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
-pytest # 1529 tests, ~93s
+pytest # 1676 tests, ~102s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -85,6 +85,7 @@ src/lembas/
admin_users.py users, groups, permissions
admin_audio.py speech-to-text and text-to-speech endpoints
admin_search.py web search provider and credentials
+ admin_images.py the ComfyUI, and the workflow templates on it
admin_prompts.py the prompt fragment editor and its preview
admin_suggestions.py the cards offered on the new-chat screen
admin_tools.py custom HTTP tools and MCP servers
@@ -104,6 +105,9 @@ src/lembas/
services/
llm/openai_client.py httpx streaming + model discovery
search/ ddgs, SearXNG and Firecrawl behind one shape
+ images/ drawing on a ComfyUI: comfy.py speaks HTTP,
+ workflow.py fills a template, tool.py ties them
+ to a chat and decides whether to keep the result
library/ documents, notes, memories, skills, FTS
mcp/ remote MCP servers: framing, transport, rows to tools
agent/ agent chats: the mode table, SSH, the six tools,
@@ -1938,6 +1942,91 @@ have to have been watching to understand. It is the one thing in a tool result
that is genuinely *not* untrusted: it is the reader's own words, so it is stated
as theirs and needs no fence.
+**Image generation is a ComfyUI workflow with holes in it, and the holes are the
+administrator's statement.** `services/images/` is three modules: `comfy.py`
+speaks HTTP, `workflow.py` fills a template, `tool.py` ties them to a chat.
+Which node holds the prompt is *declared* with `{{prompt}}` rather than sniffed
+by node type — looking for the first `CLIPTextEncode` works on the shipped
+workflow and on nothing else, and swaps positive for negative the first time
+somebody reorders them.
+
+**Substitution walks the parsed JSON, not 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. 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 hold arbitrary text. `seed` has no fixed default
+— one would make every unspecified generation identical and make the retry loop
+redraw the same rejected picture four times.
+
+**One call is one finished image, and the retrying is inside the tool.**
+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 — the admin's chosen vision model, else the chat's own if it has
+vision, else nobody — is asked about *bytes* rather than about a row: 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. Rejected images are not stored — their verdicts are, in `event.text`.
+
+**`task.image_review` is a `GROUP_TASKS` fragment**, so it is editable and
+excluded from the harness, exactly like `task.title` and `task.compact` — and
+clearing it switches reviewing off, the same way clearing `task.compact` switches
+compaction off. It is biased hard towards KEEP on purpose: a reviewer that
+retries on taste spends the GPU four times and usually ends up back at the first
+image.
+
+**Preserve VRAM unloads the chat's own connection and nothing else.**
+`Connection.unload_url` is a column because the memory being freed belongs to one
+machine: a local llama-swap answers `GET /unload`, and a box on the network has
+no reason to be unloaded when ComfyUI wants memory *here*. Empty means "cannot be
+unloaded", which is the honest default — there is no call that works everywhere.
+The swap goes round the *review*, not round the tool: unload, generate, free
+ComfyUI, ask the reviewer (which loads the LLM again), round again if it said no.
+Two model loads per retry, which is why the two settings are independent and the
+page says so when both are on. **Nothing loads the LLM back at the end** — the
+reply's next request does, and llama-swap loads on demand; that step exists in
+the description and not in the code, which is why the code says so.
+
+**A generated image rides on the assistant message, so `message_payload` sends
+images only on `user` turns.** No assistant message had ever carried one before,
+so the distinction had never been drawn — and the moment one does, the
+multimodal list form on an `assistant` turn is rejected by OpenAI and most local
+runners, breaking not that turn but every later one in the chat. What follows and
+is worth knowing: on a *later* turn the model cannot see the picture it made
+(tool results are not replayed either), so "make it bluer" regenerates rather
+than edits. Honest for a text-to-image workflow with no img2img path.
+
+**The runner writes the file; only the loop says which turn owns it.**
+`event["attachment_id"]` is carried by `generation._run` exactly as
+`event["canvas"]` and `event["plan"]` are, because `_persist` is the single
+writer. `_bind_attachments` narrows on this chat and on rows still unbound, for
+the reason `files.claim` does: the ids arrive on a dict a runner built.
+
+**`files.store(keep_original=True)` skips the resize and the transcode, and
+nothing else.** `_process_image` turns anything without alpha into JPEG q85 at
+1400px, which is right for a phone photo and a visible loss on generated art.
+Pillow still opens it, so a malformed file is still refused and the dimensions
+are still measured rather than claimed.
+
+**`/image` forces one tool for one round.** It sends the ordinary message with
+`force_tool`, which becomes `tool_choice` — reusing the whole loop rather than
+inventing a second generation path. `FORCEABLE_TOOLS` is an allow list because
+this is read off a form, and `resolve_tools` still decides whether the tool
+exists, so forcing one that was never offered does nothing. `payload.pop(
+"tool_choice")` after the first round is load-bearing: left in place the reply
+would draw a picture, be asked again, and draw another.
+
+**`ToolContext` gained `chat_id`, and that fixed a tool nobody had ever run.**
+`_run_scratch_write` read `context.chat_id` on a dataclass that had no such
+field, so **every `scratch_write` call raised `AttributeError`** — swallowed by
+`run_tool`'s blanket except into "the scratch_write tool failed", which reads
+exactly like 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. There is one that runs it now.
+
**A control wired to a method its route does not serve fails silently.** The
agent-mode select posted with `hx-post` against a route that only answers
`PATCH`, so every change returned 405 and the mode never moved — for the whole
@@ -2058,14 +2147,13 @@ notes describe the machine.
## Not built yet
-Image generation, and a nav entry marks where it goes. The tool loop in
-`services/generation.py` is what a new capability plugs into — a tool is a
-`ToolDef` reaching `tools.resolve_tools()` plus a permission and a capability
-flag, not a new code path. Its guidance is the same shape: a
-`prompts.register_source` yielding one `Fragment` per row puts it in the harness,
-on the admin page and in the preview without touching the assembler, the save
-handler or a template. Custom HTTP tools, MCP servers and agent chats are the
-three worked examples.
+Nothing large. The tool loop in `services/generation.py` is what a new
+capability plugs into — a tool is a `ToolDef` reaching `tools.resolve_tools()`
+plus a permission and a capability flag, not a new code path. Its guidance is
+the same shape: a `prompts.register_source` yielding one `Fragment` per row puts
+it in the harness, on the admin page and in the preview without touching the
+assembler, the save handler or a template. Custom HTTP tools, MCP servers, agent
+chats and image generation are the four worked examples.
**Nothing executes on this machine, and that is the design.** Agent chats run
their commands on a host reached over SSH. A local sandbox was designed in
diff --git a/PLAN.md b/PLAN.md
index b6c95dd..bf08369 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -7,8 +7,8 @@ that would be expensive to revisit. Kept current as work lands; the detail of
**Status:** usable daily. Streaming chat, attachments, reasoning, tool calling
with web search, custom HTTP tools and MCP servers, agent chats that work on a
machine over SSH, a knowledge library, notes, memory and skills, speech in and
-out, users and groups, model administration, installable as an app. 1529 tests,
-`ruff` clean.
+out, image generation over ComfyUI, users and groups, model administration,
+installable as an app. 1676 tests, `ruff` clean.
---
@@ -115,6 +115,30 @@ be a different project, not a refactor.
- [x] Local MCP over stdio is deliberately absent: spawning a subprocess would
run on this machine, which nothing here does
+### Image generation
+- [x] **Draws on a ComfyUI you are running**, as a tool the model chooses to
+ call and as an `/image` command that makes it call one. Never on this
+ machine, the same rule agent chats follow
+- [x] **Multiple workflow templates** — a name, a description and a ComfyUI API
+ export with `{{prompt}}` and ten other placeholders where the values go.
+ The model picks between them by their descriptions, and by checkpoint,
+ falling back to the chat's usual and then the instance default when it
+ names neither
+- [x] Model may set prompt, negative, seed, steps, cfg, width, height, sampler,
+ scheduler, denoise, checkpoint and template; **only the prompt is
+ required** and everything else has a default
+- [x] **The result is checked before you see it** — optionally, a vision model
+ is shown the picture and the request and says keep or retry, up to a
+ configurable number of attempts. Only clearly wrong images are retried;
+ the last attempt is kept whatever it says, so a request always produces
+ something
+- [x] **Preserve VRAM** — opt-in, for a machine that cannot hold both at once:
+ unload the chat's own language model, generate, free ComfyUI, and let the
+ next request load the model back. Per connection, so a box on the network
+ is never touched
+- [x] Instance-wide extra instructions, injected into the harness beside the
+ tool's own guidance
+
### Agent chats
- [x] A chat is a **Chat** or an **Agent**, chosen when it starts and fixed
thereafter — a transcript whose earlier turns ran somewhere else is not
@@ -290,10 +314,6 @@ be a different project, not a refactor.
In the order they are likely to be worth doing.
-### Image generation
-Left until last from the start, as it needs heavy customisation. ComfyUI is
-already running on this machine and is the obvious first target.
-
### Smaller things
- **OCR** for scanned PDFs
- **Conversation branching** — `Message.parent_id` exists unused; needs a UI for
diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py
index 62307b4..b2fe638 100644
--- a/src/lembas/__init__.py
+++ b/src/lembas/__init__.py
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
-__version__ = "0.6.5"
+__version__ = "0.7.0"
diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py
index 9a6a3ee..351aa2d 100644
--- a/src/lembas/api/admin.py
+++ b/src/lembas/api/admin.py
@@ -145,11 +145,19 @@ async def update_connection(
base_url: str = Form(...),
api_key: str = Form(""),
enabled: bool = Form(False),
+ unload_url: str = Form(""),
+ unload_method: str = Form("POST"),
) -> Response:
connection = _connection(db, connection_id)
connection.name = name.strip()[:120] or connection.name
connection.base_url = base_url.strip().rstrip("/")
connection.enabled = enabled
+ # How to ask this endpoint to drop its model, for image generation's
+ # Preserve VRAM. Empty means it cannot be unloaded, which is the honest
+ # answer for anything not running on the machine ComfyUI is on.
+ connection.unload_url = unload_url.strip()[:500]
+ method = unload_method.strip().upper()
+ connection.unload_method = method if method in ("GET", "POST") else "POST"
submitted = api_key.strip()
if submitted and submitted != UNCHANGED_SENTINEL:
diff --git a/src/lembas/api/admin_images.py b/src/lembas/api/admin_images.py
new file mode 100644
index 0000000..92b6446
--- /dev/null
+++ b/src/lembas/api/admin_images.py
@@ -0,0 +1,381 @@
+"""Image generation administration: the ComfyUI, and the workflows to run on it.
+
+Two shapes on one nav entry, because they are two different kinds of thing. The
+connection, the checkpoints and the switches are instance settings and get a
+settings page. A workflow is an authored document with a name, a description and
+a body, so the workflows are list-plus-detail -- the shape `CLAUDE.md` requires
+of any admin list, and for the reason it gives: a page that renders a ten-line
+JSON textarea per row is unusable at three rows.
+
+Route order matters and is not alphabetical. `/admin/images/workflows/new` is
+registered before `/admin/images/workflows/{workflow_id}`, or "new" is captured
+as an id and 404s. That has already been a bug twice here.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from datetime import UTC, datetime
+
+from fastapi import APIRouter, Form, HTTPException, Request, Response, status
+from fastapi.responses import RedirectResponse
+from sqlalchemy import func, select
+
+from lembas.api.deps import AdminUser, Db
+from lembas.db.models import ImageWorkflow, Model
+from lembas.services import settings_store
+from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
+from lembas.services.images import comfy
+from lembas.services.images import workflow as workflow_service
+from lembas.services.llm.openai_client import LLMError
+from lembas.web.templating import render
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/admin/images", tags=["admin-images"])
+
+SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
+
+# The placeholders a workflow has to carry to be worth having. Without a prompt
+# it draws the same picture whatever anybody types, which is the one failure
+# somebody would not think to look for.
+REQUIRED_PLACEHOLDERS = ("prompt",)
+
+
+def _lines(text: str) -> list[str]:
+ """One name per line, blanks dropped. The `admin_agents` pattern."""
+ seen: list[str] = []
+ for line in (text or "").splitlines():
+ name = line.strip()
+ if name and name not in seen:
+ seen.append(name)
+ return seen
+
+
+def _config(db: Db) -> comfy.Config:
+ values = settings_store.images(db)
+ return comfy.Config(
+ base_url=str(values.get("base_url") or ""),
+ api_key=decrypt(str(values.get("api_key_encrypted") or "")),
+ timeout=30.0,
+ )
+
+
+def _page(request: Request, db: Db, **extra) -> Response:
+ values = settings_store.images(db)
+ workflows = list(
+ db.scalars(select(ImageWorkflow).order_by(ImageWorkflow.position, ImageWorkflow.slug))
+ )
+ return render(
+ request,
+ "admin/images.html",
+ {
+ "values": values,
+ "workflows": workflows,
+ # Only models an administrator has marked as having vision can
+ # review, so the picker offers those and nothing else -- a list
+ # including text-only models would be a list of choices that
+ # silently do nothing.
+ "vision_models": list(
+ db.scalars(
+ select(Model)
+ .where(Model.enabled.is_(True))
+ .order_by(Model.position, Model.model_id)
+ )
+ ),
+ "checkpoints_text": "\n".join(values.get("checkpoints") or []),
+ "masked": mask(decrypt(values.get("api_key_encrypted") or "")),
+ "unchanged": UNCHANGED_SENTINEL,
+ **extra,
+ },
+ )
+
+
+@router.get("")
+async def images_page(request: Request, db: Db, user: AdminUser, saved: str = "") -> Response:
+ return _page(request, db, saved=saved)
+
+
+@router.post("")
+async def save_images(
+ request: Request,
+ db: Db,
+ user: AdminUser,
+ enabled: bool = Form(False),
+ base_url: str = Form(""),
+ api_key: str = Form(""),
+ timeout: float = Form(600.0),
+ checkpoints: str = Form(""),
+ default_workflow_id: str = Form(""),
+ review_enabled: bool = Form(False),
+ review_model_id: str = Form(""),
+ max_tries: int = Form(4),
+ preserve_vram: bool = Form(False),
+ instructions: str = Form(""),
+) -> Response:
+ """Save the settings.
+
+ Every toggle defaults to False because an unticked checkbox is simply absent
+ from a form post -- that absence *is* the off signal, the rule
+ `admin_audio` states.
+
+ The discovered sampler and scheduler lists are deliberately not submitted
+ and not cleared here: they belong to whatever ComfyUI was tested, and a save
+ that only changed the instructions box has no opinion about them.
+ """
+ current = settings_store.images(db)
+ settings_store.update(
+ db,
+ {
+ "enabled": enabled,
+ "base_url": base_url.strip().rstrip("/"),
+ "api_key_encrypted": keep_or_replace(
+ api_key, current.get("api_key_encrypted") or ""
+ ),
+ "timeout": min(max(timeout, 10.0), 3600.0),
+ "checkpoints": _lines(checkpoints),
+ "default_workflow_id": default_workflow_id.strip(),
+ "review_enabled": review_enabled,
+ "review_model_id": review_model_id.strip(),
+ "max_tries": min(max(max_tries, 1), 10),
+ "preserve_vram": preserve_vram,
+ "instructions": instructions.strip()[:4000],
+ },
+ key=settings_store.IMAGES,
+ )
+ log.info("image generation %s by %s", "enabled" if enabled else "disabled", user.email)
+ return RedirectResponse(
+ "/admin/images?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
+ )
+
+
+@router.post("/test")
+async def test_images(request: Request, db: Db, user: AdminUser) -> Response:
+ """Ask ComfyUI what it can do, and remember the answer.
+
+ Against the *saved* settings rather than the unsaved form, so what is tested
+ is what a chat would actually reach -- the same rule `/admin/search/test`
+ follows.
+
+ The lists are stored rather than only shown, because the request path may
+ never ask ComfyUI anything: `harness.context_variables` is synchronous and
+ the tool schema is built per request, so both read what this button wrote.
+ """
+ config = _config(db)
+ if not config.configured:
+ return render(
+ request,
+ "admin/_images_result.html",
+ {"message": "Set a base URL first.", "message_kind": "error"},
+ )
+ try:
+ checkpoints, samplers, schedulers = await comfy.discover(config)
+ except LLMError as exc:
+ return render(
+ request,
+ "admin/_images_result.html",
+ {"message": exc.message, "message_kind": "error"},
+ )
+
+ stored = settings_store.images(db)
+ changes: dict = {"samplers": samplers, "schedulers": schedulers}
+ # The checkpoint list is filled in only when nobody has one yet, for the
+ # reason a refreshed connection does not overwrite a context length an
+ # administrator typed: they are usually narrowing it deliberately.
+ if not stored.get("checkpoints"):
+ changes["checkpoints"] = checkpoints
+ settings_store.update(db, changes, key=settings_store.IMAGES)
+
+ found = (
+ f"Found {len(checkpoints)} checkpoint{'' if len(checkpoints) == 1 else 's'}, "
+ f"{len(samplers)} samplers and {len(schedulers)} schedulers."
+ )
+ return render(
+ request,
+ "admin/_images_result.html",
+ {
+ "message": found,
+ "message_kind": "success",
+ "checkpoints": checkpoints,
+ "kept": bool(stored.get("checkpoints")),
+ },
+ )
+
+
+# --- Workflows -----------------------------------------------------------------
+def _workflow(db: Db, workflow_id: str) -> ImageWorkflow:
+ row = db.get(ImageWorkflow, workflow_id)
+ if row is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That workflow no longer exists.")
+ return row
+
+
+def _detail(request: Request, row: ImageWorkflow, *, is_new: bool, error: str = "", **extra):
+ return render(
+ request,
+ "admin/workflow_detail.html",
+ {
+ "workflow": row,
+ "is_new": is_new,
+ "error": error,
+ "placeholders": workflow_service.PLACEHOLDERS,
+ "workflow_text": extra.pop(
+ "workflow_text", json.dumps(row.workflow_json or {}, indent=2)
+ ),
+ **extra,
+ },
+ )
+
+
+def _populate(row: ImageWorkflow, form) -> None:
+ row.name = str(form.get("name") or "").strip()[:120]
+ row.description = str(form.get("description") or "").strip()[:2000]
+ row.enabled = "enabled" in form
+
+
+def _problem(db: Db, row: ImageWorkflow, form, *, existing_id: str = "") -> str:
+ """Why this cannot be saved, or an empty string.
+
+ A sentence rather than a 422, so a rejected save re-renders the form with
+ what was typed still in it -- losing forty lines of JSON to a validation
+ error is not a thing to do to somebody.
+ """
+ if not row.name:
+ return "A workflow needs a name."
+
+ slug = str(form.get("slug") or "").strip().lower()
+ if not SLUG_PATTERN.match(slug):
+ return (
+ "The name the model uses must be lowercase letters, digits, "
+ "hyphens or underscores, and start with a letter or digit."
+ )
+ clash = db.scalar(select(ImageWorkflow).where(ImageWorkflow.slug == slug))
+ if clash is not None and clash.id != existing_id:
+ return f"There is already a workflow called “{slug}”."
+ row.slug = slug
+
+ raw = str(form.get("workflow") or "").strip()
+ if not raw:
+ return "Paste the workflow, in ComfyUI's API format."
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ return f"That is not valid JSON: {exc}"
+ if not isinstance(parsed, dict) or not parsed:
+ return (
+ "A ComfyUI API workflow is a JSON object keyed by node id. Use "
+ "“Export (API)” in ComfyUI rather than “Save”."
+ )
+
+ # The check worth having: a workflow with no {{prompt}} in it draws the same
+ # picture whatever anybody types, and would look like a broken model rather
+ # than an unparameterised template.
+ found = workflow_service.placeholders_in(parsed)
+ missing = [name for name in REQUIRED_PLACEHOLDERS if name not in found]
+ if missing:
+ return (
+ f"The workflow never uses {{{{{missing[0]}}}}}, so every image would be "
+ f"the same. Put it where the text prompt goes."
+ )
+ unknown = found - set(workflow_service.PLACEHOLDERS)
+ if unknown:
+ return f"Unknown placeholder {{{{{sorted(unknown)[0]}}}}}."
+
+ row.workflow_json = parsed
+ return ""
+
+
+@router.get("/workflows/new")
+async def new_workflow(request: Request, db: Db, user: AdminUser) -> Response:
+ """A draft, never persisted -- the `admin_tools` shape.
+
+ Registered before `/workflows/{workflow_id}`: FastAPI matches in
+ registration order, and with the parameterised route first "new" is an id.
+ """
+ from pathlib import Path
+
+ base = Path(__file__).resolve().parent.parent / "services/images/base_workflow.json"
+ draft = ImageWorkflow(
+ slug="",
+ name="",
+ description="",
+ workflow_json=json.loads(base.read_text(encoding="utf-8")),
+ enabled=True,
+ )
+ return _detail(request, draft, is_new=True)
+
+
+@router.post("/workflows")
+async def create_workflow(request: Request, db: Db, user: AdminUser) -> Response:
+ form = await request.form()
+ row = ImageWorkflow(workflow_json={})
+ _populate(row, form)
+ problem = _problem(db, row, form)
+ if problem:
+ return _detail(
+ request,
+ row,
+ is_new=True,
+ error=problem,
+ workflow_text=str(form.get("workflow") or ""),
+ )
+ row.position = (
+ db.scalar(select(func.coalesce(func.max(ImageWorkflow.position), -1))) or -1
+ ) + 1
+ db.add(row)
+ db.commit()
+ log.info("%s added image workflow %s", user.email, row.slug)
+ return RedirectResponse(
+ f"/admin/images?saved=Added {row.name}.", status_code=status.HTTP_303_SEE_OTHER
+ )
+
+
+@router.get("/workflows/{workflow_id}/edit")
+async def edit_workflow(request: Request, db: Db, user: AdminUser, workflow_id: str) -> Response:
+ return _detail(request, _workflow(db, workflow_id), is_new=False)
+
+
+@router.post("/workflows/{workflow_id}/delete")
+async def delete_workflow(db: Db, user: AdminUser, workflow_id: str) -> Response:
+ row = _workflow(db, workflow_id)
+ name = row.name
+ db.delete(row)
+ db.commit()
+ log.info("%s deleted image workflow %s", user.email, name)
+ return RedirectResponse(
+ f"/admin/images?saved=Deleted {name}.", status_code=status.HTTP_303_SEE_OTHER
+ )
+
+
+@router.post("/workflows/{workflow_id}")
+async def update_workflow(request: Request, db: Db, user: AdminUser, workflow_id: str) -> Response:
+ row = _workflow(db, workflow_id)
+ form = await request.form()
+
+ # Validated against a draft, so a rejected save leaves the stored row alone
+ # and the form still holds what was typed.
+ draft = ImageWorkflow(workflow_json={}, position=row.position)
+ _populate(draft, form)
+ problem = _problem(db, draft, form, existing_id=row.id)
+ if problem:
+ draft.id = row.id
+ return _detail(
+ request,
+ draft,
+ is_new=False,
+ error=problem,
+ workflow_text=str(form.get("workflow") or ""),
+ )
+
+ _populate(row, form)
+ row.slug = draft.slug
+ row.workflow_json = draft.workflow_json
+ row.last_checked_at = datetime.now(UTC)
+ row.last_error = ""
+ db.commit()
+ log.info("%s updated image workflow %s", user.email, row.slug)
+ return RedirectResponse(
+ f"/admin/images?saved=Saved {row.name}.", status_code=status.HTTP_303_SEE_OTHER
+ )
diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py
index 9604f3e..6f2d80a 100644
--- a/src/lembas/api/admin_models.py
+++ b/src/lembas/api/admin_models.py
@@ -44,6 +44,7 @@ TOOL_CAPABILITIES = (
("tool_custom", "Custom tools"),
("tool_mcp", "MCP servers"),
("tool_ask", "Ask the reader"),
+ ("tool_image", "Image generation"),
("tool_agent", "Agent execution"),
)
diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py
index 6fa7b53..f6b11a3 100644
--- a/src/lembas/api/chats.py
+++ b/src/lembas/api/chats.py
@@ -67,6 +67,13 @@ METRICS_INTERVAL = 1.0
# better than four hundred rows nobody meant to write.
MAX_QUEUED = 10
+# Which tools a request may compel the model to call. An allow list rather than
+# a passthrough: this becomes `tool_choice`, and a name read straight off a form
+# would let anyone who can send a message decide what the model must do next.
+# Being on this list is not permission to *use* the tool -- `resolve_tools` still
+# decides that, and forcing one that was never offered simply does nothing.
+FORCEABLE_TOOLS = frozenset({"image_generate"})
+
# How many things one chat may have switched off. There are a dozen families and
# sixty skills at most, so this is not a limit anybody reaches by hand -- it is
# there so a crafted POST cannot grow the column without bound.
@@ -816,12 +823,20 @@ async def post_message(
chat_id: str,
content: str = Form(""),
file_ids: list[str] = Form(default=[]),
+ force_tool: str = Form(""),
) -> Response:
"""Persist the user's turn and hand back the pair of bubbles.
The assistant bubble comes back empty, carrying the sse-connect attribute
that opens the stream below. Splitting it this way means the POST returns
immediately and the slow part is a separate, resumable connection.
+
+ `force_tool` is `/image` and nothing else. It is checked against a fixed
+ list rather than passed through: this ends up in `tool_choice`, and a name
+ taken from a form would let anybody who can send a message pick which tool
+ the model is compelled to call. Whether that tool is *offered* is still
+ decided by `resolve_tools`, so this can only ever narrow to something the
+ chat was already allowed.
"""
chat = _owned_chat(db, chat_id, user.id)
@@ -831,7 +846,8 @@ async def post_message(
if not content and not file_ids:
return Response(status_code=status.HTTP_204_NO_CONTENT)
- return _send(request, db, chat, user, content, file_ids=file_ids)
+ forced = force_tool.strip() if force_tool.strip() in FORCEABLE_TOOLS else ""
+ return _send(request, db, chat, user, content, file_ids=file_ids, force_tool=forced)
def _reply_in_flight(db: DBSession, chat: Chat) -> bool:
@@ -871,6 +887,7 @@ def _send(
content: str,
*,
file_ids: list[str] | None = None,
+ force_tool: str = "",
) -> Response:
"""Write a turn, start the reply, and hand back the pair of bubbles.
@@ -927,7 +944,7 @@ def _send(
assistant_message = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
- generation_service.ensure(chat.id, assistant_message.id)
+ generation_service.ensure(chat.id, assistant_message.id, force_tool=force_tool)
# `user` is required by the shared message template, which renders both
# roles; without it the user bubble's initial blows up.
diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py
index df9c91e..b59f167 100644
--- a/src/lembas/db/models/__init__.py
+++ b/src/lembas/db/models/__init__.py
@@ -32,6 +32,7 @@ from lembas.db.models.chat import (
Message,
)
from lembas.db.models.connection import Connection, Model, model_groups
+from lembas.db.models.image import ImageWorkflow
from lembas.db.models.library import (
AUTHOR_MODEL,
AUTHOR_USER,
@@ -119,6 +120,7 @@ __all__ = [
"Document",
"Folder",
"Group",
+ "ImageWorkflow",
"KnowledgeBase",
"McpServer",
"Memory",
diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py
index badb928..54b0098 100644
--- a/src/lembas/db/models/chat.py
+++ b/src/lembas/db/models/chat.py
@@ -218,6 +218,19 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
# representations of "on" makes "why is this off?" unanswerable.
scope_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+ # What this chat generates pictures with when the model names neither. A
+ # preference rather than a constraint -- the model may still choose another
+ # template or checkpoint for a particular image, and the harness lists what
+ # is on offer -- so this is where "in this chat I am working in SDXL" is
+ # said once instead of in every prompt.
+ #
+ # Plain columns rather than keys in `scope_json`: that one narrows what a
+ # chat may *reach* and absent means on, which is the opposite of what an
+ # empty default here means. A workflow that has since been deleted reads
+ # back as no preference, so it is validated on use like `ssh_profile_id`.
+ image_workflow_id: Mapped[str | None] = mapped_column(String(32))
+ image_checkpoint: Mapped[str] = mapped_column(String(300), default="")
+
# Which files are open in the canvas panel, and which of them is in front.
# {"tabs": [{"key": "agent:/srv/app/main.py", "title": …, "source": …}],
# "active": "agent:/srv/app/main.py"}
diff --git a/src/lembas/db/models/connection.py b/src/lembas/db/models/connection.py
index 0e0b7ab..c91c8f9 100644
--- a/src/lembas/db/models/connection.py
+++ b/src/lembas/db/models/connection.py
@@ -58,6 +58,18 @@ class Connection(UUIDPrimaryKey, Timestamps, Base):
# Extra headers merged into every request (e.g. OpenRouter's HTTP-Referer).
extra_headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+ # How to ask this endpoint to drop its model from memory, for the Preserve
+ # VRAM option in image generation. Per connection and not instance-wide,
+ # because the VRAM being freed is a particular machine's: llama-swap on this
+ # host answers `GET /unload`, while a remote vLLM has no such call and no
+ # reason to be unloaded when ComfyUI needs memory *here*.
+ #
+ # Empty means "this connection cannot be unloaded", which is the honest
+ # default -- there is no call that works everywhere, and guessing one would
+ # send an unexplained request to somebody's endpoint.
+ unload_url: Mapped[str] = mapped_column(String(500), default="")
+ unload_method: Mapped[str] = mapped_column(String(8), default="POST")
+
# Result of the most recent "Test & refresh", surfaced in the admin list.
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
diff --git a/src/lembas/db/models/image.py b/src/lembas/db/models/image.py
new file mode 100644
index 0000000..8f65a16
--- /dev/null
+++ b/src/lembas/db/models/image.py
@@ -0,0 +1,60 @@
+"""ComfyUI workflow templates an administrator saved.
+
+A table rather than a list inside the settings group, for the reason
+`McpServer.tools_json` is *not* a table: that one is a cache of somebody else's
+document, replaced wholesale on every refresh, where each entry carries one
+decision. These are the opposite -- authored by hand, individually named,
+edited, reordered and deleted, and referenced by id from a chat. Everything a
+table gives for free is exactly what is wanted.
+
+Deliberately **no group access list**, unlike `CustomTool`. The whole feature is
+already behind one capability flag and one permission; a second access system
+covering which templates a person may pick would be a screen of checkboxes
+nobody asked for, and the thing being restricted is the shape of a picture.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import Boolean, DateTime, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
+from lembas.db.types import JSONDict
+
+
+class ImageWorkflow(UUIDPrimaryKey, Timestamps, Base):
+ """One API-format ComfyUI workflow, with holes where the values go."""
+
+ __tablename__ = "image_workflows"
+
+ # What the *model* names when it picks this one, so it is short and
+ # lowercase for the same reason a tool's slug is: it lands in a schema enum
+ # and is generated by something that spells inconsistently.
+ slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+ name: Mapped[str] = mapped_column(String(120), nullable=False)
+
+ # Sent to the model beside the slug, and the only thing it has to choose
+ # with. "Photographic, SDXL, slow" is a choice; "workflow 2" is not.
+ description: Mapped[str] = mapped_column(Text, default="")
+
+ # The workflow itself, in ComfyUI's API format, with `{{placeholders}}`
+ # where the parameters go. Stored parsed rather than as text so the admin
+ # form can only ever save something that is valid JSON -- a template that
+ # does not parse would fail at generation time, minutes later, in front of
+ # somebody who was not editing it.
+ workflow_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+
+ # The result of the last time somebody pressed Test, in the shape
+ # `CustomTool` and `McpServer` already use, so the row reads the same way in
+ # the list as theirs do.
+ last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ last_error: Mapped[str] = mapped_column(Text, default="")
+
+ def __repr__(self) -> str:
+ return f"
+ Only used by image generation's Preserve VRAM, to free this endpoint's
+ memory while ComfyUI works. llama-swap answers GET /unload.
+ Leave it empty for anything on another machine — its memory is not the
+ memory being freed, and it would be an unexplained request.
+
+ {% if kept %} + Your list above was left alone. What ComfyUI has: + {% else %} + Filled in above: + {% endif %} +
+{{ checkpoints | join("\n") }}
+{% endif %}
diff --git a/src/lembas/web/templates/admin/_layout.html b/src/lembas/web/templates/admin/_layout.html
index 3a02485..34d9bc4 100644
--- a/src/lembas/web/templates/admin/_layout.html
+++ b/src/lembas/web/templates/admin/_layout.html
@@ -43,6 +43,10 @@
{{ icon("globe", "icon--sm") }}
+
+ {{ icon("image", "icon--sm") }}
+
+
{{ icon("link", "icon--sm") }}
diff --git a/src/lembas/web/templates/admin/images.html b/src/lembas/web/templates/admin/images.html
new file mode 100644
index 0000000..c37f007
--- /dev/null
+++ b/src/lembas/web/templates/admin/images.html
@@ -0,0 +1,274 @@
+{% extends "admin/_layout.html" %}
+{% from "_macros.html" import icon %}
+{% set section = "images" %}
+
+{% block title %}Image generation - LLeMbas{% endblock %}
+{% block heading %}Image generation{% endblock %}
+
+{% block admin_content %}
++ Lets a model draw a picture and show it in the conversation, on a ComfyUI you + are running. It is offered as a tool the model chooses to call, so nothing + changes for a conversation that never asks for one — and it is only offered + once there is a ComfyUI, a workflow and at least one checkpoint, because a + tool that fails on its first call is worse than a tool nobody was given. +
+ +{% if saved %} +
+ A workflow is a ComfyUI graph exported with Export (API), with
+ {{ '{{prompt}}' }} and the other placeholders where the values go.
+ The model picks between them by their descriptions, so write the description
+ for a reader who cannot see the graph.
+
+ No workflows yet. One is needed before anything can be drawn; the default + one is filled in for you when you add the first. +
+{{ item.slug }}
+ {% if item.description %}
+ {{ item.description }}
+ {% endif %} +{{ event.error }}
- {% elif not event.results and not event.text %} + {% elif not event.results and not event.text and not event.image %}Nothing was found.
{% endif %} + {% if event.image and event.image.id %} + {# + What was drawn, in the block that drew it. The picture is also an + attachment on this bubble, so the reader sees it without opening + anything; this copy is what makes the block make sense on its own once + it is expanded, beside the seed and the attempts. + + The address is built here from an id and is never taken from the event. + That is the same rule the result links a few lines down follow, and it + matters more here: an event is a dict a runner wrote, and a `src` taken + from one would be a model-supplied URL fetched by the reader's browser. + `/api/files/{id}/content` is owner-checked on the way out. + #} + +{{ event.text }}
{% endif %}
diff --git a/tests/test_admin_images.py b/tests/test_admin_images.py
new file mode 100644
index 0000000..549e4c9
--- /dev/null
+++ b/tests/test_admin_images.py
@@ -0,0 +1,272 @@
+"""The image generation admin page, and the workflows behind it."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+
+import lembas
+from lembas.db.models import ImageWorkflow
+from lembas.services import settings_store
+from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt
+
+BASE_TEXT = (Path(lembas.__file__).parent / "services/images/base_workflow.json").read_text(
+ encoding="utf-8"
+)
+BASE = json.loads(BASE_TEXT)
+
+
+@pytest.fixture
+def admin(client: TestClient, db, registered):
+ from lembas.db.models import User
+
+ db.query(User).update({"role": "admin"})
+ db.commit()
+ return client
+
+
+def _settings(client, **overrides):
+ data = {
+ "enabled": "true",
+ "base_url": "http://comfy.test:8188",
+ "api_key": "",
+ "timeout": "600",
+ "checkpoints": "sd.safetensors\nother.safetensors",
+ "default_workflow_id": "",
+ "review_enabled": "true",
+ "review_model_id": "",
+ "max_tries": "4",
+ "preserve_vram": "",
+ "instructions": "",
+ }
+ data.update(overrides)
+ return client.post("/admin/images", data=data, follow_redirects=False)
+
+
+# --- The page ------------------------------------------------------------------
+def test_the_page_renders_and_is_in_the_nav(admin):
+ page = admin.get("/admin/images").text
+ assert 'href="/admin/images"' in page
+ assert "ComfyUI" in page
+
+
+def test_settings_are_saved(admin, db):
+ assert _settings(admin).status_code == 303
+
+ values = settings_store.images(db)
+ assert values["enabled"] is True
+ assert values["base_url"] == "http://comfy.test:8188"
+ assert values["checkpoints"] == ["sd.safetensors", "other.safetensors"]
+ assert values["review_enabled"] is True
+
+
+def test_a_trailing_slash_is_stripped_from_the_url(admin, db):
+ _settings(admin, base_url="http://comfy.test:8188/")
+ assert settings_store.images(db)["base_url"] == "http://comfy.test:8188"
+
+
+def test_an_absent_checkbox_is_off(admin, db):
+ """An unticked box is simply missing from a form post -- that absence *is*
+ the off signal."""
+ data = {"base_url": "http://x", "timeout": "600", "max_tries": "4"}
+ admin.post("/admin/images", data=data, follow_redirects=False)
+ values = settings_store.images(db)
+ assert values["enabled"] is False
+ assert values["review_enabled"] is False
+ assert values["preserve_vram"] is False
+
+
+def test_the_numbers_are_clamped(admin, db):
+ _settings(admin, max_tries="500", timeout="1")
+ values = settings_store.images(db)
+ assert values["max_tries"] == 10
+ assert values["timeout"] == 10.0
+
+
+def test_max_tries_never_reaches_zero(admin, db):
+ """Zero would mean the tool generates nothing and reports success. Unlike
+ the zeroes elsewhere in the settings, there is no reading of it anybody
+ wants."""
+ _settings(admin, max_tries="0")
+ assert settings_store.images(db)["max_tries"] == 1
+
+
+def test_the_key_survives_a_save_that_did_not_touch_it(admin, db):
+ _settings(admin, api_key="secret-key")
+ assert decrypt(settings_store.images(db)["api_key_encrypted"]) == "secret-key"
+
+ _settings(admin, api_key=UNCHANGED_SENTINEL, instructions="something else")
+ assert decrypt(settings_store.images(db)["api_key_encrypted"]) == "secret-key"
+
+
+def test_an_emptied_key_is_removed(admin, db):
+ _settings(admin, api_key="secret-key")
+ _settings(admin, api_key="")
+ assert settings_store.images(db)["api_key_encrypted"] == ""
+
+
+def test_the_key_is_never_rendered(admin, db):
+ _settings(admin, api_key="secret-key")
+ assert "secret-key" not in admin.get("/admin/images").text
+
+
+def test_testing_without_a_url_says_so(admin, db):
+ _settings(admin, base_url="")
+ assert "Set a base URL first" in admin.post("/admin/images/test").text
+
+
+# --- Workflows -----------------------------------------------------------------
+def test_new_is_not_read_as_an_id(admin):
+ """FastAPI matches in registration order, so `/workflows/new` has to be
+ registered before `/workflows/{id}` or "new" is an id and 404s. This has
+ been a bug twice in this codebase."""
+ response = admin.get("/admin/images/workflows/new")
+ assert response.status_code == 200
+ assert "Export (API)" in response.text
+
+
+def test_the_default_workflow_is_offered_to_start_from(admin):
+ """Somebody setting this up for the first time should not have to find a
+ working API-format document before they can try anything."""
+ page = admin.get("/admin/images/workflows/new").text
+ assert "CheckpointLoaderSimple" in page
+ assert "{{prompt}}" in page
+
+
+def test_a_workflow_is_created(admin, db):
+ response = admin.post(
+ "/admin/images/workflows",
+ data={
+ "slug": "sdxl",
+ "name": "SDXL",
+ "description": "photo",
+ "workflow": BASE_TEXT,
+ "enabled": "true",
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+
+ row = db.scalar(select(ImageWorkflow).where(ImageWorkflow.slug == "sdxl"))
+ assert row is not None
+ assert row.workflow_json["4"]["inputs"]["ckpt_name"] == "{{model}}"
+
+
+def test_broken_json_is_refused_with_a_sentence(admin, db):
+ """A sentence and not a 422: losing forty lines of pasted JSON to a
+ validation error is not a thing to do to somebody, so the form comes back
+ with what was typed still in it."""
+ response = admin.post(
+ "/admin/images/workflows",
+ data={"slug": "x", "name": "X", "workflow": "{not json"},
+ )
+ assert response.status_code == 200
+ assert "not valid JSON" in response.text
+ assert "{not json" in response.text, "and what was typed is still there"
+ assert db.scalar(select(ImageWorkflow)) is None
+
+
+def test_a_workflow_with_no_prompt_placeholder_is_refused(admin, db):
+ """It would draw the same picture whatever anybody typed, and would look
+ like a broken model rather than an unparameterised template."""
+ without = json.loads(BASE_TEXT)
+ without["6"]["inputs"]["text"] = "a fixed prompt"
+ response = admin.post(
+ "/admin/images/workflows",
+ data={"slug": "x", "name": "X", "workflow": json.dumps(without)},
+ )
+ assert "{{prompt}}" in response.text
+ assert "every image would be the same" in response.text
+ assert db.scalar(select(ImageWorkflow)) is None
+
+
+def test_an_unknown_placeholder_is_refused(admin, db):
+ response = admin.post(
+ "/admin/images/workflows",
+ data={
+ "slug": "x",
+ "name": "X",
+ "workflow": json.dumps({"1": {"a": "{{prompt}} {{lora}}"}}),
+ },
+ )
+ assert "lora" in response.text
+ assert db.scalar(select(ImageWorkflow)) is None
+
+
+def test_a_ui_format_export_is_refused_helpfully(admin, db):
+ """The two ComfyUI formats look alike enough that this is the mistake
+ everybody makes first, and "invalid" would not tell them which button to
+ press instead."""
+ response = admin.post(
+ "/admin/images/workflows",
+ data={"slug": "x", "name": "X", "workflow": "[]"},
+ )
+ assert "Export (API)" in response.text
+
+
+def test_a_duplicate_slug_is_refused(admin, db):
+ for _ in range(2):
+ response = admin.post(
+ "/admin/images/workflows",
+ data={"slug": "dup", "name": "Dup", "workflow": BASE_TEXT},
+ )
+ assert "already a workflow" in response.text
+ assert len(list(db.scalars(select(ImageWorkflow)))) == 1
+
+
+def test_a_rejected_edit_leaves_the_stored_row_alone(admin, db):
+ admin.post(
+ "/admin/images/workflows",
+ data={"slug": "keep", "name": "Keep", "workflow": BASE_TEXT},
+ )
+ row = db.scalar(select(ImageWorkflow))
+
+ admin.post(
+ f"/admin/images/workflows/{row.id}",
+ data={"slug": "keep", "name": "Renamed", "workflow": "{broken"},
+ )
+
+ db.expire_all()
+ row = db.scalar(select(ImageWorkflow))
+ assert row.name == "Keep", "validated against a draft, so nothing was written"
+
+
+def test_a_workflow_is_deleted(admin, db):
+ admin.post(
+ "/admin/images/workflows",
+ data={"slug": "gone", "name": "Gone", "workflow": BASE_TEXT},
+ )
+ row = db.scalar(select(ImageWorkflow))
+ admin.post(f"/admin/images/workflows/{row.id}/delete", follow_redirects=False)
+
+ db.expire_all()
+ assert db.scalar(select(ImageWorkflow)) is None
+
+
+def test_only_an_administrator_can_reach_it(client: TestClient, db, registered):
+ """The first account registered is an administrator, so this needs a second
+ one -- the `test_admin_tools` fixture's reason for existing."""
+ from lembas.db.models import User
+
+ client.post("/auth/logout")
+ client.post(
+ "/auth/register",
+ data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
+ follow_redirects=False,
+ )
+ user = db.scalar(select(User).where(User.email == "sam@shire.test"))
+ user.role = "user"
+ user.active = True
+ db.commit()
+ client.post(
+ "/auth/login",
+ data={"email": "sam@shire.test", "password": "correct horse battery"},
+ follow_redirects=False,
+ )
+
+ assert client.get("/admin/images").status_code in (302, 303, 403, 404)
+ assert client.post("/admin/images/workflows", data={}).status_code in (302, 303, 403, 404)
diff --git a/tests/test_canvas_stream.py b/tests/test_canvas_stream.py
index 0905989..2383349 100644
--- a/tests/test_canvas_stream.py
+++ b/tests/test_canvas_stream.py
@@ -200,6 +200,41 @@ def test_scratch_write_opens_its_tab(db, user_id):
assert tool.risk == "read"
+async def test_scratch_write_actually_runs(db, user_id):
+ """It did not, for the whole life of the feature.
+
+ The runner read `context.chat_id` off a `ToolContext` that had no such
+ field, so every call raised `AttributeError` -- swallowed by `run_tool`'s
+ blanket except into "the scratch_write tool failed", which is
+ indistinguishable from a model calling it wrongly. Nothing exercised the
+ runner: the test above asserts the family and the risk, which are
+ attributes of the declaration rather than of the code.
+ """
+ from lembas.db.models import Chat
+ from lembas.services.tools import REGISTRY, ToolContext
+
+ chat = Chat(user_id=user_id, model_id="m")
+ db.add(chat)
+ db.commit()
+
+ outcome = await REGISTRY["scratch_write"].run(
+ ToolContext(owner_id=user_id, chat_id=chat.id), {"text": "hello", "mode": "replace"}
+ )
+
+ assert outcome.event["status"] == "ok"
+ assert outcome.event.get("canvas", {}).get("source") == "scratch"
+
+
+async def test_scratch_write_without_a_chat_says_so(db, user_id):
+ """Which is what the `chat_id` check was always for."""
+ from lembas.services.tools import REGISTRY, ToolContext
+
+ outcome = await REGISTRY["scratch_write"].run(
+ ToolContext(owner_id=user_id), {"text": "hello"}
+ )
+ assert outcome.event["status"] == "error"
+
+
def test_the_event_survives_into_the_stored_transcript():
"""Harmless and mildly useful: `_tool_activity.html` reads named keys."""
event = {"name": "file_read", "canvas": {"key": "agent:/a.py"}}
diff --git a/tests/test_images_chat.py b/tests/test_images_chat.py
new file mode 100644
index 0000000..75350c1
--- /dev/null
+++ b/tests/test_images_chat.py
@@ -0,0 +1,297 @@
+"""Image generation where it meets the conversation.
+
+Three seams, each of which fails silently if it is got wrong: how a generated
+image is bound to the reply that made it, why it is never replayed on an
+assistant turn, and what `/image` actually sends.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+
+import lembas
+from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Attachment, Chat, Connection, Message, Model
+from lembas.services import chat as chat_service
+from lembas.services import files as files_service
+from lembas.services import generation as generation_service
+from lembas.services.crypto import encrypt
+
+PNG = (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
+ b"\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U\x00\x00\x00"
+ b"\x00IEND\xaeB`\x82"
+)
+
+
+@pytest.fixture
+def vision_chat(db, user_id):
+ connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
+ db.add(connection)
+ db.commit()
+ db.add(
+ Model(
+ connection_id=connection.id,
+ model_id="m",
+ capabilities_json={"vision": True, "tools": True},
+ )
+ )
+ chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
+ db.add(chat)
+ db.commit()
+ return chat
+
+
+# --- Storing what was drawn ----------------------------------------------------
+def test_a_generated_image_is_kept_as_it_arrived(db, user_id, vision_chat):
+ """`_process_image` transcodes to JPEG q85 and downscales to 1400px, which
+ is right for a phone photo and a visible loss on the one output this feature
+ exists to produce."""
+ attachment = files_service.store(
+ db,
+ user_id=user_id,
+ chat_id=vision_chat.id,
+ payload=PNG,
+ filename="out.png",
+ keep_original=True,
+ )
+
+ assert attachment.media_type == "image/png"
+ assert files_service.stored_path(attachment.stored_name).read_bytes() == PNG
+
+
+def test_an_ordinary_upload_is_still_processed(db, user_id, vision_chat):
+ """The flag is opt-in, and the protection it skips still applies to
+ everything that arrives from outside."""
+ attachment = files_service.store(
+ db, user_id=user_id, chat_id=vision_chat.id, payload=PNG, filename="photo.png"
+ )
+ assert attachment.media_type == "image/jpeg"
+
+
+def test_a_corrupt_image_is_still_refused(db, user_id, vision_chat):
+ """What `keep_original` skips is the resize and the transcode, not the
+ check that this is an image at all."""
+ with pytest.raises(files_service.FileError):
+ files_service.store(
+ db,
+ user_id=user_id,
+ chat_id=vision_chat.id,
+ payload=b"\x89PNG\r\n\x1a\n" + b"rubbish",
+ filename="broken.png",
+ keep_original=True,
+ )
+
+
+def test_the_loop_binds_the_image_to_the_reply(db, user_id, vision_chat):
+ """A runner cannot write the message row -- `_persist` is the single writer
+ -- so the runner makes the attachment and the loop says which turn owns it,
+ exactly as it already does for a canvas tab."""
+ message = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "here it is")
+ attachment = files_service.store(
+ db, user_id=user_id, chat_id=vision_chat.id, payload=PNG, filename="a.png"
+ )
+
+ generation_service._bind_attachments(db, vision_chat, message, [attachment.id])
+ db.commit()
+
+ db.refresh(attachment)
+ assert attachment.message_id == message.id
+
+
+def test_binding_refuses_a_row_from_another_chat(db, user_id, vision_chat):
+ """The ids arrive on a tool event, which is a dict a runner built. Without
+ the narrowing a forged one would pull somebody else's file into this
+ conversation -- the reason `files.claim` is scoped the same way."""
+ other = Chat(user_id=user_id, model_id="m")
+ db.add(other)
+ db.commit()
+ elsewhere = files_service.store(
+ db, user_id=user_id, chat_id=other.id, payload=PNG, filename="a.png"
+ )
+ message = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "x")
+
+ generation_service._bind_attachments(db, vision_chat, message, [elsewhere.id])
+ db.commit()
+
+ db.refresh(elsewhere)
+ assert elsewhere.message_id is None
+
+
+def test_binding_refuses_a_row_already_bound(db, user_id, vision_chat):
+ first = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "one")
+ second = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "two")
+ attachment = files_service.store(
+ db,
+ user_id=user_id,
+ chat_id=vision_chat.id,
+ payload=PNG,
+ filename="a.png",
+ message_id=first.id,
+ )
+
+ generation_service._bind_attachments(db, vision_chat, second, [attachment.id])
+ db.commit()
+
+ db.refresh(attachment)
+ assert attachment.message_id == first.id
+
+
+# --- What reaches the model ----------------------------------------------------
+def test_an_image_on_an_assistant_turn_is_never_replayed(db, user_id, vision_chat):
+ """The multimodal list form on an `assistant` turn is rejected outright by
+ OpenAI and by most local runners -- and it would break not that turn but
+ every later one in the chat."""
+ reply = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "here it is")
+ files_service.store(
+ db,
+ user_id=user_id,
+ chat_id=vision_chat.id,
+ payload=PNG,
+ filename="a.png",
+ message_id=reply.id,
+ )
+ db.refresh(reply)
+
+ payload = chat_service.message_payload(reply, vision=True)
+
+ assert isinstance(payload["content"], str), "no image_url parts on an assistant turn"
+ assert payload["content"] == "here it is"
+
+
+def test_an_image_a_person_sent_still_reaches_the_model(db, user_id, vision_chat):
+ """The rule narrows assistant turns and nothing else."""
+ turn = chat_service.create_message(db, vision_chat, ROLE_USER, "what is this?")
+ files_service.store(
+ db,
+ user_id=user_id,
+ chat_id=vision_chat.id,
+ payload=PNG,
+ filename="a.png",
+ message_id=turn.id,
+ )
+ db.refresh(turn)
+
+ payload = chat_service.message_payload(turn, vision=True)
+
+ assert isinstance(payload["content"], list)
+ assert any(part["type"] == "image_url" for part in payload["content"])
+
+
+def test_a_generated_image_still_renders_in_the_bubble(
+ client: TestClient, db, user_id, vision_chat
+):
+ """It is an attachment on the assistant message, and `_message.html` renders
+ attachments for either role -- so the reader sees it without the template
+ learning anything new."""
+ reply = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "here it is")
+ attachment = files_service.store(
+ db,
+ user_id=user_id,
+ chat_id=vision_chat.id,
+ payload=PNG,
+ filename="a.png",
+ message_id=reply.id,
+ )
+
+ page = client.get(f"/chat/{vision_chat.id}").text
+ assert f"/api/files/{attachment.id}/content" in page
+
+
+# --- Forcing the tool ----------------------------------------------------------
+def test_the_forced_tool_reaches_the_request(db, vision_chat):
+ tools = [{"type": "function", "function": {"name": "image_generate", "parameters": {}}}]
+ body = chat_service.build_request(db, vision_chat, tools=tools, force_tool="image_generate")
+
+ assert body["tool_choice"] == {
+ "type": "function",
+ "function": {"name": "image_generate"},
+ }
+
+
+def test_nothing_is_forced_by_default(db, vision_chat):
+ """A provider strict about unknown parameters must see exactly the request
+ it always did until somebody types a slash command."""
+ tools = [{"type": "function", "function": {"name": "image_generate", "parameters": {}}}]
+ assert "tool_choice" not in chat_service.build_request(db, vision_chat, tools=tools)
+
+
+def test_a_tool_that_was_not_offered_cannot_be_forced(db, vision_chat):
+ """`resolve_tools` still decides what exists. Forcing something absent from
+ the array is a request most endpoints reject outright."""
+ tools = [{"type": "function", "function": {"name": "web_search", "parameters": {}}}]
+ body = chat_service.build_request(db, vision_chat, tools=tools, force_tool="image_generate")
+ assert "tool_choice" not in body
+
+
+def test_forcing_needs_a_tools_array_at_all(db, vision_chat):
+ assert "tool_choice" not in chat_service.build_request(
+ db, vision_chat, force_tool="image_generate"
+ )
+
+
+def test_the_endpoint_only_accepts_names_from_the_allow_list(
+ client: TestClient, db, registered, vision_chat, monkeypatch
+):
+ """This becomes `tool_choice`, so a name read straight off a form would let
+ anyone who can send a message decide what the model must do next."""
+ from lembas.api import chats as chats_api
+
+ seen: dict = {}
+ monkeypatch.setattr(
+ chats_api.generation_service,
+ "ensure",
+ lambda chat_id, message_id, *, force_tool="": seen.update(force_tool=force_tool),
+ )
+
+ client.post(
+ f"/api/chats/{vision_chat.id}/messages",
+ data={"content": "hello", "force_tool": "shell_run"},
+ )
+ assert seen["force_tool"] == "", "not on the list, so not forced"
+
+ # That first turn left an unfinished assistant row behind, and a second
+ # message while one is in flight is *queued* rather than sent -- so it would
+ # never reach `ensure` at all. Finish it first.
+ db.query(Message).filter(Message.complete.is_(False)).update({"complete": True})
+ db.commit()
+
+ client.post(
+ f"/api/chats/{vision_chat.id}/messages",
+ data={"content": "a bicycle", "force_tool": "image_generate"},
+ )
+ assert seen["force_tool"] == "image_generate"
+
+
+def test_the_image_command_is_in_the_table():
+ """`/help` reads this list, so a command missing from it is one nobody can
+ discover -- the direction this actually rots."""
+ source = (Path(lembas.__file__).parent / "web/static/js/commands.js").read_text(
+ encoding="utf-8"
+ )
+ assert 'name: "image"' in source
+ assert 'body.append("force_tool", "image_generate")' in source
+ assert "htmx.process" in source, "or the reply's sse-connect is inert markup"
+
+
+def test_a_forced_round_does_not_force_the_next_one(db, vision_chat):
+ """Leaving `tool_choice` in place would make every round call the tool
+ again: draw a picture, be asked again, draw another."""
+ source = (Path(lembas.__file__).parent / "services/generation.py").read_text(encoding="utf-8")
+ assert 'payload.pop("tool_choice", None)' in source
+
+
+def test_a_queued_turn_is_not_lost_when_it_was_forced(db, user_id, vision_chat):
+ """`/image` typed while a reply is streaming queues like anything else. The
+ forcing is on the generation, so a queued turn simply arrives unforced --
+ which is right: by then the model has the words and the context."""
+ db.add(Message(chat_id=vision_chat.id, role=ROLE_ASSISTANT, content="", complete=False))
+ db.commit()
+
+ from lembas.api import chats as chats_api
+
+ assert chats_api._reply_in_flight(db, vision_chat) is True
+ assert db.scalar(select(Attachment)) is None
diff --git a/tests/test_images_comfy.py b/tests/test_images_comfy.py
new file mode 100644
index 0000000..4810a0c
--- /dev/null
+++ b/tests/test_images_comfy.py
@@ -0,0 +1,281 @@
+"""The ComfyUI client, against recorded shapes.
+
+Every response body here was read off a real ComfyUI 0.27.0 while this was being
+written, rather than reconstructed from documentation -- including the one that
+matters most, `/history` answering with an empty object while the job is still
+queued.
+"""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from lembas.services.images import comfy
+
+CONFIG = comfy.Config(base_url="http://comfy.test:8188", timeout=5.0)
+
+HISTORY_DONE = {
+ "p1": {
+ "status": {"status_str": "success", "completed": True, "messages": []},
+ "outputs": {
+ "9": {"images": [{"filename": "LLeMbas_00001_.png", "subfolder": "", "type": "output"}]}
+ },
+ }
+}
+
+
+def _handler(routes):
+ """Answer by path, and record what was asked."""
+
+ def handle(request: httpx.Request) -> httpx.Response:
+ for path, responder in routes.items():
+ if request.url.path.startswith(path):
+ return responder(request)
+ return httpx.Response(404, json={"error": "no route"})
+
+ return handle
+
+
+# --- Submitting ----------------------------------------------------------------
+async def test_a_workflow_is_queued_and_its_id_returned(mock_http):
+ seen: dict = {}
+
+ def prompt(request):
+ seen["body"] = request.read().decode()
+ return httpx.Response(200, json={"prompt_id": "p1", "number": 3, "node_errors": {}})
+
+ mock_http(_handler({"/prompt": prompt}))
+
+ assert await comfy.submit(CONFIG, {"3": {"class_type": "KSampler"}}) == "p1"
+ assert "KSampler" in seen["body"]
+ assert "client_id" in seen["body"], "ComfyUI keys its progress socket on this"
+
+
+async def test_a_graph_error_names_the_node(mock_http):
+ """ "Invalid prompt" against a twelve-node document says nothing. The
+ commonest cause by far is a checkpoint that does not exist on that machine,
+ and the node id is what points at it."""
+ mock_http(
+ _handler(
+ {
+ "/prompt": lambda r: httpx.Response(
+ 400,
+ json={
+ "error": {"type": "prompt_outputs_failed_validation"},
+ "node_errors": {
+ "4": {"errors": [{"message": "value not in list: ckpt_name"}]}
+ },
+ },
+ )
+ }
+ )
+ )
+
+ with pytest.raises(comfy.ComfyError) as caught:
+ await comfy.submit(CONFIG, {})
+ assert "node 4" in caught.value.message
+ assert "ckpt_name" in caught.value.message
+
+
+async def test_an_accepted_workflow_with_no_id_is_refused(mock_http):
+ mock_http(_handler({"/prompt": lambda r: httpx.Response(200, json={"number": 1})}))
+ with pytest.raises(comfy.ComfyError):
+ await comfy.submit(CONFIG, {})
+
+
+async def test_an_unreachable_comfyui_says_so(mock_http):
+ def refuse(request):
+ raise httpx.ConnectError("nope", request=request)
+
+ mock_http(_handler({"/prompt": refuse}))
+ with pytest.raises(comfy.ComfyError) as caught:
+ await comfy.submit(CONFIG, {})
+ assert "Could not reach ComfyUI" in caught.value.message
+
+
+# --- Waiting -------------------------------------------------------------------
+async def test_an_empty_history_means_not_yet(mock_http, monkeypatch):
+ """`/history/{id}` is empty while the job is queued and gains the whole
+ record when it ends -- so empty is "not yet", not "nothing". Reading it the
+ other way makes every generation fail the instant it is submitted."""
+ monkeypatch.setattr(comfy, "POLL_INTERVAL", 0.01)
+ calls = {"n": 0}
+
+ def history(request):
+ calls["n"] += 1
+ return httpx.Response(200, json=HISTORY_DONE if calls["n"] >= 3 else {})
+
+ mock_http(_handler({"/history": history}))
+
+ refs = await comfy.await_images(CONFIG, "p1")
+ assert calls["n"] == 3, "it kept asking"
+ assert [ref.filename for ref in refs] == ["LLeMbas_00001_.png"]
+
+
+async def test_waiting_gives_up_eventually(mock_http, monkeypatch):
+ monkeypatch.setattr(comfy, "POLL_INTERVAL", 0.01)
+ mock_http(_handler({"/history": lambda r: httpx.Response(200, json={})}))
+
+ with pytest.raises(comfy.ComfyError) as caught:
+ await comfy.await_images(comfy.Config(base_url=CONFIG.base_url, timeout=0.05), "p1")
+ assert "did not finish" in caught.value.message
+
+
+async def test_a_failed_workflow_is_not_an_empty_one(mock_http, monkeypatch):
+ monkeypatch.setattr(comfy, "POLL_INTERVAL", 0.01)
+ mock_http(
+ _handler(
+ {
+ "/history": lambda r: httpx.Response(
+ 200,
+ json={
+ "p1": {"status": {"status_str": "error", "completed": True}, "outputs": {}}
+ },
+ )
+ }
+ )
+ )
+ with pytest.raises(comfy.ComfyError):
+ await comfy.await_images(CONFIG, "p1")
+
+
+async def test_images_are_read_from_every_node(mock_http, monkeypatch):
+ """A template is somebody else's document and may save from a node called
+ anything, or from two of them. Looking for `SaveImage` by name works on the
+ shipped workflow and on nothing else."""
+ monkeypatch.setattr(comfy, "POLL_INTERVAL", 0.01)
+ mock_http(
+ _handler(
+ {
+ "/history": lambda r: httpx.Response(
+ 200,
+ json={
+ "p1": {
+ "status": {"status_str": "success", "completed": True},
+ "outputs": {
+ "12": {
+ "images": [
+ {"filename": "a.png", "subfolder": "s", "type": "output"}
+ ]
+ },
+ "40": {"images": [{"filename": "b.png"}]},
+ },
+ }
+ },
+ )
+ }
+ )
+ )
+ refs = await comfy.await_images(CONFIG, "p1")
+ assert [r.filename for r in refs] == ["a.png", "b.png"]
+ assert refs[0].subfolder == "s"
+
+
+# --- Fetching ------------------------------------------------------------------
+async def test_the_image_comes_back_as_bytes(mock_http):
+ seen: dict = {}
+
+ def view(request):
+ seen["params"] = dict(request.url.params)
+ return httpx.Response(200, content=b"\x89PNG\r\n\x1a\n" + b"x" * 100)
+
+ mock_http(_handler({"/view": view}))
+
+ payload = await comfy.fetch_image(CONFIG, comfy.Ref("a.png", "sub", "output"))
+ assert payload.startswith(b"\x89PNG")
+ assert seen["params"] == {"filename": "a.png", "subfolder": "sub", "type": "output"}
+
+
+async def test_an_oversized_image_is_refused(mock_http, monkeypatch):
+ """The one place an external service hands back raw bytes that are written
+ to disk. Neither `audio.speak` nor `openai_client` has a cap to copy."""
+ monkeypatch.setattr(comfy, "MAX_IMAGE_BYTES", 64)
+ mock_http(_handler({"/view": lambda r: httpx.Response(200, content=b"x" * 4096)}))
+
+ with pytest.raises(comfy.ComfyError):
+ await comfy.fetch_image(CONFIG, comfy.Ref("a.png"))
+
+
+async def test_an_empty_file_is_refused(mock_http):
+ mock_http(_handler({"/view": lambda r: httpx.Response(200, content=b"")}))
+ with pytest.raises(comfy.ComfyError):
+ await comfy.fetch_image(CONFIG, comfy.Ref("a.png"))
+
+
+# --- Freeing -------------------------------------------------------------------
+async def test_freeing_never_raises(mock_http):
+ """It runs on the way out of a generation that has already produced its
+ image. Failing the whole tool because a memory hint was refused would be
+ turning a tidy-up into an error."""
+
+ def boom(request):
+ raise httpx.ConnectError("gone", request=request)
+
+ mock_http(_handler({"/free": boom}))
+ await comfy.free(CONFIG) # must not raise
+
+
+async def test_freeing_asks_for_both(mock_http):
+ seen: dict = {}
+
+ def free(request):
+ seen["body"] = request.read().decode()
+ return httpx.Response(200, json={})
+
+ mock_http(_handler({"/free": free}))
+ await comfy.free(CONFIG)
+ assert "unload_models" in seen["body"] and "free_memory" in seen["body"]
+
+
+# --- Discovery -----------------------------------------------------------------
+async def test_discovery_reads_the_option_lists(mock_http):
+ """The shape is `{node: {input: {required: {field: [[...values], {meta}]}}}}`
+ -- a list whose first element is the options."""
+ mock_http(
+ _handler(
+ {
+ "/object_info/CheckpointLoaderSimple": lambda r: httpx.Response(
+ 200,
+ json={
+ "CheckpointLoaderSimple": {
+ "input": {
+ "required": {"ckpt_name": [["a.safetensors", "b.safetensors"]]}
+ }
+ }
+ },
+ ),
+ "/object_info/KSampler": lambda r: httpx.Response(
+ 200,
+ json={
+ "KSampler": {
+ "input": {
+ "required": {
+ "sampler_name": [["euler", "dpmpp_2m"]],
+ "scheduler": [["normal", "karras"]],
+ }
+ }
+ }
+ },
+ ),
+ }
+ )
+ )
+
+ checkpoints, samplers, schedulers = await comfy.discover(CONFIG)
+ assert checkpoints == ["a.safetensors", "b.safetensors"]
+ assert samplers == ["euler", "dpmpp_2m"]
+ assert schedulers == ["normal", "karras"]
+
+
+async def test_a_custom_node_pack_that_changes_the_shape_is_survived(mock_http):
+ """It is somebody else's schema. An empty list is a page that says "none
+ found"; an exception is a page that says nothing at all."""
+ mock_http(
+ _handler(
+ {
+ "/object_info": lambda r: httpx.Response(200, json={"Whatever": {"input": {}}}),
+ }
+ )
+ )
+ assert await comfy.discover(CONFIG) == ([], [], [])
diff --git a/tests/test_images_tool.py b/tests/test_images_tool.py
new file mode 100644
index 0000000..b80bb5b
--- /dev/null
+++ b/tests/test_images_tool.py
@@ -0,0 +1,395 @@
+"""Generating an image: what is offered, and what the loop does.
+
+The gate tests matter more than they look. Image generation is the first tool
+whose availability depends on an instance being *configured* as well as
+permitted, and the branch it needs in `_family_allowed` is easy to leave out --
+without it the family falls through to the last line and silently requires
+`library.use`, which has nothing to do with drawing a picture.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+import lembas
+from lembas.db.models import Chat, Connection, ImageWorkflow, Model, User
+from lembas.services import settings_store
+from lembas.services import tools as tools_service
+from lembas.services.crypto import encrypt
+from lembas.services.images import comfy
+from lembas.services.images import tool as image_tool
+from lembas.services.tools import ToolContext
+
+BASE = json.loads(
+ (Path(lembas.__file__).parent / "services/images/base_workflow.json").read_text(
+ encoding="utf-8"
+ )
+)
+PNG = (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
+ b"\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U\x00\x00\x00"
+ b"\x00IEND\xaeB`\x82"
+)
+
+
+@pytest.fixture
+def configured(db, user_id):
+ """An instance that can draw: a ComfyUI, a checkpoint and a workflow."""
+ connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
+ db.add(connection)
+ db.commit()
+ model = Model(
+ connection_id=connection.id,
+ model_id="m",
+ capabilities_json={"tools": True, "tool_image": True},
+ )
+ db.add(model)
+ db.add(ImageWorkflow(slug="base", name="Base", description="general", workflow_json=BASE))
+ db.commit()
+ chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
+ db.add(chat)
+ settings_store.update(
+ db,
+ {
+ "enabled": True,
+ "base_url": "http://comfy.test:8188",
+ "checkpoints": ["sd.safetensors", "other.safetensors"],
+ "review_enabled": False,
+ },
+ key=settings_store.IMAGES,
+ )
+ db.commit()
+ return chat
+
+
+def _context(db, user_id, chat) -> ToolContext:
+ return tools_service.context_for(db, db.get(User, user_id), chat)
+
+
+# --- Whether it is offered at all ----------------------------------------------
+def test_it_is_offered_once_the_instance_can_draw(db, user_id, configured):
+ offered = tools_service.resolve_tools(db, configured, db.get(User, user_id))
+ assert "image_generate" in offered.by_name
+
+
+def test_it_is_withheld_without_a_comfyui(db, user_id, configured):
+ settings_store.update(db, {"base_url": ""}, key=settings_store.IMAGES)
+ assert (
+ "image_generate"
+ not in tools_service.resolve_tools(db, configured, db.get(User, user_id)).by_name
+ )
+
+
+def test_it_is_withheld_with_no_checkpoints(db, user_id, configured):
+ """A model naming a checkpoint that does not exist gets a refusal from
+ ComfyUI and spends a round finding out, so an instance that has listed none
+ should not offer the tool -- the same rule `skill_get` follows for an empty
+ library."""
+ settings_store.update(db, {"checkpoints": []}, key=settings_store.IMAGES)
+ assert (
+ "image_generate"
+ not in tools_service.resolve_tools(db, configured, db.get(User, user_id)).by_name
+ )
+
+
+def test_it_is_withheld_when_the_feature_is_off(db, user_id, configured):
+ settings_store.update(db, {"enabled": False}, key=settings_store.IMAGES)
+ assert (
+ "image_generate"
+ not in tools_service.resolve_tools(db, configured, db.get(User, user_id)).by_name
+ )
+
+
+def test_it_is_withheld_without_the_permission(db, user_id, configured):
+ user = db.get(User, user_id)
+ user.role = "user" # administrators are given every permission
+ settings_store.update(db, {"default_permissions": {"tools.image": False}})
+ db.commit()
+ assert "image_generate" not in tools_service.resolve_tools(db, configured, user).by_name
+
+
+def test_it_does_not_need_the_library_permission(db, user_id, configured):
+ """The trap the explicit branch in `_family_allowed` exists to avoid: with
+ no branch the family falls to the last line and requires `library.use`,
+ which is a coincidence of naming rather than a rule."""
+ user = db.get(User, user_id)
+ user.role = "user"
+ settings_store.update(db, {"default_permissions": {"library.use": False}})
+ db.commit()
+ assert "image_generate" in tools_service.resolve_tools(db, configured, user).by_name
+
+
+def test_the_model_capability_can_withhold_it(db, user_id, configured):
+ model = db.scalar(Model.__table__.select().where(Model.model_id == "m"))
+ db.query(Model).filter(Model.model_id == "m").update(
+ {"capabilities_json": {"tools": True, "tool_image": False}}
+ )
+ db.commit()
+ assert model is not None
+ assert (
+ "image_generate"
+ not in tools_service.resolve_tools(db, configured, db.get(User, user_id)).by_name
+ )
+
+
+def test_a_chat_can_switch_it_off(db, user_id, configured):
+ configured.scope_json = {"families": {"image": False}}
+ db.commit()
+ assert (
+ "image_generate"
+ not in tools_service.resolve_tools(db, configured, db.get(User, user_id)).by_name
+ )
+
+
+def test_the_registry_knows_it_so_the_guidance_applies(db):
+ """`harness._families` maps an offered name back to a family through
+ `registry(db)`. A tool missing from there resolves to no family, and its
+ fragment never appears -- the omission that cost custom tools their guidance
+ once already."""
+ assert tools_service.registry(db)["image_generate"].family == "image"
+ assert "image" in tools_service.families(db)
+
+
+# --- The schema ----------------------------------------------------------------
+def test_the_schema_offers_this_instance_s_own_choices(db, user_id, configured):
+ schema = image_tool.schema_for(db, settings_store.images(db))
+ assert schema["properties"]["model"]["enum"] == ["sd.safetensors", "other.safetensors"]
+ assert schema["properties"]["template"]["enum"] == ["base"]
+ assert "general" in schema["properties"]["template"]["description"]
+
+
+def test_only_the_prompt_is_required_and_it_is_first(db, user_id, configured):
+ """`tools.parse_arguments` puts the raw string into the first *required*
+ parameter when a model emits arguments that are not valid JSON -- common
+ with small models. This way that degrades into a prompt rather than a seed.
+ """
+ schema = image_tool.schema_for(db, settings_store.images(db))
+ assert schema["required"] == ["prompt"]
+ assert next(iter(schema["properties"])) == "prompt"
+
+
+def test_the_samplers_are_not_an_enum(db, user_id, configured):
+ """Forty-four of them, on every request, forever, to prevent a mistake worth
+ one sentence of correction."""
+ schema = image_tool.schema_for(db, settings_store.images(db))
+ assert "enum" not in schema["properties"]["sampler"]
+
+
+# --- The loop ------------------------------------------------------------------
+class FakeComfy:
+ """Stands in for the far side, counting what it was asked to do."""
+
+ def __init__(self):
+ self.submits: list[dict] = []
+ self.frees = 0
+
+ async def submit(self, config, wf):
+ self.submits.append(wf)
+ return f"p{len(self.submits)}"
+
+ async def await_images(self, config, prompt_id):
+ return [comfy.Ref(f"{prompt_id}.png")]
+
+ async def fetch_image(self, config, ref):
+ return PNG
+
+ async def free(self, config):
+ self.frees += 1
+
+
+@pytest.fixture
+def fake(monkeypatch):
+ stub = FakeComfy()
+ for name in ("submit", "await_images", "fetch_image", "free"):
+ monkeypatch.setattr(comfy, name, getattr(stub, name))
+ return stub
+
+
+async def test_one_call_produces_one_stored_image(db, user_id, configured, fake):
+ from lembas.db.models import Attachment
+
+ outcome = await image_tool.run(
+ _context(db, user_id, configured), {"prompt": "a bicycle", "model": "sd.safetensors"}
+ )
+
+ assert outcome.event["status"] == "ok"
+ attachment = db.get(Attachment, outcome.event["attachment_id"])
+ assert attachment is not None
+ assert attachment.media_type == "image/png", "kept as ComfyUI made it, not transcoded"
+ assert attachment.message_id is None, "the loop binds it, not the runner"
+ assert attachment.source_label == "Image generation"
+ assert len(fake.submits) == 1
+
+
+async def test_the_model_is_told_it_is_already_on_screen(db, user_id, configured, fake):
+ """Without this the commonest next thing a model does is offer to show you
+ the image, which it has no way of doing and which has already happened."""
+ outcome = await image_tool.run(_context(db, user_id, configured), {"prompt": "a bicycle"})
+ assert "already" in outcome.content or "is shown to them" in outcome.content
+
+
+async def test_a_prompt_is_required(db, user_id, configured, fake):
+ outcome = await image_tool.run(_context(db, user_id, configured), {"prompt": " "})
+ assert outcome.event["status"] == "error"
+ assert not fake.submits
+
+
+async def test_an_unknown_checkpoint_falls_back_rather_than_failing(db, user_id, configured, fake):
+ """It would reach ComfyUI, be refused, and cost a round to discover -- and
+ the model was shown the list it may choose from."""
+ await image_tool.run(
+ _context(db, user_id, configured), {"prompt": "x", "model": "invented.safetensors"}
+ )
+ assert fake.submits[0]["4"]["inputs"]["ckpt_name"] == "sd.safetensors"
+
+
+async def test_the_chat_s_own_preference_is_used_when_the_model_names_none(
+ db, user_id, configured, fake
+):
+ configured.image_checkpoint = "other.safetensors"
+ db.commit()
+ await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert fake.submits[0]["4"]["inputs"]["ckpt_name"] == "other.safetensors"
+
+
+async def test_a_failure_out_there_is_reported_not_raised(
+ db, user_id, configured, fake, monkeypatch
+):
+ async def refuse(config, wf):
+ raise comfy.ComfyError("ComfyUI refused the workflow — node 4")
+
+ monkeypatch.setattr(comfy, "submit", refuse)
+ outcome = await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert outcome.event["status"] == "error"
+ assert "node 4" in outcome.event["error"]
+
+
+# --- Reviewing -----------------------------------------------------------------
+def _with_review(db, chat, *, tries=3, vision=True):
+ db.query(Model).filter(Model.model_id == "m").update(
+ {"capabilities_json": {"tools": True, "tool_image": True, "vision": vision}}
+ )
+ settings_store.update(
+ db, {"review_enabled": True, "max_tries": tries}, key=settings_store.IMAGES
+ )
+ db.commit()
+
+
+async def test_a_kept_image_stops_the_loop(db, user_id, configured, fake, monkeypatch):
+ _with_review(db, configured)
+ monkeypatch.setattr(image_tool, "_review", _verdicts([(True, "")]))
+
+ await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert len(fake.submits) == 1
+
+
+async def test_a_rejected_image_is_tried_again(db, user_id, configured, fake, monkeypatch):
+ _with_review(db, configured)
+ monkeypatch.setattr(image_tool, "_review", _verdicts([(False, "no bicycle"), (True, "")]))
+
+ outcome = await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert len(fake.submits) == 2
+ assert "no bicycle" in outcome.event["text"]
+ assert "2 attempts" in outcome.content
+
+
+async def test_the_ceiling_is_enforced_and_the_last_one_is_kept(
+ db, user_id, configured, fake, monkeypatch
+):
+ """A request always produces a picture. Returning nothing after four
+ rejections would be spending a minute of somebody's GPU to say no."""
+ _with_review(db, configured, tries=3)
+ monkeypatch.setattr(image_tool, "_review", _verdicts([(False, "nope")] * 5))
+
+ outcome = await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert len(fake.submits) == 3
+ assert outcome.event["status"] == "ok"
+ assert outcome.event["attachment_id"]
+
+
+async def test_each_attempt_gets_a_new_seed(db, user_id, configured, fake, monkeypatch):
+ """Retrying with the same seed regenerates the same rejected image."""
+ _with_review(db, configured, tries=3)
+ monkeypatch.setattr(
+ image_tool, "_review", _verdicts([(False, "no"), (False, "no"), (True, "")])
+ )
+
+ await image_tool.run(_context(db, user_id, configured), {"prompt": "x", "seed": 99})
+ seeds = [wf["3"]["inputs"]["seed"] for wf in fake.submits]
+ assert seeds[0] == 99, "the seed that was asked for is honoured"
+ assert len(set(seeds)) == 3, "and the retries are not the same picture again"
+
+
+async def test_nothing_is_reviewed_without_a_vision_model(db, user_id, configured, fake):
+ """ "You asked for a picture and got an error about vision" is a worse answer
+ than a picture."""
+ _with_review(db, configured, vision=False)
+ outcome = await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert len(fake.submits) == 1
+ assert outcome.event["status"] == "ok"
+
+
+def _verdicts(sequence):
+ calls = {"n": 0}
+
+ async def review(context, endpoint, model_id, prompt, payload):
+ index = min(calls["n"], len(sequence) - 1)
+ calls["n"] += 1
+ return sequence[index]
+
+ return review
+
+
+# --- Preserve VRAM -------------------------------------------------------------
+async def test_nothing_is_unloaded_by_default(db, user_id, configured, fake, monkeypatch):
+ calls = {"n": 0}
+
+ async def unload(context):
+ calls["n"] += 1
+ return True
+
+ monkeypatch.setattr(image_tool, "_unload_llm", unload)
+ await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert calls["n"] == 0
+ assert fake.frees == 0
+
+
+async def test_preserve_vram_unloads_and_frees(db, user_id, configured, fake, monkeypatch):
+ settings_store.update(db, {"preserve_vram": True}, key=settings_store.IMAGES)
+ db.commit()
+ calls = {"n": 0}
+
+ async def unload(context):
+ calls["n"] += 1
+ return True
+
+ monkeypatch.setattr(image_tool, "_unload_llm", unload)
+ await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert calls["n"] == 1, "once, before generating"
+ assert fake.frees >= 1, "and ComfyUI is asked to let go afterwards"
+
+
+async def test_a_connection_with_no_unload_url_is_never_called(db, user_id, configured):
+ """The whole of the per-connection design: a chat talking to a box on the
+ network must not have that box unloaded, because its memory is not the
+ memory ComfyUI wants."""
+ context = _context(db, user_id, configured)
+ assert await image_tool._unload_llm(context) is False
+
+
+async def test_an_unload_that_fails_does_not_stop_the_generation(
+ db, user_id, configured, fake, monkeypatch
+):
+ """It is a hint before a slow operation. A machine that will not answer it is
+ one where the picture should still be drawn."""
+ connection = db.scalar(Connection.__table__.select())
+ db.query(Connection).update({"unload_url": "http://127.0.0.1:9/unload"})
+ settings_store.update(db, {"preserve_vram": True}, key=settings_store.IMAGES)
+ db.commit()
+ assert connection is not None
+
+ outcome = await image_tool.run(_context(db, user_id, configured), {"prompt": "x"})
+ assert outcome.event["status"] == "ok"
diff --git a/tests/test_images_workflow.py b/tests/test_images_workflow.py
new file mode 100644
index 0000000..ed83efa
--- /dev/null
+++ b/tests/test_images_workflow.py
@@ -0,0 +1,122 @@
+"""Filling a ComfyUI template.
+
+The thing worth pinning here is types. ComfyUI validates its inputs, so a
+workflow whose `steps` arrives as the string "20" is refused -- and the refusal
+happens on somebody's machine a minute later rather than in a test.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+import lembas
+from lembas.services.images import workflow as wf
+
+BASE = json.loads(
+ (Path(lembas.__file__).parent / "services/images/base_workflow.json").read_text(
+ encoding="utf-8"
+ )
+)
+
+
+def _filled(**given):
+ return wf.fill(BASE, wf.resolve({"prompt": "a bicycle", **given}))
+
+
+# --- Substitution --------------------------------------------------------------
+def test_a_whole_placeholder_keeps_its_type():
+ """`"steps": "{{steps}}"` has to become the number 20, not the text "20".
+ Substituting textually is the obvious implementation and produces a document
+ ComfyUI refuses."""
+ sampler = _filled(steps=25, cfg=7.5, seed=42)["3"]["inputs"]
+
+ assert sampler["steps"] == 25
+ assert isinstance(sampler["steps"], int)
+ assert sampler["cfg"] == 7.5
+ assert isinstance(sampler["cfg"], float)
+ assert sampler["seed"] == 42
+ assert isinstance(sampler["seed"], int)
+
+
+def test_a_placeholder_inside_a_string_is_text():
+ """Which is what makes `"{{prompt}}, masterpiece"` a usable template."""
+ filled = wf.fill(
+ {"n": {"inputs": {"text": "{{prompt}}, masterpiece"}}}, wf.resolve({"prompt": "a cat"})
+ )
+ assert filled["n"]["inputs"]["text"] == "a cat, masterpiece"
+
+
+def test_the_prompt_and_the_negative_go_to_different_nodes():
+ filled = _filled(negative="blurry")
+ assert filled["6"]["inputs"]["text"] == "a bicycle"
+ assert filled["7"]["inputs"]["text"] == "blurry"
+
+
+def test_the_links_between_nodes_survive():
+ """A workflow is a graph, and `["4", 0]` is an edge rather than a value. A
+ filler that walked only dicts would flatten every one of them."""
+ filled = _filled()
+ assert filled["3"]["inputs"]["model"] == ["4", 0]
+ assert filled["8"]["inputs"]["vae"] == ["4", 2]
+ assert filled["5"]["inputs"]["batch_size"] == 1
+
+
+def test_an_unknown_placeholder_is_left_alone():
+ """`prompts.substitute`'s rule. A literal `{{x}}` is not a feature, but
+ silently deleting one is worse than leaving it where somebody can see it."""
+ filled = wf.fill({"n": {"inputs": {"lora": "{{lora_name}}"}}}, wf.resolve({"prompt": "x"}))
+ assert filled["n"]["inputs"]["lora"] == "{{lora_name}}"
+
+
+def test_the_shipped_template_uses_every_placeholder_it_should():
+ assert wf.placeholders_in(BASE) == set(wf.PLACEHOLDERS)
+
+
+# --- Resolution ----------------------------------------------------------------
+def test_what_is_not_asked_for_takes_its_default():
+ values = wf.resolve({"prompt": "a bicycle"})
+ assert values["steps"] == 20
+ assert values["cfg"] == 8.0
+ assert values["width"] == 512
+ assert values["negative"] == "text, watermark"
+ assert values["sampler"] == "euler"
+
+
+def test_the_seed_is_random_when_nobody_names_one():
+ """A fixed default would make every unspecified generation identical -- and
+ would make the retry loop produce the same rejected image four times."""
+ seeds = {wf.resolve({"prompt": "x"})["seed"] for _ in range(8)}
+ assert len(seeds) == 8
+
+
+def test_a_named_seed_is_kept():
+ assert wf.resolve({"prompt": "x", "seed": 1234})["seed"] == 1234
+
+
+@pytest.mark.parametrize("empty", [None, ""])
+def test_null_and_empty_mean_no_opinion(empty):
+ """A model that emits `"steps": null` rather than omitting the key is common
+ enough that reading it as a request for zero steps would be a bug nobody
+ could see."""
+ assert wf.resolve({"prompt": "x", "steps": empty})["steps"] == 20
+
+
+def test_out_of_range_numbers_are_clamped_rather_than_refused():
+ """A model asking for 300 steps has misjudged rather than misbehaved, and a
+ clarifying round costs more than doing the sensible thing."""
+ values = wf.resolve({"prompt": "x", "steps": 5000, "cfg": -4, "denoise": 12})
+ assert values["steps"] == 150
+ assert values["cfg"] == 0.0
+ assert values["denoise"] == 1.0
+
+
+def test_nonsense_falls_back_instead_of_raising():
+ """Arguments come from a model, so "twenty" is a thing that will arrive."""
+ assert wf.resolve({"prompt": "x", "steps": "twenty"})["steps"] == 20
+
+
+def test_a_seed_larger_than_comfyui_allows_is_wrapped():
+ assert 0 <= wf.resolve({"prompt": "x", "seed": wf.MAX_SEED + 5})["seed"] <= wf.MAX_SEED