feat: config manager using secret store

This commit is contained in:
sam hoang
2025-01-05 00:52:00 +07:00
committed by Matt Rubens
parent c30e9c6ed3
commit 352f34d8ce
10 changed files with 1026 additions and 96 deletions

View File

@@ -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<void> {
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<ApiConfigMeta[]> {
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<void> {
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<ApiConfiguration> {
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<void> {
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<void> {
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<ApiConfigData> {
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<void> {
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}`)
}
}
}

View File

@@ -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'
)
})
})
})

View File

@@ -12,9 +12,9 @@ import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme" import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub" 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 { findLast } from "../../shared/array"
import { ExtensionMessage } from "../../shared/ExtensionMessage" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem" import { HistoryItem } from "../../shared/HistoryItem"
import { WebviewMessage } from "../../shared/WebviewMessage" import { WebviewMessage } from "../../shared/WebviewMessage"
import { fileExistsAtPath } from "../../utils/fs" import { fileExistsAtPath } from "../../utils/fs"
@@ -23,8 +23,10 @@ import { openMention } from "../mentions"
import { getNonce } from "./getNonce" import { getNonce } from "./getNonce"
import { getUri } from "./getUri" import { getUri } from "./getUri"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { enhancePrompt } from "../../utils/enhance-prompt" import { enhancePrompt } from "../../utils/enhance-prompt"
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" 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 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" | "geminiApiKey"
| "openAiNativeApiKey" | "openAiNativeApiKey"
| "deepSeekApiKey" | "deepSeekApiKey"
| "apiConfigPassword"
type GlobalStateKey = type GlobalStateKey =
| "apiProvider" | "apiProvider"
| "apiModelId" | "apiModelId"
@@ -85,6 +88,9 @@ type GlobalStateKey =
| "mcpEnabled" | "mcpEnabled"
| "alwaysApproveResubmit" | "alwaysApproveResubmit"
| "requestDelaySeconds" | "requestDelaySeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
export const GlobalFileNames = { export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json", apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json", uiMessages: "ui_messages.json",
@@ -103,6 +109,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private workspaceTracker?: WorkspaceTracker private workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub mcpHub?: McpHub
private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement
configManager: ConfigManager
constructor( constructor(
readonly context: vscode.ExtensionContext, readonly context: vscode.ExtensionContext,
@@ -112,6 +119,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
ClineProvider.activeInstances.add(this) ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(this) this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this) this.mcpHub = new McpHub(this)
this.configManager = new ConfigManager(this.context)
} }
/* /*
@@ -321,15 +329,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Use a nonce to only allow a specific script to be run. // 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 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 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. 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.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection - '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:; - 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() const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below // 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 break
case "newTask": case "newTask":
// Code that should run in response to the hello message command // Code that should run in response to the hello message command
@@ -424,70 +459,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break break
case "apiConfiguration": case "apiConfiguration":
if (message.apiConfiguration) { if (message.apiConfiguration) {
const { await this.updateApiConfiguration(message.apiConfiguration)
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.postStateToWebview() await this.postStateToWebview()
break break
@@ -805,6 +777,106 @@ export class ClineProvider implements vscode.WebviewViewProvider {
} }
break 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, 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) { async updateCustomInstructions(instructions?: string) {
// User may be clearing the field // User may be clearing the field
await this.updateGlobalState("customInstructions", instructions || undefined) await this.updateGlobalState("customInstructions", instructions || undefined)
@@ -1256,6 +1396,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled, mcpEnabled,
alwaysApproveResubmit, alwaysApproveResubmit,
requestDelaySeconds, requestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
apiKeyPassword
} = await this.getState() } = await this.getState()
const allowedCommands = vscode.workspace const allowedCommands = vscode.workspace
@@ -1290,6 +1433,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled: mcpEnabled ?? true, mcpEnabled: mcpEnabled ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false, alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 5, requestDelaySeconds: requestDelaySeconds ?? 5,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
apiKeyPassword: apiKeyPassword ?? ""
} }
} }
@@ -1397,6 +1543,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled, mcpEnabled,
alwaysApproveResubmit, alwaysApproveResubmit,
requestDelaySeconds, requestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
apiKeyPassword,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>, this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1449,6 +1598,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>, this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>, this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>, this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
this.getSecret("apiConfigPassword") as Promise<string | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider
@@ -1545,6 +1697,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled: mcpEnabled ?? true, mcpEnabled: mcpEnabled ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false, alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 5, requestDelaySeconds: requestDelaySeconds ?? 5,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
apiKeyPassword: apiKeyPassword ?? ""
} }
} }
@@ -1622,6 +1777,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
"geminiApiKey", "geminiApiKey",
"openAiNativeApiKey", "openAiNativeApiKey",
"deepSeekApiKey", "deepSeekApiKey",
"apiConfigPassword"
] ]
for (const key of secretKeys) { for (const key of secretKeys) {
await this.storeSecret(key, undefined) await this.storeSecret(key, undefined)

View File

@@ -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' // 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 { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp" import { McpServer } from "./mcp"
import { GitCommit } from "../utils/git" import { GitCommit } from "../utils/git"
@@ -23,6 +23,7 @@ export interface ExtensionMessage {
| "mcpServers" | "mcpServers"
| "enhancedPrompt" | "enhancedPrompt"
| "commitSearchResults" | "commitSearchResults"
| "listApiConfig"
text?: string text?: string
action?: action?:
| "chatButtonClicked" | "chatButtonClicked"
@@ -42,6 +43,12 @@ export interface ExtensionMessage {
openAiModels?: string[] openAiModels?: string[]
mcpServers?: McpServer[] mcpServers?: McpServer[]
commits?: GitCommit[] commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]
}
export interface ApiConfigMeta {
name: string
apiProvider?: ApiProvider
} }
export interface ExtensionState { export interface ExtensionState {
@@ -50,6 +57,8 @@ export interface ExtensionState {
taskHistory: HistoryItem[] taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean shouldShowAnnouncement: boolean
apiConfiguration?: ApiConfiguration apiConfiguration?: ApiConfiguration
currentApiConfigName?: string
listApiConfigMeta?: ApiConfigMeta[]
customInstructions?: string customInstructions?: string
alwaysAllowReadOnly?: boolean alwaysAllowReadOnly?: boolean
alwaysAllowWrite?: boolean alwaysAllowWrite?: boolean

View File

@@ -5,6 +5,12 @@ export type AudioType = "notification" | "celebration" | "progress_loop"
export interface WebviewMessage { export interface WebviewMessage {
type: type:
| "apiConfiguration" | "apiConfiguration"
| "currentApiConfigName"
| "upsertApiConfiguration"
| "deleteApiConfiguration"
| "loadApiConfiguration"
| "renameApiConfiguration"
| "getListApiConfiguration"
| "customInstructions" | "customInstructions"
| "allowedCommands" | "allowedCommands"
| "alwaysAllowReadOnly" | "alwaysAllowReadOnly"
@@ -54,6 +60,7 @@ export interface WebviewMessage {
| "searchCommits" | "searchCommits"
| "alwaysApproveResubmit" | "alwaysApproveResubmit"
| "requestDelaySeconds" | "requestDelaySeconds"
| "setApiConfigPassword"
text?: string text?: string
disabled?: boolean disabled?: boolean
askResponse?: ClineAskResponse askResponse?: ClineAskResponse

View File

@@ -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;
}

View File

@@ -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 (
<div>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
API Configuration
</label>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{isNewMode ? (
<>
<VSCodeTextField
value={newConfigName}
onInput={(e: any) => setNewConfigName(e.target.value)}
placeholder="Enter configuration name"
style={{ flexGrow: 1 }}
/>
<VSCodeButton
appearance="secondary"
disabled={!newConfigName.trim()}
onClick={handleSaveNewConfig}
>
<span className="codicon codicon-check" /> Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
onClick={handleCancelNewConfig}
>
<span className="codicon codicon-close" /> Cancel
</VSCodeButton>
</>
) : isRenameMode ? (
<>
<VSCodeTextField
value={renamedConfigName}
onInput={(e: any) => setRenamedConfigName(e.target.value)}
placeholder="Enter new name"
style={{ flexGrow: 1 }}
/>
<VSCodeButton
appearance="secondary"
disabled={!renamedConfigName.trim()}
onClick={handleSaveRename}
>
<span className="codicon codicon-check" /> Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
onClick={handleCancelRename}
>
<span className="codicon codicon-close" /> Cancel
</VSCodeButton>
</>
) : (
<>
<select
value={currentApiConfigName}
onChange={(e) => onSelectConfig(e.target.value)}
style={{
flexGrow: 1,
padding: "4px 8px",
backgroundColor: "var(--vscode-input-background)",
color: "var(--vscode-input-foreground)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
height: "28px"
}}>
{listApiConfigMeta?.map((config) => (
<option key={config.name} value={config.name}>{config.name} {config.apiProvider ? `(${config.apiProvider})` : ""}
</option>
))}
</select>
<VSCodeButton
appearance="secondary"
onClick={handleNewConfig}
>
<span className="codicon codicon-add" /> New
</VSCodeButton>
<VSCodeButton
appearance="secondary"
disabled={!currentApiConfigName}
onClick={handleStartRename}
>
<span className="codicon codicon-edit" /> Rename
</VSCodeButton>
<VSCodeButton
appearance="secondary"
disabled={!currentApiConfigName}
onClick={() => onDeleteConfig(currentApiConfigName!)}
>
<span className="codicon codicon-trash" /> Delete
</VSCodeButton>
</>
)}
</div>
<VSCodeDivider style={{ margin: "15px 0" }} />
</div>
)
}
export default memo(ApiConfigManager)

View File

@@ -5,6 +5,7 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode" import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions" import ApiOptions from "./ApiOptions"
import McpEnabledToggle from "../mcp/McpEnabledToggle" import McpEnabledToggle from "../mcp/McpEnabledToggle"
import ApiConfigManager from "./ApiConfigManager"
const IS_DEV = false // FIXME: use flags when packaging const IS_DEV = false // FIXME: use flags when packaging
@@ -55,10 +56,15 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysApproveResubmit, setAlwaysApproveResubmit,
requestDelaySeconds, requestDelaySeconds,
setRequestDelaySeconds, setRequestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
} = useExtensionState() } = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined) const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("") const [commandInput, setCommandInput] = useState("")
// const [draftNewMode, setDraftNewMode] = useState(false)
const handleSubmit = () => { const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration) const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
@@ -89,6 +95,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration
})
onDone() onDone()
} }
} }
@@ -150,6 +163,42 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</div> </div>
<div <div
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}> style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
<div style={{ marginBottom: 5 }}>
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) => {
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)
// }}
/>
</div>
<div style={{ marginBottom: 5 }}> <div style={{ marginBottom: 5 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Provider Settings</h3> <h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Provider Settings</h3>
<ApiOptions <ApiOptions

View File

@@ -11,6 +11,16 @@ jest.mock('../../../utils/vscode', () => ({
}, },
})) }))
// Mock ApiConfigManager component
jest.mock('../ApiConfigManager', () => ({
__esModule: true,
default: ({ currentApiConfigName, listApiConfigMeta, onSelectConfig, onDeleteConfig, onRenameConfig, onUpsertConfig }: any) => (
<div data-testid="api-config-management">
<span>Current config: {currentApiConfigName}</span>
</div>
)
}))
// Mock VSCode components // Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({ jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick, appearance }: any) => ( 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', () => { describe('SettingsView - Allowed Commands', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()

View File

@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
import { useEvent } from "react-use" import { useEvent } from "react-use"
import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
import { import {
ApiConfiguration, ApiConfiguration,
ModelInfo, ModelInfo,
@@ -13,6 +13,9 @@ import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs" import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { findLastIndex } from "../../../src/shared/array" import { findLastIndex } from "../../../src/shared/array"
import { McpServer } from "../../../src/shared/mcp" import { McpServer } from "../../../src/shared/mcp"
import {
checkExistKey
} from "../../../src/shared/checkExistApiConfig"
export interface ExtensionStateContextType extends ExtensionState { export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean didHydrateState: boolean
@@ -50,6 +53,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysApproveResubmit: (value: boolean) => void setAlwaysApproveResubmit: (value: boolean) => void
requestDelaySeconds: number requestDelaySeconds: number
setRequestDelaySeconds: (value: number) => void setRequestDelaySeconds: (value: number) => void
setCurrentApiConfigName: (value: string) => void
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
} }
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined) export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -72,7 +77,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
terminalOutputLineLimit: 500, terminalOutputLineLimit: 500,
mcpEnabled: true, mcpEnabled: true,
alwaysApproveResubmit: false, alwaysApproveResubmit: false,
requestDelaySeconds: 5 requestDelaySeconds: 5,
currentApiConfigName: 'default',
listApiConfigMeta: [],
}) })
const [didHydrateState, setDidHydrateState] = useState(false) const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false) const [showWelcome, setShowWelcome] = useState(false)
@@ -88,27 +95,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openAiModels, setOpenAiModels] = useState<string[]>([]) const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([]) const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState])
const handleMessage = useCallback((event: MessageEvent) => { const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data const message: ExtensionMessage = event.data
switch (message.type) { switch (message.type) {
case "state": { case "state": {
setState(message.state!) setState(message.state!)
const config = message.state?.apiConfiguration const config = message.state?.apiConfiguration
const hasKey = config const hasKey = checkExistKey(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
setShowWelcome(!hasKey) setShowWelcome(!hasKey)
setDidHydrateState(true) setDidHydrateState(true)
break break
@@ -162,8 +158,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMcpServers(message.mcpServers ?? []) setMcpServers(message.mcpServers ?? [])
break break
} }
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
}
} }
}, []) }, [setListApiConfigMeta])
useEvent("message", handleMessage) useEvent("message", handleMessage)
@@ -208,7 +208,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: 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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider> return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>