Defaults an administrator can actually set

There were none. `workflow.DEFAULTS` was the only source, so 512x512, euler and
twenty steps were what every instance got whatever card it was running on -- and
512 square on an SDXL checkpoint is precisely what the tool's own description
warns produces duplicated limbs. The two ways round it were both bad: bake
literals into a template where the placeholders should be, or write prose in the
instructions box and hope.

Three rungs now, most specific winning, with DEFAULTS staying underneath as the
floor so an instance that sets nothing behaves exactly as it did and a floor
improved in code still reaches everybody. An empty box is "no opinion" rather
than zero, which matters: read as a number it would set every instance to zero
steps, and ComfyUI refuses that in a way that looks like a broken model.

The right control for each, because a text box is wrong for most of them. The
samplers and schedulers were already being discovered by the Test button, stored,
and read by nothing at all -- they are the pickers now. A stored value missing
from the list is kept as an option anyway, or opening this page and pressing Save
would silently clear a working setting. Checkpoints are chosen rather than typed,
and the instance default is a rung of its own instead of "whatever happens to be
first in a textarea somebody filled in some order".

And batch, at last: `batch_size` was a literal 1 in the base template, so an
administrator whose card can comfortably make four had no way of saying so.
Deliberately not something a model may set -- one asking for six because it is
unsure is the exact cost this must not invite.

The tool's schema restates the defaults it quotes. Every "Default 20." in there
was written when there was one set of defaults in the world; left alone, an
instance drawing at 1024 would go on telling the model 512, and the model reasons
from that sentence rather than ignoring it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 11:50:37 +02:00
parent fb54a236ae
commit 6fcb9c9892
16 changed files with 784 additions and 39 deletions
+1 -1
View File
@@ -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 # 1897 tests, ~2min
pytest # 1914 tests, ~2min
# 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;
+13 -7
View File
@@ -9,7 +9,7 @@ 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, image generation over ComfyUI, users and groups,
model administration, installable as an app, reports, messages, and scheduled
work that runs on its own. 1897 tests, `ruff` clean.
work that runs on its own. 1914 tests, `ruff` clean.
What remains before the first stable release is written out below, in phases,
under [The road to 1.0.0](#the-road-to-100).
@@ -439,14 +439,20 @@ seen working.
when a window of its own has focus
### Phase 2 — image generation admin (`0.9.2`)
- [ ] **Defaults an administrator can set** — steps, cfg, size, sampler,
scheduler, denoise, negative, batch. There were none: one hardcoded set
from the SD1.5 era, and prose in a box as the only way to change it
- [ ] The right control for each: samplers and schedulers as selects, from the
- [x] **Defaults an administrator can set** — steps, cfg, size, sampler,
scheduler, denoise, negative, checkpoint, batch. There were none: one
hardcoded set from the SD1.5 era, and prose in a box as the only way to
change it. An empty box means "no opinion" and falls through, so a floor
improved in code still reaches everyone
- [x] The right control for each: samplers and schedulers as selects, from the
lists ComfyUI has been discovering and nothing has been reading;
checkpoints picked rather than typed; sizes as numbers with presets
- [ ] A legend on the workflow editor saying what each placeholder fills and
what type it lands as
- [x] **`batch` at last** — `batch_size` was a literal `1` in the template.
Deliberately not something a model may set
- [x] **The tool's schema restates the defaults it quotes**, or it goes on
telling the model "Default 512" beside an instance that draws at 1024
- [x] A legend on the workflow editor saying what each placeholder fills, what
it lands as, and what it resolves to right now
### Phase 3 — subagents (`0.9.2`)
- [ ] **A model can delegate.** A bounded, unattended agent with the parent's
+49
View File
@@ -124,3 +124,52 @@ 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.
## The defaults an administrator can set
**There were none, for the whole life of the feature.** `workflow.DEFAULTS` was
the only source, so 512×512, `euler` and twenty steps were what every instance
got whatever card it was running on — and 512² on an SDXL checkpoint is exactly
what the tool's own `width` description warns produces duplicated limbs. The two
ways round it were both bad: bake literals into a template where the
placeholders should be, or write prose in the instructions box and hope the
model obeys it.
`resolve(given, settings=…)` is three rungs now, most specific winning:
**`DEFAULTS` → the instance's `default_*` settings → what the model asked for.**
`DEFAULTS` stays underneath as the floor, so an instance that sets nothing
behaves exactly as it did, and improving a floor in code still reaches everyone.
**An empty setting is "no opinion", not zero.** `_number` in `admin_images`
returns `""` for an empty box and `instance_defaults` skips it. Reading it as a
number instead would set every instance to zero steps, which ComfyUI refuses in
a way that looks like a broken model.
**The samplers and schedulers were already being discovered and read by
nothing.** `comfy.discover()` has fetched all three lists since the Test button
existed, and only `checkpoints` was ever used. The pickers are built from the
other two. A stored value that is not in the list is kept as an option anyway,
or opening the page and pressing Save would silently clear a working setting.
**`batch` is a placeholder a model cannot set.** `batch_size` was a literal `1`
in the base template, so an administrator whose card can make four at a time had
no way of saying so. It is absent from `MODEL_SETTABLE`, deliberately: a model
asking for six because it is unsure is the exact cost this must not invite.
**The schema restates the defaults it quotes.** Every "Default 20." in
`SCHEMA` was written when there was one set of defaults in the world.
`_restate_defaults` rewrites each one from what this instance actually resolves
to — a schema saying "Default 512" beside an instance that draws at 1024 is
worse than saying nothing, because the model reasons from it and omits the
parameter, arriving at the right behaviour for the wrong reason or the wrong one
silently. The regex keeps the punctuation it found, since `denoise` says
"Default 1, which is…" and the rest use a full stop.
**The workflow editor's legend shows the resolved value beside each
placeholder.** A list of names answers "what may I write"; the question somebody
has in front of a workflow that came out wrong is "what happens if I leave this
out", and that answer moved the day instance defaults arrived. It is resolved
through the same call a generation makes, so the two cannot disagree. The legend
also states the two names that are not ComfyUI's own — `{{model}}` fills
`ckpt_name` and `{{sampler}}` fills `sampler_name` — which is the mistake that
costs an afternoon.
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.9.1"
__version__ = "0.9.2"
+84 -3
View File
@@ -18,6 +18,7 @@ import json
import logging
import re
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
@@ -54,6 +55,27 @@ def _lines(text: str) -> list[str]:
return seen
def _number(raw: str, name: str, *, whole: bool = True) -> Any:
"""A filled box as a clamped number, an empty one as "".
The empty string is load-bearing and is not a missing value: it is how an
administrator says "no opinion about this one", which `workflow.resolve`
reads as "fall through to the built-in floor". Turning it into a zero here
would silently set every instance to zero steps.
"""
text = (raw or "").strip()
if not text:
return ""
try:
value = float(text)
except ValueError:
return ""
low, high = workflow_service.LIMITS.get(name, (None, None))
if low is not None:
value = min(max(value, low), high)
return int(value) if whole else value
def _config(db: Db) -> comfy.Config:
values = settings_store.images(db)
return comfy.Config(
@@ -114,6 +136,21 @@ async def save_images(
max_tries: int = Form(4),
preserve_vram: bool = Form(False),
instructions: str = Form(""),
# The generation defaults. Every one is a *string* even where it is a
# number, because "" is how an administrator says "no opinion" and an
# `int = Form(0)` cannot express that -- zero steps is a value, and one
# somebody could mean. `_number` below turns a filled box into a clamped
# number and an empty one back into "".
default_checkpoint: str = Form(""),
default_steps: str = Form(""),
default_cfg: str = Form(""),
default_width: str = Form(""),
default_height: str = Form(""),
default_sampler: str = Form(""),
default_scheduler: str = Form(""),
default_denoise: str = Form(""),
default_negative: str = Form(""),
default_batch: str = Form(""),
) -> Response:
"""Save the settings.
@@ -142,6 +179,20 @@ async def save_images(
"max_tries": min(max(max_tries, 1), 10),
"preserve_vram": preserve_vram,
"instructions": instructions.strip()[:4000],
# Clamped here to the same bounds `workflow.LIMITS` uses on the way
# out. Twice, deliberately: a number stored by an earlier version,
# or written straight into the settings row, still has to be safe
# when a generation reads it.
"default_checkpoint": default_checkpoint.strip(),
"default_steps": _number(default_steps, "steps"),
"default_cfg": _number(default_cfg, "cfg", whole=False),
"default_width": _number(default_width, "width"),
"default_height": _number(default_height, "height"),
"default_sampler": default_sampler.strip(),
"default_scheduler": default_scheduler.strip(),
"default_denoise": _number(default_denoise, "denoise", whole=False),
"default_negative": default_negative.strip()[:500],
"default_batch": _number(default_batch, "batch"),
},
key=settings_store.IMAGES,
)
@@ -212,7 +263,34 @@ def _workflow(db: Db, workflow_id: str) -> ImageWorkflow:
return row
def _detail(request: Request, row: ImageWorkflow, *, is_new: bool, error: str = "", **extra):
def _placeholder_help(db: Db) -> list[tuple[str, str, str, str]]:
"""Every placeholder, what it fills, and what it resolves to *today*.
The last column is the point. A legend listing names answers "what may I
write"; the question somebody actually has, standing in front of a workflow
that came out wrong, is "what happens if I leave this out" -- and the answer
moved the day instance defaults arrived. Resolved through the same call a
generation makes, so the two cannot disagree.
"""
resolved = workflow_service.resolve({}, settings=settings_store.images(db))
out: list[tuple[str, str, str, str]] = []
for name in workflow_service.PLACEHOLDERS:
kind, what = workflow_service.DESCRIPTIONS.get(name, ("text", ""))
if name == "prompt":
shown = "whatever is asked for"
elif name == "seed":
shown = "a fresh random one"
elif name == "model":
shown = str(resolved.get("model") or "") or "the first checkpoint listed"
else:
shown = str(resolved.get(name, ""))
out.append((name, kind, what, shown))
return out
def _detail(
request: Request, db: Db, row: ImageWorkflow, *, is_new: bool, error: str = "", **extra
):
return render(
request,
"admin/workflow_detail.html",
@@ -221,6 +299,7 @@ def _detail(request: Request, row: ImageWorkflow, *, is_new: bool, error: str =
"is_new": is_new,
"error": error,
"placeholders": workflow_service.PLACEHOLDERS,
"placeholder_help": _placeholder_help(db),
"workflow_text": extra.pop(
"workflow_text", json.dumps(row.workflow_json or {}, indent=2)
),
@@ -304,7 +383,7 @@ async def new_workflow(request: Request, db: Db, user: AdminUser) -> Response:
workflow_json=json.loads(base.read_text(encoding="utf-8")),
enabled=True,
)
return _detail(request, draft, is_new=True)
return _detail(request, db, draft, is_new=True)
@router.post("/workflows")
@@ -316,6 +395,7 @@ async def create_workflow(request: Request, db: Db, user: AdminUser) -> Response
if problem:
return _detail(
request,
db,
row,
is_new=True,
error=problem,
@@ -334,7 +414,7 @@ async def create_workflow(request: Request, db: Db, user: AdminUser) -> Response
@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)
return _detail(request, db, _workflow(db, workflow_id), is_new=False)
@router.post("/workflows/{workflow_id}/delete")
@@ -363,6 +443,7 @@ async def update_workflow(request: Request, db: Db, user: AdminUser, workflow_id
draft.id = row.id
return _detail(
request,
db,
draft,
is_new=False,
error=problem,
@@ -24,7 +24,7 @@
"inputs": {
"width": "{{width}}",
"height": "{{height}}",
"batch_size": 1
"batch_size": "{{batch}}"
},
"class_type": "EmptyLatentImage",
"_meta": { "title": "Empty Latent Image" }
+63 -8
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
@@ -196,6 +197,43 @@ def _choices(db, values: dict[str, Any]) -> tuple[list[Any], list[str]]:
return rows, [str(name) for name in (values.get("checkpoints") or [])]
_DEFAULT_SENTENCE = re.compile(r"Default ([^.,]+)([.,])")
def _restate_defaults(schema: dict[str, Any], values: dict[str, Any]) -> None:
"""Rewrite each "Default 20." to say what this instance actually uses.
Every one of those descriptions was written when there was one set of
defaults in the world. Now an administrator can move them, and a schema
still saying "Default 512" beside an instance that draws at 1024 is worse
than saying nothing: the model reasons from it, decides 512 is fine for the
SDXL checkpoint it was handed, and omits the parameter arriving at the
right behaviour for the wrong reason, or the wrong one silently.
A rewrite rather than a `{default}` placeholder in the prose, because the
sentence around it differs per parameter and half of them go on to say what
to do *instead* of the default. The regex keeps the punctuation it found,
since `denoise` says "Default 1, which is…" and the rest use a full stop.
"""
resolved = workflow.resolve({}, settings=values)
for name, spec in schema.get("properties", {}).items():
if name not in resolved or name in ("prompt", "seed", "model", "template"):
continue
shown = resolved[name]
# A float that is whole reads better as "8" than "8.0", and this is the
# text a model reasons about.
if isinstance(shown, float) and shown.is_integer():
shown = int(shown)
spec["description"] = _DEFAULT_SENTENCE.sub(
# Bound now rather than closed over: `shown` is a loop variable, and
# a lambda reading it later would restate every description with the
# last parameter's value.
lambda match, shown=shown: f"Default {shown}{match.group(2)}",
spec["description"],
count=1,
)
def schema_for(db, values: dict[str, Any]) -> dict[str, Any]:
"""The parameter schema, with this instance's own choices in it.
@@ -207,6 +245,7 @@ def schema_for(db, values: dict[str, Any]) -> dict[str, Any]:
"""
rows, checkpoints = _choices(db, values)
schema = json.loads(json.dumps(SCHEMA))
_restate_defaults(schema, values)
if rows:
schema["properties"]["template"]["enum"] = [row.slug for row in rows]
schema["properties"]["template"]["description"] = "Which workflow to use. " + "; ".join(
@@ -421,7 +460,12 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
{**event, "status": "error", "error": str(exc)},
)
checkpoint = _checkpoint(str(args.get("model") or ""), context.image_checkpoint, checkpoints)
checkpoint = _checkpoint(
str(args.get("model") or ""),
context.image_checkpoint,
checkpoints,
instance_default=str(values.get("default_checkpoint") or ""),
)
if checkpoint is None:
return ToolOutcome(
"No checkpoint is available. An administrator has to list them on the "
@@ -429,7 +473,7 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
{**event, "status": "error", "error": "No checkpoint."},
)
given = {name: args.get(name) for name in workflow.PLACEHOLDERS if name in args}
given = {name: args.get(name) for name in workflow.MODEL_SETTABLE if name in args}
given["model"] = checkpoint
given["prompt"] = prompt
@@ -441,14 +485,16 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
kept: tuple[bytes, dict[str, Any]] | None = None
# What the last attempt actually asked for, so a failure can name concrete
# numbers back at the model rather than saying "try something smaller".
params_used: dict[str, Any] = workflow.resolve(given)
params_used: dict[str, Any] = workflow.resolve(given, settings=values)
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})
params = workflow.resolve(
{**given, "seed": args.get("seed") if number == 1 else None}, settings=values
)
params_used = params
refs = await comfy.await_images(
config, await comfy.submit(config, workflow.fill(template, params))
@@ -571,17 +617,26 @@ def _pick(rows: list[Any], wanted: str, chat_default: str, values: dict[str, Any
return rows[0] if rows else None
def _checkpoint(wanted: str, chat_default: str, available: list[str]) -> str | None:
def _checkpoint(
wanted: str, chat_default: str, available: list[str], *, instance_default: 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.
Most specific first: what the model named, then this chat's own, then the
instance default, then whatever is first in the list. The instance rung is
the new one -- without it, "the default" was position zero in a textarea an
administrator had typed in some order, which is a default by accident.
A name the instance does not have is ignored at every rung 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
if instance_default and instance_default in available:
return instance_default
return available[0] if available else None
+83 -12
View File
@@ -40,12 +40,25 @@ PLACEHOLDERS = (
"sampler",
"scheduler",
"denoise",
# How many pictures one run produces. Late to the list, and the reason is
# worth stating: `batch_size` was a literal `1` in the base template, so an
# administrator whose card can comfortably make four at a time had no way of
# saying so short of editing the JSON. Not a tool parameter -- a model asking
# for six images because it is unsure is exactly the cost this should not
# invite -- so it fills from the instance default and nowhere else.
"batch",
)
# 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.
# What a model may name. Everything else in `PLACEHOLDERS` fills from a default.
MODEL_SETTABLE = tuple(name for name in PLACEHOLDERS if name != "batch")
# The floor, taken from the base template. An instance's own defaults sit above
# this (see `resolve`), and this stays as the last resort so a fresh install
# behaves exactly as it always did.
#
# `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,
@@ -55,6 +68,26 @@ DEFAULTS: dict[str, Any] = {
"sampler": "euler",
"scheduler": "normal",
"denoise": 1.0,
"batch": 1,
}
# What each hole is for, and what it lands as. Read by the workflow editor, so
# somebody writing a template is told what `{{sampler}}` fills without reading
# this file -- and in particular is told the two names that do not match
# ComfyUI's own, which is the mistake that costs an afternoon.
DESCRIPTIONS: dict[str, tuple[str, str]] = {
"model": ("text", "The checkpoint. Fills ComfyUI's `ckpt_name`, not `model`."),
"prompt": ("text", "What to draw. The only value a model must supply."),
"negative": ("text", "What to keep out of the picture."),
"seed": ("number", "The noise seed. Absent or negative means a fresh random one."),
"steps": ("number", "How many denoising steps. More is slower, not always better."),
"cfg": ("number", "How closely to follow the prompt. A decimal."),
"width": ("number", "Pixels across. A multiple of 64."),
"height": ("number", "Pixels down. A multiple of 64."),
"sampler": ("text", "The sampling method. Fills ComfyUI's `sampler_name`, not `sampler`."),
"scheduler": ("text", "The noise schedule."),
"denoise": ("number", "How much of the latent to redraw. 1.0 for text-to-image."),
"batch": ("number", "How many images one run makes. Fills `batch_size`."),
}
# ComfyUI's own ranges, read off `/object_info`. Clamped rather than refused: a
@@ -66,6 +99,10 @@ LIMITS: dict[str, tuple[float, float]] = {
"width": (64, 2048),
"height": (64, 2048),
"denoise": (0.0, 1.0),
# Not ComfyUI's ceiling, which is 4096, but a sane one: this multiplies
# every generation's time and VRAM, and an administrator who wants more than
# eight at once wants a different workflow rather than a bigger number here.
"batch": (1, 8),
}
# ComfyUI's seed is a uint64. Generated here rather than left to the far side
@@ -80,12 +117,43 @@ 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.
def instance_defaults(values: dict[str, Any] | None) -> dict[str, Any]:
"""The `default_*` keys out of the image settings, as placeholder names.
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.
Only the ones actually set: an absent or empty key means "no opinion", and
must fall through to `DEFAULTS` rather than land as an empty string in a
workflow. That is the same reading `resolve` gives a model's own arguments,
and it is why an administrator can set two of these and leave the rest.
"""
out: dict[str, Any] = {}
for name in PLACEHOLDERS:
if name in ("prompt", "seed", "model"):
# A default prompt is not a thing; a default seed would make every
# picture identical; the checkpoint has its own setting and its own
# per-chat override, resolved before this is reached.
continue
value = (values or {}).get(f"default_{name}")
if value is None or value == "":
continue
out[name] = value
return out
def resolve(given: dict[str, Any], *, settings: dict[str, Any] | None = None) -> dict[str, Any]:
"""The full parameter set: what was asked for, over what this instance
prefers, over the built-in floor.
Three rungs, most specific winning, and the middle one is the new part. For
the whole life of this feature there were only two -- so 512x512, euler and
twenty steps were the values every instance got, whatever card it was
running on, and the only ways to move them were to bake literals into a
template instead of placeholders or to write prose in the instructions box
and hope. `DEFAULTS` stays underneath so an instance that sets nothing
behaves exactly as it did.
Absent and null are both "no opinion", at both levels. 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.
**A negative seed means random**, which is what `-1` means in ComfyUI's own
interface, in A1111, and in every other thing that has ever asked somebody
@@ -94,14 +162,17 @@ def resolve(given: dict[str, Any]) -> dict[str, Any]:
a perfectly valid *fixed* seed, so "give me something new" produced the same
picture every time. Exactly the wrong answer, arrived at silently.
"""
values: dict[str, Any] = {**DEFAULTS}
values: dict[str, Any] = {**DEFAULTS, **instance_defaults(settings)}
for name, value in (given or {}).items():
if name in PLACEHOLDERS and value is not None and value != "":
# `batch` is absent from `MODEL_SETTABLE`, so a model naming it is
# ignored here rather than refused -- the tool schema never offered it,
# and one that invents the key has guessed rather than misbehaved.
if name in MODEL_SETTABLE and value is not None and value != "":
values[name] = value
seed = _whole(values.get("seed"), default=-1)
values["seed"] = random_seed() if seed < 0 else seed % (MAX_SEED + 1)
for name in ("steps", "width", "height"):
for name in ("steps", "width", "height", "batch"):
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)
+25
View File
@@ -260,6 +260,31 @@ def _images_defaults() -> dict[str, Any]:
"samplers": [],
"schedulers": [],
"default_workflow_id": "",
# What a generation uses when nothing names otherwise. Empty means "no
# opinion" for every one of them, falling through to
# `workflow.DEFAULTS` -- which is why they are empty here rather than
# holding a copy of that dict. A copy would freeze an instance on
# whatever this file said the day it was installed, and would make
# improving a floor in code reach nobody.
#
# These exist because for the whole life of this feature there were
# none: 512x512, euler and twenty steps were what every instance got
# whatever card it was running on, and the only ways to move them were
# to bake literals into a template instead of placeholders, or to write
# prose in the instructions box and hope.
"default_checkpoint": "",
"default_steps": "",
"default_cfg": "",
"default_width": "",
"default_height": "",
"default_sampler": "",
"default_scheduler": "",
"default_denoise": "",
"default_negative": "",
# How many pictures one run makes. Not offered to the model at all --
# see workflow.MODEL_SETTABLE -- because a model asking for six because
# it is unsure is exactly the cost this must not invite.
"default_batch": "",
# Whether a vision model looks at what came back and says whether to
# keep it. Deliberately independent of `preserve_vram` below: on a
# machine that can hold both models this costs nothing, and on one that
+18
View File
@@ -203,6 +203,24 @@ a.tabs__tab { text-decoration: none; }
flex-wrap: wrap;
}
/*
Two or three short fields on one line.
Numbers with their own units width and height, steps and cfg and denoise
read as a set and take a fraction of the width each, so stacking them makes a
card that is mostly whitespace and a form that is mostly scrolling. Auto-fit
rather than a fixed count: the same markup gives three columns on a wide
screen and one on a narrow one with no breakpoint, which matters because
`chat.css` has a test refusing media queries and this file should not drift
from that habit without a reason.
*/
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: var(--sp-3);
}
.field-row > .field { margin-bottom: var(--sp-4); }
/* Legacy aliases so existing admin templates keep their spacing. */
.form-grid { display: block; }
.connection__head { display: flex; align-items: center; justify-content: space-between;
+26
View File
@@ -759,6 +759,32 @@
document.addEventListener("htmx:afterSettle", scan);
})();
/*
Two number fields set together.
Image sizes come in pairs and nobody types 1024 and then 1536; they pick
"portrait". A button rather than a select of pairs, because a select would
have to enumerate every combination somebody might want and these are only
the common ones the two boxes are still there and still authoritative.
Delegated and keyed on the attributes rather than on the image page's ids, so
the next screen with a paired number wants no new code.
*/
document.addEventListener("click", function (event) {
var preset = event.target.closest("[data-width][data-height]");
if (!preset) return;
var row = preset.closest("[data-size-presets]");
if (!row) return;
event.preventDefault();
var form = preset.closest("form");
if (!form) return;
var width = form.querySelector('[name="default_width"]');
var height = form.querySelector('[name="default_height"]');
if (width) width.value = preset.dataset.width;
if (height) height.value = preset.dataset.height;
});
/*
A toast asked for by the server.
+137
View File
@@ -100,6 +100,143 @@
<div id="images-test-result"></div>
</section>
<section class="card">
<h2 class="card__title">What a generation uses by default</h2>
<p class="card__lede">
What every picture is drawn with unless the model names otherwise. Leave a
box empty to use the built-in value shown beside it — the built-ins are
SD1.5-era, and 512×512 on an SDXL checkpoint is what produces the
duplicated limbs.
</p>
<div class="field">
<label class="field__label" for="default_checkpoint">Checkpoint</label>
{# A select rather than a box, from the list above. Typing a name that is
not there produced a picture drawn with whatever happened to be first,
with nothing anywhere saying so. #}
<select class="select" id="default_checkpoint" name="default_checkpoint">
<option value="">The first in the list above</option>
{% for name in values.checkpoints or [] %}
<option value="{{ name }}" {{ 'selected' if values.default_checkpoint == name }}>
{{ name }}
</option>
{% endfor %}
</select>
</div>
<div class="field-row">
<div class="field">
<label class="field__label" for="default_width">Width</label>
<input class="input" type="number" id="default_width" name="default_width"
min="64" max="2048" step="64" placeholder="512"
value="{{ values.default_width }}">
</div>
<div class="field">
<label class="field__label" for="default_height">Height</label>
<input class="input" type="number" id="default_height" name="default_height"
min="64" max="2048" step="64" placeholder="512"
value="{{ values.default_height }}">
</div>
</div>
{# The five sizes anybody actually picks. Buttons rather than a select
because they set two fields at once, and a select of pairs would have to
name every combination somebody might want. #}
<div class="btn-row" data-size-presets>
{% for label, w, h in [
("512²", 512, 512), ("768²", 768, 768), ("1024²", 1024, 1024),
("1024×1536", 1024, 1536), ("1536×1024", 1536, 1024)
] %}
<button class="btn btn--sm" type="button"
data-width="{{ w }}" data-height="{{ h }}">{{ label }}</button>
{% endfor %}
</div>
<div class="field-row">
<div class="field">
<label class="field__label" for="default_steps">Steps</label>
<input class="input" type="number" id="default_steps" name="default_steps"
min="1" max="150" step="1" placeholder="20"
value="{{ values.default_steps }}">
</div>
<div class="field">
<label class="field__label" for="default_cfg">Guidance (cfg)</label>
<input class="input" type="number" id="default_cfg" name="default_cfg"
min="0" max="30" step="0.5" placeholder="8"
value="{{ values.default_cfg }}">
</div>
<div class="field">
<label class="field__label" for="default_denoise">Denoise</label>
<input class="input" type="number" id="default_denoise" name="default_denoise"
min="0" max="1" step="0.05" placeholder="1"
value="{{ values.default_denoise }}">
</div>
</div>
<div class="field-row">
<div class="field">
<label class="field__label" for="default_sampler">Sampler</label>
{# From what the Test button read off ComfyUI. There are forty-odd and
spelling one wrong is a refused workflow, so it is picked rather than
typed — and the current value is kept as an option even when the list
has not been read, or saving this page would quietly clear it. #}
<select class="select" id="default_sampler" name="default_sampler">
<option value="">euler (built-in)</option>
{% for name in values.samplers or [] %}
<option value="{{ name }}" {{ 'selected' if values.default_sampler == name }}>
{{ name }}
</option>
{% endfor %}
{% if values.default_sampler and values.default_sampler not in (values.samplers or []) %}
<option value="{{ values.default_sampler }}" selected>
{{ values.default_sampler }} (not in this ComfyUI's list)
</option>
{% endif %}
</select>
</div>
<div class="field">
<label class="field__label" for="default_scheduler">Scheduler</label>
<select class="select" id="default_scheduler" name="default_scheduler">
<option value="">normal (built-in)</option>
{% for name in values.schedulers or [] %}
<option value="{{ name }}" {{ 'selected' if values.default_scheduler == name }}>
{{ name }}
</option>
{% endfor %}
{% if values.default_scheduler
and values.default_scheduler not in (values.schedulers or []) %}
<option value="{{ values.default_scheduler }}" selected>
{{ values.default_scheduler }} (not in this ComfyUI's list)
</option>
{% endif %}
</select>
</div>
<div class="field">
<label class="field__label" for="default_batch">Images per run</label>
<input class="input" type="number" id="default_batch" name="default_batch"
min="1" max="8" step="1" placeholder="1"
value="{{ values.default_batch }}">
</div>
</div>
{% if not values.samplers %}
<p class="field__hint">
Press <strong>Test &amp; read what it has</strong> above to fill the sampler
and scheduler lists from your ComfyUI.
</p>
{% endif %}
<div class="field">
<label class="field__label" for="default_negative">Negative prompt</label>
<input class="input" type="text" id="default_negative" name="default_negative"
placeholder="text, watermark" value="{{ values.default_negative }}">
<p class="field__hint">
Used when the model does not write one of its own. It writes one often,
so this is a floor rather than something always applied — “always add
these words” belongs in the instructions below, where the model is told
to include them.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Checking the result</h2>
<div class="field">
@@ -75,15 +75,46 @@
<code>"{{ '{{prompt}}' }}, masterpiece"</code> works. Anything you leave out
takes its default.
</p>
<p class="field__hint">
Available:
{% for name in placeholders %}<code>{{ '{{' ~ name ~ '}}' }}</code>{{ ", " if not loop.last }}{% endfor %}.
<code>{{ '{{prompt}}' }}</code> is required — without it every image would
be the same.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">The placeholders</h2>
<p class="card__lede">
Every hole a template may carry, what it fills, and what it resolves to
right now. <code>{{ '{{prompt}}' }}</code> is required — without it every
image would be the same one, whatever anybody typed.
</p>
{# The current value beside each name, the way /admin/prompts shows a
variable's. A legend that lists names and not values answers "what may I
write" and not "what will happen", and the second is the question
somebody has while looking at a workflow that came out wrong. #}
<div class="ref-list">
{% for name, kind, what, current in placeholder_help %}
<div class="ref-row">
<code>{{ '{{' ~ name ~ '}}' }}</code>
<span>
{{ what }}
{% if name == "prompt" %}<strong>Required.</strong>{% endif %}
</span>
<code class="faint">{{ current }}</code>
</div>
{% endfor %}
</div>
<p class="field__hint">
Two of these are not spelled the way ComfyUI spells them, which is the
mistake that costs an afternoon: <code>{{ '{{model}}' }}</code> fills
<code>ckpt_name</code> and <code>{{ '{{sampler}}' }}</code> fills
<code>sampler_name</code>.
</p>
<p class="field__hint">
The right-hand column is what an omitted placeholder resolves to today —
the instance defaults from the image settings page, falling back to the
built-in floor. A model may override any of them except
<code>{{ '{{batch}}' }}</code>, which is yours alone.
</p>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">
{{ "Add workflow" if is_new else "Save changes" }}
+121
View File
@@ -270,3 +270,124 @@ def test_only_an_administrator_can_reach_it(client: TestClient, db, registered):
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)
# --- The defaults card ----------------------------------------------------------
def test_the_defaults_are_saved_and_clamped(client, db, registered):
"""Clamped at the save as well as on the way out. A number stored by an
earlier version, or written straight into the settings row, still has to be
safe when a generation reads it."""
from lembas.services import settings_store
client.post(
"/admin/images",
data={
"base_url": "http://comfy.test",
"api_key": "",
"timeout": "600",
"checkpoints": "sd.safetensors",
"default_steps": "9000",
"default_width": "1024",
"default_cfg": "5.5",
"default_sampler": "dpmpp_2m",
"default_batch": "4",
},
follow_redirects=False,
)
values = settings_store.images(db)
assert values["default_steps"] == 150 # the ceiling, not 9000
assert values["default_width"] == 1024
assert values["default_cfg"] == 5.5
assert values["default_sampler"] == "dpmpp_2m"
assert values["default_batch"] == 4
def test_an_empty_default_stays_empty_rather_than_becoming_zero(client, db, registered):
"""The empty string is how an administrator says "no opinion", which
`workflow.resolve` reads as "fall through to the built-in". Read as a number
it would set the instance to zero steps."""
from lembas.services import settings_store
client.post(
"/admin/images",
data={"base_url": "http://comfy.test", "api_key": "", "timeout": "600"},
follow_redirects=False,
)
values = settings_store.images(db)
assert values["default_steps"] == ""
assert values["default_cfg"] == ""
def test_saving_the_page_does_not_clear_the_discovered_lists(client, db, registered):
"""They belong to whatever ComfyUI was tested, and a save that only changed
the instructions box has no opinion about them. The sampler picker is built
from them, so clearing them would empty it."""
from lembas.services import settings_store
settings_store.update(
db, {"samplers": ["euler", "dpmpp_2m"], "schedulers": ["normal"]},
key=settings_store.IMAGES,
)
db.commit()
client.post(
"/admin/images",
data={"base_url": "http://comfy.test", "api_key": "", "timeout": "600"},
follow_redirects=False,
)
values = settings_store.images(db)
assert values["samplers"] == ["euler", "dpmpp_2m"]
def test_the_sampler_picker_offers_what_comfyui_advertised(client, db, registered):
"""Discovered, stored, and — until now — read by nothing at all. There are
forty-odd samplers and spelling one wrong is a refused workflow, so it is
picked rather than typed."""
from lembas.services import settings_store
settings_store.update(
db, {"samplers": ["euler", "dpmpp_2m_sde"], "schedulers": ["karras"]},
key=settings_store.IMAGES,
)
db.commit()
body = client.get("/admin/images").text
assert 'name="default_sampler"' in body
assert "dpmpp_2m_sde" in body
assert "karras" in body
def test_a_stored_sampler_survives_a_list_that_does_not_have_it(client, db, registered):
"""Otherwise opening the page and saving it silently clears a working
setting, because the select had no option matching the stored value."""
from lembas.services import settings_store
settings_store.update(
db, {"samplers": ["euler"], "default_sampler": "some_custom_sampler"},
key=settings_store.IMAGES,
)
db.commit()
body = client.get("/admin/images").text
assert "some_custom_sampler" in body
def test_the_workflow_editor_says_what_each_placeholder_resolves_to(client, db, registered):
"""A legend listing names answers "what may I write"; the question somebody
has in front of a workflow that came out wrong is "what happens if I leave
this out"."""
from lembas.services import settings_store
settings_store.update(db, {"default_width": 1024}, key=settings_store.IMAGES)
db.commit()
body = client.get("/admin/images/workflows/new").text
assert "ckpt_name" in body # the name that is not the placeholder's
assert "sampler_name" in body
assert "1024" in body # the resolved default, beside {{width}}
+43
View File
@@ -487,3 +487,46 @@ def test_every_parameter_says_when_to_move_it(db, user_id, configured):
"the negative prompt's one real trap: phrasing it as an instruction"
)
assert "portrait" in schema["properties"]["width"]["description"]
def test_the_schema_states_this_instance_s_defaults(db, registered):
"""Every "Default 20." in those descriptions was written when there was one
set of defaults in the world. A schema still saying "Default 512" beside an
instance that draws at 1024 is worse than saying nothing: the model reasons
from it and omits the parameter, arriving at the right behaviour for the
wrong reason or the wrong one silently."""
from lembas.services.images import tool as image_tool
schema = image_tool.schema_for(
db, {"default_width": 1024, "default_steps": 30, "default_sampler": "dpmpp_2m"}
)
properties = schema["properties"]
assert "Default 1024." in properties["width"]["description"]
assert "Default 30." in properties["steps"]["description"]
assert "Default dpmpp_2m." in properties["sampler"]["description"]
# And the rest of the sentence survives -- these say what to do *instead* of
# the default, which is most of their value.
assert "SDXL" in properties["width"]["description"]
def test_a_whole_number_default_is_not_written_as_a_decimal(db, registered):
""""Default 8.0" is text a model reasons about, and reads as a precision
somebody chose."""
from lembas.services.images import tool as image_tool
schema = image_tool.schema_for(db, {"default_cfg": 6})
assert "Default 6." in schema["properties"]["cfg"]["description"]
assert "Default 6.0" not in schema["properties"]["cfg"]["description"]
def test_the_punctuation_after_a_default_is_kept(db, registered):
"""`denoise` says "Default 1, which is what you want…" while the rest use a
full stop. A rewrite that assumed one would produce a sentence that does not
read."""
from lembas.services.images import tool as image_tool
schema = image_tool.schema_for(db, {})
assert "Default 1, which is" in schema["properties"]["denoise"]["description"]
+82
View File
@@ -141,3 +141,85 @@ def test_nonsense_falls_back_instead_of_raising():
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
# --- Instance defaults: the rung that did not exist -----------------------------
def test_an_instance_default_beats_the_built_in_floor():
"""For the whole life of this feature there were two rungs, so 512x512 and
twenty steps were what every instance got whatever card it was running on.
The only ways to move them were to bake literals into a template instead of
placeholders, or to write prose in the instructions box and hope."""
values = wf.resolve({}, settings={"default_width": 1024, "default_steps": 30})
assert values["width"] == 1024
assert values["steps"] == 30
# And everything unset still falls through.
assert values["height"] == wf.DEFAULTS["height"]
assert values["cfg"] == wf.DEFAULTS["cfg"]
def test_a_model_still_beats_an_instance_default():
"""Most specific wins, which is the same ladder the checkpoint and the
workflow already follow."""
values = wf.resolve({"width": 768}, settings={"default_width": 1024})
assert values["width"] == 768
def test_an_empty_setting_is_no_opinion_rather_than_zero():
"""The empty string is how an administrator says "leave this alone". Read as
a number it would set every instance to zero steps, and a zero-step
generation is a refusal from ComfyUI that looks like a broken model."""
values = wf.resolve({}, settings={"default_steps": "", "default_cfg": None})
assert values["steps"] == wf.DEFAULTS["steps"]
assert values["cfg"] == wf.DEFAULTS["cfg"]
def test_an_instance_default_is_clamped_like_any_other():
"""A number written straight into the settings row, or stored by an earlier
version, still has to be safe when a generation reads it."""
values = wf.resolve({}, settings={"default_steps": 9000, "default_width": 4})
assert values["steps"] == wf.LIMITS["steps"][1]
assert values["width"] == wf.LIMITS["width"][0]
def test_no_settings_at_all_behaves_exactly_as_before():
"""The whole safety of adding a rung: an instance that sets nothing is
unchanged.
Everything but the seed, which is deliberately fresh on every call two
resolves that agreed about it would mean the retry loop produced the same
rejected image four times over.
"""
without = wf.resolve({}, settings=None)
plain = wf.resolve({})
del without["seed"], plain["seed"]
assert without == plain
# --- Batch ----------------------------------------------------------------------
def test_batch_fills_from_the_instance_and_not_from_the_model():
"""`batch_size` was a literal 1 in the base template, so an administrator
whose card can comfortably make four at a time had no way of saying so short
of editing JSON. It is deliberately not a tool parameter: a model asking for
six because it is unsure is exactly the cost this must not invite."""
assert "batch" not in wf.MODEL_SETTABLE
assert wf.resolve({}, settings={"default_batch": 4})["batch"] == 4
# A model that invents the key is ignored rather than refused.
assert wf.resolve({"batch": 6}, settings={"default_batch": 2})["batch"] == 2
def test_the_base_template_takes_the_batch_placeholder():
"""A placeholder nothing references is a setting that silently does
nothing."""
assert "batch" in wf.placeholders_in(BASE)
def test_every_placeholder_is_described():
"""The editor's legend is built from this table, so a placeholder added
without one appears in the list with a blank beside it."""
for name in wf.PLACEHOLDERS:
assert name in wf.DESCRIPTIONS, name
assert wf.DESCRIPTIONS[name][1].strip(), name