Changes
diff --git a/designs/design-4-ui.md b/designs/design-4-ui.md
new file mode 100644
index 0000000..32c971c
--- /dev/null
+++ b/designs/design-4-ui.md
@@ -0,0 +1,716 @@
+# Web UI Design
+
+## Purpose
+
+This document describes how `diffusion-cli` should expose a simple
+browser-based web UI on top of server mode.
+
+The UI is for a local user who wants to generate images without typing a
+full command line request each time. It should reuse the existing server
+mode and the existing generation service. It should not become a new
+image generation backend, a replacement for compatibility API profiles,
+or a separate frontend build project.
+
+The UI must be written with Preact and plain JavaScript that runs
+directly in the browser. There should be no Node.js toolchain, no
+bundler, no transpiler, and no JSX transform. The browser should load
+ordinary static files served by Flask.
+
+## Goals
+
+The feature must:
+
+- Add a simple local web UI to `diffusion-cli serve`.
+- Keep the UI separate from API profiles such as `sillytavern-sdcpp`.
+- Use Preact in browser-native JavaScript module files.
+- Avoid a frontend build step.
+- Avoid Bootstrap.
+- Reuse `GenerationService` for all actual image generation.
+- Use the same internal request model as the rest of the server code.
+- Return generated PNG images to the browser for immediate display.
+- Show clear validation, running, success, and failure states.
+- Keep the first implementation small enough to test and maintain.
+
+The feature should not:
+
+- Add image-to-image support.
+- Add LoRA support.
+- Add prompt history persistence.
+- Add user accounts, login, or multi-user authorization.
+- Add async job queues, cancellation, or progress streaming.
+- Add a Node.js package manager workflow.
+- Call the SillyTavern compatibility endpoint from the UI.
+- Change the existing SillyTavern API response shape.
+- Change the generation algorithm.
+
+The reason to keep the first UI small is that image generation requests
+are already expensive and stateful. The value of the first UI is making
+the existing local generator easier to drive. Features such as queues,
+progress bars, persistent galleries, and cancellation require additional
+server semantics and should be designed separately.
+
+## Current State
+
+Server mode already exists in:
+
+```text
+diffusion_cli/server.py
+```
+
+The server currently creates a Flask app, logs requests, constructs a
+`GenerationService`, and registers routes for the selected API profile.
+
+API profile routes live in:
+
+```text
+diffusion_cli/api_profiles.py
+```
+
+The first profile is `sillytavern-sdcpp`. It exists to match the route
+and payload expectations of SillyTavern's stable-diffusion.cpp adapter.
+That profile currently registers:
+
+```text
+GET /health
+OPTIONS /v1/images/generations
+GET /v1/models
+POST /sdapi/v1/txt2img
+```
+
+The generation boundary lives in:
+
+```text
+diffusion_cli/generation_service.py
+```
+
+`GenerationService.generateImages()` accepts an
+`ImageGenerationRequest` and returns PNG bytes wrapped in
+`GeneratedImage` objects. This is the correct layer for the UI to call
+because it is independent of Flask request details and independent of
+external API compatibility quirks.
+
+## Design Summary
+
+Add a project-owned UI layer that is always available when server mode
+is running.
+
+Recommended routes:
+
+```text
+GET /ui/
+GET /ui/app.js
+GET /ui/style.css
+GET /ui/vendor/preact.module.js
+GET /ui/api/defaults
+GET /ui/api/health
+POST /ui/api/txt2img
+```
+
+The `/ui/` routes serve static browser assets. The `/ui/api/...` routes
+are first-party JSON endpoints used only by the web UI. They are not
+compatibility API routes and should not be placed in `api_profiles.py`.
+
+The browser app should call `/ui/api/txt2img`, not
+`/sdapi/v1/txt2img`. The two endpoints may accept similar fields, but
+they have different responsibilities:
+
+- `/sdapi/v1/txt2img` is a compatibility endpoint for external clients.
+- `/ui/api/txt2img` is a local UI endpoint controlled by this project.
+
+This distinction matters because compatibility endpoints often preserve
+client-specific behavior that is not ideal for a first-party UI. For
+example, the SillyTavern endpoint returns A1111-style fields such as
+`images`, `parameters`, and `info`. The UI can use a clearer response
+shape because no external client depends on it.
+
+## External References
+
+The implementation should use these references:
+
+- [Preact no-build tools guide][preact-no-build] for browser-native
+ Preact usage without a build step.
+- [MDN JavaScript modules][mdn-modules] for native browser module
+ loading with `type="module"`.
+- [MDN Fetch API guide][mdn-fetch] for browser JSON requests.
+- [Flask `send_from_directory` documentation][flask-send] for serving
+ static UI files from a package directory.
+- [Flask file upload documentation][flask-upload] for the security
+ rationale behind constraining file serving paths, even though this
+ design does not require uploads.
+
+[preact-no-build]: https://preactjs.com/guide/v10/getting-started/#no-build-tools-route
+[mdn-modules]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
+[mdn-fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
+[flask-send]: https://flask.palletsprojects.com/en/stable/api/#flask.send_from_directory
+[flask-upload]: https://flask.palletsprojects.com/en/stable/patterns/fileuploads/
+
+## Frontend Asset Layout
+
+Add static assets under the Python package:
+
+```text
+diffusion_cli/static/ui/index.html
+diffusion_cli/static/ui/app.js
+diffusion_cli/static/ui/style.css
+diffusion_cli/static/ui/vendor/preact.module.js
+diffusion_cli/static/ui/vendor/LICENSE.preact
+```
+
+The assets live under `diffusion_cli/static` so they can be included in
+the Python package and served by Flask at runtime. Keeping the UI inside
+the package also means users do not need a second checkout, a build
+artifact directory, or a separate web server.
+
+The Preact file should be vendored instead of loaded from a CDN. This
+project already assumes local model files and explicitly avoids
+downloading model files at runtime. The UI should follow the same
+operational style: once installed, server mode should work without
+network access.
+
+`preact.module.js` should be an unmodified browser ESM distribution of
+Preact 10.x. Its license must be included next to the vendored file.
+The exact source URL and version should be documented in a short comment
+or in the license file so the dependency can be updated deliberately.
+
+## Python Packaging
+
+`pyproject.toml` currently uses setuptools package discovery. Static UI
+files must be included in the installed package.
+
+Add package data for the UI files:
+
+```toml
+[tool.setuptools.package-data]
+diffusion_cli = [
+ "static/ui/*",
+ "static/ui/vendor/*",
+]
+```
+
+The package-data entry matters because Python packages do not
+automatically include arbitrary static files in every build backend
+configuration. Without package data, tests may pass from the source tree
+while an installed wheel misses `index.html` or `app.js`.
+
+## Route Registration
+
+UI routes should be registered from `server.py`, not from an API
+profile.
+
+Recommended structure:
+
+```text
+createApp(...)
+ registerUiRoutes(app, context)
+ API_PROFILES[server_config.api_profile].register_routes(app, context)
+```
+
+`registerUiRoutes()` can live in a new module:
+
+```text
+diffusion_cli/ui.py
+```
+
+That keeps the UI-specific request parsing, response formatting, and
+static file path handling out of the API compatibility module.
+
+Recommended public function:
+
+```python
+def registerUiRoutes(app, context) -> None:
+ """Register first-party browser UI routes."""
+```
+
+This function should be the only public entry point in `ui.py` for the
+first implementation. It receives the same app and context objects used
+by API profiles, so it can call `context.generation_service` without
+creating a second service instance.
+
+## Static File Routes
+
+The UI should use explicit routes rather than exposing an arbitrary
+static directory.
+
+Recommended routes:
+
+```text
+GET /ui/
+GET /ui/app.js
+GET /ui/style.css
+GET /ui/vendor/preact.module.js
+GET /ui/vendor/LICENSE.preact
+```
+
+Each route should return a file from:
+
+```text
+diffusion_cli/static/ui
+```
+
+Use Flask's `send_from_directory()` or `send_file()` with a path derived
+from the package directory, not from user input. Avoid a catch-all route
+such as `/ui/<path:name>` in the first implementation. A catch-all route
+is convenient, but explicit routes make it clear which files are public
+and avoid serving files that were not meant to become part of the HTTP
+surface.
+
+The root route should support a trailing slash:
+
+```text
+GET /ui/
+```
+
+The implementation may also redirect:
+
+```text
+GET /ui -> /ui/
+```
+
+That redirect is a usability detail. It is not part of the API contract.
+
+## UI API Routes
+
+### `GET /ui/api/health`
+
+Return basic server and profile information.
+
+Response:
+
+```json
+{
+ "status": "ok",
+ "api_profile": "sillytavern-sdcpp",
+ "model_residency": "cpu-cache"
+}
+```
+
+This endpoint lets the UI verify that it is talking to the expected
+server. It is separate from `/health` because `/health` currently belongs
+to the selected compatibility profile. A future profile might not expose
+`/health`, or it might expose a different response shape.
+
+### `GET /ui/api/defaults`
+
+Return the default values that the form should show before the user
+edits anything.
+
+Response:
+
+```json
+{
+ "negative_prompt": "",
+ "width": 832,
+ "height": 1248,
+ "batch_size": 1,
+ "steps": 10,
+ "cfg": 1.0,
+ "seed": -1
+}
+```
+
+The values should be derived from the loaded `UserConfig` plus built-in
+generation defaults. They should not be copied into JavaScript as a
+second source of truth.
+
+If producing these defaults requires a new helper in `config.py`, prefer
+that helper over duplicating constants in `ui.py`. A reasonable helper
+shape is:
+
+```python
+def buildDefaultGenerationRequest(user_config: UserConfig):
+ """Build UI-visible generation defaults from config and fallbacks."""
+```
+
+The helper should not require a prompt because the UI form starts empty.
+Prompt is request-specific user input, not a persistent default.
+
+### `POST /ui/api/txt2img`
+
+Generate images from a browser form request.
+
+Request:
+
+```json
+{
+ "prompt": "a ceramic mug on a desk",
+ "negative_prompt": "text, watermark",
+ "width": 832,
+ "height": 1248,
+ "batch_size": 1,
+ "steps": 10,
+ "cfg": 1.0,
+ "seed": -1
+}
+```
+
+Field meanings:
+
+`prompt`
+
+: Required positive prompt. It must be a non-empty string after
+ trimming whitespace.
+
+`negative_prompt`
+
+: Optional negative prompt. Empty string should be treated like no
+ negative prompt if the internal request model expects `None`.
+
+`width`
+
+: Optional output width. It must be a positive multiple of 8.
+
+`height`
+
+: Optional output height. It must be a positive multiple of 8.
+
+`batch_size`
+
+: Optional number of images. It must be at least 1.
+
+`steps`
+
+: Optional denoising step count. It must be at least 1.
+
+`cfg`
+
+: Optional classifier-free guidance scale. It must be non-negative.
+
+`seed`
+
+: Optional seed. A value of `-1` means random seed and should become
+ `None` in `ImageGenerationRequest`.
+
+Response:
+
+```json
+{
+ "images": [
+ {
+ "mime_type": "image/png",
+ "data": "iVBORw0KGgo...",
+ "seed": 123
+ }
+ ]
+}
+```
+
+The response should use bare base64 image data plus an explicit MIME
+type. The frontend can build a display URL with:
+
+```text
+data:image/png;base64,<data>
+```
+
+The API should not write UI results to disk. File output is part of the
+CLI path. The UI path is interactive and should return image bytes to
+the browser.
+
+## Request Validation
+
+The UI API should validate JSON before calling `GenerationService`.
+
+Invalid requests should return:
+
+```json
+{
+ "error": "bad_request",
+ "message": "prompt required"
+}
+```
+
+with HTTP status:
+
+```text
+400 Bad Request
+```
+
+Generation failures should return:
+
+```json
+{
+ "error": "generation_failed",
+ "message": "generation failed"
+}
+```
+
+with HTTP status:
+
+```text
+500 Internal Server Error
+```
+
+When the failure is a `DiffusionCliError`, the response may include the
+exception message. For unexpected exceptions, log the exception on the
+server and return a generic message to the browser. This matches the
+existing API profile pattern and avoids exposing Python tracebacks in the
+UI.
+
+Validation should reuse existing helpers where possible. In particular,
+dimension checks should use `validateDimensions()` from `config.py` so
+the UI does not drift away from CLI behavior.
+
+## Frontend Implementation
+
+The browser app should use Preact without JSX.
+
+`index.html` should load the app as a module:
+
+```html
+<script type="module" src="/ui/app.js"></script>
+```
+
+`app.js` should import Preact from the vendored module:
+
+```js
+import { h, render } from "/ui/vendor/preact.module.js";
+```
+
+The app can define small components with plain `h()` calls. This is more
+verbose than JSX, but it keeps the implementation honest: the file is
+plain JavaScript that the browser can execute directly.
+
+Recommended component split:
+
+```text
+App
+GenerationForm
+NumberField
+TextAreaField
+ResultGallery
+StatusMessage
+```
+
+The split should stay practical. Components should exist when they make
+state and markup easier to understand, not to imitate a larger frontend
+framework structure.
+
+### Frontend State
+
+Recommended top-level state:
+
+```js
+{
+ defaults: null,
+ form: {
+ prompt: "",
+ negative_prompt: "",
+ width: 832,
+ height: 1248,
+ batch_size: 1,
+ steps: 10,
+ cfg: 1.0,
+ seed: -1
+ },
+ is_loading_defaults: true,
+ is_generating: false,
+ error_message: "",
+ images: []
+}
+```
+
+`is_generating` should disable the generate button and input fields
+while a request is in flight. The server serializes generation with a
+lock, so allowing multiple rapid submissions from the same UI would only
+queue HTTP requests and make the user experience less clear.
+
+### Frontend Request Flow
+
+The submit flow should be:
+
+1. Prevent the browser's default form submit.
+2. Clear the previous error.
+3. Set `is_generating` to `true`.
+4. Send `POST /ui/api/txt2img` with JSON.
+5. If the response is not OK, parse the JSON error message when present.
+6. If the response is OK, replace `images` with the returned images.
+7. Set `is_generating` to `false` in a `finally` block.
+
+The `finally` step matters because failed network requests, server
+errors, and successful generation must all return the UI to an editable
+state.
+
+## Visual Design
+
+The UI should be a compact tool, not a marketing page.
+
+Recommended layout:
+
+- A full-width app shell with a narrow top bar.
+- A two-column desktop layout.
+- A single-column mobile layout.
+- The form on the left or top.
+- The result area on the right or below.
+- No cards inside cards.
+- No Bootstrap classes.
+- No decorative gradients or ornamental backgrounds.
+
+The target user is repeatedly generating local images. The layout should
+therefore favor scanning, editing, and re-running requests. Controls
+should stay close to the result area so the prompt-to-output loop is
+short.
+
+Recommended controls:
+
+- Textarea for `prompt`.
+- Textarea for `negative_prompt`.
+- Number inputs for `width`, `height`, `steps`, `cfg`, `batch_size`,
+ and `seed`.
+- Checkbox or small button for random seed behavior.
+- Primary button for generation.
+
+The UI should display generated images at a bounded size while allowing
+the user to open the full image in a new tab. The simplest first version
+is to wrap each image in an anchor whose `href` is the data URL.
+
+## Security Model
+
+The server default host remains:
+
+```text
+127.0.0.1
+```
+
+This means the UI is local by default. If a user binds the server to
+`0.0.0.0`, the UI becomes reachable by other machines on the network.
+That is already true for the API routes. The UI design should not add
+features that make remote exposure more dangerous, such as arbitrary
+file browsing, file uploads, shell command execution, or config editing.
+
+The first UI should not implement authentication. Authentication only
+makes sense with a broader remote-use design, including transport
+security and deployment guidance. A local-only first version is simpler
+and matches the current server's default behavior.
+
+The UI must not accept file paths from the browser. Model paths and
+output paths remain server-side configuration. This avoids exposing local
+filesystem details and avoids turning the browser into a remote file
+selection surface.
+
+## Concurrency Model
+
+`GenerationService` already serializes generation with a lock.
+
+The UI should assume exactly one generation request is active per browser
+tab. It should disable submission while waiting. This does not prevent a
+second browser tab from submitting another request, but the service lock
+will serialize those requests on the server.
+
+The first UI should not display queue position because the server does
+not have a queue abstraction. The Flask development server may have
+multiple request threads, but the generation lock is the actual
+generation concurrency boundary.
+
+## Error Handling
+
+Frontend error handling should distinguish:
+
+- Validation errors returned by the server.
+- Generation failures returned by the server.
+- Network failures where no JSON response is available.
+
+For validation errors, show the server message. For generation failures,
+show the server message if present. For network failures, show a short
+generic message such as:
+
+```text
+Request failed. Check the server log for details.
+```
+
+Do not show Python tracebacks in the browser. The server log is the
+right place for tracebacks.
+
+## Testing Strategy
+
+### Python Route Tests
+
+Add tests for UI route registration in a new file:
+
+```text
+tests/test_ui.py
+```
+
+Recommended tests:
+
+- `GET /ui/` returns `200`.
+- `GET /ui/app.js` returns JavaScript content.
+- `GET /ui/style.css` returns CSS content.
+- `GET /ui/api/health` returns profile and residency values.
+- `GET /ui/api/defaults` returns numeric generation defaults.
+- `POST /ui/api/txt2img` calls a fake generation service.
+- Invalid prompt returns `400`.
+- Generation service errors return `500`.
+
+The fake generation service should mirror the existing server tests:
+
+```python
+class FakeGenerationService:
+ def __init__(self):
+ self.requests = []
+
+ def generateImages(self, request):
+ self.requests.append(request)
+ return [GeneratedImage(b"\x89PNG\r\n\x1a\nimage", "png", 123)]
+```
+
+This keeps the UI route tests fast and avoids loading model files.
+
+### Static Asset Tests
+
+Static asset tests should verify that package paths are correct. They
+should not execute Preact in Python.
+
+Useful assertions:
+
+- `index.html` contains `/ui/app.js`.
+- `app.js` imports `/ui/vendor/preact.module.js`.
+- `pyproject.toml` includes UI static package data.
+
+### Browser Tests
+
+Do not require browser automation for the first implementation unless
+the project already has that dependency. The no-build Preact app is small
+enough that server-side route tests plus manual browser verification are
+acceptable for the first version.
+
+If browser automation is added later, it should verify:
+
+- The form renders.
+- The generate button disables while a request is active.
+- A successful fake response displays an image.
+- A server error displays an error message.
+
+## Implementation Plan
+
+1. Add `diffusion_cli/ui.py`.
+2. Add static files under `diffusion_cli/static/ui`.
+3. Add vendored Preact and its license under `static/ui/vendor`.
+4. Register UI routes from `createApp()` in `server.py`.
+5. Add UI API request parsing and validation.
+6. Add UI API response formatting.
+7. Add package-data configuration in `pyproject.toml`.
+8. Add tests in `tests/test_ui.py`.
+9. Run the existing test suite.
+10. Start `uv run diffusion-cli serve ...` and manually open `/ui/`.
+
+The implementation should keep each step small. Static serving can be
+tested before image generation is wired up. The UI API can be tested
+with a fake generation service before the browser app is complete.
+
+## Future Work
+
+The following features are intentionally out of scope for the first
+version:
+
+- Async job IDs.
+- Cancellation.
+- Progress updates.
+- Server-sent events or WebSockets.
+- Prompt history.
+- Persistent image gallery.
+- Saving UI results to a configured output directory.
+- Model and checkpoint selection from the browser.
+- Authentication for remote access.
+
+These features are useful, but each one changes server behavior enough
+to deserve a separate design. The first UI should establish a clean local
+control surface without changing the generation backend contract.
diff --git a/diffusion_cli/config.py b/diffusion_cli/config.py
index 42b4cce..c7a6f0f 100644
--- a/diffusion_cli/config.py
+++ b/diffusion_cli/config.py
@@ -25,6 +25,42 @@ DEFAULT_MULTIPLIER = 1.0
LATENT_CHANNELS = 16
LATENT_DOWNSCALE = 8
TOKENIZER_FILES = ("vocab.json", "merges.txt", "tokenizer_config.json")
+# UI-visible Z Image Turbo resolution presets, grouped by base resolution.
+UI_RESOLUTION_PRESETS = (
+ (1024, 1024, "1024", "1:1"),
+ (1152, 896, "1024", "9:7"),
+ (896, 1152, "1024", "7:9"),
+ (1152, 864, "1024", "4:3"),
+ (864, 1152, "1024", "3:4"),
+ (1248, 832, "1024", "3:2"),
+ (832, 1248, "1024", "2:3"),
+ (1280, 720, "1024", "16:9"),
+ (720, 1280, "1024", "9:16"),
+ (1344, 576, "1024", "21:9"),
+ (576, 1344, "1024", "9:21"),
+ (1280, 1280, "1280", "1:1"),
+ (1440, 1120, "1280", "9:7"),
+ (1120, 1440, "1280", "7:9"),
+ (1472, 1104, "1280", "4:3"),
+ (1104, 1472, "1280", "3:4"),
+ (1536, 1024, "1280", "3:2"),
+ (1024, 1536, "1280", "2:3"),
+ (1536, 864, "1280", "16:9"),
+ (864, 1536, "1280", "9:16"),
+ (1680, 720, "1280", "21:9"),
+ (720, 1680, "1280", "9:21"),
+ (1536, 1536, "1536", "1:1"),
+ (1728, 1344, "1536", "9:7"),
+ (1344, 1728, "1536", "7:9"),
+ (1728, 1296, "1536", "4:3"),
+ (1296, 1728, "1536", "3:4"),
+ (1872, 1248, "1536", "3:2"),
+ (1248, 1872, "1536", "2:3"),
+ (2048, 1152, "1536", "16:9"),
+ (1152, 2048, "1536", "9:16"),
+ (2016, 864, "1536", "21:9"),
+ (864, 2016, "1536", "9:21"),
+)
TOP_LEVEL_CONFIG_KEYS = {"models", "generation"}
MODEL_CONFIG_KEYS = {
"checkpoint",
@@ -468,6 +504,24 @@ def buildGenerationConfigFromRequest(
)
+def buildDefaultGenerationRequest(
+ user_config: UserConfig,
+) -> ImageGenerationRequest:
+ """Build UI-visible generation defaults from config and fallbacks."""
+
+ generation = user_config.generation
+ return ImageGenerationRequest(
+ prompt="",
+ negative_prompt=generation.negative_prompt or "",
+ seed=-1,
+ width=coalesce(generation.width, DEFAULT_WIDTH),
+ height=coalesce(generation.height, DEFAULT_HEIGHT),
+ batch_size=coalesce(generation.batch_size, DEFAULT_BATCH_SIZE),
+ steps=coalesce(generation.steps, DEFAULT_STEPS),
+ cfg=coalesce(generation.cfg, DEFAULT_CFG),
+ )
+
+
def buildGenerationConfig(
args,
user_config: UserConfig | None = None,
diff --git a/diffusion_cli/server.py b/diffusion_cli/server.py
index 507d054..8791bf3 100644
--- a/diffusion_cli/server.py
+++ b/diffusion_cli/server.py
@@ -13,6 +13,7 @@ from diffusion_cli.generation_service import (
MODEL_RESIDENCY_VALUES,
GenerationService,
)
+from diffusion_cli.ui import registerUiRoutes
BINARY_JSON_FIELDS = ("init_images", "mask", "extra_images")
@@ -93,6 +94,7 @@ def createApp(server_config: ServerConfig, generation_service) -> Flask:
body,
)
+ registerUiRoutes(app, context)
API_PROFILES[server_config.api_profile].register_routes(app, context)
return app
diff --git a/diffusion_cli/static/ui/app.js b/diffusion_cli/static/ui/app.js
new file mode 100644
index 0000000..9430dc8
--- /dev/null
+++ b/diffusion_cli/static/ui/app.js
@@ -0,0 +1,456 @@
+import { Component, h, render } from "/ui/vendor/preact.module.js";
+
+const INITIAL_FORM = {
+ prompt: "",
+ negative_prompt: "",
+ width: 832,
+ height: 1248,
+ batch_size: 1,
+ steps: 10,
+ cfg: 1.0,
+ seed: -1,
+};
+
+function apiMessage(error, fallback) {
+ if(error && typeof error.message === "string" && error.message)
+ {
+ return error.message;
+ }
+ return fallback;
+}
+
+async function readJson(response) {
+ const text = await response.text();
+ if(!text)
+ {
+ return null;
+ }
+ try
+ {
+ return JSON.parse(text);
+ }
+ catch(_error)
+ {
+ return null;
+ }
+}
+
+function fieldValue(value, fallback) {
+ if(value === "" || value === null || value === undefined)
+ {
+ return fallback;
+ }
+ return value;
+}
+
+function NumberField(props) {
+ return h(
+ "label",
+ { class: "field" },
+ h("span", { class: "field-label" }, props.label),
+ h("input", {
+ type: "number",
+ name: props.name,
+ min: props.min,
+ step: props.step,
+ value: props.value,
+ disabled: props.disabled,
+ onInput: props.onInput,
+ }),
+ );
+}
+
+function TextAreaField(props) {
+ return h(
+ "label",
+ { class: "field field-wide" },
+ h("span", { class: "field-label" }, props.label),
+ h("textarea", {
+ name: props.name,
+ rows: props.rows,
+ value: props.value,
+ disabled: props.disabled,
+ onInput: props.onInput,
+ }),
+ );
+}
+
+function ResolutionField(props) {
+ const groups = {};
+ for(const preset of props.presets)
+ {
+ if(!groups[preset.resolution])
+ {
+ groups[preset.resolution] = [];
+ }
+ groups[preset.resolution].push(preset);
+ }
+
+ return h(
+ "label",
+ { class: "field field-wide" },
+ h("span", { class: "field-label" }, "Resolution"),
+ h(
+ "select",
+ {
+ name: "resolution",
+ value: props.value,
+ disabled: props.disabled,
+ onInput: props.onInput,
+ },
+ Object.keys(groups).map((resolution) => h(
+ "optgroup",
+ { key: resolution, label: resolution },
+ groups[resolution].map((preset) => h(
+ "option",
+ {
+ key: preset.width + "x" + preset.height,
+ value: preset.width + "x" + preset.height,
+ },
+ preset.label,
+ )),
+ )),
+ ),
+ );
+}
+
+function StatusMessage(props) {
+ const class_name = props.error
+ ? "status-message status-error"
+ : "status-message";
+ return h(
+ "div",
+ { class: class_name, role: props.error ? "alert" : null },
+ props.children,
+ );
+}
+
+function ResultGallery(props) {
+ if(props.images.length === 0)
+ {
+ return h(
+ "section",
+ { class: "results empty-results", "aria-label": "Results" },
+ h("div", { class: "empty-box" }, "No image generated"),
+ );
+ }
+
+ return h(
+ "section",
+ { class: "results", "aria-label": "Results" },
+ props.images.map((image, index) => {
+ const url = "data:" + image.mime_type + ";base64," + image.data;
+ return h(
+ "figure",
+ { class: "result-item", key: index },
+ h(
+ "a",
+ {
+ href: url,
+ target: "_blank",
+ rel: "noreferrer",
+ },
+ h("img", { src: url, alt: "Generated image" }),
+ ),
+ h("figcaption", null, "Seed ", String(image.seed)),
+ );
+ }),
+ );
+}
+
+function GenerationForm(props) {
+ const form = props.form;
+ const disabled = props.disabled;
+ const numeric = [
+ ["steps", "Steps", 1, 1],
+ ["cfg", "CFG", 0, 0.1],
+ ["batch_size", "Batch", 1, 1],
+ ];
+
+ return h(
+ "form",
+ { class: "generation-form", onSubmit: props.onSubmit },
+ h(TextAreaField, {
+ label: "Prompt",
+ name: "prompt",
+ rows: 7,
+ value: form.prompt,
+ disabled,
+ onInput: props.onInput,
+ }),
+ h(TextAreaField, {
+ label: "Negative prompt",
+ name: "negative_prompt",
+ rows: 4,
+ value: form.negative_prompt,
+ disabled,
+ onInput: props.onInput,
+ }),
+ h(ResolutionField, {
+ presets: props.resolution_presets,
+ value: form.width + "x" + form.height,
+ disabled,
+ onInput: props.onResolutionInput,
+ }),
+ h(
+ "div",
+ { class: "field-grid" },
+ numeric.map((item) => h(NumberField, {
+ key: item[0],
+ name: item[0],
+ label: item[1],
+ min: item[2],
+ step: item[3],
+ value: form[item[0]],
+ disabled,
+ onInput: props.onInput,
+ })),
+ h(
+ "div",
+ { class: "seed-row" },
+ h(NumberField, {
+ name: "seed",
+ label: "Seed",
+ step: 1,
+ value: form.seed,
+ disabled: disabled || form.seed === -1,
+ onInput: props.onInput,
+ }),
+ h(
+ "label",
+ { class: "random-seed" },
+ h("input", {
+ type: "checkbox",
+ checked: form.seed === -1,
+ disabled,
+ onChange: props.onRandomSeed,
+ }),
+ h("span", null, "Random"),
+ ),
+ ),
+ ),
+ h(
+ "button",
+ {
+ class: "primary-action",
+ type: "submit",
+ disabled,
+ },
+ props.is_generating ? "Generating" : "Generate",
+ ),
+ );
+}
+
+class App extends Component {
+ constructor() {
+ super();
+ this.state = {
+ defaults: null,
+ health: null,
+ form: { ...INITIAL_FORM },
+ resolution_presets: [],
+ is_loading_defaults: true,
+ is_generating: false,
+ error_message: "",
+ images: [],
+ };
+ this.handleInput = this.handleInput.bind(this);
+ this.handleResolutionInput = this.handleResolutionInput.bind(this);
+ this.handleRandomSeed = this.handleRandomSeed.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ componentDidMount() {
+ this.loadInitialState();
+ }
+
+ async loadInitialState() {
+ try
+ {
+ const [defaults_response, health_response] = await Promise.all([
+ fetch("/ui/api/defaults"),
+ fetch("/ui/api/health"),
+ ]);
+ const defaults = await readJson(defaults_response);
+ const health = await readJson(health_response);
+ if(!defaults_response.ok || !defaults)
+ {
+ throw new Error("Could not load defaults");
+ }
+ const resolution_presets = defaults.resolution_presets || [];
+ const form_defaults = { ...defaults };
+ delete form_defaults.resolution_presets;
+ const form = { ...INITIAL_FORM, ...form_defaults, prompt: "" };
+ const is_valid_resolution = resolution_presets.some(
+ (preset) => (
+ preset.width === form.width
+ && preset.height === form.height
+ ),
+ );
+ if(!is_valid_resolution && resolution_presets.length > 0)
+ {
+ form.width = resolution_presets[0].width;
+ form.height = resolution_presets[0].height;
+ }
+ this.setState({
+ defaults,
+ health: health_response.ok ? health : null,
+ form,
+ resolution_presets,
+ is_loading_defaults: false,
+ });
+ }
+ catch(_error)
+ {
+ this.setState({
+ is_loading_defaults: false,
+ error_message: "Could not load defaults.",
+ });
+ }
+ }
+
+ handleInput(event) {
+ const target = event.currentTarget;
+ const name = target.name;
+ let value = target.value;
+ if(target.type === "number")
+ {
+ value = value === "" ? "" : Number(value);
+ }
+ this.setState((state) => ({
+ form: { ...state.form, [name]: value },
+ }));
+ }
+
+ handleResolutionInput(event) {
+ const value = event.currentTarget.value;
+ const preset = this.state.resolution_presets.find(
+ (item) => value === item.width + "x" + item.height,
+ );
+ if(!preset)
+ {
+ return;
+ }
+ this.setState((state) => ({
+ form: {
+ ...state.form,
+ width: preset.width,
+ height: preset.height,
+ },
+ }));
+ }
+
+ handleRandomSeed(event) {
+ const checked = event.currentTarget.checked;
+ this.setState((state) => ({
+ form: {
+ ...state.form,
+ seed: checked ? -1 : 0,
+ },
+ }));
+ }
+
+ requestBody() {
+ const form = this.state.form;
+ return {
+ prompt: form.prompt,
+ negative_prompt: form.negative_prompt,
+ width: fieldValue(form.width, null),
+ height: fieldValue(form.height, null),
+ batch_size: fieldValue(form.batch_size, null),
+ steps: fieldValue(form.steps, null),
+ cfg: fieldValue(form.cfg, null),
+ seed: fieldValue(form.seed, -1),
+ };
+ }
+
+ async handleSubmit(event) {
+ event.preventDefault();
+ this.setState({
+ error_message: "",
+ is_generating: true,
+ });
+
+ try
+ {
+ const response = await fetch("/ui/api/txt2img", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(this.requestBody()),
+ });
+ const data = await readJson(response);
+ if(!response.ok)
+ {
+ throw new Error(apiMessage(
+ data,
+ "Request failed. Check the server log for details.",
+ ));
+ }
+ this.setState({ images: data.images || [] });
+ }
+ catch(error)
+ {
+ this.setState({
+ error_message: apiMessage(
+ error,
+ "Request failed. Check the server log for details.",
+ ),
+ });
+ }
+ finally
+ {
+ this.setState({ is_generating: false });
+ }
+ }
+
+ render(_props, state) {
+ const disabled = state.is_loading_defaults || state.is_generating;
+ const status_text = state.is_generating
+ ? "Generating"
+ : state.is_loading_defaults ? "Loading" : "Ready";
+ const health_text = state.health
+ ? state.health.api_profile + " / " + state.health.model_residency
+ : "Server";
+
+ return h(
+ "div",
+ { class: "app-shell" },
+ h(
+ "header",
+ { class: "top-bar" },
+ h("div", { class: "brand" }, "diffusion-cli"),
+ h(
+ "div",
+ { class: "server-state" },
+ h("span", null, health_text),
+ h("span", { class: "state-pill" }, status_text),
+ ),
+ ),
+ h(
+ "main",
+ { class: "workspace" },
+ h(
+ "section",
+ { class: "form-panel", "aria-label": "Generation" },
+ state.error_message
+ ? h(StatusMessage, { error: true },
+ state.error_message)
+ : h(StatusMessage, null, status_text),
+ h(GenerationForm, {
+ form: state.form,
+ resolution_presets: state.resolution_presets,
+ disabled,
+ is_generating: state.is_generating,
+ onInput: this.handleInput,
+ onResolutionInput: this.handleResolutionInput,
+ onRandomSeed: this.handleRandomSeed,
+ onSubmit: this.handleSubmit,
+ }),
+ ),
+ h(ResultGallery, { images: state.images }),
+ ),
+ );
+ }
+}
+
+render(h(App), document.getElementById("app"));
diff --git a/diffusion_cli/static/ui/index.html b/diffusion_cli/static/ui/index.html
new file mode 100644
index 0000000..615c595
--- /dev/null
+++ b/diffusion_cli/static/ui/index.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>diffusion-cli</title>
+ <link rel="stylesheet" href="/ui/style.css">
+</head>
+<body>
+ <div id="app"></div>
+ <script type="module" src="/ui/app.js"></script>
+</body>
+</html>
diff --git a/diffusion_cli/static/ui/style.css b/diffusion_cli/static/ui/style.css
new file mode 100644
index 0000000..4b620d4
--- /dev/null
+++ b/diffusion_cli/static/ui/style.css
@@ -0,0 +1,338 @@
+:root
+{
+ color-scheme: light;
+ --bg: #f6f7f4;
+ --surface: #ffffff;
+ --surface-muted: #eef1ed;
+ --border: #cfd5cf;
+ --text: #1d2520;
+ --muted: #647067;
+ --accent: #1f7a64;
+ --accent-dark: #155846;
+ --danger: #a83333;
+}
+
+*
+{
+ box-sizing: border-box;
+}
+
+body
+{
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+}
+
+button,
+input,
+select,
+textarea
+{
+ font: inherit;
+}
+
+.app-shell
+{
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.top-bar
+{
+ min-height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.brand
+{
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.server-state
+{
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.state-pill
+{
+ min-width: 78px;
+ padding: 4px 10px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--text);
+ text-align: center;
+}
+
+.workspace
+{
+ width: 100%;
+ max-width: 1360px;
+ margin: 0 auto;
+ padding: 20px;
+ display: grid;
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
+ gap: 20px;
+ flex: 1;
+}
+
+.form-panel,
+.results
+{
+ min-width: 0;
+}
+
+.form-panel
+{
+ align-self: start;
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.generation-form
+{
+ display: grid;
+ gap: 14px;
+}
+
+.field
+{
+ display: grid;
+ gap: 6px;
+}
+
+.field-label
+{
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+textarea,
+select,
+input[type="number"]
+{
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: #fbfcfb;
+ color: var(--text);
+ outline: none;
+}
+
+textarea
+{
+ resize: vertical;
+ padding: 10px 11px;
+ line-height: 1.45;
+}
+
+input[type="number"]
+{
+ height: 38px;
+ padding: 0 10px;
+}
+
+select
+{
+ height: 38px;
+ padding: 0 36px 0 10px;
+}
+
+textarea:focus,
+select:focus,
+input[type="number"]:focus
+{
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(31, 122, 100, 0.16);
+}
+
+textarea:disabled,
+select:disabled,
+input:disabled
+{
+ opacity: 0.64;
+}
+
+.field-grid
+{
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.seed-row
+{
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 12px;
+}
+
+.random-seed
+{
+ min-height: 38px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface-muted);
+ color: var(--text);
+ font-size: 14px;
+}
+
+.primary-action
+{
+ width: 100%;
+ height: 42px;
+ border: 1px solid var(--accent-dark);
+ border-radius: 6px;
+ background: var(--accent);
+ color: #ffffff;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.primary-action:hover:not(:disabled)
+{
+ background: var(--accent-dark);
+}
+
+.primary-action:disabled
+{
+ cursor: wait;
+ opacity: 0.72;
+}
+
+.status-message
+{
+ min-height: 36px;
+ margin-bottom: 12px;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface-muted);
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.status-error
+{
+ border-color: #d9abab;
+ background: #fff4f3;
+ color: var(--danger);
+}
+
+.results
+{
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ align-content: start;
+ gap: 16px;
+}
+
+.empty-results
+{
+ min-height: 360px;
+ display: grid;
+ place-items: center;
+ border: 1px dashed var(--border);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.5);
+}
+
+.empty-box
+{
+ color: var(--muted);
+ font-size: 15px;
+}
+
+.result-item
+{
+ margin: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--surface);
+}
+
+.result-item a
+{
+ display: block;
+ background: #dfe5df;
+}
+
+.result-item img
+{
+ width: 100%;
+ max-height: 72vh;
+ display: block;
+ object-fit: contain;
+}
+
+.result-item figcaption
+{
+ padding: 8px 10px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+@media (max-width: 820px)
+{
+ .top-bar
+ {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .workspace
+ {
+ grid-template-columns: 1fr;
+ padding: 14px;
+ }
+
+ .form-panel
+ {
+ padding: 14px;
+ }
+}
+
+@media (max-width: 480px)
+{
+ .field-grid,
+ .seed-row
+ {
+ grid-template-columns: 1fr;
+ }
+
+ .server-state
+ {
+ width: 100%;
+ justify-content: space-between;
+ }
+}
diff --git a/diffusion_cli/static/ui/vendor/LICENSE.preact b/diffusion_cli/static/ui/vendor/LICENSE.preact
new file mode 100644
index 0000000..680805c
--- /dev/null
+++ b/diffusion_cli/static/ui/vendor/LICENSE.preact
@@ -0,0 +1,26 @@
+Vendored dependency:
+Preact 10.26.9 browser ESM distribution
+Source: https://unpkg.com/preact@10.26.9/dist/preact.module.js
+License source: https://github.com/preactjs/preact/blob/10.26.9/LICENSE
+
+The MIT License (MIT)
+
+Copyright (c) 2015-present Jason Miller
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/diffusion_cli/static/ui/vendor/preact.module.js b/diffusion_cli/static/ui/vendor/preact.module.js
new file mode 100644
index 0000000..c65f28f
--- /dev/null
+++ b/diffusion_cli/static/ui/vendor/preact.module.js
@@ -0,0 +1,2 @@
+var n,l,u,t,i,r,o,e,f,c,s,a,h,p={},v=[],y=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,w=Array.isArray;function d(n,l){for(var u in l)n[u]=l[u];return n}function g(n){n&&n.parentNode&&n.parentNode.removeChild(n)}function _(l,u,t){var i,r,o,e={};for(o in u)"key"==o?i=u[o]:"ref"==o?r=u[o]:e[o]=u[o];if(arguments.length>2&&(e.children=arguments.length>3?n.call(arguments,2):t),"function"==typeof l&&null!=l.defaultProps)for(o in l.defaultProps)void 0===e[o]&&(e[o]=l.defaultProps[o]);return m(l,e,i,r,null)}function m(n,t,i,r,o){var e={type:n,props:t,key:i,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:null==o?++u:o,__i:-1,__u:0};return null==o&&null!=l.vnode&&l.vnode(e),e}function b(){return{current:null}}function k(n){return n.children}function x(n,l){this.props=n,this.context=l}function S(n,l){if(null==l)return n.__?S(n.__,n.__i+1):null;for(var u;l<n.__k.length;l++)if(null!=(u=n.__k[l])&&null!=u.__e)return u.__e;return"function"==typeof n.type?S(n):null}function C(n){var l,u;if(null!=(n=n.__)&&null!=n.__c){for(n.__e=n.__c.base=null,l=0;l<n.__k.length;l++)if(null!=(u=n.__k[l])&&null!=u.__e){n.__e=n.__c.base=u.__e;break}return C(n)}}function M(n){(!n.__d&&(n.__d=!0)&&i.push(n)&&!$.__r++||r!=l.debounceRendering)&&((r=l.debounceRendering)||o)($)}function $(){for(var n,u,t,r,o,f,c,s=1;i.length;)i.length>s&&i.sort(e),n=i.shift(),s=i.length,n.__d&&(t=void 0,o=(r=(u=n).__v).__e,f=[],c=[],u.__P&&((t=d({},r)).__v=r.__v+1,l.vnode&&l.vnode(t),O(u.__P,t,r,u.__n,u.__P.namespaceURI,32&r.__u?[o]:null,f,null==o?S(r):o,!!(32&r.__u),c),t.__v=r.__v,t.__.__k[t.__i]=t,z(f,t,c),t.__e!=o&&C(t)));$.__r=0}function I(n,l,u,t,i,r,o,e,f,c,s){var a,h,y,w,d,g,_=t&&t.__k||v,m=l.length;for(f=P(u,l,_,f,m),a=0;a<m;a++)null!=(y=u.__k[a])&&(h=-1==y.__i?p:_[y.__i]||p,y.__i=a,g=O(n,y,h,i,r,o,e,f,c,s),w=y.__e,y.ref&&h.ref!=y.ref&&(h.ref&&q(h.ref,null,y),s.push(y.ref,y.__c||w,y)),null==d&&null!=w&&(d=w),4&y.__u||h.__k===y.__k?f=A(y,f,n):"function"==typeof y.type&&void 0!==g?f=g:w&&(f=w.nextSibling),y.__u&=-7);return u.__e=d,f}function P(n,l,u,t,i){var r,o,e,f,c,s=u.length,a=s,h=0;for(n.__k=new Array(i),r=0;r<i;r++)null!=(o=l[r])&&"boolean"!=typeof o&&"function"!=typeof o?(f=r+h,(o=n.__k[r]="string"==typeof o||"number"==typeof o||"bigint"==typeof o||o.constructor==String?m(null,o,null,null,null):w(o)?m(k,{children:o},null,null,null):null==o.constructor&&o.__b>0?m(o.type,o.props,o.key,o.ref?o.ref:null,o.__v):o).__=n,o.__b=n.__b+1,e=null,-1!=(c=o.__i=L(o,u,f,a))&&(a--,(e=u[c])&&(e.__u|=2)),null==e||null==e.__v?(-1==c&&(i>s?h--:i<s&&h++),"function"!=typeof o.type&&(o.__u|=4)):c!=f&&(c==f-1?h--:c==f+1?h++:(c>f?h--:h++,o.__u|=4))):n.__k[r]=null;if(a)for(r=0;r<s;r++)null!=(e=u[r])&&0==(2&e.__u)&&(e.__e==t&&(t=S(e)),B(e,e));return t}function A(n,l,u){var t,i;if("function"==typeof n.type){for(t=n.__k,i=0;t&&i<t.length;i++)t[i]&&(t[i].__=n,l=A(t[i],l,u));return l}n.__e!=l&&(l&&n.type&&!u.contains(l)&&(l=S(n)),u.insertBefore(n.__e,l||null),l=n.__e);do{l=l&&l.nextSibling}while(null!=l&&8==l.nodeType);return l}function H(n,l){return l=l||[],null==n||"boolean"==typeof n||(w(n)?n.some(function(n){H(n,l)}):l.push(n)),l}function L(n,l,u,t){var i,r,o=n.key,e=n.type,f=l[u];if(null===f&&null==n.key||f&&o==f.key&&e==f.type&&0==(2&f.__u))return u;if(t>(null!=f&&0==(2&f.__u)?1:0))for(i=u-1,r=u+1;i>=0||r<l.length;){if(i>=0){if((f=l[i])&&0==(2&f.__u)&&o==f.key&&e==f.type)return i;i--}if(r<l.length){if((f=l[r])&&0==(2&f.__u)&&o==f.key&&e==f.type)return r;r++}}return-1}function T(n,l,u){"-"==l[0]?n.setProperty(l,null==u?"":u):n[l]=null==u?"":"number"!=typeof u||y.test(l)?u:u+"px"}function j(n,l,u,t,i){var r,o;n:if("style"==l)if("string"==typeof u)n.style.cssText=u;else{if("string"==typeof t&&(n.style.cssText=t=""),t)for(l in t)u&&l in u||T(n.style,l,"");if(u)for(l in u)t&&u[l]==t[l]||T(n.style,l,u[l])}else if("o"==l[0]&&"n"==l[1])r=l!=(l=l.replace(f,"$1")),o=l.toLowerCase(),l=o in n||"onFocusOut"==l||"onFocusIn"==l?o.slice(2):l.slice(2),n.l||(n.l={}),n.l[l+r]=u,u?t?u.u=t.u:(u.u=c,n.addEventListener(l,r?a:s,r)):n.removeEventListener(l,r?a:s,r);else{if("http://www.w3.org/2000/svg"==i)l=l.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!=l&&"height"!=l&&"href"!=l&&"list"!=l&&"form"!=l&&"tabIndex"!=l&&"download"!=l&&"rowSpan"!=l&&"colSpan"!=l&&"role"!=l&&"popover"!=l&&l in n)try{n[l]=null==u?"":u;break n}catch(n){}"function"==typeof u||(null==u||!1===u&&"-"!=l[4]?n.removeAttribute(l):n.setAttribute(l,"popover"==l&&1==u?"":u))}}function F(n){return function(u){if(this.l){var t=this.l[u.type+n];if(null==u.t)u.t=c++;else if(u.t<t.u)return;return t(l.event?l.event(u):u)}}}function O(n,u,t,i,r,o,e,f,c,s){var a,h,p,v,y,_,m,b,S,C,M,$,P,A,H,L,T,j=u.type;if(null!=u.constructor)return null;128&t.__u&&(c=!!(32&t.__u),o=[f=u.__e=t.__e]),(a=l.__b)&&a(u);n:if("function"==typeof j)try{if(b=u.props,S="prototype"in j&&j.prototype.render,C=(a=j.contextType)&&i[a.__c],M=a?C?C.props.value:a.__:i,t.__c?m=(h=u.__c=t.__c).__=h.__E:(S?u.__c=h=new j(b,M):(u.__c=h=new x(b,M),h.constructor=j,h.render=D),C&&C.sub(h),h.props=b,h.state||(h.state={}),h.context=M,h.__n=i,p=h.__d=!0,h.__h=[],h._sb=[]),S&&null==h.__s&&(h.__s=h.state),S&&null!=j.getDerivedStateFromProps&&(h.__s==h.state&&(h.__s=d({},h.__s)),d(h.__s,j.getDerivedStateFromProps(b,h.__s))),v=h.props,y=h.state,h.__v=u,p)S&&null==j.getDerivedStateFromProps&&null!=h.componentWillMount&&h.componentWillMount(),S&&null!=h.componentDidMount&&h.__h.push(h.componentDidMount);else{if(S&&null==j.getDerivedStateFromProps&&b!==v&&null!=h.componentWillReceiveProps&&h.componentWillReceiveProps(b,M),!h.__e&&null!=h.shouldComponentUpdate&&!1===h.shouldComponentUpdate(b,h.__s,M)||u.__v==t.__v){for(u.__v!=t.__v&&(h.props=b,h.state=h.__s,h.__d=!1),u.__e=t.__e,u.__k=t.__k,u.__k.some(function(n){n&&(n.__=u)}),$=0;$<h._sb.length;$++)h.__h.push(h._sb[$]);h._sb=[],h.__h.length&&e.push(h);break n}null!=h.componentWillUpdate&&h.componentWillUpdate(b,h.__s,M),S&&null!=h.componentDidUpdate&&h.__h.push(function(){h.componentDidUpdate(v,y,_)})}if(h.context=M,h.props=b,h.__P=n,h.__e=!1,P=l.__r,A=0,S){for(h.state=h.__s,h.__d=!1,P&&P(u),a=h.render(h.props,h.state,h.context),H=0;H<h._sb.length;H++)h.__h.push(h._sb[H]);h._sb=[]}else do{h.__d=!1,P&&P(u),a=h.render(h.props,h.state,h.context),h.state=h.__s}while(h.__d&&++A<25);h.state=h.__s,null!=h.getChildContext&&(i=d(d({},i),h.getChildContext())),S&&!p&&null!=h.getSnapshotBeforeUpdate&&(_=h.getSnapshotBeforeUpdate(v,y)),L=a,null!=a&&a.type===k&&null==a.key&&(L=N(a.props.children)),f=I(n,w(L)?L:[L],u,t,i,r,o,e,f,c,s),h.base=u.__e,u.__u&=-161,h.__h.length&&e.push(h),m&&(h.__E=h.__=null)}catch(n){if(u.__v=null,c||null!=o)if(n.then){for(u.__u|=c?160:128;f&&8==f.nodeType&&f.nextSibling;)f=f.nextSibling;o[o.indexOf(f)]=null,u.__e=f}else for(T=o.length;T--;)g(o[T]);else u.__e=t.__e,u.__k=t.__k;l.__e(n,u,t)}else null==o&&u.__v==t.__v?(u.__k=t.__k,u.__e=t.__e):f=u.__e=V(t.__e,u,t,i,r,o,e,c,s);return(a=l.diffed)&&a(u),128&u.__u?void 0:f}function z(n,u,t){for(var i=0;i<t.length;i++)q(t[i],t[++i],t[++i]);l.__c&&l.__c(u,n),n.some(function(u){try{n=u.__h,u.__h=[],n.some(function(n){n.call(u)})}catch(n){l.__e(n,u.__v)}})}function N(n){return"object"!=typeof n||null==n||n.__b&&n.__b>0?n:w(n)?n.map(N):d({},n)}function V(u,t,i,r,o,e,f,c,s){var a,h,v,y,d,_,m,b=i.props,k=t.props,x=t.type;if("svg"==x?o="http://www.w3.org/2000/svg":"math"==x?o="http://www.w3.org/1998/Math/MathML":o||(o="http://www.w3.org/1999/xhtml"),null!=e)for(a=0;a<e.length;a++)if((d=e[a])&&"setAttribute"in d==!!x&&(x?d.localName==x:3==d.nodeType)){u=d,e[a]=null;break}if(null==u){if(null==x)return document.createTextNode(k);u=document.createElementNS(o,x,k.is&&k),c&&(l.__m&&l.__m(t,e),c=!1),e=null}if(null==x)b===k||c&&u.data==k||(u.data=k);else{if(e=e&&n.call(u.childNodes),b=i.props||p,!c&&null!=e)for(b={},a=0;a<u.attributes.length;a++)b[(d=u.attributes[a]).name]=d.value;for(a in b)if(d=b[a],"children"==a);else if("dangerouslySetInnerHTML"==a)v=d;else if(!(a in k)){if("value"==a&&"defaultValue"in k||"checked"==a&&"defaultChecked"in k)continue;j(u,a,null,d,o)}for(a in k)d=k[a],"children"==a?y=d:"dangerouslySetInnerHTML"==a?h=d:"value"==a?_=d:"checked"==a?m=d:c&&"function"!=typeof d||b[a]===d||j(u,a,d,b[a],o);if(h)c||v&&(h.__html==v.__html||h.__html==u.innerHTML)||(u.innerHTML=h.__html),t.__k=[];else if(v&&(u.innerHTML=""),I("template"==t.type?u.content:u,w(y)?y:[y],t,i,r,"foreignObject"==x?"http://www.w3.org/1999/xhtml":o,e,f,e?e[0]:i.__k&&S(i,0),c,s),null!=e)for(a=e.length;a--;)g(e[a]);c||(a="value","progress"==x&&null==_?u.removeAttribute("value"):null!=_&&(_!==u[a]||"progress"==x&&!_||"option"==x&&_!=b[a])&&j(u,a,_,b[a],o),a="checked",null!=m&&m!=u[a]&&j(u,a,m,b[a],o))}return u}function q(n,u,t){try{if("function"==typeof n){var i="function"==typeof n.__u;i&&n.__u(),i&&null==u||(n.__u=n(u))}else n.current=u}catch(n){l.__e(n,t)}}function B(n,u,t){var i,r;if(l.unmount&&l.unmount(n),(i=n.ref)&&(i.current&&i.current!=n.__e||q(i,null,u)),null!=(i=n.__c)){if(i.componentWillUnmount)try{i.componentWillUnmount()}catch(n){l.__e(n,u)}i.base=i.__P=null}if(i=n.__k)for(r=0;r<i.length;r++)i[r]&&B(i[r],u,t||"function"!=typeof n.type);t||g(n.__e),n.__c=n.__=n.__e=void 0}function D(n,l,u){return this.constructor(n,u)}function E(u,t,i){var r,o,e,f;t==document&&(t=document.documentElement),l.__&&l.__(u,t),o=(r="function"==typeof i)?null:i&&i.__k||t.__k,e=[],f=[],O(t,u=(!r&&i||t).__k=_(k,null,[u]),o||p,p,t.namespaceURI,!r&&i?[i]:o?null:t.firstChild?n.call(t.childNodes):null,e,!r&&i?i:o?o.__e:t.firstChild,r,f),z(e,u,f)}function G(n,l){E(n,l,G)}function J(l,u,t){var i,r,o,e,f=d({},l.props);for(o in l.type&&l.type.defaultProps&&(e=l.type.defaultProps),u)"key"==o?i=u[o]:"ref"==o?r=u[o]:f[o]=void 0===u[o]&&null!=e?e[o]:u[o];return arguments.length>2&&(f.children=arguments.length>3?n.call(arguments,2):t),m(l.type,f,i||l.key,r||l.ref,null)}function K(n){function l(n){var u,t;return this.getChildContext||(u=new Set,(t={})[l.__c]=this,this.getChildContext=function(){return t},this.componentWillUnmount=function(){u=null},this.shouldComponentUpdate=function(n){this.props.value!=n.value&&u.forEach(function(n){n.__e=!0,M(n)})},this.sub=function(n){u.add(n);var l=n.componentWillUnmount;n.componentWillUnmount=function(){u&&u.delete(n),l&&l.call(n)}}),n.children}return l.__c="__cC"+h++,l.__=n,l.Provider=l.__l=(l.Consumer=function(n,l){return n.children(l)}).contextType=l,l}n=v.slice,l={__e:function(n,l,u,t){for(var i,r,o;l=l.__;)if((i=l.__c)&&!i.__)try{if((r=i.constructor)&&null!=r.getDerivedStateFromError&&(i.setState(r.getDerivedStateFromError(n)),o=i.__d),null!=i.componentDidCatch&&(i.componentDidCatch(n,t||{}),o=i.__d),o)return i.__E=i}catch(l){n=l}throw n}},u=0,t=function(n){return null!=n&&null==n.constructor},x.prototype.setState=function(n,l){var u;u=null!=this.__s&&this.__s!=this.state?this.__s:this.__s=d({},this.state),"function"==typeof n&&(n=n(d({},u),this.props)),n&&d(u,n),null!=n&&this.__v&&(l&&this._sb.push(l),M(this))},x.prototype.forceUpdate=function(n){this.__v&&(this.__e=!0,n&&this.__h.push(n),M(this))},x.prototype.render=k,i=[],o="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,e=function(n,l){return n.__v.__b-l.__v.__b},$.__r=0,f=/(PointerCapture)$|Capture$/i,c=0,s=F(!1),a=F(!0),h=0;export{x as Component,k as Fragment,J as cloneElement,K as createContext,_ as createElement,b as createRef,_ as h,G as hydrate,t as isValidElement,l as options,E as render,H as toChildArray};
+//# sourceMappingURL=preact.module.js.map
diff --git a/diffusion_cli/ui.py b/diffusion_cli/ui.py
new file mode 100644
index 0000000..a32a706
--- /dev/null
+++ b/diffusion_cli/ui.py
@@ -0,0 +1,203 @@
+"""First-party browser UI routes."""
+
+from __future__ import annotations
+
+from base64 import b64encode
+from pathlib import Path
+
+from flask import jsonify, redirect, request, send_from_directory
+
+from diffusion_cli.config import (
+ ImageGenerationRequest,
+ UI_RESOLUTION_PRESETS,
+ buildDefaultGenerationRequest,
+ validateDimensions,
+)
+from diffusion_cli.errors import DiffusionCliError
+
+UI_ROOT = Path(__file__).parent / "static" / "ui"
+
+
+def _badRequest(message: str):
+ return jsonify({"error": "bad_request", "message": message}), 400
+
+
+def _generationFailed(message: str):
+ return jsonify({"error": "generation_failed", "message": message}), 500
+
+
+def _optionalString(data: dict, key: str) -> str | None:
+ value = data.get(key)
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ raise DiffusionCliError(f"{key} must be a string")
+ return value
+
+
+def _optionalInt(data: dict, key: str) -> int | None:
+ value = data.get(key)
+ if value is None:
+ return None
+ if not isinstance(value, int) or isinstance(value, bool):
+ raise DiffusionCliError(f"{key} must be an integer")
+ return value
+
+
+def _optionalFloat(data: dict, key: str) -> float | None:
+ value = data.get(key)
+ if value is None:
+ return None
+ if not isinstance(value, int | float) or isinstance(value, bool):
+ raise DiffusionCliError(f"{key} must be a number")
+ return float(value)
+
+
+def _validateOptionalDimensions(width: int | None, height: int | None) -> None:
+ if width is None and height is None:
+ return
+ effective_width = 8 if width is None else width
+ effective_height = 8 if height is None else height
+ validateDimensions(effective_width, effective_height)
+
+
+def _txt2imgRequest(data: dict) -> ImageGenerationRequest:
+ prompt = data.get("prompt")
+ if not isinstance(prompt, str) or not prompt.strip():
+ raise DiffusionCliError("prompt required")
+
+ negative_prompt = _optionalString(data, "negative_prompt")
+ if negative_prompt is not None and not negative_prompt.strip():
+ negative_prompt = None
+
+ seed = _optionalInt(data, "seed")
+ if seed == -1:
+ seed = None
+
+ width = _optionalInt(data, "width")
+ height = _optionalInt(data, "height")
+ _validateOptionalDimensions(width, height)
+
+ batch_size = _optionalInt(data, "batch_size")
+ steps = _optionalInt(data, "steps")
+ cfg = _optionalFloat(data, "cfg")
+ if batch_size is not None and batch_size < 1:
+ raise DiffusionCliError(
+ f"Batch size must be at least 1: got {batch_size}"
+ )
+ if steps is not None and steps < 1:
+ raise DiffusionCliError(f"Steps must be at least 1: got {steps}")
+ if cfg is not None and cfg < 0:
+ raise DiffusionCliError(f"CFG must be non-negative: got {cfg}")
+
+ return ImageGenerationRequest(
+ prompt=prompt.strip(),
+ negative_prompt=negative_prompt,
+ seed=seed,
+ width=width,
+ height=height,
+ batch_size=batch_size,
+ steps=steps,
+ cfg=cfg,
+ )
+
+
+def _defaultResponse(context) -> dict:
+ default_request = buildDefaultGenerationRequest(
+ context.generation_service.user_config,
+ )
+ return {
+ "negative_prompt": default_request.negative_prompt,
+ "width": default_request.width,
+ "height": default_request.height,
+ "batch_size": default_request.batch_size,
+ "steps": default_request.steps,
+ "cfg": default_request.cfg,
+ "seed": default_request.seed,
+ "resolution_presets": [
+ {
+ "width": width,
+ "height": height,
+ "resolution": resolution,
+ "aspect_ratio": aspect_ratio,
+ "label": (
+ f"{width}x{height} ({aspect_ratio})"
+ ),
+ }
+ for width, height, resolution, aspect_ratio
+ in UI_RESOLUTION_PRESETS
+ ],
+ }
+
+
+def registerUiRoutes(app, context) -> None:
+ """Register first-party browser UI routes."""
+
+ @app.route("/ui", methods=["GET"])
+ def uiRootRedirect():
+ return redirect("/ui/")
+
+ @app.route("/ui/", methods=["GET"])
+ def uiIndex():
+ return send_from_directory(UI_ROOT, "index.html")
+
+ @app.route("/ui/app.js", methods=["GET"])
+ def uiApp():
+ return send_from_directory(UI_ROOT, "app.js")
+
+ @app.route("/ui/style.css", methods=["GET"])
+ def uiStyle():
+ return send_from_directory(UI_ROOT, "style.css")
+
+ @app.route("/ui/vendor/preact.module.js", methods=["GET"])
+ def uiPreact():
+ return send_from_directory(UI_ROOT / "vendor", "preact.module.js")
+
+ @app.route("/ui/vendor/LICENSE.preact", methods=["GET"])
+ def uiPreactLicense():
+ return send_from_directory(UI_ROOT / "vendor", "LICENSE.preact")
+
+ @app.route("/ui/api/health", methods=["GET"])
+ def uiHealth():
+ return jsonify({
+ "status": "ok",
+ "api_profile": context.server_config.api_profile,
+ "model_residency": context.server_config.model_residency,
+ })
+
+ @app.route("/ui/api/defaults", methods=["GET"])
+ def uiDefaults():
+ return jsonify(_defaultResponse(context))
+
+ @app.route("/ui/api/txt2img", methods=["POST"])
+ def uiTxt2Img():
+ data = request.get_json(silent=True)
+ if not isinstance(data, dict) or not data:
+ return _badRequest("JSON object body required")
+
+ try:
+ generation_request = _txt2imgRequest(data)
+ except DiffusionCliError as exc:
+ return _badRequest(str(exc))
+
+ try:
+ images = context.generation_service.generateImages(
+ generation_request,
+ )
+ except DiffusionCliError as exc:
+ app.logger.exception("Generation failed")
+ return _generationFailed(str(exc))
+ except Exception:
+ app.logger.exception("Generation failed")
+ return _generationFailed("generation failed")
+
+ return jsonify({
+ "images": [
+ {
+ "mime_type": "image/png",
+ "data": b64encode(image.data).decode("ascii"),
+ "seed": image.seed,
+ }
+ for image in images
+ ]
+ })
diff --git a/pyproject.toml b/pyproject.toml
index 34bc199..8b35129 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,3 +24,9 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["diffusion_cli*"]
+
+[tool.setuptools.package-data]
+diffusion_cli = [
+ "static/ui/*",
+ "static/ui/vendor/*",
+]
diff --git a/tests/test_config.py b/tests/test_config.py
index 41474b5..626819c 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -11,6 +11,7 @@ from diffusion_cli.config import (
ModelPathConfig,
UserConfig,
TOKENIZER_FILES,
+ buildDefaultGenerationRequest,
buildGenerationConfig,
loadUserConfig,
validateDimensions,
@@ -274,6 +275,21 @@ class ConfigTest(unittest.TestCase):
self.assertEqual(config.height, DEFAULT_HEIGHT)
self.assertEqual(config.steps, 4)
+ def testDefaultGenerationRequestUsesUiVisibleFallbacks(self):
+ user_config = UserConfig(
+ models=ModelPathConfig(),
+ generation=GenerationDefaults(width=512, steps=12),
+ )
+
+ request = buildDefaultGenerationRequest(user_config)
+
+ self.assertEqual(request.prompt, "")
+ self.assertEqual(request.negative_prompt, "")
+ self.assertEqual(request.width, 512)
+ self.assertEqual(request.height, DEFAULT_HEIGHT)
+ self.assertEqual(request.steps, 12)
+ self.assertEqual(request.seed, -1)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_ui.py b/tests/test_ui.py
new file mode 100644
index 0000000..09bdf6d
--- /dev/null
+++ b/tests/test_ui.py
@@ -0,0 +1,182 @@
+import base64
+import unittest
+
+from diffusion_cli.config import (
+ GenerationDefaults,
+ ModelPathConfig,
+ UserConfig,
+)
+from diffusion_cli.errors import DiffusionCliError
+from diffusion_cli.generation_service import GeneratedImage
+from diffusion_cli.server import ServerConfig, createApp
+
+
+class FakeGenerationService:
+ def __init__(self):
+ self.user_config = UserConfig(
+ models=ModelPathConfig(),
+ generation=GenerationDefaults(width=512, steps=12),
+ )
+ self.requests = []
+ self.error = None
+
+ def generateImages(self, request):
+ if self.error:
+ raise self.error
+ self.requests.append(request)
+ return [GeneratedImage(b"\x89PNG\r\n\x1a\nimage", "png", 321)]
+
+
+class UiTest(unittest.TestCase):
+ def makeClient(self):
+ service = FakeGenerationService()
+ app = createApp(
+ ServerConfig("sillytavern-sdcpp", "127.0.0.1", 7860, "cpu-cache"),
+ service,
+ )
+ return app.test_client(), service
+
+ def testUiHealthReturnsServerProfileInformation(self):
+ client, _service = self.makeClient()
+
+ response = client.get("/ui/api/health")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json["status"], "ok")
+ self.assertEqual(response.json["api_profile"], "sillytavern-sdcpp")
+ self.assertEqual(response.json["model_residency"], "cpu-cache")
+
+ def testUiDefaultsComeFromGenerationConfig(self):
+ client, _service = self.makeClient()
+
+ response = client.get("/ui/api/defaults")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json["negative_prompt"], "")
+ self.assertEqual(response.json["width"], 512)
+ self.assertEqual(response.json["height"], 1248)
+ self.assertEqual(response.json["batch_size"], 1)
+ self.assertEqual(response.json["steps"], 12)
+ self.assertEqual(response.json["cfg"], 1.0)
+ self.assertEqual(response.json["seed"], -1)
+ presets = response.json["resolution_presets"]
+ self.assertEqual(len(presets), 33)
+ self.assertEqual(
+ presets[0],
+ {
+ "width": 1024,
+ "height": 1024,
+ "resolution": "1024",
+ "aspect_ratio": "1:1",
+ "label": "1024x1024 (1:1)",
+ },
+ )
+ self.assertEqual(presets[-1]["label"], "864x2016 (9:21)")
+
+ def testUiStaticAssetsAreServedByExplicitRoutes(self):
+ client, _service = self.makeClient()
+
+ index = client.get("/ui/")
+ app_js = client.get("/ui/app.js")
+ style = client.get("/ui/style.css")
+ preact = client.get("/ui/vendor/preact.module.js")
+
+ self.assertEqual(index.status_code, 200)
+ self.assertIn(b'type="module"', index.data)
+ self.assertEqual(app_js.status_code, 200)
+ self.assertIn(b'from "/ui/vendor/preact.module.js"', app_js.data)
+ self.assertEqual(style.status_code, 200)
+ self.assertEqual(preact.status_code, 200)
+ self.assertIn(b"export", preact.data)
+ index.close()
+ app_js.close()
+ style.close()
+ preact.close()
+
+ def testUiRootRedirectsToSlashRoot(self):
+ client, _service = self.makeClient()
+
+ response = client.get("/ui")
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.headers["Location"], "/ui/")
+
+ def testUiTxt2ImgReturnsImageObjects(self):
+ client, service = self.makeClient()
+
+ response = client.post(
+ "/ui/api/txt2img",
+ json={
+ "prompt": " a mug ",
+ "negative_prompt": "",
+ "width": 512,
+ "height": 768,
+ "steps": 12,
+ "cfg": 1.5,
+ "seed": -1,
+ "batch_size": 2,
+ },
+ )
+
+ self.assertEqual(response.status_code, 200)
+ request = service.requests[0]
+ self.assertEqual(request.prompt, "a mug")
+ self.assertIsNone(request.negative_prompt)
+ self.assertEqual(request.width, 512)
+ self.assertEqual(request.height, 768)
+ self.assertEqual(request.steps, 12)
+ self.assertEqual(request.cfg, 1.5)
+ self.assertIsNone(request.seed)
+ self.assertEqual(request.batch_size, 2)
+ image = response.json["images"][0]
+ self.assertEqual(image["mime_type"], "image/png")
+ self.assertEqual(
+ base64.b64decode(image["data"]),
+ b"\x89PNG\r\n\x1a\nimage",
+ )
+ self.assertEqual(image["seed"], 321)
+
+ def testUiTxt2ImgRejectsEmptyPrompt(self):
+ client, _service = self.makeClient()
+
+ response = client.post("/ui/api/txt2img", json={"prompt": " "})
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json["error"], "bad_request")
+ self.assertEqual(response.json["message"], "prompt required")
+
+ def testUiTxt2ImgRejectsInvalidCfg(self):
+ client, _service = self.makeClient()
+
+ response = client.post(
+ "/ui/api/txt2img",
+ json={"prompt": "a mug", "cfg": -1},
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("CFG must be non-negative", response.json["message"])
+
+ def testUiTxt2ImgRejectsZeroWidth(self):
+ client, _service = self.makeClient()
+
+ response = client.post(
+ "/ui/api/txt2img",
+ json={"prompt": "a mug", "width": 0},
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("Width must be", response.json["message"])
+
+ def testUiTxt2ImgReportsDiffusionCliErrors(self):
+ client, service = self.makeClient()
+ service.error = DiffusionCliError("missing model")
+
+ response = client.post("/ui/api/txt2img", json={"prompt": "a mug"})
+
+ self.assertEqual(response.status_code, 500)
+ self.assertEqual(response.json["error"], "generation_failed")
+ self.assertEqual(response.json["message"], "missing model")
+
+
+if __name__ == "__main__":
+ unittest.main()