← back to Exo
Enable UI settings for image editing (#1258)
a1939c89f2d22bd7a53706f527801f938233d353 · 2026-01-23 13:37:25 +0000 · ciaranbor
## Motivation
Image editing was missing UI controls for quality, output format, and
advanced parameters that text-to-image generation already supported.
## Changes
- Added quality, output_format, and advanced_params to image edit API
endpoints
- Extended isImageModel check to include image editing models
## Why It Works
The API now accepts and forwards these settings for image edits, and the
UI displays the appropriate controls for image editing models.
## Test Plan
### Manual Testing
Verified parameters can be set in UI and that they progagate through to
model inference
Files touched
M dashboard/src/lib/components/ChatForm.svelteM dashboard/src/lib/components/ImageParamsPanel.svelteM src/exo/master/api.py
Diff
commit a1939c89f2d22bd7a53706f527801f938233d353
Author: ciaranbor <81697641+ciaranbor@users.noreply.github.com>
Date: Fri Jan 23 13:37:25 2026 +0000
Enable UI settings for image editing (#1258)
## Motivation
Image editing was missing UI controls for quality, output format, and
advanced parameters that text-to-image generation already supported.
## Changes
- Added quality, output_format, and advanced_params to image edit API
endpoints
- Extended isImageModel check to include image editing models
## Why It Works
The API now accepts and forwards these settings for image edits, and the
UI displays the appropriate controls for image editing models.
## Test Plan
### Manual Testing
Verified parameters can be set in UI and that they progagate through to
model inference
---
dashboard/src/lib/components/ChatForm.svelte | 22 ++-
.../src/lib/components/ImageParamsPanel.svelte | 160 +++++++++++----------
src/exo/master/api.py | 34 +++++
3 files changed, 136 insertions(+), 80 deletions(-)
diff --git a/dashboard/src/lib/components/ChatForm.svelte b/dashboard/src/lib/components/ChatForm.svelte
index 42a9bb4c..6801287d 100644
--- a/dashboard/src/lib/components/ChatForm.svelte
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -89,7 +89,10 @@
const isImageModel = $derived(() => {
if (!currentModel) return false;
- return modelSupportsTextToImage(currentModel);
+ return (
+ modelSupportsTextToImage(currentModel) ||
+ modelSupportsImageEditing(currentModel)
+ );
});
const isEditOnlyWithoutImage = $derived(
@@ -646,6 +649,23 @@
</svg>
<span>EDIT</span>
</span>
+ {:else if isEditOnlyWithoutImage}
+ <span class="inline-flex items-center gap-1.5">
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ <span>EDIT</span>
+ </span>
{:else if isImageModel()}
<span class="inline-flex items-center gap-1.5">
<svg
diff --git a/dashboard/src/lib/components/ImageParamsPanel.svelte b/dashboard/src/lib/components/ImageParamsPanel.svelte
index ef17c278..d3a9fbef 100644
--- a/dashboard/src/lib/components/ImageParamsPanel.svelte
+++ b/dashboard/src/lib/components/ImageParamsPanel.svelte
@@ -164,90 +164,92 @@
<div class="border-b border-exo-medium-gray/30 px-3 py-2">
<!-- Basic params row -->
<div class="flex items-center gap-3 flex-wrap">
- <!-- Size -->
- <div class="flex items-center gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >SIZE:</span
- >
- <div class="relative">
- <button
- bind:this={sizeButtonRef}
- type="button"
- onclick={() => (isSizeDropdownOpen = !isSizeDropdownOpen)}
- class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen
- ? 'border-exo-yellow/70'
- : ''}"
- >
- {params.size}
- </button>
- <div
- class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen
- ? 'rotate-180'
- : ''}"
+ <!-- Size (hidden in edit mode - output size comes from input image) -->
+ {#if !isEditMode}
+ <div class="flex items-center gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >SIZE:</span
>
- <svg
- class="w-3 h-3 text-exo-yellow/60"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
+ <div class="relative">
+ <button
+ bind:this={sizeButtonRef}
+ type="button"
+ onclick={() => (isSizeDropdownOpen = !isSizeDropdownOpen)}
+ class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen
+ ? 'border-exo-yellow/70'
+ : ''}"
>
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M19 9l-7 7-7-7"
- />
- </svg>
+ {params.size}
+ </button>
+ <div
+ class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen
+ ? 'rotate-180'
+ : ''}"
+ >
+ <svg
+ class="w-3 h-3 text-exo-yellow/60"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M19 9l-7 7-7-7"
+ />
+ </svg>
+ </div>
</div>
- </div>
- {#if isSizeDropdownOpen}
- <!-- Backdrop to close dropdown -->
- <button
- type="button"
- class="fixed inset-0 z-[9998] cursor-default"
- onclick={() => (isSizeDropdownOpen = false)}
- aria-label="Close dropdown"
- ></button>
-
- <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
- <div
- class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
- style="bottom: calc(100vh - {sizeDropdownPosition()
- .top}px + 4px); left: {sizeDropdownPosition().left}px;"
- >
- <div class="py-1">
- {#each sizeOptions as size}
- <button
- type="button"
- onclick={() => selectSize(size)}
- class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.size ===
- size
- ? 'bg-transparent text-exo-yellow'
- : 'text-exo-light-gray hover:text-exo-yellow'}"
- >
- {#if params.size === size}
- <svg
- class="w-3 h-3 flex-shrink-0"
- fill="currentColor"
- viewBox="0 0 20 20"
- >
- <path
- fill-rule="evenodd"
- d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
- clip-rule="evenodd"
- />
- </svg>
- {:else}
- <span class="w-3"></span>
- {/if}
- <span>{size}</span>
- </button>
- {/each}
+ {#if isSizeDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-[9998] cursor-default"
+ onclick={() => (isSizeDropdownOpen = false)}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+ <div
+ class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
+ style="bottom: calc(100vh - {sizeDropdownPosition()
+ .top}px + 4px); left: {sizeDropdownPosition().left}px;"
+ >
+ <div class="py-1">
+ {#each sizeOptions as size}
+ <button
+ type="button"
+ onclick={() => selectSize(size)}
+ class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.size ===
+ size
+ ? 'bg-transparent text-exo-yellow'
+ : 'text-exo-light-gray hover:text-exo-yellow'}"
+ >
+ {#if params.size === size}
+ <svg
+ class="w-3 h-3 flex-shrink-0"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ {:else}
+ <span class="w-3"></span>
+ {/if}
+ <span>{size}</span>
+ </button>
+ {/each}
+ </div>
</div>
- </div>
- {/if}
- </div>
+ {/if}
+ </div>
+ {/if}
<!-- Quality -->
<div class="flex items-center gap-1.5">
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index f893fedf..989378b5 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -1,4 +1,5 @@
import base64
+import contextlib
import json
import time
from collections.abc import AsyncGenerator
@@ -33,6 +34,7 @@ from exo.shared.models.model_cards import (
ModelId,
)
from exo.shared.types.api import (
+ AdvancedImageParams,
BenchChatCompletionResponse,
BenchChatCompletionTaskParams,
BenchImageGenerationResponse,
@@ -1025,6 +1027,9 @@ class API:
stream: bool,
partial_images: int,
bench: bool,
+ quality: Literal["high", "medium", "low"],
+ output_format: Literal["png", "jpeg", "webp"],
+ advanced_params: AdvancedImageParams | None,
) -> ImageEdits:
"""Prepare and send an image edits command with chunked image upload."""
resolved_model = await self._validate_image_model(model)
@@ -1053,6 +1058,9 @@ class API:
stream=stream,
partial_images=partial_images,
bench=bench,
+ quality=quality,
+ output_format=output_format,
+ advanced_params=advanced_params,
),
)
@@ -1087,12 +1095,22 @@ class API:
input_fidelity: Literal["low", "high"] = Form("low"),
stream: str = Form("false"),
partial_images: str = Form("0"),
+ quality: Literal["high", "medium", "low"] = Form("medium"),
+ output_format: Literal["png", "jpeg", "webp"] = Form("png"),
+ advanced_params: str | None = Form(None),
) -> ImageGenerationResponse | StreamingResponse:
"""Handle image editing requests (img2img)."""
# Parse string form values to proper types
stream_bool = stream.lower() in ("true", "1", "yes")
partial_images_int = int(partial_images) if partial_images.isdigit() else 0
+ parsed_advanced_params: AdvancedImageParams | None = None
+ if advanced_params:
+ with contextlib.suppress(Exception):
+ parsed_advanced_params = AdvancedImageParams.model_validate_json(
+ advanced_params
+ )
+
command = await self._send_image_edits_command(
image=image,
prompt=prompt,
@@ -1104,6 +1122,9 @@ class API:
stream=stream_bool,
partial_images=partial_images_int,
bench=False,
+ quality=quality,
+ output_format=output_format,
+ advanced_params=parsed_advanced_params,
)
if stream_bool and partial_images_int > 0:
@@ -1134,8 +1155,18 @@ class API:
size: str = Form("1024x1024"),
response_format: Literal["url", "b64_json"] = Form("b64_json"),
input_fidelity: Literal["low", "high"] = Form("low"),
+ quality: Literal["high", "medium", "low"] = Form("medium"),
+ output_format: Literal["png", "jpeg", "webp"] = Form("png"),
+ advanced_params: str | None = Form(None),
) -> BenchImageGenerationResponse:
"""Handle benchmark image editing requests with generation stats."""
+ parsed_advanced_params: AdvancedImageParams | None = None
+ if advanced_params:
+ with contextlib.suppress(Exception):
+ parsed_advanced_params = AdvancedImageParams.model_validate_json(
+ advanced_params
+ )
+
command = await self._send_image_edits_command(
image=image,
prompt=prompt,
@@ -1147,6 +1178,9 @@ class API:
stream=False,
partial_images=0,
bench=True,
+ quality=quality,
+ output_format=output_format,
+ advanced_params=parsed_advanced_params,
)
return await self._collect_image_generation_with_stats(
← cb9c9ee5 Enable generating multiple images. Optionally stream partial
·
back to Exo
·
dashboard: decouple prettier-svelte from dashboard source f255345a →