From 352f34d8ce39b12b4645f0d223b5545758779d7d Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sun, 5 Jan 2025 00:52:00 +0700 Subject: [PATCH 01/17] feat: config manager using secret store --- src/core/config/ConfigManager.ts | 153 ++++++++ .../config/__tests__/ConfigManager.test.ts | 348 ++++++++++++++++++ src/core/webview/ClineProvider.ts | 308 ++++++++++++---- src/shared/ExtensionMessage.ts | 11 +- src/shared/WebviewMessage.ts | 7 + src/shared/checkExistApiConfig.ts | 19 + .../components/settings/ApiConfigManager.tsx | 165 +++++++++ .../src/components/settings/SettingsView.tsx | 49 +++ .../settings/__tests__/SettingsView.test.tsx | 22 ++ .../src/context/ExtensionStateContext.tsx | 40 +- 10 files changed, 1026 insertions(+), 96 deletions(-) create mode 100644 src/core/config/ConfigManager.ts create mode 100644 src/core/config/__tests__/ConfigManager.test.ts create mode 100644 src/shared/checkExistApiConfig.ts create mode 100644 webview-ui/src/components/settings/ApiConfigManager.tsx diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts new file mode 100644 index 0000000..9082cf7 --- /dev/null +++ b/src/core/config/ConfigManager.ts @@ -0,0 +1,153 @@ +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 = "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() + console.log("config", config) + 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}`) + } + } + + 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..a6527ab --- /dev/null +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -0,0 +1,348 @@ +import { ExtensionContext } from 'vscode' +import { ConfigManager } from '../ConfigManager' +import { ApiConfiguration } from '../../../shared/api' +import { ApiConfigData } from '../ConfigManager' + +// 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( + '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( + '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( + '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( + '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( + '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' + ) + }) + }) +}) \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 579a12e..97d3521 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 @@ -43,6 +45,7 @@ type SecretKey = | "geminiApiKey" | "openAiNativeApiKey" | "deepSeekApiKey" + | "apiConfigPassword" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -85,6 +88,9 @@ type GlobalStateKey = | "mcpEnabled" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "currentApiConfigName" + | "listApiConfigMeta" + export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", @@ -103,6 +109,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 +119,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 +243,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -255,7 +263,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -321,15 +329,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 +418,33 @@ 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]) && listApiConfig[0].name === "default") { + const { + apiConfiguration, + } = await this.getState() + await this.configManager.SaveConfig("default", apiConfiguration) + listApiConfig[0].apiProvider = apiConfiguration.apiProvider + } + } + + 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 +459,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 +538,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 +777,106 @@ 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.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) { + try { + await this.configManager.DeleteConfig(message.text); + let currentApiConfigName = (await this.getGlobalState("currentApiConfigName") as string) ?? "default" + + if (message.text === currentApiConfigName) { + await this.updateGlobalState("currentApiConfigName", "default") + } + + let listApiConfig = await this.configManager.ListConfig(); + await this.updateGlobalState("listApiConfigMeta", listApiConfig) + this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + + } 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 + case "setApiConfigPassword": + if (message.text) { + try { + await this.storeSecret("apiConfigPassword", message.text !== "" ? message.text : undefined) + } catch (error) { + console.error("Error set apiKey password:", error) + vscode.window.showErrorMessage("Failed to set apiKey password") + } + } + break } }, null, @@ -812,6 +884,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 +1396,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, + apiKeyPassword } = await this.getState() - + const allowedCommands = vscode.workspace .getConfiguration('roo-cline') .get('allowedCommands') || [] @@ -1290,6 +1433,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], + apiKeyPassword: apiKeyPassword ?? "" } } @@ -1397,6 +1543,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, + apiKeyPassword, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1449,6 +1598,9 @@ 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, + this.getSecret("apiConfigPassword") as Promise, ]) let apiProvider: ApiProvider @@ -1545,6 +1697,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], + apiKeyPassword: apiKeyPassword ?? "" } } @@ -1622,6 +1777,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "geminiApiKey", "openAiNativeApiKey", "deepSeekApiKey", + "apiConfigPassword" ] for (const key of secretKeys) { await this.storeSecret(key, undefined) 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/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx new file mode 100644 index 0000000..2464840 --- /dev/null +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -0,0 +1,165 @@ +import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { memo, 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 + // setDraftNewConfig: (mode: boolean) => void +} + +const ApiConfigManager = ({ + currentApiConfigName, + listApiConfigMeta, + onSelectConfig, + onDeleteConfig, + onRenameConfig, + onUpsertConfig, + // setDraftNewConfig, +}: ApiConfigManagerProps) => { + const [isNewMode, setIsNewMode] = useState(false); + const [isRenameMode, setIsRenameMode] = useState(false); + const [newConfigName, setNewConfigName] = useState(""); + const [renamedConfigName, setRenamedConfigName] = useState(""); + + const handleNewConfig = () => { + setIsNewMode(true); + setNewConfigName(""); + // setDraftNewConfig(true) + }; + + const handleSaveNewConfig = () => { + if (newConfigName.trim()) { + onUpsertConfig(newConfigName.trim()); + setIsNewMode(false); + setNewConfigName(""); + // setDraftNewConfig(false) + } + }; + + const handleCancelNewConfig = () => { + setIsNewMode(false); + setNewConfigName(""); + // setDraftNewConfig(false) + }; + + const handleStartRename = () => { + setIsRenameMode(true); + setRenamedConfigName(currentApiConfigName || ""); + }; + + const handleSaveRename = () => { + if (renamedConfigName.trim() && currentApiConfigName) { + onRenameConfig(currentApiConfigName, renamedConfigName.trim()); + setIsRenameMode(false); + setRenamedConfigName(""); + } + }; + + const handleCancelRename = () => { + setIsRenameMode(false); + setRenamedConfigName(""); + }; + + return ( +
+ +
+ {isNewMode ? ( + <> + setNewConfigName(e.target.value)} + placeholder="Enter configuration name" + style={{ flexGrow: 1 }} + /> + + Save + + + Cancel + + + ) : isRenameMode ? ( + <> + setRenamedConfigName(e.target.value)} + placeholder="Enter new name" + style={{ flexGrow: 1 }} + /> + + Save + + + Cancel + + + ) : ( + <> + + + New + + + Rename + + onDeleteConfig(currentApiConfigName!)} + > + Delete + + + )} +
+ +
+ ) +} + +export default memo(ApiConfigManager) \ No newline at end of file diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 956b76b..8940833 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -5,6 +5,7 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate" import { vscode } from "../../utils/vscode" import ApiOptions from "./ApiOptions" import McpEnabledToggle from "../mcp/McpEnabledToggle" +import ApiConfigManager from "./ApiConfigManager" const IS_DEV = false // FIXME: use flags when packaging @@ -55,10 +56,15 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysApproveResubmit, requestDelaySeconds, setRequestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) const [commandInput, setCommandInput] = useState("") + // const [draftNewMode, setDraftNewMode] = useState(false) + + const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) @@ -89,6 +95,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) + vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration + }) + onDone() } } @@ -150,6 +163,42 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
+
+ { + vscode.postMessage({ + type: "loadApiConfiguration", + text: configName + }) + }} + onDeleteConfig={(configName: string) => { + vscode.postMessage({ + type: "deleteApiConfiguration", + text: configName + }) + }} + onRenameConfig={(oldName: string, newName: string) => { + vscode.postMessage({ + type: "renameApiConfiguration", + values: {oldName, newName}, + apiConfiguration + }) + }} + onUpsertConfig={(configName: string) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: configName, + apiConfiguration + }) + }} + // setDraftNewConfig={(mode: boolean) => { + // setDraftNewMode(mode) + // }} + /> +
+

Provider Settings

({ }, })) +// Mock ApiConfigManager component +jest.mock('../ApiConfigManager', () => ({ + __esModule: true, + default: ({ currentApiConfigName, listApiConfigMeta, onSelectConfig, onDeleteConfig, onRenameConfig, onUpsertConfig }: any) => ( +
+ Current config: {currentApiConfigName} +
+ ) +})) + // Mock VSCode components jest.mock('@vscode/webview-ui-toolkit/react', () => ({ VSCodeButton: ({ children, onClick, appearance }: any) => ( @@ -185,6 +195,18 @@ describe('SettingsView - Sound Settings', () => { }) }) +describe('SettingsView - API Configuration', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders ApiConfigManagement with correct props', () => { + renderSettingsView() + + expect(screen.getByTestId('api-config-management')).toBeInTheDocument() + }) +}) + describe('SettingsView - Allowed Commands', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 131364b..4aa874e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import { useEvent } from "react-use" -import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" +import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" import { ApiConfiguration, ModelInfo, @@ -13,6 +13,9 @@ import { vscode } from "../utils/vscode" import { convertTextMateToHljs } from "../utils/textMateToHljs" import { findLastIndex } from "../../../src/shared/array" import { McpServer } from "../../../src/shared/mcp" +import { + checkExistKey +} from "../../../src/shared/checkExistApiConfig" export interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean @@ -50,6 +53,8 @@ export interface ExtensionStateContextType extends ExtensionState { setAlwaysApproveResubmit: (value: boolean) => void requestDelaySeconds: number setRequestDelaySeconds: (value: number) => void + setCurrentApiConfigName: (value: string) => void + setListApiConfigMeta: (value: ApiConfigMeta[]) => void } export const ExtensionStateContext = createContext(undefined) @@ -72,7 +77,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode terminalOutputLineLimit: 500, mcpEnabled: true, alwaysApproveResubmit: false, - requestDelaySeconds: 5 + requestDelaySeconds: 5, + currentApiConfigName: 'default', + listApiConfigMeta: [], }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -88,27 +95,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) + + const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState]) + const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { case "state": { setState(message.state!) const config = message.state?.apiConfiguration - const hasKey = 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 + const hasKey = checkExistKey(config) setShowWelcome(!hasKey) setDidHydrateState(true) break @@ -162,8 +158,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpServers(message.mcpServers ?? []) break } + case "listApiConfig": { + setListApiConfigMeta(message.listApiConfig ?? []) + break + } } - }, []) + }, [setListApiConfigMeta]) useEvent("message", handleMessage) @@ -208,7 +208,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), - setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })) + setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), + setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), + setListApiConfigMeta } return {children} From 840276b2976e5c29a4a040b2be2e8ac43c5ca224 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sun, 5 Jan 2025 11:09:16 +0700 Subject: [PATCH 02/17] chore: remove verbose log --- src/core/config/ConfigManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 9082cf7..960babe 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -29,7 +29,6 @@ export class ConfigManager { async initConfig(): Promise { try { const config = await this.readConfig() - console.log("config", config) if (!config) { await this.writeConfig(this.defaultConfig) } From c3fa10b367f81638f848a4077c0623e62cf14fa6 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 04:21:21 -0500 Subject: [PATCH 03/17] UI cleanup --- .../src/components/chat/ChatTextArea.tsx | 62 +++- .../components/settings/ApiConfigManager.tsx | 286 +++++++++++------- .../src/components/settings/SettingsView.tsx | 8 +- 3 files changed, 235 insertions(+), 121 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 2078013..8762466 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(() => { @@ -656,6 +668,54 @@ const ChatTextArea = forwardRef( }} /> )} + {(listApiConfigMeta || []).length > 1 && ( +
+ +
+ )}
{apiConfiguration?.apiProvider === "openrouter" && ( diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 2464840..b6cf5dd 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -1,5 +1,5 @@ -import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { memo, useState } from "react" +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { memo, useEffect, useRef, useState } from "react" import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage" interface ApiConfigManagerProps { @@ -9,7 +9,6 @@ interface ApiConfigManagerProps { onDeleteConfig: (configName: string) => void onRenameConfig: (oldName: string, newName: string) => void onUpsertConfig: (configName: string) => void - // setDraftNewConfig: (mode: boolean) => void } const ApiConfigManager = ({ @@ -19,145 +18,206 @@ const ApiConfigManager = ({ onDeleteConfig, onRenameConfig, onUpsertConfig, - // setDraftNewConfig, }: ApiConfigManagerProps) => { - const [isNewMode, setIsNewMode] = useState(false); - const [isRenameMode, setIsRenameMode] = useState(false); - const [newConfigName, setNewConfigName] = useState(""); - const [renamedConfigName, setRenamedConfigName] = useState(""); + const [editState, setEditState] = useState<'new' | 'rename' | null>(null); + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(); - const handleNewConfig = () => { - setIsNewMode(true); - setNewConfigName(""); - // setDraftNewConfig(true) - }; - - const handleSaveNewConfig = () => { - if (newConfigName.trim()) { - onUpsertConfig(newConfigName.trim()); - setIsNewMode(false); - setNewConfigName(""); - // setDraftNewConfig(false) + // Focus input when entering edit mode + useEffect(() => { + if (editState) { + setTimeout(() => inputRef.current?.focus(), 0); } - }; + }, [editState]); - const handleCancelNewConfig = () => { - setIsNewMode(false); - setNewConfigName(""); - // setDraftNewConfig(false) + // Reset edit state when current profile changes + useEffect(() => { + setEditState(null); + setInputValue(""); + }, [currentApiConfigName]); + + const handleStartNew = () => { + setEditState('new'); + setInputValue(""); }; const handleStartRename = () => { - setIsRenameMode(true); - setRenamedConfigName(currentApiConfigName || ""); + setEditState('rename'); + setInputValue(currentApiConfigName || ""); }; - const handleSaveRename = () => { - if (renamedConfigName.trim() && currentApiConfigName) { - onRenameConfig(currentApiConfigName, renamedConfigName.trim()); - setIsRenameMode(false); - setRenamedConfigName(""); + 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 handleCancelRename = () => { - setIsRenameMode(false); - setRenamedConfigName(""); + 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 ( -
- -
- {isNewMode ? ( - <> +
+
+ + + {editState ? ( +
setNewConfigName(e.target.value)} - placeholder="Enter configuration name" + ref={inputRef as any} + value={inputValue} + onInput={(e: any) => 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(); + } + }} /> - Save + - Cancel + - - ) : isRenameMode ? ( - <> - setRenamedConfigName(e.target.value)} - placeholder="Enter new name" - style={{ flexGrow: 1 }} - /> - - Save - - - Cancel - - +
) : ( <> - - - New - - - Rename - - onDeleteConfig(currentApiConfigName!)} - > - Delete - +
+ + + + + {currentApiConfigName && ( + <> + + + + + + + + )} +
+

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

)}
-
) } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 8940833..6a0883c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -164,6 +164,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
+

Provider Settings

{ apiConfiguration }) }} - // setDraftNewConfig={(mode: boolean) => { - // setDraftNewMode(mode) - // }} /> -
- -
-

Provider Settings

Date: Tue, 7 Jan 2025 20:16:44 +0700 Subject: [PATCH 04/17] fix: change provider not update without done, update chatbox change provider from MrUbens --- src/core/config/ConfigManager.ts | 12 +++ .../config/__tests__/ConfigManager.test.ts | 40 +++++++++- src/core/webview/ClineProvider.ts | 76 +++++++++++++------ .../components/settings/ApiConfigManager.tsx | 4 +- .../src/components/settings/ApiOptions.tsx | 8 +- .../src/components/settings/SettingsView.tsx | 12 ++- .../src/components/welcome/WelcomeView.tsx | 2 +- 7 files changed, 121 insertions(+), 33 deletions(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 960babe..562ecb3 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -125,6 +125,18 @@ export class ConfigManager { } } + /** + * 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` diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts index a6527ab..f185ede 100644 --- a/src/core/config/__tests__/ConfigManager.test.ts +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -1,7 +1,6 @@ import { ExtensionContext } from 'vscode' -import { ConfigManager } from '../ConfigManager' +import { ConfigManager, ApiConfigData } from '../ConfigManager' import { ApiConfiguration } from '../../../shared/api' -import { ApiConfigData } from '../ConfigManager' // Mock VSCode ExtensionContext const mockSecrets = { @@ -345,4 +344,41 @@ describe('ConfigManager', () => { ) }) }) + + 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 97d3521..94b8ea5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -45,7 +45,6 @@ type SecretKey = | "geminiApiKey" | "openAiNativeApiKey" | "deepSeekApiKey" - | "apiConfigPassword" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -428,15 +427,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (listApiConfig.length === 1) { // check if first time init then sync with exist config - if (!checkExistKey(listApiConfig[0]) && listApiConfig[0].name === "default") { + if (!checkExistKey(listApiConfig[0])) { const { apiConfiguration, } = await this.getState() - await this.configManager.SaveConfig("default", apiConfiguration) + 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), @@ -785,6 +806,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { let listApiConfig = await this.configManager.ListConfig(); await Promise.all([ + this.updateApiConfiguration(message.apiConfiguration), this.updateGlobalState("currentApiConfigName", message.text), this.updateGlobalState("listApiConfigMeta", listApiConfig), ]) @@ -800,7 +822,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message.values && message.apiConfiguration) { try { - const {oldName, newName} = message.values + const { oldName, newName } = message.values await this.configManager.SaveConfig(newName, message.apiConfiguration); @@ -839,17 +861,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "deleteApiConfiguration": if (message.text) { + + const answer = await vscode.window.showInformationMessage( + "What would you like to delete this api config?", + { modal: true }, + "Yes", + "No", + ) + + if (answer === "No" || answer === undefined) { + break + } + try { await this.configManager.DeleteConfig(message.text); - let currentApiConfigName = (await this.getGlobalState("currentApiConfigName") as string) ?? "default" + let listApiConfig = await this.configManager.ListConfig() + let currentApiConfigName = await this.getGlobalState("currentApiConfigName") if (message.text === currentApiConfigName) { - await this.updateGlobalState("currentApiConfigName", "default") + 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() + } } - let listApiConfig = await this.configManager.ListConfig(); - await this.updateGlobalState("listApiConfigMeta", listApiConfig) - this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + // this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) } catch (error) { console.error("Error delete api configuration:", error) @@ -867,16 +909,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { vscode.window.showErrorMessage("Failed to get list api configuration") } break - case "setApiConfigPassword": - if (message.text) { - try { - await this.storeSecret("apiConfigPassword", message.text !== "" ? message.text : undefined) - } catch (error) { - console.error("Error set apiKey password:", error) - vscode.window.showErrorMessage("Failed to set apiKey password") - } - } - break } }, null, @@ -1398,7 +1430,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, - apiKeyPassword } = await this.getState() const allowedCommands = vscode.workspace @@ -1435,7 +1466,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds: requestDelaySeconds ?? 5, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], - apiKeyPassword: apiKeyPassword ?? "" } } @@ -1545,7 +1575,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, - apiKeyPassword, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1600,7 +1629,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestDelaySeconds") as Promise, this.getGlobalState("currentApiConfigName") as Promise, this.getGlobalState("listApiConfigMeta") as Promise, - this.getSecret("apiConfigPassword") as Promise, ]) let apiProvider: ApiProvider @@ -1699,7 +1727,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds: requestDelaySeconds ?? 5, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], - apiKeyPassword: apiKeyPassword ?? "" } } @@ -1777,7 +1804,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { "geminiApiKey", "openAiNativeApiKey", "deepSeekApiKey", - "apiConfigPassword" ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index b6cf5dd..e69865d 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -12,8 +12,8 @@ interface ApiConfigManagerProps { } const ApiConfigManager = ({ - currentApiConfigName, - listApiConfigMeta, + currentApiConfigName = "", + listApiConfigMeta = [], onSelectConfig, onDeleteConfig, onRenameConfig, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index f38a0a3..3ddb55f 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -46,9 +46,10 @@ interface ApiOptionsProps { showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string + onSelectProvider: (apiProvider: any) => void } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { +const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, onSelectProvider }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) @@ -130,7 +131,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: { + onSelectProvider(event.target.value); + handleInputChange("apiProvider")(event); + }} style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 6a0883c..0567d92 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -183,7 +183,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { onRenameConfig={(oldName: string, newName: string) => { vscode.postMessage({ type: "renameApiConfiguration", - values: {oldName, newName}, + values: { oldName, newName }, apiConfiguration }) }} @@ -199,6 +199,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { showModelOptions={true} apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} + onSelectProvider={(apiProvider: any) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + apiProvider: apiProvider, + } + }) + }} />
diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index ef15a4e..fdaedb9 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -38,7 +38,7 @@ const WelcomeView = () => { To get started, this extension needs an API provider for Claude 3.5 Sonnet.
- + {}} /> Let's go! From 20322af5df0bffcaa8b13d7abd1f4181dc3f98d8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 09:28:45 -0500 Subject: [PATCH 05/17] Delete confirmation tweak --- src/core/webview/ClineProvider.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 94b8ea5..2e64379 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -863,13 +863,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message.text) { const answer = await vscode.window.showInformationMessage( - "What would you like to delete this api config?", + "Are you sure you want to delete this configuration profile?", { modal: true }, "Yes", - "No", ) - if (answer === "No" || answer === undefined) { + if (answer !== "Yes") { break } From 921f8844ebb9455dc3a677b0024228c02c29719d Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 8 Jan 2025 00:29:52 +0700 Subject: [PATCH 06/17] fix: config manager not update when model, key, another optionn... --- webview-ui/src/components/settings/ApiOptions.tsx | 14 ++++++-------- .../src/components/settings/GlamaModelPicker.tsx | 9 ++++++--- .../src/components/settings/OpenAiModelPicker.tsx | 9 ++++++--- .../components/settings/OpenRouterModelPicker.tsx | 9 ++++++--- .../src/components/settings/SettingsView.tsx | 10 ---------- webview-ui/src/components/welcome/WelcomeView.tsx | 2 +- webview-ui/src/context/ExtensionStateContext.tsx | 12 +++++++++++- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 3ddb55f..ff1535d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -46,11 +46,10 @@ interface ApiOptionsProps { showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string - onSelectProvider: (apiProvider: any) => void } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, onSelectProvider }: ApiOptionsProps) => { - const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() +const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { + const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) @@ -58,7 +57,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, on 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(() => { @@ -131,10 +132,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, on { - onSelectProvider(event.target.value); - handleInputChange("apiProvider")(event); - }} + onChange={handleInputChange("apiProvider")} style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 6823cc0..1b6164d 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const GlamaModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, glamaModels } = useExtensionState() + const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState() const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId) const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) @@ -22,11 +22,14 @@ const GlamaModelPicker: React.FC = () => { const handleModelChange = (newModelId: string) => { // could be setting invalid model id/undefined info but validation will catch it - setApiConfiguration({ + const apiConfig = { ...apiConfiguration, glamaModelId: newModelId, glamaModelInfo: glamaModels[newModelId], - }) + } + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) + setSearchTerm(newModelId) } diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 31cbddc..7979244 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -8,7 +8,7 @@ import { vscode } from "../../utils/vscode" import { highlight } from "../history/HistoryView" const OpenAiModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, openAiModels } = useExtensionState() + const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState() const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "") const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) @@ -18,10 +18,13 @@ const OpenAiModelPicker: React.FC = () => { const handleModelChange = (newModelId: string) => { // could be setting invalid model id/undefined info but validation will catch it - setApiConfiguration({ + const apiConfig = { ...apiConfiguration, openAiModelId: newModelId, - }) + } + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) + setSearchTerm(newModelId) } diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index bd4efd8..10086e7 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const OpenRouterModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState() + const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState() const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId) const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) @@ -22,11 +22,14 @@ const OpenRouterModelPicker: React.FC = () => { const handleModelChange = (newModelId: string) => { // could be setting invalid model id/undefined info but validation will catch it - setApiConfiguration({ + const apiConfig = { ...apiConfiguration, openRouterModelId: newModelId, openRouterModelInfo: openRouterModels[newModelId], - }) + } + + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) setSearchTerm(newModelId) } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 0567d92..2d2a04e 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -199,16 +199,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { showModelOptions={true} apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} - onSelectProvider={(apiProvider: any) => { - vscode.postMessage({ - type: "upsertApiConfiguration", - text: currentApiConfigName, - apiConfiguration: { - ...apiConfiguration, - apiProvider: apiProvider, - } - }) - }} />
diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index fdaedb9..ef15a4e 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -38,7 +38,7 @@ const WelcomeView = () => { To get started, this extension needs an API provider for Claude 3.5 Sonnet.
- {}} /> + Let's go! diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 4aa874e..48a0757 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -55,6 +55,7 @@ export interface ExtensionStateContextType extends ExtensionState { setRequestDelaySeconds: (value: number) => void setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ApiConfigMeta[]) => void + onUpdateApiConfig: (apiConfig: ApiConfiguration) => void } export const ExtensionStateContext = createContext(undefined) @@ -98,6 +99,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState]) + const onUpdateApiConfig = useCallback((apiConfig: ApiConfiguration) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: state.currentApiConfigName, + apiConfiguration: apiConfig, + }) + }, [state]) + const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { @@ -210,7 +219,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), - setListApiConfigMeta + setListApiConfigMeta, + onUpdateApiConfig } return {children} From f39eaa14ffb774259d2bf1e8129ffc7cb05cf70b Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 8 Jan 2025 07:53:56 +0700 Subject: [PATCH 07/17] fix: sync model picker search terms with selected models Added useEffect hooks to GlamaModelPicker, OpenAiModelPicker, and OpenRouterModelPicker components to ensure the search term stays synchronized with the selected model ID from apiConfiguration. This prevents the search term from getting out of sync when the model is changed. --- webview-ui/src/components/settings/GlamaModelPicker.tsx | 9 +++++++++ webview-ui/src/components/settings/OpenAiModelPicker.tsx | 8 ++++++++ .../src/components/settings/OpenRouterModelPicker.tsx | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 1b6164d..2df9984 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -37,6 +37,15 @@ const GlamaModelPicker: React.FC = () => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) + + useEffect(() => { + if (apiConfiguration?.glamaModelId) { + if (apiConfiguration?.glamaModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.glamaModelId) + } + } + }, [apiConfiguration, searchTerm]) + useMount(() => { vscode.postMessage({ type: "refreshGlamaModels" }) }) diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 7979244..cb8a6a4 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -28,6 +28,14 @@ const OpenAiModelPicker: React.FC = () => { setSearchTerm(newModelId) } + useEffect(() => { + if (apiConfiguration?.openAiModelId) { + if (apiConfiguration?.openAiModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.openAiModelId) + } + } + }, [apiConfiguration, searchTerm]) + useEffect(() => { if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) { return diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index 10086e7..df2883c 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -37,6 +37,14 @@ const OpenRouterModelPicker: React.FC = () => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) + useEffect(() => { + if (apiConfiguration?.openRouterModelId) { + if (apiConfiguration?.openRouterModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.openRouterModelId) + } + } + }, [apiConfiguration, searchTerm]) + useMount(() => { vscode.postMessage({ type: "refreshOpenRouterModels" }) }) From bb774e17eb8ce1c11f73f30361f330037973ff1c Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 20:14:12 -0500 Subject: [PATCH 08/17] Release --- .changeset/shiny-seahorses-peel.md | 5 +++++ README.md | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/shiny-seahorses-peel.md diff --git a/.changeset/shiny-seahorses-peel.md b/.changeset/shiny-seahorses-peel.md new file mode 100644 index 0000000..60f9108 --- /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 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 From 2cffbc860bf1f28253a46bcd2c94d7d4d7bc9200 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 20:30:23 -0500 Subject: [PATCH 09/17] Tweak thumbnail display --- webview-ui/src/components/chat/ChatTextArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8762466..ec94cba 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -661,7 +661,7 @@ const ChatTextArea = forwardRef( style={{ position: "absolute", paddingTop: 4, - bottom: 14, + bottom: 32, left: 22, right: 67, zIndex: 2, From 525b7424fe1fa38098419cddac4bef132d2f7823 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 22:30:21 -0500 Subject: [PATCH 10/17] Cleanup the welcome screen to be less Claude focused --- src/core/config/ConfigManager.ts | 2 +- src/core/config/__tests__/ConfigManager.test.ts | 10 +++++----- webview-ui/src/components/chat/ChatView.tsx | 9 ++------- webview-ui/src/components/settings/ApiOptions.tsx | 10 ++++------ webview-ui/src/components/settings/SettingsView.tsx | 3 --- webview-ui/src/components/welcome/WelcomeView.tsx | 13 ++++--------- 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 562ecb3..7e4393d 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -16,7 +16,7 @@ export class ConfigManager { default: {} } } - private readonly SCOPE_PREFIX = "cline_config_" + private readonly SCOPE_PREFIX = "roo_cline_config_" private readonly context: ExtensionContext constructor(context: ExtensionContext) { diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts index f185ede..b8170ee 100644 --- a/src/core/config/__tests__/ConfigManager.test.ts +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -121,7 +121,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -154,7 +154,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -196,7 +196,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -256,7 +256,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -314,7 +314,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) 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/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index ff1535d..cc30ae9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -43,12 +43,11 @@ import OpenAiModelPicker from "./OpenAiModelPicker" import GlamaModelPicker from "./GlamaModelPicker" interface ApiOptionsProps { - showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { +const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) @@ -695,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" && ( <>