feat(image): apply generation settings live

This commit is contained in:
Xubin Ren
2026-07-23 12:42:24 +08:00
parent c7393c785e
commit 1616fa9f14
11 changed files with 481 additions and 18 deletions
+40 -7
View File
@@ -3574,6 +3574,20 @@ function ImageGenerationSettings({
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
form.defaultImageSize,
);
const modelOptions = optionRowsWithCurrent(
(selectedProvider?.models ?? []).map((model) => ({ name: model, label: model })),
form.model,
);
const hasModelCatalog = Boolean(selectedProvider?.models?.length);
const selectProvider = (provider: string) => {
const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
onChangeForm((prev) => ({
...prev,
provider,
model: nextProvider?.default_model || nextProvider?.models?.[0] || prev.model,
}));
};
return (
<div className="space-y-7">
@@ -3600,7 +3614,7 @@ function ImageGenerationSettings({
value={form.provider}
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
showProviderLogos={showBrandLogos}
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
onChange={selectProvider}
/>
</SettingsRow>
<SettingsRow
@@ -3635,11 +3649,22 @@ function ImageGenerationSettings({
title={tx("settings.rows.imageModel", "Image model")}
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
>
<Input
value={form.model}
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
/>
{hasModelCatalog ? (
<ProviderPicker
providers={modelOptions}
value={form.model}
emptyLabel={tx("settings.image.selectModel", "Select image model")}
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
triggerClassName="w-[min(300px,70vw)]"
contentClassName="w-[320px]"
/>
) : (
<Input
value={form.model}
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
/>
)}
</SettingsRow>
<SettingsRow
title={tx("settings.rows.defaultAspectRatio", "Default aspect")}
@@ -7632,12 +7657,16 @@ function ProviderPicker({
value,
emptyLabel,
showProviderLogos = false,
triggerClassName,
contentClassName,
onChange,
}: {
providers: Array<{ name: string; label: string }>;
value: string;
emptyLabel: string;
showProviderLogos?: boolean;
triggerClassName?: string;
contentClassName?: string;
onChange: (provider: string) => void;
}) {
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
@@ -7654,6 +7683,7 @@ function ProviderPicker({
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
disabled && "text-muted-foreground",
triggerClassName,
)}
>
<span className="flex min-w-0 items-center gap-2">
@@ -7670,7 +7700,10 @@ function ProviderPicker({
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
className={cn(
"max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent",
contentClassName,
)}
>
{providers.map((provider) => {
const selected = provider.name === value;
+2
View File
@@ -501,6 +501,8 @@ export interface SettingsPayload {
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
models?: string[];
default_model?: string | null;
}>;
};
transcription?: {
+51
View File
@@ -303,6 +303,7 @@ function renderSettingsView(
| "automations"
| "advanced"
| "models"
| "image"
| "browser"
| "runtime";
initialSettings?: SettingsPayload;
@@ -2277,6 +2278,56 @@ describe("SettingsView Apps catalog", () => {
);
});
it("selects image models from provider-specific options", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
image_generation: {
...base.image_generation,
providers: [
{
name: "openrouter",
label: "OpenRouter",
configured: true,
models: ["openai/gpt-5.4-image-2"],
default_model: "openai/gpt-5.4-image-2",
},
{
name: "gemini",
label: "Gemini",
configured: true,
models: ["gemini-2.5-flash-image", "imagen-4.0-generate-001"],
default_model: "gemini-2.5-flash-image",
},
{
name: "custom",
label: "Custom",
configured: true,
models: [],
default_model: null,
},
],
},
};
renderSettingsView({ initialSection: "image", initialSettings: payload });
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "imagen-4.0-generate-001" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
expect(screen.getByDisplayValue("imagen-4.0-generate-001")).toBeInTheDocument();
});
it("keeps the default model distinct from the active named configuration", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {