diff --git a/apps/desktop/src/bun/remote/remote-runtime-client.ts b/apps/desktop/src/bun/remote/remote-runtime-client.ts index 71b5c9e1..2d195786 100644 --- a/apps/desktop/src/bun/remote/remote-runtime-client.ts +++ b/apps/desktop/src/bun/remote/remote-runtime-client.ts @@ -1,5 +1,5 @@ import type { - ArkImageGenerationConfig, + ImageGenerationConfig, AgentEvent, BuiltinTool, CustomModel, @@ -135,7 +135,7 @@ export class RemoteRuntimeClient implements RuntimeClient { } createSubagentThread( - input: Parameters[0], + input: Parameters[0] ) { return this._rpc< Awaited> @@ -315,7 +315,7 @@ export class RemoteRuntimeClient implements RuntimeClient { api?: "anthropic-messages" | "openai-completions" | "openai-responses" | null; icon?: string | null; - imageGeneration?: ArkImageGenerationConfig; + imageGeneration?: ImageGenerationConfig; }) { return this._rpc("models.updateProvider", input); } diff --git a/apps/desktop/src/components/settings/image-model-editor-dialog.tsx b/apps/desktop/src/components/settings/image-model-editor-dialog.tsx index 979826a7..7f581cf1 100644 --- a/apps/desktop/src/components/settings/image-model-editor-dialog.tsx +++ b/apps/desktop/src/components/settings/image-model-editor-dialog.tsx @@ -1,9 +1,12 @@ "use client"; import { + getOpenAIImageSizes, + normalizeImageSize, SEEDREAM_IMAGE_SIZES, - type SeedreamImageModelDefinition, - type SeedreamImageSize, + type ImageGenerationApi, + type ImageModelDefinition, + type ImageSize, } from "@llm-space/core"; import { ModelAvatar } from "@llm-space/ui/components/thread-playground/model-avatar"; import { Button } from "@llm-space/ui/ui/button"; @@ -33,68 +36,90 @@ interface ImageModelFormState { id: string; name: string; icon: string; - supportedSizes: SeedreamImageSize[]; - defaultSize: SeedreamImageSize; + supportedSizes: ImageSize[]; + defaultSize: ImageSize; + responseFormat: "auto" | "b64_json"; } /** Create the editable form state for a new or existing image model. */ function _initialState( - model: SeedreamImageModelDefinition | null | undefined + model: ImageModelDefinition | null | undefined, + api: ImageGenerationApi ): ImageModelFormState { - return model - ? { - id: model.id, - name: model.name, - icon: model.icon ?? "", - supportedSizes: [...model.supportedSizes], - defaultSize: model.defaultSize, - } - : { - id: "", - name: "", - icon: "", - supportedSizes: [...SEEDREAM_IMAGE_SIZES], - defaultSize: "2K", - }; + if (model) { + return { + id: model.id, + name: model.name, + icon: model.icon ?? "", + supportedSizes: [ + ...new Set( + model.supportedSizes.map((size) => normalizeImageSize(size, api)) + ), + ], + defaultSize: normalizeImageSize(model.defaultSize, api), + responseFormat: model.responseFormat ?? "auto", + }; + } + return { + id: "", + name: "", + icon: "", + supportedSizes: + api === "openai-images" ? ["1024x1024"] : [...SEEDREAM_IMAGE_SIZES], + defaultSize: api === "openai-images" ? "1024x1024" : "2K", + responseFormat: "auto", + }; } /** Add or edit one provider-owned custom image model definition. */ export function ImageModelEditorDialog({ open, onOpenChange, + api, model, existingIds, onSave, }: { open: boolean; onOpenChange: (open: boolean) => void; - model?: SeedreamImageModelDefinition | null; + api: ImageGenerationApi; + model?: ImageModelDefinition | null; existingIds: readonly string[]; - onSave: (model: SeedreamImageModelDefinition, originalId?: string) => void; + onSave: (model: ImageModelDefinition, originalId?: string) => void; }) { const { t } = useI18n(); const [form, setForm] = useState(() => - _initialState(model) + _initialState(model, api) ); useEffect(() => { if (open) { - setForm(_initialState(model)); + setForm(_initialState(model, api)); } - }, [model, open]); + }, [model, open, api]); const id = form.id.trim(); + const sizeOptions: readonly ImageSize[] = + api === "openai-images" ? getOpenAIImageSizes(id) : SEEDREAM_IMAGE_SIZES; + const unsupportedSizes = form.supportedSizes.filter( + (size) => !sizeOptions.includes(size) + ); + const displayedSizes = [...sizeOptions, ...unsupportedSizes]; const duplicateId = existingIds.some( (candidate) => candidate === id && candidate !== model?.id ); const canSave = - id.length > 0 && form.supportedSizes.length > 0 && !duplicateId; + id.length > 0 && + form.supportedSizes.length > 0 && + form.supportedSizes.includes(form.defaultSize) && + unsupportedSizes.length === 0 && + !duplicateId; /** Keep the default size valid while the supported-size set changes. */ - const handleSizeToggle = (size: SeedreamImageSize, enabled: boolean) => { + const handleSizeToggle = (size: ImageSize, enabled: boolean) => { setForm((current) => { const supportedSizes = enabled - ? SEEDREAM_IMAGE_SIZES.filter( + ? displayedSizes.filter( (candidate) => current.supportedSizes.includes(candidate) || candidate === size ) @@ -121,6 +146,9 @@ export function ImageModelEditorDialog({ name: form.name.trim() || id, supportedSizes: form.supportedSizes, defaultSize: form.defaultSize, + ...(form.responseFormat === "b64_json" + ? { responseFormat: "b64_json" as const } + : {}), ...(icon ? { icon } : {}), }, model?.id @@ -197,7 +225,7 @@ export function ImageModelEditorDialog({ <_Field label={t.models.supportedSizes}>
- {SEEDREAM_IMAGE_SIZES.map((size) => ( + {displayedSizes.map((size) => (
))}
+ {unsupportedSizes.length > 0 && ( +

+ {formatMessage(t.models.unsupportedImageSizes, { + sizes: unsupportedSizes.join(", "), + })} +

+ )} <_Field label={t.models.defaultSize}> @@ -223,7 +258,7 @@ export function ImageModelEditorDialog({ onValueChange={(value) => setForm((current) => ({ ...current, - defaultSize: value as SeedreamImageSize, + defaultSize: value as ImageSize, })) } > @@ -242,6 +277,36 @@ export function ImageModelEditorDialog({ + + {api === "openai-images" && ( + <_Field label={t.models.responseFormat}> + + + )}
diff --git a/apps/desktop/src/components/settings/models-page.tsx b/apps/desktop/src/components/settings/models-page.tsx index 9affed02..165242ff 100644 --- a/apps/desktop/src/components/settings/models-page.tsx +++ b/apps/desktop/src/components/settings/models-page.tsx @@ -3,11 +3,13 @@ import { formatProviderProfileLabel, getArkImageModelDefinitions, - type ArkImageGenerationConfig, + getImageModelDefinitions, type CustomModel, + type ImageGenerationApi, + type ImageGenerationConfig, + type ImageModelDefinition, type ModelProviderGroup, type ProviderProfile, - type SeedreamImageModelDefinition, } from "@llm-space/core"; import { ConfirmDialog } from "@llm-space/ui/components/confirm-dialog"; import { Link } from "@llm-space/ui/components/link"; @@ -1094,8 +1096,8 @@ function ProviderEditor({ ) : null} - {provider.id === "ark" && canManageModels ? ( - <_ArkImageGenerationEditor provider={provider} /> + {(provider.id === "ark" || !isBuiltin) && canManageModels ? ( + <_ImageGenerationEditor provider={provider} /> ) : null} @@ -1236,8 +1238,8 @@ function _ProviderProfileEditor({ ); } -/** Chat-model-parity inventory management for Ark image models. */ -function _ArkImageGenerationEditor({ +/** Chat-model-parity inventory management for provider-owned image models. */ +function _ImageGenerationEditor({ provider, }: { provider: ModelProviderGroup; @@ -1246,7 +1248,12 @@ function _ArkImageGenerationEditor({ const displayName = providerDisplayName(provider, lang); const updateProvider = useUpdateProvider(); const config = provider.imageGeneration ?? {}; - const models = getArkImageModelDefinitions(config); + const imageApi = + config.api ?? (provider.id === "ark" ? "ark-images" : "openai-images"); + const models = + provider.id === "ark" + ? getArkImageModelDefinitions(config) + : getImageModelDefinitions(config); const disabledModels = new Set(config.disabledModels ?? []); const enabledModels = models.filter((model) => !disabledModels.has(model.id)); const customModels = new Set((config.models ?? []).map((model) => model.id)); @@ -1255,8 +1262,9 @@ function _ArkImageGenerationEditor({ ); const [modelListRef] = useAutoAnimation(); const [editorOpen, setEditorOpen] = useState(false); - const [editingModel, setEditingModel] = - useState(null); + const [editingModel, setEditingModel] = useState( + null + ); const visibleModels = models.filter((model) => { if (modelView === "enabled") return !disabledModels.has(model.id); @@ -1264,7 +1272,7 @@ function _ArkImageGenerationEditor({ return true; }); - const update = (imageGeneration: ArkImageGenerationConfig) => { + const update = (imageGeneration: ImageGenerationConfig) => { void updateProvider(provider.id, { imageGeneration }).catch((error) => { toast.error("Failed to update image generation", { description: @@ -1296,7 +1304,7 @@ function _ArkImageGenerationEditor({ /** Add or replace a custom image model and preserve its disabled state. */ const handleSaveCustomModel = ( - model: SeedreamImageModelDefinition, + model: ImageModelDefinition, originalId?: string ) => { const custom = (config.models ?? []).filter( @@ -1331,6 +1339,37 @@ function _ArkImageGenerationEditor({ return ( <> + {provider.id !== "ark" ? ( +
+ {t.models.imageApiType} + +
+ ) : null}
{t.models.imageModels} @@ -1427,6 +1466,7 @@ function _ArkImageGenerationEditor({ model.id)} onSave={handleSaveCustomModel} @@ -1446,7 +1486,7 @@ function _ImageModelListItem({ onDelete, }: { providerName: string; - model: SeedreamImageModelDefinition; + model: ImageModelDefinition; enabled: boolean; isCustom: boolean; onToggle: (enabled: boolean) => void; diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 104f929f..39d06f36 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -467,6 +467,11 @@ const APP_MESSAGES = { 'Use "${ENV_NAME}" to reference environment variables. e.g. "$OPENAI_API_KEY"', officialEnvVariableHelp: "Leave it blank to use the official {name} environment variable", + imageApiType: "Image API type", + imageApiAriaLabel: "{name} image API type", + arkImagesApi: "Ark Images", + openAIImagesApi: "OpenAI Images", + openAIImagesExtraBodyApi: "OpenAI Images with extra_body", imageModels: "Image models", reasoningSupported: "Reasoning supported", deepseekThinking: "Use DeepSeek thinking format", @@ -487,6 +492,12 @@ const APP_MESSAGES = { supportedSizes: "Supported sizes", defaultSize: "Default size", supportSize: "Support {size}", + unsupportedImageSizes: + "Unsupported sizes for this model and API: {sizes}.", + responseFormat: "Response format", + imageResponseFormat: "Image response format", + automaticResponseFormat: "Auto", + base64ResponseFormat: "Base64 (b64_json)", }, mcp: { title: "MCP", @@ -1086,6 +1097,11 @@ const APP_MESSAGES = { envReferenceHelp: '使用 "${ENV_NAME}" 引用环境变量,例如 "$OPENAI_API_KEY"。', officialEnvVariableHelp: "留空将使用官方的 {name} 环境变量", + imageApiType: "图像 API 类型", + imageApiAriaLabel: "{name} 图像 API 类型", + arkImagesApi: "Ark Images", + openAIImagesApi: "OpenAI Images", + openAIImagesExtraBodyApi: "OpenAI Images(extra_body)", imageModels: "图像模型", reasoningSupported: "支持推理", deepseekThinking: "使用 DeepSeek 思考格式", @@ -1105,6 +1121,11 @@ const APP_MESSAGES = { supportedSizes: "支持的尺寸", defaultSize: "默认尺寸", supportSize: "支持 {size}", + unsupportedImageSizes: "此模型和 API 不支持以下尺寸:{sizes}。", + responseFormat: "响应格式", + imageResponseFormat: "图像响应格式", + automaticResponseFormat: "自动", + base64ResponseFormat: "Base64(b64_json)", }, mcp: { title: "MCP", diff --git a/apps/desktop/src/i18n/playground-labels.ts b/apps/desktop/src/i18n/playground-labels.ts index b8bd5d77..5cbfcf7f 100644 --- a/apps/desktop/src/i18n/playground-labels.ts +++ b/apps/desktop/src/i18n/playground-labels.ts @@ -139,7 +139,10 @@ export const PLAYGROUND_LABELS: Record = { loadFailed: "Failed to load built-in tools", chooseImageModel: "Choose an enabled image model", chooseImageModelHint: - "Enable an Ark image model in Settings, then select it here.", + "Enable an image model in Settings, then select it here.", + provider: "Provider", + chooseProvider: "Choose provider", + imageProviderAria: "Generate image provider", model: "Model", profile: "Profile", defaultSize: "Default size", @@ -149,7 +152,7 @@ export const PLAYGROUND_LABELS: Record = { imageSizeAria: "Default image size", imageWatermarkAria: "Add AI-generated watermark", noImageModels: - "Enable an Ark image model in Settings before adding this tool.", + "Enable an image model for this provider in Settings before adding this tool.", }, mcp: { title: "Add MCP tools", @@ -551,8 +554,10 @@ export const PLAYGROUND_LABELS: Record = { emptyCategory: "此分类中没有内置工具。", loadFailed: "加载内置工具失败", chooseImageModel: "请选择已启用的图像模型", - chooseImageModelHint: - "请先在设置中启用 Ark 图像模型,然后在这里选择它。", + chooseImageModelHint: "请先在设置中启用图像模型,然后在这里选择它。", + provider: "提供商", + chooseProvider: "选择提供商", + imageProviderAria: "生成图像的提供商", model: "模型", profile: "配置档", defaultSize: "默认尺寸", @@ -561,7 +566,7 @@ export const PLAYGROUND_LABELS: Record = { chooseModel: "选择模型", imageSizeAria: "默认图像尺寸", imageWatermarkAria: "添加 AI 生成水印", - noImageModels: "请先在设置中启用 Ark 图像模型,再添加此工具。", + noImageModels: "请先在设置中为此提供商启用图像模型,再添加此工具。", }, mcp: { title: "添加 MCP 工具", diff --git a/apps/desktop/src/shared/rpc.ts b/apps/desktop/src/shared/rpc.ts index 15387bf9..ad734950 100644 --- a/apps/desktop/src/shared/rpc.ts +++ b/apps/desktop/src/shared/rpc.ts @@ -1,5 +1,5 @@ import type { - ArkImageGenerationConfig, + ImageGenerationConfig, AgentEvent, AgentStreamRequest, BuiltinTool, @@ -208,7 +208,7 @@ export interface DesktopRPCType { | "openai-responses" | null; icon?: string | null; - imageGeneration?: ArkImageGenerationConfig; + imageGeneration?: ImageGenerationConfig; }; response: ModelProviderGroup[]; }; diff --git a/apps/desktop/tests/components/settings/provider-image-settings.fixture.tsx b/apps/desktop/tests/components/settings/provider-image-settings.fixture.tsx new file mode 100644 index 00000000..2eade8be --- /dev/null +++ b/apps/desktop/tests/components/settings/provider-image-settings.fixture.tsx @@ -0,0 +1,445 @@ +import { afterAll, afterEach, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { + BuiltinTool, + ImageGenerationApi, + ImageModelDefinition, + ModelProviderGroup, +} from "@llm-space/core"; +import { + act, + createContext, + useContext, + type ComponentProps, + type ReactElement, + type ReactNode, +} from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { createArkImageGenerator } from "../../../../../packages/runtime/src/models/ark-image-generation"; +import { ModelManager } from "../../../../../packages/runtime/src/models/model-manager"; +import { I18nProvider } from "../../../src/i18n/i18n-provider"; +import { + installReactTestDom, + TestEvent, + type TestElement, +} from "../../../src/test/react-test-dom"; + +const DOM = installReactTestDom(); +const MOUNTS: { root: Root; container: TestElement }[] = []; +const TEMP_DIRS: string[] = []; +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X2NDWQAAAABJRU5ErkJggg=="; +const LEGACY_MODEL: ImageModelDefinition = { + id: "dall-e-3", + name: "Original", + supportedSizes: ["1K"], + defaultSize: "1K", +}; +const TOOL: BuiltinTool = { + type: "builtin", + name: "generate_image", + description: "Generate an image.", + parameters: { type: "object", properties: {} }, +}; +let providers: ModelProviderGroup[] = []; +const HOST = { builtinTools: { list: () => Promise.resolve([TOOL]) } }; + +function _Container({ children }: { children?: ReactNode }) { + return
{children}
; +} + +const SELECT_CONTEXT = createContext<{ + value?: string; + disabled?: boolean; + onValueChange?: (value: string) => void; +}>({}); + +await mock.module("@llm-space/ui/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => + open ?
{children}
: null, + DialogContent: _Container, + DialogHeader: _Container, + DialogFooter: _Container, + DialogTitle: _Container, + DialogDescription: _Container, +})); +await mock.module("@llm-space/ui/ui/select", () => ({ + Select: ({ + children, + ...state + }: { + children?: ReactNode; + value?: string; + disabled?: boolean; + onValueChange?: (value: string) => void; + }) => ( + {children} + ), + SelectTrigger: function TestSelectTrigger({ + "aria-label": label, + }: { + "aria-label"?: string; + }) { + const { value, disabled } = useContext(SELECT_CONTEXT); + return + ); + }, +})); +await mock.module("@llm-space/ui/ui/switch", () => ({ + Switch: ({ + checked, + disabled, + onCheckedChange, + "aria-label": label, + }: { + checked?: boolean; + disabled?: boolean; + onCheckedChange?: (checked: boolean) => void; + "aria-label"?: string; + }) => ( +