refactor: flatten image generation settings structure (#7536)

This commit is contained in:
Daniel 2025-08-29 17:35:24 -05:00 • committed by Hannes Rudolph
parent 5236675d9a
commit a96fe08b18
12 changed files with 120 additions and 86 deletions

View file

@ -143,13 +143,6 @@ const openRouterSchema = baseProviderSettingsSchema.extend({
openRouterBaseUrl: z.string().optional(),
openRouterSpecificProvider: z.string().optional(),
openRouterUseMiddleOutTransform: z.boolean().optional(),
// Image generation settings (experimental)
openRouterImageGenerationSettings: z
.object({
openRouterApiKey: z.string().optional(),
selectedModel: z.string().optional(),
})
.optional(),
})
const bedrockSchema = apiModelIdProviderModelSchema.extend({

View file

@ -46,12 +46,8 @@ describe("generateImageTool", () => {
experiments: {
[EXPERIMENT_IDS.IMAGE_GENERATION]: true,
},
apiConfiguration: {
openRouterImageGenerationSettings: {
openRouterApiKey: "test-api-key",
selectedModel: "google/gemini-2.5-flash-image-preview",
},
},
openRouterImageApiKey: "test-api-key",
openRouterImageGenerationSelectedModel: "google/gemini-2.5-flash-image-preview",
}),
}),
},

View file

@ -129,10 +129,8 @@ export async function generateImageTool(
// Check if file is write-protected
const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false
// Get OpenRouter API key from experimental settings ONLY (no fallback to profile)
const apiConfiguration = state?.apiConfiguration
const imageGenerationSettings = apiConfiguration?.openRouterImageGenerationSettings
const openRouterApiKey = imageGenerationSettings?.openRouterApiKey
// Get OpenRouter API key from global settings (experimental image generation)
const openRouterApiKey = state?.openRouterImageApiKey
if (!openRouterApiKey) {
await cline.say(
@ -148,7 +146,7 @@ export async function generateImageTool(
}
// Get selected model from settings or use default
const selectedModel = imageGenerationSettings?.selectedModel || IMAGE_GENERATION_MODELS[0]
const selectedModel = state?.openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0]
// Determine if the path is outside the workspace
const fullPath = path.resolve(cline.cwd, removeClosingTag("path", relPath))

View file

@ -1820,6 +1820,8 @@ export class ClineProvider
maxDiagnosticMessages,
includeTaskHistoryInEnhance,
remoteControlEnabled,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
} = await this.getState()
const telemetryKey = process.env.POSTHOG_API_KEY
@ -1952,6 +1954,8 @@ export class ClineProvider
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
remoteControlEnabled,
filesChangedEnabled: this.getGlobalState("filesChangedEnabled") ?? true,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
}
}
@ -2151,6 +2155,9 @@ export class ClineProvider
return false
}
})(),
// Add image generation settings
openRouterImageApiKey: stateValues.openRouterImageApiKey,
openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel,
}
}

View file

@ -584,6 +584,8 @@ describe("ClineProvider", () => {
openRouterImageApiKey: undefined,
openRouterImageGenerationSelectedModel: undefined,
filesChangedEnabled: true,
openRouterImageApiKey: undefined,
openRouterImageGenerationSelectedModel: undefined,
}
const message: ExtensionMessage = {

View file

@ -1314,6 +1314,14 @@ export const webviewMessageHandler = async (
await updateGlobalState("language", message.text as Language)
await provider.postStateToWebview()
break
case "openRouterImageApiKey":
await provider.contextProxy.setValue("openRouterImageApiKey", message.text)
await provider.postStateToWebview()
break
case "openRouterImageGenerationSelectedModel":
await provider.contextProxy.setValue("openRouterImageGenerationSelectedModel", message.text)
await provider.postStateToWebview()
break
case "showRooIgnoredFiles":
await updateGlobalState("showRooIgnoredFiles", message.bool ?? false)
await provider.postStateToWebview()

View file

@ -286,6 +286,7 @@ export type ExtensionState = Pick<
| "includeDiagnosticMessages"
| "maxDiagnosticMessages"
| "remoteControlEnabled"
| "openRouterImageGenerationSelectedModel"
> & {
version: string
clineMessages: ClineMessage[]
@ -340,6 +341,7 @@ export type ExtensionState = Pick<
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
filesChangedEnabled: boolean
openRouterImageApiKey?: string
}
export interface ClineSayTool {

View file

@ -218,6 +218,9 @@ export interface WebviewMessage {
| "filesChangedEnabled"
| "filesChangedRequest"
| "filesChangedBaselineUpdate"
| "imageGenerationSettings"
| "openRouterImageApiKey"
| "openRouterImageGenerationSelectedModel"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
@ -254,6 +257,7 @@ export interface WebviewMessage {
messageTs?: number
historyPreviewCollapsed?: boolean
filters?: { type?: string; search?: string; tags?: string[] }
settings?: any
url?: string // For openExternal
mpItem?: MarketplaceItem
mpInstallOptions?: InstallMarketplaceItemOptions

View file

@ -23,6 +23,10 @@ type ExperimentalSettingsProps = HTMLAttributes<HTMLDivElement> & {
setCachedStateField?: SetCachedStateField<"filesChangedEnabled">
apiConfiguration?: any
setApiConfigurationField?: any
openRouterImageApiKey?: string
openRouterImageGenerationSelectedModel?: string
setOpenRouterImageApiKey?: (apiKey: string) => void
setImageGenerationSelectedModel?: (model: string) => void
}
export const ExperimentalSettings = ({
@ -32,6 +36,10 @@ export const ExperimentalSettings = ({
setCachedStateField,
apiConfiguration,
setApiConfigurationField,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
setOpenRouterImageApiKey,
setImageGenerationSelectedModel,
className,
...props
}: ExperimentalSettingsProps) => {
@ -80,7 +88,11 @@ export const ExperimentalSettings = ({
/>
)
}
if (config[0] === "IMAGE_GENERATION" && apiConfiguration && setApiConfigurationField) {
if (
config[0] === "IMAGE_GENERATION" &&
setOpenRouterImageApiKey &&
setImageGenerationSelectedModel
) {
return (
<ImageGenerationSettings
key={config[0]}
@ -88,8 +100,10 @@ export const ExperimentalSettings = ({
onChange={(enabled) =>
setExperimentEnabled(EXPERIMENT_IDS.IMAGE_GENERATION, enabled)
}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
openRouterImageApiKey={openRouterImageApiKey}
openRouterImageGenerationSelectedModel={openRouterImageGenerationSelectedModel}
setOpenRouterImageApiKey={setOpenRouterImageApiKey}
setImageGenerationSelectedModel={setImageGenerationSelectedModel}
/>
)
}

View file

@ -1,17 +1,14 @@
import React, { useState, useEffect } from "react"
import { VSCodeCheckbox, VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import type { ProviderSettings } from "@roo-code/types"
interface ImageGenerationSettingsProps {
enabled: boolean
onChange: (enabled: boolean) => void
apiConfiguration: ProviderSettings
setApiConfigurationField: <K extends keyof ProviderSettings>(
field: K,
value: ProviderSettings[K],
isUserAction?: boolean,
) => void
openRouterImageApiKey?: string
openRouterImageGenerationSelectedModel?: string
setOpenRouterImageApiKey: (apiKey: string) => void
setImageGenerationSelectedModel: (model: string) => void
}
// Hardcoded list of image generation models
@ -24,43 +21,34 @@ const IMAGE_GENERATION_MODELS = [
export const ImageGenerationSettings = ({
enabled,
onChange,
apiConfiguration,
setApiConfigurationField,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
setOpenRouterImageApiKey,
setImageGenerationSelectedModel,
}: ImageGenerationSettingsProps) => {
const { t } = useAppTranslation()
// Get image generation settings from apiConfiguration
const imageGenerationSettings = apiConfiguration?.openRouterImageGenerationSettings || {}
const [openRouterApiKey, setOpenRouterApiKey] = useState(imageGenerationSettings.openRouterApiKey || "")
const [apiKey, setApiKey] = useState(openRouterImageApiKey || "")
const [selectedModel, setSelectedModel] = useState(
imageGenerationSettings.selectedModel || IMAGE_GENERATION_MODELS[0].value,
openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0].value,
)
// Update local state when apiConfiguration changes (e.g., when switching profiles)
// Update local state when props change (e.g., when switching profiles)
useEffect(() => {
setOpenRouterApiKey(imageGenerationSettings.openRouterApiKey || "")
setSelectedModel(imageGenerationSettings.selectedModel || IMAGE_GENERATION_MODELS[0].value)
}, [imageGenerationSettings.openRouterApiKey, imageGenerationSettings.selectedModel])
// Helper function to update settings
const updateSettings = (newApiKey: string, newModel: string) => {
const newSettings = {
openRouterApiKey: newApiKey,
selectedModel: newModel,
}
setApiConfigurationField("openRouterImageGenerationSettings", newSettings, true)
}
setApiKey(openRouterImageApiKey || "")
setSelectedModel(openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0].value)
}, [openRouterImageApiKey, openRouterImageGenerationSelectedModel])
// Handle API key changes
const handleApiKeyChange = (value: string) => {
setOpenRouterApiKey(value)
updateSettings(value, selectedModel)
setApiKey(value)
setOpenRouterImageApiKey(value)
}
// Handle model selection changes
const handleModelChange = (value: string) => {
setSelectedModel(value)
updateSettings(openRouterApiKey, value)
setImageGenerationSelectedModel(value)
}
return (
@ -84,7 +72,7 @@ export const ImageGenerationSettings = ({
{t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyLabel")}
</label>
<VSCodeTextField
value={openRouterApiKey}
value={apiKey}
onInput={(e: any) => handleApiKeyChange(e.target.value)}
placeholder={t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyPlaceholder")}
className="w-full"
@ -123,13 +111,13 @@ export const ImageGenerationSettings = ({
</div>
{/* Status Message */}
{enabled && !openRouterApiKey && (
{enabled && !apiKey && (
<div className="p-2 bg-vscode-editorWarning-background text-vscode-editorWarning-foreground rounded text-sm">
{t("settings:experimental.IMAGE_GENERATION.warningMissingKey")}
</div>
)}
{enabled && openRouterApiKey && (
{enabled && apiKey && (
<div className="p-2 bg-vscode-editorInfo-background text-vscode-editorInfo-foreground rounded text-sm">
{t("settings:experimental.IMAGE_GENERATION.successConfigured")}
</div>

View file

@ -183,6 +183,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
maxDiagnosticMessages,
includeTaskHistoryInEnhance,
filesChangedEnabled,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -262,6 +264,20 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
})
}, [])
const setOpenRouterImageApiKey = useCallback((apiKey: string) => {
setCachedState((prevState) => {
setChangeDetected(true)
return { ...prevState, openRouterImageApiKey: apiKey }
})
}, [])
const setImageGenerationSelectedModel = useCallback((model: string) => {
setCachedState((prevState) => {
setChangeDetected(true)
return { ...prevState, openRouterImageGenerationSelectedModel: model }
})
}, [])
const setCustomSupportPromptsField = useCallback((prompts: Record<string, string | undefined>) => {
setCachedState((prevState) => {
if (JSON.stringify(prevState.customSupportPrompts) === JSON.stringify(prompts)) {
@ -346,6 +362,11 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
vscode.postMessage({ type: "openRouterImageApiKey", text: openRouterImageApiKey })
vscode.postMessage({
type: "openRouterImageGenerationSelectedModel",
text: openRouterImageGenerationSelectedModel,
})
setChangeDetected(false)
}
}
@ -724,10 +745,16 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
<ExperimentalSettings
setExperimentEnabled={setExperimentEnabled}
experiments={experiments}
filesChangedEnabled={filesChangedEnabled}
setCachedStateField={setCachedStateField as SetCachedStateField<"filesChangedEnabled">}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
filesChangedEnabled={filesChangedEnabled}
setCachedStateField={setCachedStateField as SetCachedStateField<"filesChangedEnabled">}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
openRouterImageApiKey={openRouterImageApiKey as string | undefined}
openRouterImageGenerationSelectedModel={
openRouterImageGenerationSelectedModel as string | undefined
}
setOpenRouterImageApiKey={setOpenRouterImageApiKey}
setImageGenerationSelectedModel={setImageGenerationSelectedModel}
/>
)}

View file

@ -1,7 +1,5 @@
import { render, fireEvent } from "@testing-library/react"
import type { ProviderSettings } from "@roo-code/types"
import { ImageGenerationSettings } from "../ImageGenerationSettings"
// Mock the translation context
@ -12,14 +10,17 @@ vi.mock("@/i18n/TranslationContext", () => ({
}))
describe("ImageGenerationSettings", () => {
const mockSetApiConfigurationField = vi.fn()
const mockSetOpenRouterImageApiKey = vi.fn()
const mockSetImageGenerationSelectedModel = vi.fn()
const mockOnChange = vi.fn()
const defaultProps = {
enabled: false,
onChange: mockOnChange,
apiConfiguration: {} as ProviderSettings,
setApiConfigurationField: mockSetApiConfigurationField,
openRouterImageApiKey: undefined,
openRouterImageGenerationSelectedModel: undefined,
setOpenRouterImageApiKey: mockSetOpenRouterImageApiKey,
setImageGenerationSelectedModel: mockSetImageGenerationSelectedModel,
}
beforeEach(() => {
@ -27,30 +28,31 @@ describe("ImageGenerationSettings", () => {
})
describe("Initial Mount Behavior", () => {
it("should not call setApiConfigurationField on initial mount with empty configuration", () => {
it("should not call setter functions on initial mount with empty configuration", () => {
render(<ImageGenerationSettings {...defaultProps} />)
// Should NOT call setApiConfigurationField on initial mount to prevent dirty state
expect(mockSetApiConfigurationField).not.toHaveBeenCalled()
// Should NOT call setter functions on initial mount to prevent dirty state
expect(mockSetOpenRouterImageApiKey).not.toHaveBeenCalled()
expect(mockSetImageGenerationSelectedModel).not.toHaveBeenCalled()
})
it("should not call setApiConfigurationField on initial mount with existing configuration", () => {
const apiConfiguration = {
openRouterImageGenerationSettings: {
openRouterApiKey: "existing-key",
selectedModel: "google/gemini-2.5-flash-image-preview:free",
},
} as ProviderSettings
it("should not call setter functions on initial mount with existing configuration", () => {
render(
<ImageGenerationSettings
{...defaultProps}
openRouterImageApiKey="existing-key"
openRouterImageGenerationSelectedModel="google/gemini-2.5-flash-image-preview:free"
/>,
)
render(<ImageGenerationSettings {...defaultProps} apiConfiguration={apiConfiguration} />)
// Should NOT call setApiConfigurationField on initial mount to prevent dirty state
expect(mockSetApiConfigurationField).not.toHaveBeenCalled()
// Should NOT call setter functions on initial mount to prevent dirty state
expect(mockSetOpenRouterImageApiKey).not.toHaveBeenCalled()
expect(mockSetImageGenerationSelectedModel).not.toHaveBeenCalled()
})
})
describe("User Interaction Behavior", () => {
it("should call setApiConfigurationField when user changes API key", async () => {
it("should call setimageGenerationSettings when user changes API key", async () => {
const { getByPlaceholderText } = render(<ImageGenerationSettings {...defaultProps} enabled={true} />)
const apiKeyInput = getByPlaceholderText(
@ -60,15 +62,8 @@ describe("ImageGenerationSettings", () => {
// Simulate user typing
fireEvent.input(apiKeyInput, { target: { value: "new-api-key" } })
// Should call setApiConfigurationField with isUserAction=true
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(
"openRouterImageGenerationSettings",
{
openRouterApiKey: "new-api-key",
selectedModel: "google/gemini-2.5-flash-image-preview",
},
true, // This should be true for user actions
)
// Should call setimageGenerationSettings
expect(defaultProps.setOpenRouterImageApiKey).toHaveBeenCalledWith("new-api-key")
})
// Note: Testing VSCode dropdown components is complex due to their custom nature