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}