BareGit

Add local image generation web UI

Expose a no-build Preact UI with first-party Flask routes, vendored
assets, resolution presets, and focused endpoint tests.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Jul 7 14:43:42 2026 -0700
Commit: 4363ca6c2ac02ae6d81f17048dcf7c0b1298e45a

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()