diff --git a/.changeset/shiny-seahorses-peel.md b/.changeset/shiny-seahorses-peel.md new file mode 100644 index 0000000..275be27 --- /dev/null +++ b/.changeset/shiny-seahorses-peel.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Save different API configurations to quickly switch between providers and settings (thanks @samhvw8!) diff --git a/README.md b/README.md index d067902..07f0e7f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f - Drag and drop images into chats - Delete messages from chats - @-mention Git commits to include their context in the chat +- Save different API configurations to quickly switch between providers and settings - "Enhance prompt" button (OpenRouter models only for now) - Sound effects for feedback - Option to use browsers of different sizes and adjust screenshot quality diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 840caff..9f650c2 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -799,8 +799,30 @@ export class Cline { } } - // Convert to Anthropic.MessageParam by spreading only the API-required properties - const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => ({ role, content })) + // Clean conversation history by: + // 1. Converting to Anthropic.MessageParam by spreading only the API-required properties + // 2. Converting image blocks to text descriptions if model doesn't support images + const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => { + // Handle array content (could contain image blocks) + if (Array.isArray(content)) { + if (!this.api.getModel().info.supportsImages) { + // Convert image blocks to text descriptions + content = content.map(block => { + if (block.type === 'image') { + // Convert image blocks to text descriptions + // Note: We can't access the actual image content/url due to API limitations, + // but we can indicate that an image was present in the conversation + return { + type: 'text', + text: '[Referenced image in conversation]' + }; + } + return block; + }); + } + } + return { role, content } + }) const stream = this.api.createMessage(systemPrompt, cleanConversationHistory) const iterator = stream[Symbol.asyncIterator]() diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 4e9b98b..c21b172 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -1,7 +1,8 @@ import { Cline } from '../Cline'; import { ClineProvider } from '../webview/ClineProvider'; -import { ApiConfiguration } from '../../shared/api'; +import { ApiConfiguration, ModelInfo } from '../../shared/api'; import { ApiStreamChunk } from '../../api/transform/stream'; +import { Anthropic } from '@anthropic-ai/sdk'; import * as vscode from 'vscode'; // Mock all MCP-related modules @@ -498,6 +499,133 @@ describe('Cline', () => { expect(passedMessage).not.toHaveProperty('ts'); expect(passedMessage).not.toHaveProperty('extraProp'); }); + + it('should handle image blocks based on model capabilities', async () => { + // Create two configurations - one with image support, one without + const configWithImages = { + ...mockApiConfig, + apiModelId: 'claude-3-sonnet' + }; + const configWithoutImages = { + ...mockApiConfig, + apiModelId: 'gpt-3.5-turbo' + }; + + // Create test conversation history with mixed content + const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [ + { + role: 'user' as const, + content: [ + { + type: 'text' as const, + text: 'Here is an image' + } satisfies Anthropic.TextBlockParam, + { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: 'image/jpeg', + data: 'base64data' + } + } satisfies Anthropic.ImageBlockParam + ] + }, + { + role: 'assistant' as const, + content: [{ + type: 'text' as const, + text: 'I see the image' + } satisfies Anthropic.TextBlockParam] + } + ]; + + // Test with model that supports images + const clineWithImages = new Cline( + mockProvider, + configWithImages, + undefined, + false, + undefined, + 'test task' + ); + // Mock the model info to indicate image support + jest.spyOn(clineWithImages.api, 'getModel').mockReturnValue({ + id: 'claude-3-sonnet', + info: { + supportsImages: true, + supportsPromptCache: true, + supportsComputerUse: true, + contextWindow: 200000, + maxTokens: 4096, + inputPrice: 0.25, + outputPrice: 0.75 + } as ModelInfo + }); + clineWithImages.apiConversationHistory = conversationHistory; + + // Test with model that doesn't support images + const clineWithoutImages = new Cline( + mockProvider, + configWithoutImages, + undefined, + false, + undefined, + 'test task' + ); + // Mock the model info to indicate no image support + jest.spyOn(clineWithoutImages.api, 'getModel').mockReturnValue({ + id: 'gpt-3.5-turbo', + info: { + supportsImages: false, + supportsPromptCache: false, + supportsComputerUse: false, + contextWindow: 16000, + maxTokens: 2048, + inputPrice: 0.1, + outputPrice: 0.2 + } as ModelInfo + }); + clineWithoutImages.apiConversationHistory = conversationHistory; + + // Create message spy for both instances + const createMessageSpyWithImages = jest.fn(); + const createMessageSpyWithoutImages = jest.fn(); + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { type: 'text', text: '' }; + } + } as AsyncGenerator; + + jest.spyOn(clineWithImages.api, 'createMessage').mockImplementation((...args) => { + createMessageSpyWithImages(...args); + return mockStream; + }); + jest.spyOn(clineWithoutImages.api, 'createMessage').mockImplementation((...args) => { + createMessageSpyWithoutImages(...args); + return mockStream; + }); + + // Trigger API requests for both instances + await clineWithImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]); + await clineWithoutImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]); + + // Verify model with image support preserves image blocks + const callsWithImages = createMessageSpyWithImages.mock.calls; + const historyWithImages = callsWithImages[0][1][0]; + expect(historyWithImages.content).toHaveLength(2); + expect(historyWithImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' }); + expect(historyWithImages.content[1]).toHaveProperty('type', 'image'); + + // Verify model without image support converts image blocks to text + const callsWithoutImages = createMessageSpyWithoutImages.mock.calls; + const historyWithoutImages = callsWithoutImages[0][1][0]; + expect(historyWithoutImages.content).toHaveLength(2); + expect(historyWithoutImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' }); + expect(historyWithoutImages.content[1]).toEqual({ + type: 'text', + text: '[Referenced image in conversation]' + }); + }); }); }); }); diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts new file mode 100644 index 0000000..7e4393d --- /dev/null +++ b/src/core/config/ConfigManager.ts @@ -0,0 +1,164 @@ +import { ExtensionContext } from 'vscode' +import { ApiConfiguration } from '../../shared/api' +import { ApiConfigMeta } from '../../shared/ExtensionMessage' + +export interface ApiConfigData { + currentApiConfigName: string + apiConfigs: { + [key: string]: ApiConfiguration + } +} + +export class ConfigManager { + private readonly defaultConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: { + default: {} + } + } + private readonly SCOPE_PREFIX = "roo_cline_config_" + private readonly context: ExtensionContext + + constructor(context: ExtensionContext) { + this.context = context + } + + /** + * Initialize config if it doesn't exist + */ + async initConfig(): Promise { + try { + const config = await this.readConfig() + if (!config) { + await this.writeConfig(this.defaultConfig) + } + } catch (error) { + throw new Error(`Failed to initialize config: ${error}`) + } + } + + /** + * List all available configs with metadata + */ + async ListConfig(): Promise { + try { + const config = await this.readConfig() + return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({ + name, + apiProvider: apiConfig.apiProvider, + })) + } catch (error) { + throw new Error(`Failed to list configs: ${error}`) + } + } + + /** + * Save a config with the given name + */ + async SaveConfig(name: string, config: ApiConfiguration): Promise { + try { + const currentConfig = await this.readConfig() + currentConfig.apiConfigs[name] = config + await this.writeConfig(currentConfig) + } catch (error) { + throw new Error(`Failed to save config: ${error}`) + } + } + + /** + * Load a config by name + */ + async LoadConfig(name: string): Promise { + try { + const config = await this.readConfig() + const apiConfig = config.apiConfigs[name] + + if (!apiConfig) { + throw new Error(`Config '${name}' not found`) + } + + config.currentApiConfigName = name; + await this.writeConfig(config) + + return apiConfig + } catch (error) { + throw new Error(`Failed to load config: ${error}`) + } + } + + /** + * Delete a config by name + */ + async DeleteConfig(name: string): Promise { + try { + const currentConfig = await this.readConfig() + if (!currentConfig.apiConfigs[name]) { + throw new Error(`Config '${name}' not found`) + } + + // Don't allow deleting the default config + if (Object.keys(currentConfig.apiConfigs).length === 1) { + throw new Error(`Cannot delete the last remaining configuration.`) + } + + delete currentConfig.apiConfigs[name] + await this.writeConfig(currentConfig) + } catch (error) { + throw new Error(`Failed to delete config: ${error}`) + } + } + + /** + * Set the current active API configuration + */ + async SetCurrentConfig(name: string): Promise { + try { + const currentConfig = await this.readConfig() + if (!currentConfig.apiConfigs[name]) { + throw new Error(`Config '${name}' not found`) + } + + currentConfig.currentApiConfigName = name + await this.writeConfig(currentConfig) + } catch (error) { + throw new Error(`Failed to set current config: ${error}`) + } + } + + /** + * Check if a config exists by name + */ + async HasConfig(name: string): Promise { + try { + const config = await this.readConfig() + return name in config.apiConfigs + } catch (error) { + throw new Error(`Failed to check config existence: ${error}`) + } + } + + private async readConfig(): Promise { + try { + const configKey = `${this.SCOPE_PREFIX}api_config` + const content = await this.context.secrets.get(configKey) + + if (!content) { + return this.defaultConfig + } + + return JSON.parse(content) + } catch (error) { + throw new Error(`Failed to read config from secrets: ${error}`) + } + } + + private async writeConfig(config: ApiConfigData): Promise { + try { + const configKey = `${this.SCOPE_PREFIX}api_config` + const content = JSON.stringify(config, null, 2) + await this.context.secrets.store(configKey, content) + } catch (error) { + throw new Error(`Failed to write config to secrets: ${error}`) + } + } +} \ No newline at end of file diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts new file mode 100644 index 0000000..b8170ee --- /dev/null +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -0,0 +1,384 @@ +import { ExtensionContext } from 'vscode' +import { ConfigManager, ApiConfigData } from '../ConfigManager' +import { ApiConfiguration } from '../../../shared/api' + +// Mock VSCode ExtensionContext +const mockSecrets = { + get: jest.fn(), + store: jest.fn(), + delete: jest.fn() +} + +const mockContext = { + secrets: mockSecrets +} as unknown as ExtensionContext + +describe('ConfigManager', () => { + let configManager: ConfigManager + + beforeEach(() => { + jest.clearAllMocks() + configManager = new ConfigManager(mockContext) + }) + + describe('initConfig', () => { + it('should not write to storage when secrets.get returns null', async () => { + // Mock readConfig to return null + mockSecrets.get.mockResolvedValueOnce(null) + + await configManager.initConfig() + + // Should not write to storage because readConfig returns defaultConfig + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + + it('should not initialize config if it exists', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { + default: {} + } + })) + + await configManager.initConfig() + + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + + it('should throw error if secrets storage fails', async () => { + mockSecrets.get.mockRejectedValue(new Error('Storage failed')) + + await expect(configManager.initConfig()).rejects.toThrow( + 'Failed to initialize config: Error: Failed to read config from secrets: Error: Storage failed' + ) + }) + }) + + describe('ListConfig', () => { + it('should list all available configs', async () => { + const existingConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: { + default: {}, + test: { + apiProvider: 'anthropic' + } + } + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const configs = await configManager.ListConfig() + expect(configs).toEqual([ + { name: 'default', apiProvider: undefined }, + { name: 'test', apiProvider: 'anthropic' } + ]) + }) + + it('should handle empty config file', async () => { + const emptyConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: {} + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(emptyConfig)) + + const configs = await configManager.ListConfig() + expect(configs).toEqual([]) + }) + + it('should throw error if reading from secrets fails', async () => { + mockSecrets.get.mockRejectedValue(new Error('Read failed')) + + await expect(configManager.ListConfig()).rejects.toThrow( + 'Failed to list configs: Error: Failed to read config from secrets: Error: Read failed' + ) + }) + }) + + describe('SaveConfig', () => { + it('should save new config', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { + default: {} + } + })) + + const newConfig: ApiConfiguration = { + apiProvider: 'anthropic', + apiKey: 'test-key' + } + + await configManager.SaveConfig('test', newConfig) + + const expectedConfig = { + currentApiConfigName: 'default', + apiConfigs: { + default: {}, + test: newConfig + } + } + + expect(mockSecrets.store).toHaveBeenCalledWith( + 'roo_cline_config_api_config', + JSON.stringify(expectedConfig, null, 2) + ) + }) + + it('should update existing config', async () => { + const existingConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: { + test: { + apiProvider: 'anthropic', + apiKey: 'old-key' + } + } + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const updatedConfig: ApiConfiguration = { + apiProvider: 'anthropic', + apiKey: 'new-key' + } + + await configManager.SaveConfig('test', updatedConfig) + + const expectedConfig = { + currentApiConfigName: 'default', + apiConfigs: { + test: updatedConfig + } + } + + expect(mockSecrets.store).toHaveBeenCalledWith( + 'roo_cline_config_api_config', + JSON.stringify(expectedConfig, null, 2) + ) + }) + + it('should throw error if secrets storage fails', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { default: {} } + })) + mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed')) + + await expect(configManager.SaveConfig('test', {})).rejects.toThrow( + 'Failed to save config: Error: Failed to write config to secrets: Error: Storage failed' + ) + }) + }) + + describe('DeleteConfig', () => { + it('should delete existing config', async () => { + const existingConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: { + default: {}, + test: { + apiProvider: 'anthropic' + } + } + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + await configManager.DeleteConfig('test') + + const expectedConfig = { + currentApiConfigName: 'default', + apiConfigs: { + default: {} + } + } + + expect(mockSecrets.store).toHaveBeenCalledWith( + 'roo_cline_config_api_config', + JSON.stringify(expectedConfig, null, 2) + ) + }) + + it('should throw error when trying to delete non-existent config', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { default: {} } + })) + + await expect(configManager.DeleteConfig('nonexistent')).rejects.toThrow( + "Config 'nonexistent' not found" + ) + }) + + it('should throw error when trying to delete last remaining config', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { default: {} } + })) + + await expect(configManager.DeleteConfig('default')).rejects.toThrow( + 'Cannot delete the last remaining configuration.' + ) + }) + }) + + describe('LoadConfig', () => { + it('should load config and update current config name', async () => { + const existingConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: { + test: { + apiProvider: 'anthropic', + apiKey: 'test-key' + } + } + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const config = await configManager.LoadConfig('test') + + expect(config).toEqual({ + apiProvider: 'anthropic', + apiKey: 'test-key' + }) + + const expectedConfig = { + currentApiConfigName: 'test', + apiConfigs: { + test: { + apiProvider: 'anthropic', + apiKey: 'test-key' + } + } + } + + expect(mockSecrets.store).toHaveBeenCalledWith( + 'roo_cline_config_api_config', + JSON.stringify(expectedConfig, null, 2) + ) + }) + + it('should throw error when config does not exist', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { default: {} } + })) + + await expect(configManager.LoadConfig('nonexistent')).rejects.toThrow( + "Config 'nonexistent' not found" + ) + }) + + it('should throw error if secrets storage fails', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { + test: { apiProvider: 'anthropic' } + } + })) + mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed')) + + await expect(configManager.LoadConfig('test')).rejects.toThrow( + 'Failed to load config: Error: Failed to write config to secrets: Error: Storage failed' + ) + }) + }) + + describe('SetCurrentConfig', () => { + it('should set current config', async () => { + const existingConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: { + default: {}, + test: { + apiProvider: 'anthropic' + } + } + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + await configManager.SetCurrentConfig('test') + + const expectedConfig = { + currentApiConfigName: 'test', + apiConfigs: { + default: {}, + test: { + apiProvider: 'anthropic' + } + } + } + + expect(mockSecrets.store).toHaveBeenCalledWith( + 'roo_cline_config_api_config', + JSON.stringify(expectedConfig, null, 2) + ) + }) + + it('should throw error when config does not exist', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { default: {} } + })) + + await expect(configManager.SetCurrentConfig('nonexistent')).rejects.toThrow( + "Config 'nonexistent' not found" + ) + }) + + it('should throw error if secrets storage fails', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { + test: { apiProvider: 'anthropic' } + } + })) + mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed')) + + await expect(configManager.SetCurrentConfig('test')).rejects.toThrow( + 'Failed to set current config: Error: Failed to write config to secrets: Error: Storage failed' + ) + }) + }) + + describe('HasConfig', () => { + it('should return true for existing config', async () => { + const existingConfig: ApiConfigData = { + currentApiConfigName: 'default', + apiConfigs: { + default: {}, + test: { + apiProvider: 'anthropic' + } + } + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const hasConfig = await configManager.HasConfig('test') + expect(hasConfig).toBe(true) + }) + + it('should return false for non-existent config', async () => { + mockSecrets.get.mockResolvedValue(JSON.stringify({ + currentApiConfigName: 'default', + apiConfigs: { default: {} } + })) + + const hasConfig = await configManager.HasConfig('nonexistent') + expect(hasConfig).toBe(false) + }) + + it('should throw error if secrets storage fails', async () => { + mockSecrets.get.mockRejectedValue(new Error('Storage failed')) + + await expect(configManager.HasConfig('test')).rejects.toThrow( + 'Failed to check config existence: Error: Failed to read config from secrets: Error: Storage failed' + ) + }) + }) +}) \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 579a12e..da75d27 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -12,9 +12,9 @@ import { selectImages } from "../../integrations/misc/process-images" import { getTheme } from "../../integrations/theme/getTheme" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" -import { ApiProvider, ModelInfo } from "../../shared/api" +import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api" import { findLast } from "../../shared/array" -import { ExtensionMessage } from "../../shared/ExtensionMessage" +import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { HistoryItem } from "../../shared/HistoryItem" import { WebviewMessage } from "../../shared/WebviewMessage" import { fileExistsAtPath } from "../../utils/fs" @@ -23,8 +23,10 @@ import { openMention } from "../mentions" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" +import { checkExistKey } from "../../shared/checkExistApiConfig" import { enhancePrompt } from "../../utils/enhance-prompt" import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" +import { ConfigManager } from "../config/ConfigManager" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -85,6 +87,9 @@ type GlobalStateKey = | "mcpEnabled" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "currentApiConfigName" + | "listApiConfigMeta" + export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", @@ -103,6 +108,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private workspaceTracker?: WorkspaceTracker mcpHub?: McpHub private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement + configManager: ConfigManager constructor( readonly context: vscode.ExtensionContext, @@ -112,6 +118,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { ClineProvider.activeInstances.add(this) this.workspaceTracker = new WorkspaceTracker(this) this.mcpHub = new McpHub(this) + this.configManager = new ConfigManager(this.context) } /* @@ -235,7 +242,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -255,7 +262,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -321,15 +328,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Use a nonce to only allow a specific script to be run. /* - content security policy of your webview to only allow scripts that have a specific nonce - create a content security policy meta tag so that only loading scripts with a nonce is allowed - As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. - + content security policy of your webview to only allow scripts that have a specific nonce + create a content security policy meta tag so that only loading scripts with a nonce is allowed + As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. + - 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection - since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:; - in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. - */ + in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. + */ const nonce = getNonce() // Tip: Install the es6-string-html VS Code extension to enable code highlighting below @@ -410,6 +417,55 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } }) + + + this.configManager.ListConfig().then(async (listApiConfig) => { + + if (!listApiConfig) { + return + } + + if (listApiConfig.length === 1) { + // check if first time init then sync with exist config + if (!checkExistKey(listApiConfig[0])) { + const { + apiConfiguration, + } = await this.getState() + await this.configManager.SaveConfig(listApiConfig[0].name ?? "default", apiConfiguration) + listApiConfig[0].apiProvider = apiConfiguration.apiProvider + } + } + + let currentConfigName = await this.getGlobalState("currentApiConfigName") as string + + if (currentConfigName) { + if (!await this.configManager.HasConfig(currentConfigName)) { + // current config name not valid, get first config in list + await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name) + if (listApiConfig?.[0]?.name) { + const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name); + + await Promise.all([ + this.updateGlobalState("listApiConfigMeta", listApiConfig), + this.postMessageToWebview({ type: "listApiConfig", listApiConfig }), + this.updateApiConfiguration(apiConfig), + ]) + await this.postStateToWebview() + return + } + + } + } + + + await Promise.all( + [ + await this.updateGlobalState("listApiConfigMeta", listApiConfig), + await this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + ] + ) + }).catch(console.error); + break case "newTask": // Code that should run in response to the hello message command @@ -424,70 +480,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "apiConfiguration": if (message.apiConfiguration) { - const { - apiProvider, - apiModelId, - apiKey, - glamaModelId, - glamaModelInfo, - glamaApiKey, - openRouterApiKey, - awsAccessKey, - awsSecretKey, - awsSessionToken, - awsRegion, - awsUseCrossRegionInference, - vertexProjectId, - vertexRegion, - openAiBaseUrl, - openAiApiKey, - openAiModelId, - ollamaModelId, - ollamaBaseUrl, - lmStudioModelId, - lmStudioBaseUrl, - anthropicBaseUrl, - geminiApiKey, - openAiNativeApiKey, - azureApiVersion, - openAiStreamingEnabled, - openRouterModelId, - openRouterModelInfo, - openRouterUseMiddleOutTransform, - } = message.apiConfiguration - await this.updateGlobalState("apiProvider", apiProvider) - await this.updateGlobalState("apiModelId", apiModelId) - await this.storeSecret("apiKey", apiKey) - await this.updateGlobalState("glamaModelId", glamaModelId) - await this.updateGlobalState("glamaModelInfo", glamaModelInfo) - await this.storeSecret("glamaApiKey", glamaApiKey) - await this.storeSecret("openRouterApiKey", openRouterApiKey) - await this.storeSecret("awsAccessKey", awsAccessKey) - await this.storeSecret("awsSecretKey", awsSecretKey) - await this.storeSecret("awsSessionToken", awsSessionToken) - await this.updateGlobalState("awsRegion", awsRegion) - await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference) - await this.updateGlobalState("vertexProjectId", vertexProjectId) - await this.updateGlobalState("vertexRegion", vertexRegion) - await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl) - await this.storeSecret("openAiApiKey", openAiApiKey) - await this.updateGlobalState("openAiModelId", openAiModelId) - await this.updateGlobalState("ollamaModelId", ollamaModelId) - await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl) - await this.updateGlobalState("lmStudioModelId", lmStudioModelId) - await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl) - await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl) - await this.storeSecret("geminiApiKey", geminiApiKey) - await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey) - await this.storeSecret("deepSeekApiKey", message.apiConfiguration.deepSeekApiKey) - await this.updateGlobalState("azureApiVersion", azureApiVersion) - await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled) - await this.updateGlobalState("openRouterModelId", openRouterModelId) - await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) - await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform) - if (this.cline) { - this.cline.api = buildApiHandler(message.apiConfiguration) - } + await this.updateApiConfiguration(message.apiConfiguration) } await this.postStateToWebview() break @@ -566,7 +559,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message?.values?.baseUrl && message?.values?.apiKey) { const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey) this.postMessageToWebview({ type: "openAiModels", openAiModels }) - } + } break case "openImage": openImage(message.text!) @@ -805,6 +798,113 @@ export class ClineProvider implements vscode.WebviewViewProvider { } break } + case "upsertApiConfiguration": + if (message.text && message.apiConfiguration) { + try { + await this.configManager.SaveConfig(message.text, message.apiConfiguration); + + let listApiConfig = await this.configManager.ListConfig(); + + await Promise.all([ + this.updateApiConfiguration(message.apiConfiguration), + this.updateGlobalState("currentApiConfigName", message.text), + this.updateGlobalState("listApiConfigMeta", listApiConfig), + ]) + + this.postStateToWebview() + } catch (error) { + console.error("Error create new api configuration:", error) + vscode.window.showErrorMessage("Failed to create api configuration") + } + } + break + case "renameApiConfiguration": + if (message.values && message.apiConfiguration) { + try { + + const { oldName, newName } = message.values + + await this.configManager.SaveConfig(newName, message.apiConfiguration); + + await this.configManager.DeleteConfig(oldName) + + let listApiConfig = await this.configManager.ListConfig(); + + await Promise.all([ + this.updateGlobalState("currentApiConfigName", newName), + this.updateGlobalState("listApiConfigMeta", listApiConfig), + ]) + + this.postStateToWebview() + } catch (error) { + console.error("Error create new api configuration:", error) + vscode.window.showErrorMessage("Failed to create api configuration") + } + } + break + case "loadApiConfiguration": + if (message.text) { + try { + const apiConfig = await this.configManager.LoadConfig(message.text); + + await Promise.all([ + this.updateGlobalState("currentApiConfigName", message.text), + this.updateApiConfiguration(apiConfig), + ]) + + await this.postStateToWebview() + } catch (error) { + console.error("Error load api configuration:", error) + vscode.window.showErrorMessage("Failed to load api configuration") + } + } + break + case "deleteApiConfiguration": + if (message.text) { + + const answer = await vscode.window.showInformationMessage( + "Are you sure you want to delete this configuration profile?", + { modal: true }, + "Yes", + ) + + if (answer !== "Yes") { + break + } + + try { + await this.configManager.DeleteConfig(message.text); + let listApiConfig = await this.configManager.ListConfig() + let currentApiConfigName = await this.getGlobalState("currentApiConfigName") + + if (message.text === currentApiConfigName) { + await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name) + if (listApiConfig?.[0]?.name) { + const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name); + + await Promise.all([ + this.updateGlobalState("listApiConfigMeta", listApiConfig), + this.updateApiConfiguration(apiConfig), + ]) + await this.postStateToWebview() + } + } + } catch (error) { + console.error("Error delete api configuration:", error) + vscode.window.showErrorMessage("Failed to delete api configuration") + } + } + break + case "getListApiConfiguration": + try { + let listApiConfig = await this.configManager.ListConfig(); + await this.updateGlobalState("listApiConfigMeta", listApiConfig) + this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + } catch (error) { + console.error("Error get list api configuration:", error) + vscode.window.showErrorMessage("Failed to get list api configuration") + } + break } }, null, @@ -812,6 +912,74 @@ export class ClineProvider implements vscode.WebviewViewProvider { ) } + private async updateApiConfiguration(apiConfiguration: ApiConfiguration) { + const { + apiProvider, + apiModelId, + apiKey, + glamaModelId, + glamaModelInfo, + glamaApiKey, + openRouterApiKey, + awsAccessKey, + awsSecretKey, + awsSessionToken, + awsRegion, + awsUseCrossRegionInference, + vertexProjectId, + vertexRegion, + openAiBaseUrl, + openAiApiKey, + openAiModelId, + ollamaModelId, + ollamaBaseUrl, + lmStudioModelId, + lmStudioBaseUrl, + anthropicBaseUrl, + geminiApiKey, + openAiNativeApiKey, + deepSeekApiKey, + azureApiVersion, + openAiStreamingEnabled, + openRouterModelId, + openRouterModelInfo, + openRouterUseMiddleOutTransform, + } = apiConfiguration + await this.updateGlobalState("apiProvider", apiProvider) + await this.updateGlobalState("apiModelId", apiModelId) + await this.storeSecret("apiKey", apiKey) + await this.updateGlobalState("glamaModelId", glamaModelId) + await this.updateGlobalState("glamaModelInfo", glamaModelInfo) + await this.storeSecret("glamaApiKey", glamaApiKey) + await this.storeSecret("openRouterApiKey", openRouterApiKey) + await this.storeSecret("awsAccessKey", awsAccessKey) + await this.storeSecret("awsSecretKey", awsSecretKey) + await this.storeSecret("awsSessionToken", awsSessionToken) + await this.updateGlobalState("awsRegion", awsRegion) + await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference) + await this.updateGlobalState("vertexProjectId", vertexProjectId) + await this.updateGlobalState("vertexRegion", vertexRegion) + await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl) + await this.storeSecret("openAiApiKey", openAiApiKey) + await this.updateGlobalState("openAiModelId", openAiModelId) + await this.updateGlobalState("ollamaModelId", ollamaModelId) + await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl) + await this.updateGlobalState("lmStudioModelId", lmStudioModelId) + await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl) + await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl) + await this.storeSecret("geminiApiKey", geminiApiKey) + await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey) + await this.storeSecret("deepSeekApiKey", deepSeekApiKey) + await this.updateGlobalState("azureApiVersion", azureApiVersion) + await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled) + await this.updateGlobalState("openRouterModelId", openRouterModelId) + await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) + await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform) + if (this.cline) { + this.cline.api = buildApiHandler(apiConfiguration) + } + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field await this.updateGlobalState("customInstructions", instructions || undefined) @@ -1256,8 +1424,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, } = await this.getState() - + const allowedCommands = vscode.workspace .getConfiguration('roo-cline') .get('allowedCommands') || [] @@ -1290,6 +1460,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], } } @@ -1397,6 +1569,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1449,6 +1623,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("mcpEnabled") as Promise, this.getGlobalState("alwaysApproveResubmit") as Promise, this.getGlobalState("requestDelaySeconds") as Promise, + this.getGlobalState("currentApiConfigName") as Promise, + this.getGlobalState("listApiConfigMeta") as Promise, ]) let apiProvider: ApiProvider @@ -1545,6 +1721,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 6b877a0..8972958 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -1,6 +1,6 @@ // type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello' -import { ApiConfiguration, ModelInfo } from "./api" +import { ApiConfiguration, ApiProvider, ModelInfo } from "./api" import { HistoryItem } from "./HistoryItem" import { McpServer } from "./mcp" import { GitCommit } from "../utils/git" @@ -23,6 +23,7 @@ export interface ExtensionMessage { | "mcpServers" | "enhancedPrompt" | "commitSearchResults" + | "listApiConfig" text?: string action?: | "chatButtonClicked" @@ -42,6 +43,12 @@ export interface ExtensionMessage { openAiModels?: string[] mcpServers?: McpServer[] commits?: GitCommit[] + listApiConfig?: ApiConfigMeta[] +} + +export interface ApiConfigMeta { + name: string + apiProvider?: ApiProvider } export interface ExtensionState { @@ -50,6 +57,8 @@ export interface ExtensionState { taskHistory: HistoryItem[] shouldShowAnnouncement: boolean apiConfiguration?: ApiConfiguration + currentApiConfigName?: string + listApiConfigMeta?: ApiConfigMeta[] customInstructions?: string alwaysAllowReadOnly?: boolean alwaysAllowWrite?: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 0ca7cb3..4072526 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -5,6 +5,12 @@ export type AudioType = "notification" | "celebration" | "progress_loop" export interface WebviewMessage { type: | "apiConfiguration" + | "currentApiConfigName" + | "upsertApiConfiguration" + | "deleteApiConfiguration" + | "loadApiConfiguration" + | "renameApiConfiguration" + | "getListApiConfiguration" | "customInstructions" | "allowedCommands" | "alwaysAllowReadOnly" @@ -54,6 +60,7 @@ export interface WebviewMessage { | "searchCommits" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "setApiConfigPassword" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts new file mode 100644 index 0000000..b347ccf --- /dev/null +++ b/src/shared/checkExistApiConfig.ts @@ -0,0 +1,19 @@ +import { ApiConfiguration } from "../shared/api"; + +export function checkExistKey(config: ApiConfiguration | undefined) { + return config + ? [ + config.apiKey, + config.glamaApiKey, + config.openRouterApiKey, + config.awsRegion, + config.vertexProjectId, + config.openAiApiKey, + config.ollamaModelId, + config.lmStudioModelId, + config.geminiApiKey, + config.openAiNativeApiKey, + config.deepSeekApiKey + ].some((key) => key !== undefined) + : false; +} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 2078013..3954094 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -44,9 +44,21 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, apiConfiguration } = useExtensionState() + const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [gitCommits, setGitCommits] = useState([]) + const [showDropdown, setShowDropdown] = useState(false) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (showDropdown) { + setShowDropdown(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [showDropdown]) // Handle enhanced prompt response useEffect(() => { @@ -649,13 +661,62 @@ const ChatTextArea = forwardRef( style={{ position: "absolute", paddingTop: 4, - bottom: 14, + bottom: 32, left: 22, right: 67, zIndex: 2, }} /> )} + {(listApiConfigMeta || []).length > 1 && ( +
+ +
+ )}
{apiConfiguration?.apiProvider === "openrouter" && ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index db11547..12a7e93 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,4 +1,4 @@ -import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import debounce from "debounce" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent, useMount } from "react-use" @@ -868,12 +868,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie

What can I do for you?

- Thanks to{" "} - - Claude 3.5 Sonnet's agentic coding capabilities, - {" "} + Thanks to the latest breakthroughs in agentic coding capabilities, I can handle complex software development tasks step-by-step. With tools that let me create & edit files, explore complex projects, use the browser, and execute terminal commands (after you grant permission), I can assist you in ways that go beyond code completion or diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx new file mode 100644 index 0000000..e05af14 --- /dev/null +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -0,0 +1,225 @@ +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { memo, useEffect, useRef, useState } from "react" +import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage" + +interface ApiConfigManagerProps { + currentApiConfigName?: string + listApiConfigMeta?: ApiConfigMeta[] + onSelectConfig: (configName: string) => void + onDeleteConfig: (configName: string) => void + onRenameConfig: (oldName: string, newName: string) => void + onUpsertConfig: (configName: string) => void +} + +const ApiConfigManager = ({ + currentApiConfigName = "", + listApiConfigMeta = [], + onSelectConfig, + onDeleteConfig, + onRenameConfig, + onUpsertConfig, +}: ApiConfigManagerProps) => { + const [editState, setEditState] = useState<'new' | 'rename' | null>(null); + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(); + + // Focus input when entering edit mode + useEffect(() => { + if (editState) { + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [editState]); + + // Reset edit state when current profile changes + useEffect(() => { + setEditState(null); + setInputValue(""); + }, [currentApiConfigName]); + + const handleAdd = () => { + const newConfigName = currentApiConfigName + " (copy)"; + onUpsertConfig(newConfigName); + }; + + const handleStartRename = () => { + setEditState('rename'); + setInputValue(currentApiConfigName || ""); + }; + + const handleCancel = () => { + setEditState(null); + setInputValue(""); + }; + + const handleSave = () => { + const trimmedValue = inputValue.trim(); + if (!trimmedValue) return; + + if (editState === 'new') { + onUpsertConfig(trimmedValue); + } else if (editState === 'rename' && currentApiConfigName) { + onRenameConfig(currentApiConfigName, trimmedValue); + } + + setEditState(null); + setInputValue(""); + }; + + const handleDelete = () => { + if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return; + + // Let the extension handle both deletion and selection + onDeleteConfig(currentApiConfigName); + }; + + const isOnlyProfile = listApiConfigMeta?.length === 1; + + return ( +

+
+ + + {editState ? ( +
+ setInputValue(e.target.value)} + placeholder={editState === 'new' ? "Enter profile name" : "Enter new name"} + style={{ flexGrow: 1 }} + onKeyDown={(e: any) => { + if (e.key === 'Enter' && inputValue.trim()) { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }} + /> + + + + + + +
+ ) : ( + <> +
+ + + + + {currentApiConfigName && ( + <> + + + + + + + + )} +
+

+ Save different API configurations to quickly switch between providers and settings +

+ + )} +
+
+ ) +} + +export default memo(ApiConfigManager) \ No newline at end of file diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index f38a0a3..cc30ae9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -43,13 +43,12 @@ import OpenAiModelPicker from "./OpenAiModelPicker" import GlamaModelPicker from "./GlamaModelPicker" interface ApiOptionsProps { - showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { - const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() +const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { + const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) @@ -57,7 +56,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => { - setApiConfiguration({ ...apiConfiguration, [field]: event.target.value }) + const apiConfig = { ...apiConfiguration, [field]: event.target.value } + onUpdateApiConfig(apiConfig) + setApiConfiguration(apiConfig) } const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => { @@ -693,16 +694,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:

)} - {selectedProvider === "glama" && showModelOptions && } + {selectedProvider === "glama" && } - {selectedProvider === "openrouter" && showModelOptions && } + {selectedProvider === "openrouter" && } {selectedProvider !== "glama" && selectedProvider !== "openrouter" && selectedProvider !== "openai" && selectedProvider !== "ollama" && - selectedProvider !== "lmstudio" && - showModelOptions && ( + selectedProvider !== "lmstudio" && ( <>