diff --git a/src/components/VisionFallbackSwitch.tsx b/src/components/VisionFallbackSwitch.tsx new file mode 100644 index 0000000000..7a3dea0b3a --- /dev/null +++ b/src/components/VisionFallbackSwitch.tsx @@ -0,0 +1,24 @@ +import { useSettings } from "@/hooks/useSettings"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +export function VisionFallbackSwitch() { + const { settings, updateSettings } = useSettings(); + const enabled = settings?.enableVisionFallback ?? false; + + return ( +
+ When enabled, Dyad will describe images for text-only models, allowing + them to understand and respond to image content without needing a + vision-capable model. Your attached images are sent to another + configured model provider to be described, which may not be the + provider of the model you selected for this chat. +
+diff --git a/src/pro/main/ipc/handlers/local_agent/local_agent_handler.test.ts b/src/pro/main/ipc/handlers/local_agent/local_agent_handler.test.ts index 6156a3afc1..455ddabc11 100644 --- a/src/pro/main/ipc/handlers/local_agent/local_agent_handler.test.ts +++ b/src/pro/main/ipc/handlers/local_agent/local_agent_handler.test.ts @@ -257,6 +257,7 @@ vi.mock("@/ipc/utils/get_model_client", () => ({ vi.mock("@/ipc/utils/token_utils", () => ({ getMaxTokens: vi.fn(async () => 4096), getTemperature: vi.fn(async () => 0.7), + supportsVision: vi.fn(async () => true), })); vi.mock("@/ipc/utils/provider_options", () => ({ diff --git a/src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts b/src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts index 0f35ef06d0..1b49d65fac 100644 --- a/src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts +++ b/src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts @@ -43,7 +43,12 @@ import { cancelOrphanedBaseStream, fastTextOutput, } from "@/ipc/utils/stream_text_utils"; -import { getMaxTokens, getTemperature } from "@/ipc/utils/token_utils"; +import { + getMaxTokens, + getTemperature, + supportsVision, +} from "@/ipc/utils/token_utils"; +import { stripImageParts } from "@/ipc/utils/vision_fallback"; import { getProviderOptions, getAiHeaders, @@ -81,6 +86,7 @@ import { hasIncompleteTodos, formatTodoSummary, sanitizeStepMessages, + stripImageContentParts, type InjectedMessage, } from "./prepare_step_utils"; import { deleteTodos, loadTodos, saveTodos } from "./todo_persistence"; @@ -714,6 +720,8 @@ export async function handleLocalAgentStream( let persistedTodos: Todo[] = []; try { + const modelSupportsVision = await supportsVision(settings.selectedModel); + // Get model client const { modelClient } = await getModelClient( settings.selectedModel, @@ -797,7 +805,12 @@ export async function handleLocalAgentStream( }); }, appendUserMessage: (content: UserMessageContentPart[]) => { - pendingUserMessages.push(content); + // Tools inject images mid-turn (web_crawl screenshots), long after the + // history was stripped above. Gate the injection point itself so every + // tool is covered, not just the ones that do it today. + pendingUserMessages.push( + modelSupportsVision ? content : stripImageContentParts(content), + ); }, onUpdateTodos: (todos) => { broadcastToRegisteredWindows(event.sender, "agent-tool:todos-update", { @@ -917,9 +930,14 @@ export async function handleLocalAgentStream( // Use messageOverride if provided (e.g., for summarization) // If a compaction summary exists, only include messages from that point onward // (pre-compaction messages are preserved in DB for the user but not sent to LLM) - const messageHistory: ModelMessage[] = messageOverride - ? messageOverride - : buildChatMessageHistory(chat.messages); + // History from `aiMessagesJson` can contain image parts persisted when a + // vision-capable model was selected. Sending those to a text-only model is + // a hard provider error, so strip them here rather than at attach time. + const builtMessageHistory = + messageOverride ?? buildChatMessageHistory(chat.messages); + const messageHistory: ModelMessage[] = modelSupportsVision + ? builtMessageHistory + : stripImageParts(builtMessageHistory); const latestUserMessage = [...messageHistory] .reverse() .find((message) => message.role === "user"); @@ -1133,7 +1151,7 @@ export async function handleLocalAgentStream( // cause injectMessagesAtPositions to splice at wrong positions. allInjectedMessages.length = 0; const preCompactionBaseCount = baseMessageHistoryCount; - const compactedMessageHistory = buildChatMessageHistory( + const rebuiltMessageHistory = buildChatMessageHistory( chat.messages, { // Keep the structured in-flight assistant/tool messages from @@ -1141,6 +1159,9 @@ export async function handleLocalAgentStream( excludeMessageIds: new Set([placeholderMessageId]), }, ); + const compactedMessageHistory = modelSupportsVision + ? rebuiltMessageHistory + : stripImageParts(rebuiltMessageHistory); // The referenced-apps reminder lives only in-memory on the // latest user message and is not persisted, so rebuilding // history from the DB drops it. Re-inject so post-compaction diff --git a/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.test.ts b/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.test.ts index efc4dd3200..222f48b82a 100644 --- a/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.test.ts +++ b/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.test.ts @@ -8,6 +8,7 @@ import { buildTodoReminderMessage, ensureToolResultOrdering, sanitizeStepMessages, + stripImageContentParts, type InjectedMessage, } from "@/pro/main/ipc/handlers/local_agent/prepare_step_utils"; import type { @@ -21,6 +22,29 @@ function textToolResult(value: string) { } describe("prepare_step_utils", () => { + describe("stripImageContentParts", () => { + it("replaces image-url parts with an omitted note", () => { + const content: UserMessageContentPart[] = [ + { type: "text", text: "Clone this page" }, + { type: "image-url", url: "data:image/png;base64,abc" }, + ]; + + expect(stripImageContentParts(content)).toEqual([ + { type: "text", text: "Clone this page" }, + { + type: "text", + text: "[image omitted: the selected model cannot read images]", + }, + ]); + }); + + it("returns image-free content untouched", () => { + const content: UserMessageContentPart[] = [{ type: "text", text: "hi" }]; + + expect(stripImageContentParts(content)).toBe(content); + }); + }); + describe("transformContentPart", () => { it("transforms text parts correctly", () => { const part: UserMessageContentPart = { diff --git a/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.ts b/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.ts index 7a9c8b4177..7e8b5e4f93 100644 --- a/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.ts +++ b/src/pro/main/ipc/handlers/local_agent/prepare_step_utils.ts @@ -14,6 +14,27 @@ import { sanitizeToolCallTranscript, } from "@/ipc/utils/ai_messages_utils"; import { validateImageDimensions } from "./tools/image_utils"; +import { IMAGE_OMITTED_NOTE } from "@/ipc/utils/vision_fallback"; + +/** + * Drop image parts from tool-injected message content. + * + * Mid-turn injections (web_crawl screenshots) bypass the history-level strip in + * handleLocalAgentStream, so a text-only model would still receive an image on + * the next step. + */ +export function stripImageContentParts( + content: UserMessageContentPart[], +): UserMessageContentPart[] { + if (!content.some((part) => part.type === "image-url")) { + return content; + } + return content.map((part) => + part.type === "image-url" + ? ({ type: "text", text: IMAGE_OMITTED_NOTE } as const) + : part, + ); +} /** * Check if a single todo is incomplete (pending or in_progress). diff --git a/testing/fake-llm-server/index.ts b/testing/fake-llm-server/index.ts index 41c6f2bc87..7f09eaf804 100644 --- a/testing/fake-llm-server/index.ts +++ b/testing/fake-llm-server/index.ts @@ -363,6 +363,12 @@ export function createFakeLlmApp(getPort: () => number) { displayName: "GPT 5.2 Remote Only", description: "Remote-only catalog OpenAI model for E2E coverage", }, + { + apiName: "gpt-5.2-no-vision", + displayName: "GPT 5.2 No Vision", + description: "Remote-only text-only model for vision-fallback E2E", + supportsVision: false, + }, ], anthropic: [ { @@ -462,6 +468,14 @@ export function createFakeLlmApp(getPort: () => number) { }, purpose: "help-bot", }, + { + id: "dyad/vision/default", + resolvedModel: { + providerId: "google", + apiName: "gemini-3.1-pro-preview", + }, + purpose: "vision", + }, ], curatedSelections: { themeGenerationOptions: [