diff --git a/apps/desktop/electron/main/builtin-skills.ts b/apps/desktop/electron/main/builtin-skills.ts index 9e7d5d71f6..3dcb12005a 100644 --- a/apps/desktop/electron/main/builtin-skills.ts +++ b/apps/desktop/electron/main/builtin-skills.ts @@ -15,13 +15,16 @@ import type { PluginSkillDef } from "@pi-desktop/agent-runtime"; /** Bundled skill teaching the plugin-development loop. */ export const PLUGIN_DEV_SKILL_FILE = "plugin-development.md"; export const PLUGIN_DEV_SKILL_ID = "pi-desktop/plugin-development"; +export const IMAGE_GENERATION_SKILL_ID = "pi-desktop/imagegen"; +const IMAGE_GENERATION_SKILL_FILE = "image-generation.md"; /** electron-builder copies `resources/skills` to `/skills`. */ function resolveBuiltinSkillPath(fileName: string): string | null { + const moduleDir = typeof __dirname === "string" ? __dirname : import.meta.dirname; const candidates = [ join(process.resourcesPath || "", "skills", fileName), - join(__dirname, "../../resources/skills", fileName), - join(__dirname, "../../../resources/skills", fileName), + join(moduleDir, "../../resources/skills", fileName), + join(moduleDir, "../../../resources/skills", fileName), ]; for (const candidate of candidates) { if (candidate && existsSync(candidate)) return candidate; @@ -85,18 +88,15 @@ export type BuiltinSkillInput = { * fresh so a packaged update takes effect without a restart. */ export function builtinSkills(input: BuiltinSkillInput): PluginSkillDef[] { - if (!isPluginWorkspace(input.workspacePath, input.pluginPaths)) return []; - const raw = readBuiltinSkill(PLUGIN_DEV_SKILL_FILE); - if (!raw?.trim()) return []; - const parsed = parseSkillFrontmatter(raw); - if (!parsed.body) return []; - return [ - { - id: PLUGIN_DEV_SKILL_ID, - name: parsed.name ?? "PI-Desktop plugin development", - description: parsed.description, - }, - ]; + const ids = [IMAGE_GENERATION_SKILL_ID]; + if (isPluginWorkspace(input.workspacePath, input.pluginPaths)) ids.push(PLUGIN_DEV_SKILL_ID); + return ids.flatMap((id) => { + const file = id === IMAGE_GENERATION_SKILL_ID ? IMAGE_GENERATION_SKILL_FILE : PLUGIN_DEV_SKILL_FILE; + const raw = readBuiltinSkill(file); + if (!raw?.trim()) return []; + const parsed = parseSkillFrontmatter(raw); + return parsed.body ? [{ id, name: parsed.name ?? id, description: parsed.description }] : []; + }); } /** @@ -106,14 +106,14 @@ export function builtinSkills(input: BuiltinSkillInput): PluginSkillDef[] { export function loadBuiltinSkillBody( id: string, ): { id: string; name: string; body: string } | null { - if (id !== PLUGIN_DEV_SKILL_ID) return null; - const raw = readBuiltinSkill(PLUGIN_DEV_SKILL_FILE); + if (id !== PLUGIN_DEV_SKILL_ID && id !== IMAGE_GENERATION_SKILL_ID) return null; + const raw = readBuiltinSkill(id === IMAGE_GENERATION_SKILL_ID ? IMAGE_GENERATION_SKILL_FILE : PLUGIN_DEV_SKILL_FILE); if (!raw?.trim()) return null; const parsed = parseSkillFrontmatter(raw); if (!parsed.body) return null; return { - id: PLUGIN_DEV_SKILL_ID, - name: parsed.name ?? "PI-Desktop plugin development", + id, + name: parsed.name ?? id, body: parsed.body, }; } diff --git a/apps/desktop/electron/main/runtime/provider-catalog.ts b/apps/desktop/electron/main/runtime/provider-catalog.ts index 8da390e3d7..e3d0eccd46 100644 --- a/apps/desktop/electron/main/runtime/provider-catalog.ts +++ b/apps/desktop/electron/main/runtime/provider-catalog.ts @@ -181,9 +181,7 @@ export function createProviderCatalogRuntime({ return { ...(value as T), infiniteProviderRetry: (value as T & { infiniteProviderRetry?: unknown }) - .infiniteProviderRetry === true - ? true - : undefined, + .infiniteProviderRetry === true, defaultCommandShell: isCommandShellId(value.defaultCommandShell) ? value.defaultCommandShell : defaultCommandShellForPlatform(process.platform), diff --git a/apps/desktop/electron/main/runtime/session-launch.ts b/apps/desktop/electron/main/runtime/session-launch.ts index d901c2e828..0e4a326098 100644 --- a/apps/desktop/electron/main/runtime/session-launch.ts +++ b/apps/desktop/electron/main/runtime/session-launch.ts @@ -3,6 +3,7 @@ import { ErrorCodes as SharedErrorCodes, isActiveInProject, isCommandShellCatalog, + isImageGenerationModel, normalizeMode, resolveBindingContextWindow, trustedExtensionAgentKeyFromProviderId, @@ -326,6 +327,11 @@ export function createSessionLaunchRuntime({ errorCode: ErrorCodes.MODEL_NOT_CONFIGURED, }); } + if (isImageGenerationModel(settings.imageGeneration, provider.id, modelId)) { + throw Object.assign(new Error("The image model cannot be used for conversation; select a chat model"), { + errorCode: ErrorCodes.MODEL_NOT_CONFIGURED, + }); + } // The authenticated collection owns a vendor account's available model IDs // and wire endpoint. models.dev owns metadata; one account can span multiple // wire APIs and gateway catalogs. diff --git a/apps/desktop/electron/main/runtime/sidecar.ts b/apps/desktop/electron/main/runtime/sidecar.ts index 685734af07..fcd68d044a 100644 --- a/apps/desktop/electron/main/runtime/sidecar.ts +++ b/apps/desktop/electron/main/runtime/sidecar.ts @@ -7,6 +7,7 @@ import { subagentProviderLookupError, } from "@pi-desktop/agent-runtime"; import { loadBuiltinSkillBody } from "../builtin-skills"; +import { createImageGenerationTool } from "../services/image-generation-service"; import { registerPluginDevTools } from "../plugin-dev-tools"; import { resolveLocalFile } from "../browser-view"; import { modelConfigFromModelsDev } from "../models-dev-catalog"; @@ -469,6 +470,7 @@ export function createSidecarRuntime({ }); // Agent-driven work panel preview (D100): open a workspace HTML file in // the embedded browser; live reload keeps it current through later edits. + s.setLocalTool("GenerateImages", createImageGenerationTool({ dataDir, getHost: () => runtimeState.host })); s.setLocalTool("BrowserPreview", async ({ args, sessionId }) => { const raw = String((args as { path?: unknown })?.path ?? "").trim(); if (!raw) { diff --git a/apps/desktop/electron/main/services/image-generation-service.ts b/apps/desktop/electron/main/services/image-generation-service.ts new file mode 100644 index 0000000000..b85bf7ac5e --- /dev/null +++ b/apps/desktop/electron/main/services/image-generation-service.ts @@ -0,0 +1,114 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, realpath, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { generateImageBatch } from "@pi-desktop/agent-runtime"; +import { + imageGenerationPrompts, + parseImageGenerationBinding, + type AppSettings, + type ProviderPublic, +} from "@pi-desktop/shared"; +import type { HostProcess } from "../host-process"; +import type { LocalToolHandler } from "../agent-sidecar"; +import { imageInputLoader } from "./image-inputs"; + +function failure(errorCode: string, content: string) { + return { + ok: false, + isError: true, + errorCode, + content: { kind: "image-generation-error", errorCode, message: content }, + }; +} + +export function createImageGenerationTool(options: { + dataDir: string; + getHost: () => Pick | null; + fetchImpl?: typeof fetch; +}): LocalToolHandler { + return async ({ sessionId, args, signal }) => { + imageGenerationPrompts(args); + const host = options.getHost(); + if (!host) return failure("HOST_UNAVAILABLE", "Host unavailable."); + const settings = await host.call("settings.get"); + const binding = parseImageGenerationBinding(settings.imageGeneration); + if (!binding) + return failure( + "IMAGE_NOT_CONFIGURED", + "Configure an image generation model in Settings > Models > Image generation model before generating images. Do not substitute another model.", + ); + const { provider } = await host.call<{ provider?: ProviderPublic }>("providers.get", { + id: binding.providerId, + }); + if ( + !provider?.enabled || + !provider.baseUrl || + !provider.models.some((model) => model.id === binding.modelId) + ) { + return failure( + "IMAGE_MODEL_UNAVAILABLE", + "The configured image model is unavailable. Update Settings > Models > Image generation model.", + ); + } + if (provider.authKind === "oauth") + return failure( + "IMAGE_AUTH_UNSUPPORTED", + "Image generation requires an API-key or no-auth service.", + ); + const { value } = await host.call<{ value?: string }>("providers.getSecret", { + id: provider.id, + }); + if (provider.authKind !== "none" && !value) + return failure("IMAGE_AUTH_FAILED", "The image provider needs an API key."); + const { path } = await host.call<{ path: string }>("session.getScratchPath", { sessionId }); + const root = resolve(options.dataDir, "scratch"); + const within = (base: string, target: string) => { + const rel = relative(base, target); + return !!rel && !rel.startsWith("..") && !isAbsolute(rel); + }; + if (typeof path !== "string" || !within(root, resolve(path))) + return failure("INVALID_ARGUMENT", "Invalid image output directory."); + await mkdir(path, { recursive: true }); + const realRoot = await realpath(root); + const realDir = await realpath(path); + if (!within(realRoot, realDir)) + return failure("INVALID_ARGUMENT", "Invalid image output directory."); + signal.throwIfAborted(); + const { session } = await host.call<{ session?: { projectPath?: string } }>("session.get", { + id: sessionId, + }); + if (!session) return failure("SESSION_NOT_FOUND", "The image session no longer exists."); + const results = await generateImageBatch({ + input: args, + endpoint: { + baseUrl: provider.baseUrl, + modelId: binding.modelId, + apiKey: value, + headers: provider.headers, + }, + signal, + fetchImpl: options.fetchImpl, + loadImages: imageInputLoader({ + dataDir: options.dataDir, + scratchPath: realDir, + projectPath: session?.projectPath, + }), + save: async (image) => { + const target = join(realDir, `generated-${randomUUID()}.${image.extension}`); + await writeFile(target, image.bytes, { flag: "wx" }); + return target; + }, + }); + const ok = results.some((result) => result.status === "succeeded"); + return { + ok, + isError: !ok, + content: { + kind: "generated-images", + providerId: binding.providerId, + modelId: binding.modelId, + results, + }, + }; + }; +} diff --git a/apps/desktop/electron/main/services/image-inputs.ts b/apps/desktop/electron/main/services/image-inputs.ts new file mode 100644 index 0000000000..6a9fbfb6a9 --- /dev/null +++ b/apps/desktop/electron/main/services/image-inputs.ts @@ -0,0 +1,88 @@ +import { open, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import { generatedImageType, MAX_IMAGE_BYTES } from "@pi-desktop/agent-runtime"; + +/** Session/project roots are captured by the host, never supplied by the model. */ +export function imageInputLoader(options: { + projectPath?: string; + scratchPath: string; + dataDir: string; +}) { + let loadedBytes = 0; + const cache = new Map< + string, + Promise<{ bytes: Uint8Array; mimeType: string; extension: string }> + >(); + const read = async (ref: string) => { + const candidate = /^attachments[\\/][a-f0-9]{64}$/.test(ref) + ? resolve(options.dataDir, ref) + : isAbsolute(ref) + ? ref + : options.projectPath + ? resolve(options.projectPath, ref) + : resolve(options.scratchPath, ref); + const path = await realpath(candidate); + const roots = await Promise.all( + [options.projectPath, options.scratchPath, resolve(options.dataDir, "attachments")] + .filter((root): root is string => !!root) + .map((root) => + realpath(root).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }), + ), + ); + if ( + !roots.some((root) => { + if (!root) return false; + const rel = relative(root, path); + return !!rel && !rel.startsWith("..") && !isAbsolute(rel); + }) + ) + throw Object.assign(new Error("Image input is outside the session and project roots"), { + errorCode: "IMAGE_INPUT_OUTSIDE_ROOT", + }); + const file = await open(path, "r"); + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size > MAX_IMAGE_BYTES) + throw Object.assign(new Error("Image input is too large"), { + errorCode: "IMAGE_INPUT_INVALID", + }); + // A bounded read still holds if another process grows the file after stat. + const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_IMAGE_BYTES + 1)); + let size = 0; + while (size < bytes.length) { + const read = await file.read(bytes, size, bytes.length - size, null); + if (!read.bytesRead) break; + size += read.bytesRead; + } + const data = bytes.subarray(0, size); + if (loadedBytes + size > 64 * 1024 * 1024) + throw Object.assign(new Error("Batch image inputs exceed 64 MB"), { + errorCode: "IMAGE_INPUT_TOO_LARGE", + }); + loadedBytes += size; + return { bytes: data, ...generatedImageType(data) }; + } finally { + await file.close(); + } + }; + return async (refs: string[]) => { + const images = await Promise.all( + refs.map((ref) => { + let image = cache.get(ref); + if (!image) { + image = read(ref); + cache.set(ref, image); + } + return image; + }), + ); + if (images.reduce((size, image) => size + image.bytes.length, 0) > 32 * 1024 * 1024) + throw Object.assign(new Error("Image inputs exceed 32 MB"), { + errorCode: "IMAGE_INPUT_TOO_LARGE", + }); + return images; + }; +} diff --git a/apps/desktop/resources/skills/image-generation.md b/apps/desktop/resources/skills/image-generation.md new file mode 100644 index 0000000000..7e4178b48d --- /dev/null +++ b/apps/desktop/resources/skills/image-generation.md @@ -0,0 +1,59 @@ +--- +name: imagegen +description: Generate or edit raster images, illustrations, photos, banners, and project assets with the configured image model. Supports reference images, edits of earlier results, variants, and batches. Prefer existing code-native tools for SVG/CSS edits. +--- + +# Image generation + +Use the desktop `GenerateImages` tool. The user selects its provider and model +under Settings → Models → Image generation model; this is independent of the chat +model. If ToolSearch is available and GenerateImages is not loaded, discover it +there first. Do not install an SDK, run an API script, ask for a key in chat, or +substitute the conversation model. + +## Prepare the request + +- Preserve the requested subject, composition, style, exact text, and constraints. + Add useful detail to a vague prompt without inventing additional deliverables. +- For a project asset, include its intended use and required framing. Prefer the + existing SVG/CSS asset system for changes to code-native icons or diagrams. +- For an edit, pass the source file paths as `images` (one to four per item). + Specify what changes and what must stay unchanged. For a follow-up edit, use + the previous result path. Inputs must be under the current project, session + scratch directory, or attachment store. Do not use remote URLs. +- Editing uses OpenAI Images multipart requests and depends on the selected + model supporting that endpoint. Do not silently replace an edit with a fresh + generation if it fails. Mask painting is not a desktop UI feature. + +## Generate one image or a batch + +Call `GenerateImages` with `items`, where each item has `prompt` and optional +`count` (default 1), plus optional `images` for edits. Use one item with `count` for variants of the same prompt; +use separate items for different assets. A batch permits at most 10 images +total. For a larger explicitly requested set, split it into bounded batches. +Generate only the quantity the user asked for; do not add unrequested variants. + +Example: two cover variants and one distinct icon: + +```json +{"items":[{"prompt":"Editorial cover: a ceramic cup on a quiet desk, warm daylight, no text","count":2},{"prompt":"A small raster illustration of a green leaf on a white background","count":1}]} +``` + +Generation may incur cost. Do not retry failed or timed-out items automatically, +including after cancellation: the provider may already have processed them. +Report partial success and wait for a user request before retrying. If no image +model is configured, direct the user to Settings → Models; do not select one silently. + +## Deliver the result + +Results are ordered and contain a status and, for successes, a local image path. +Show the successful images with Markdown image links and report failed items. +The desktop also renders their previews directly from the tool result. + +Use available image inspection tools to check the result when possible. Do not +claim to have visually inspected an image if you only received its file path. +For project deliverables, copy the selected image into the requested project +location using existing file/shell tools and update the consuming reference. +Use a new filename unless replacement was requested. Preview-only images may +remain in session storage. Report the final project paths, the prompt used, +and any requested images that did not complete. diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index a60883b20b..58631f85c2 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -12,6 +12,7 @@ import type { } from "@pi-desktop/shared"; import { initialThinkingLevelForBinding, + isImageGenerationModel, modelIdsMatch, normalizeLargePasteThreshold, stripInlineComposerFileReferenceTokens, @@ -391,6 +392,7 @@ export function Composer({ : !!provider && provider.enabled && !!modelId && + !isImageGenerationModel(settings?.imageGeneration, provider.id, modelId) && (provider.hasSecret || provider.authKind === "none"); const enterToSend = settings?.enterToSend ?? true; const hasDraftContent = Boolean(value.trim() || activeFileReferences.length); diff --git a/apps/desktop/src/components/Markdown.tsx b/apps/desktop/src/components/Markdown.tsx index 9a03b77997..253829c8c8 100644 --- a/apps/desktop/src/components/Markdown.tsx +++ b/apps/desktop/src/components/Markdown.tsx @@ -51,6 +51,7 @@ import { } from "../lib/latex-math"; import { useAppStore } from "../stores/app-store"; import { useReferencedImageDataUrl } from "../lib/use-referenced-image-data-url"; +import { absoluteImagePath, remarkLocalImagePaths } from "../lib/markdown-image-paths"; import { useOpenChatFileRef } from "../hooks/use-preview-target"; import { remarkChatFileLinks, @@ -621,7 +622,7 @@ function MarkdownImage({ !isRemote && /^attachments\/[0-9a-f]{64}$/i.test(decoded.replace(/\\/g, "/")) ? decoded.replace(/\\/g, "/") : null; - const localRef = rel ?? attachmentRef; + const localRef = (isRemote ? null : absoluteImagePath(source)) ?? rel ?? attachmentRef; // Always run the hook before any branch so hook order stays stable when a // streaming src flips between remote and local. Remote images pass null. const dataUrl = useReferencedImageDataUrl(isRemote ? null : localRef); @@ -716,7 +717,7 @@ const markdownComponents: Components = { table: Table, }; -const staticRemarkPlugins = [remarkGfm, remarkMath]; +const staticRemarkPlugins = [remarkGfm, remarkMath, remarkLocalImagePaths]; // Extend the default schema only for the media elements rendered above, plus // `remark-math`'s math classes on ``: the default `language-*` allow list diff --git a/apps/desktop/src/components/settings/ImageGenerationModelRow.tsx b/apps/desktop/src/components/settings/ImageGenerationModelRow.tsx new file mode 100644 index 0000000000..0f829985f6 --- /dev/null +++ b/apps/desktop/src/components/settings/ImageGenerationModelRow.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import type { AppSettings, ProviderPublic } from "@pi-desktop/shared"; + +export function ImageGenerationModelRow({ settings, providers }: { + settings: AppSettings; + providers: ProviderPublic[]; +}) { + const { t } = useTranslation(); + const binding = settings.imageGeneration; + const provider = providers.find((entry) => entry.id === binding?.providerId); + const valid = provider?.enabled && provider.authKind !== "oauth" && + !!provider.baseUrl && (provider.hasSecret || provider.authKind === "none") && + provider.models.some((model) => model.id === binding?.modelId); + + return ( +
+
+
{t("settings.imageModel")}
+
+ {valid && binding ? ( + <> + {provider.name} + · + {binding.modelId} + + ) : ( + {t("settings.imageModelUnavailable")} + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/components/settings/ModelConfigPage.tsx b/apps/desktop/src/components/settings/ModelConfigPage.tsx index dc9a342cae..074af7699f 100644 --- a/apps/desktop/src/components/settings/ModelConfigPage.tsx +++ b/apps/desktop/src/components/settings/ModelConfigPage.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { OAUTH_AUTH_KIND, + isImageGenerationModel, modelIdsMatch, type ModelBinding, type ProviderPublic, @@ -32,11 +33,11 @@ import { } from "../icons"; import { AnchoredMenu } from "./AnchoredMenu"; import { - defaultModelIdOf, defaultModelOptions, displayedDefaultModelId, } from "./default-model"; import { copyProviderConfiguration, type ProviderCopyDraft } from "./provider-copy"; +import { ImageGenerationModelRow } from "./ImageGenerationModelRow"; import { ProviderSetupDialog } from "./ProviderSetupDialog"; import { useProviderReorder } from "./useProviderReorder"; import { VendorAccountsSection } from "./VendorAccountsSection"; @@ -110,7 +111,7 @@ export function ModelConfigPage() { const providerReady = (provider: ProviderPublic) => provider.enabled && - !!defaultModelIdOf(provider) && + defaultModelOptions([provider], settings?.imageGeneration).length > 0 && (provider.hasSecret || provider.hasOauth || provider.authKind === "none"); const aiProviders = useMemo( @@ -119,7 +120,7 @@ export function ModelConfigPage() { ); const reorder = useProviderReorder(aiProviders, busyId !== null || testingId !== null || setupFor !== null); const readyProviders = providers.filter(providerReady); - const defaultModelOptionsList = defaultModelOptions(readyProviders); + const defaultModelOptionsList = defaultModelOptions(readyProviders, settings?.imageGeneration); const visibleDefaultModelOptions = useMemo(() => { const query = defaultModelQuery.trim().toLowerCase(); if (!query) return defaultModelOptionsList; @@ -135,10 +136,13 @@ export function ModelConfigPage() { providers.find((provider) => provider.id === settings.defaultProviderId) ?? null; const editingProvider = setupFor ? providers.find((provider) => provider.id === setupFor) ?? null : null; - const defaultProviderReady = defaultProvider !== null && providerReady(defaultProvider); + const defaultProviderReady = defaultProvider !== null && providerReady(defaultProvider) && + !isImageGenerationModel(settings.imageGeneration, defaultProvider.id, + displayedDefaultModelId(defaultProvider, settings.defaultModelId)); const setDefaultModel = async (provider: ProviderPublic, modelId: string) => { + if (isImageGenerationModel(useAppStore.getState().settings?.imageGeneration, provider.id, modelId)) return; setBusyId(provider.id); try { await api.setSettings({ @@ -161,10 +165,14 @@ export function ModelConfigPage() { /** * Preserve the selected app default unless it was removed from the provider. */ - const afterSaved = async (saved: ProviderPublic, models: ModelBinding[]) => { + const afterSaved = async (saved: ProviderPublic, models: ModelBinding[], imageModelId?: string) => { const firstModelId = models[0]?.id; try { - if (copyDraft) { + if (imageModelId) { + await api.setSettings({ ...(await api.getSettings()), imageGeneration: { providerId: saved.id, modelId: imageModelId } }); + useAppStore.setState({ settings: await api.getSettings() }); + showToast(t("settings.providerSaved"), { variant: "success" }); + } else if (copyDraft) { showToast(t("settings.providerSaved"), { variant: "success" }); } else if (!editingProvider) { await api.setSettings({ @@ -418,6 +426,7 @@ export function ModelConfigPage() { + @@ -519,7 +528,7 @@ export function ModelConfigPage() { variant="ghost" disabled={rowBusy || !providerReady(provider)} onClick={() => - void setDefaultModel(provider, defaultModelIdOf(provider) ?? "") + void setDefaultModel(provider, defaultModelOptions([provider], settings.imageGeneration)[0]?.modelId ?? "") } > {t("settings.makeDefault")} @@ -722,7 +731,8 @@ export function ModelConfigPage() { provider={editingProvider} initialDraft={copyDraft} onClose={() => { setSetupFor(null); setCopyDraft(null); }} - onSaved={(saved, models) => void afterSaved(saved, models)} + imageModelId={settings.imageGeneration?.providerId === editingProvider?.id ? settings.imageGeneration?.modelId : undefined} + onSaved={afterSaved} /> ) : null} diff --git a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx index b4a8872d80..33cf11ed48 100644 --- a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx +++ b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx @@ -179,6 +179,8 @@ export function applyVisibleModelSelection( } export type ModelSelectionPanesProps = { + imageModelId?: string; + onImageModelChange?: (id: string) => void; discovery: ProviderModelsState & { canReload?: boolean }; selection: ModelSelection; /** Heading of the discovered list: a service's models, or an account's. */ @@ -207,6 +209,8 @@ export function ModelSelectionPanes({ busy = false, onReload, apiStyle, + imageModelId, + onImageModelChange, }: ModelSelectionPanesProps) { const { t } = useTranslation(); const { rows, models, publishedLevelsById, setModels } = selection; @@ -598,6 +602,7 @@ export function ModelSelectionPanes({ id={advancedId} hidden={!expanded} > + {onImageModelChange ? : null}