mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
Merge pull request #297 from RooVetGit/api_config
Save different API configurations to quickly switch between providers and settings
This commit is contained in:
5
.changeset/shiny-seahorses-peel.md
Normal file
5
.changeset/shiny-seahorses-peel.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"roo-cline": patch
|
||||
---
|
||||
|
||||
Save different API configurations to quickly switch between providers and settings (thanks @samhvw8!)
|
||||
@@ -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
|
||||
|
||||
@@ -799,8 +799,30 @@ export class Cline {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Anthropic.MessageParam by spreading only the API-required properties
|
||||
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => ({ role, content }))
|
||||
// Clean conversation history by:
|
||||
// 1. Converting to Anthropic.MessageParam by spreading only the API-required properties
|
||||
// 2. Converting image blocks to text descriptions if model doesn't support images
|
||||
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => {
|
||||
// Handle array content (could contain image blocks)
|
||||
if (Array.isArray(content)) {
|
||||
if (!this.api.getModel().info.supportsImages) {
|
||||
// Convert image blocks to text descriptions
|
||||
content = content.map(block => {
|
||||
if (block.type === 'image') {
|
||||
// Convert image blocks to text descriptions
|
||||
// Note: We can't access the actual image content/url due to API limitations,
|
||||
// but we can indicate that an image was present in the conversation
|
||||
return {
|
||||
type: 'text',
|
||||
text: '[Referenced image in conversation]'
|
||||
};
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
}
|
||||
return { role, content }
|
||||
})
|
||||
const stream = this.api.createMessage(systemPrompt, cleanConversationHistory)
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Cline } from '../Cline';
|
||||
import { ClineProvider } from '../webview/ClineProvider';
|
||||
import { ApiConfiguration } from '../../shared/api';
|
||||
import { ApiConfiguration, ModelInfo } from '../../shared/api';
|
||||
import { ApiStreamChunk } from '../../api/transform/stream';
|
||||
import { Anthropic } from '@anthropic-ai/sdk';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// Mock all MCP-related modules
|
||||
@@ -498,6 +499,133 @@ describe('Cline', () => {
|
||||
expect(passedMessage).not.toHaveProperty('ts');
|
||||
expect(passedMessage).not.toHaveProperty('extraProp');
|
||||
});
|
||||
|
||||
it('should handle image blocks based on model capabilities', async () => {
|
||||
// Create two configurations - one with image support, one without
|
||||
const configWithImages = {
|
||||
...mockApiConfig,
|
||||
apiModelId: 'claude-3-sonnet'
|
||||
};
|
||||
const configWithoutImages = {
|
||||
...mockApiConfig,
|
||||
apiModelId: 'gpt-3.5-turbo'
|
||||
};
|
||||
|
||||
// Create test conversation history with mixed content
|
||||
const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'Here is an image'
|
||||
} satisfies Anthropic.TextBlockParam,
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: 'image/jpeg',
|
||||
data: 'base64data'
|
||||
}
|
||||
} satisfies Anthropic.ImageBlockParam
|
||||
]
|
||||
},
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: 'I see the image'
|
||||
} satisfies Anthropic.TextBlockParam]
|
||||
}
|
||||
];
|
||||
|
||||
// Test with model that supports images
|
||||
const clineWithImages = new Cline(
|
||||
mockProvider,
|
||||
configWithImages,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
'test task'
|
||||
);
|
||||
// Mock the model info to indicate image support
|
||||
jest.spyOn(clineWithImages.api, 'getModel').mockReturnValue({
|
||||
id: 'claude-3-sonnet',
|
||||
info: {
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsComputerUse: true,
|
||||
contextWindow: 200000,
|
||||
maxTokens: 4096,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 0.75
|
||||
} as ModelInfo
|
||||
});
|
||||
clineWithImages.apiConversationHistory = conversationHistory;
|
||||
|
||||
// Test with model that doesn't support images
|
||||
const clineWithoutImages = new Cline(
|
||||
mockProvider,
|
||||
configWithoutImages,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
'test task'
|
||||
);
|
||||
// Mock the model info to indicate no image support
|
||||
jest.spyOn(clineWithoutImages.api, 'getModel').mockReturnValue({
|
||||
id: 'gpt-3.5-turbo',
|
||||
info: {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsComputerUse: false,
|
||||
contextWindow: 16000,
|
||||
maxTokens: 2048,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.2
|
||||
} as ModelInfo
|
||||
});
|
||||
clineWithoutImages.apiConversationHistory = conversationHistory;
|
||||
|
||||
// Create message spy for both instances
|
||||
const createMessageSpyWithImages = jest.fn();
|
||||
const createMessageSpyWithoutImages = jest.fn();
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: 'text', text: '' };
|
||||
}
|
||||
} as AsyncGenerator<ApiStreamChunk>;
|
||||
|
||||
jest.spyOn(clineWithImages.api, 'createMessage').mockImplementation((...args) => {
|
||||
createMessageSpyWithImages(...args);
|
||||
return mockStream;
|
||||
});
|
||||
jest.spyOn(clineWithoutImages.api, 'createMessage').mockImplementation((...args) => {
|
||||
createMessageSpyWithoutImages(...args);
|
||||
return mockStream;
|
||||
});
|
||||
|
||||
// Trigger API requests for both instances
|
||||
await clineWithImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
|
||||
await clineWithoutImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
|
||||
|
||||
// Verify model with image support preserves image blocks
|
||||
const callsWithImages = createMessageSpyWithImages.mock.calls;
|
||||
const historyWithImages = callsWithImages[0][1][0];
|
||||
expect(historyWithImages.content).toHaveLength(2);
|
||||
expect(historyWithImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
|
||||
expect(historyWithImages.content[1]).toHaveProperty('type', 'image');
|
||||
|
||||
// Verify model without image support converts image blocks to text
|
||||
const callsWithoutImages = createMessageSpyWithoutImages.mock.calls;
|
||||
const historyWithoutImages = callsWithoutImages[0][1][0];
|
||||
expect(historyWithoutImages.content).toHaveLength(2);
|
||||
expect(historyWithoutImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
|
||||
expect(historyWithoutImages.content[1]).toEqual({
|
||||
type: 'text',
|
||||
text: '[Referenced image in conversation]'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
164
src/core/config/ConfigManager.ts
Normal file
164
src/core/config/ConfigManager.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { ExtensionContext } from 'vscode'
|
||||
import { ApiConfiguration } from '../../shared/api'
|
||||
import { ApiConfigMeta } from '../../shared/ExtensionMessage'
|
||||
|
||||
export interface ApiConfigData {
|
||||
currentApiConfigName: string
|
||||
apiConfigs: {
|
||||
[key: string]: ApiConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
private readonly defaultConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {}
|
||||
}
|
||||
}
|
||||
private readonly SCOPE_PREFIX = "roo_cline_config_"
|
||||
private readonly context: ExtensionContext
|
||||
|
||||
constructor(context: ExtensionContext) {
|
||||
this.context = context
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize config if it doesn't exist
|
||||
*/
|
||||
async initConfig(): Promise<void> {
|
||||
try {
|
||||
const config = await this.readConfig()
|
||||
if (!config) {
|
||||
await this.writeConfig(this.defaultConfig)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to initialize config: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available configs with metadata
|
||||
*/
|
||||
async ListConfig(): Promise<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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a config exists by name
|
||||
*/
|
||||
async HasConfig(name: string): Promise<boolean> {
|
||||
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<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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/core/config/__tests__/ConfigManager.test.ts
Normal file
384
src/core/config/__tests__/ConfigManager.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { ExtensionContext } from 'vscode'
|
||||
import { ConfigManager, ApiConfigData } from '../ConfigManager'
|
||||
import { ApiConfiguration } from '../../../shared/api'
|
||||
|
||||
// Mock VSCode ExtensionContext
|
||||
const mockSecrets = {
|
||||
get: jest.fn(),
|
||||
store: jest.fn(),
|
||||
delete: jest.fn()
|
||||
}
|
||||
|
||||
const mockContext = {
|
||||
secrets: mockSecrets
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let configManager: ConfigManager
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
configManager = new ConfigManager(mockContext)
|
||||
})
|
||||
|
||||
describe('initConfig', () => {
|
||||
it('should not write to storage when secrets.get returns null', async () => {
|
||||
// Mock readConfig to return null
|
||||
mockSecrets.get.mockResolvedValueOnce(null)
|
||||
|
||||
await configManager.initConfig()
|
||||
|
||||
// Should not write to storage because readConfig returns defaultConfig
|
||||
expect(mockSecrets.store).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not initialize config if it exists', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {}
|
||||
}
|
||||
}))
|
||||
|
||||
await configManager.initConfig()
|
||||
|
||||
expect(mockSecrets.store).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw error if secrets storage fails', async () => {
|
||||
mockSecrets.get.mockRejectedValue(new Error('Storage failed'))
|
||||
|
||||
await expect(configManager.initConfig()).rejects.toThrow(
|
||||
'Failed to initialize config: Error: Failed to read config from secrets: Error: Storage failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ListConfig', () => {
|
||||
it('should list all available configs', async () => {
|
||||
const existingConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {},
|
||||
test: {
|
||||
apiProvider: 'anthropic'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const configs = await configManager.ListConfig()
|
||||
expect(configs).toEqual([
|
||||
{ name: 'default', apiProvider: undefined },
|
||||
{ name: 'test', apiProvider: 'anthropic' }
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty config file', async () => {
|
||||
const emptyConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {}
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(emptyConfig))
|
||||
|
||||
const configs = await configManager.ListConfig()
|
||||
expect(configs).toEqual([])
|
||||
})
|
||||
|
||||
it('should throw error if reading from secrets fails', async () => {
|
||||
mockSecrets.get.mockRejectedValue(new Error('Read failed'))
|
||||
|
||||
await expect(configManager.ListConfig()).rejects.toThrow(
|
||||
'Failed to list configs: Error: Failed to read config from secrets: Error: Read failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SaveConfig', () => {
|
||||
it('should save new config', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {}
|
||||
}
|
||||
}))
|
||||
|
||||
const newConfig: ApiConfiguration = {
|
||||
apiProvider: 'anthropic',
|
||||
apiKey: 'test-key'
|
||||
}
|
||||
|
||||
await configManager.SaveConfig('test', newConfig)
|
||||
|
||||
const expectedConfig = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {},
|
||||
test: newConfig
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockSecrets.store).toHaveBeenCalledWith(
|
||||
'roo_cline_config_api_config',
|
||||
JSON.stringify(expectedConfig, null, 2)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update existing config', async () => {
|
||||
const existingConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
test: {
|
||||
apiProvider: 'anthropic',
|
||||
apiKey: 'old-key'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const updatedConfig: ApiConfiguration = {
|
||||
apiProvider: 'anthropic',
|
||||
apiKey: 'new-key'
|
||||
}
|
||||
|
||||
await configManager.SaveConfig('test', updatedConfig)
|
||||
|
||||
const expectedConfig = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
test: updatedConfig
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockSecrets.store).toHaveBeenCalledWith(
|
||||
'roo_cline_config_api_config',
|
||||
JSON.stringify(expectedConfig, null, 2)
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error if secrets storage fails', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: { default: {} }
|
||||
}))
|
||||
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
|
||||
|
||||
await expect(configManager.SaveConfig('test', {})).rejects.toThrow(
|
||||
'Failed to save config: Error: Failed to write config to secrets: Error: Storage failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteConfig', () => {
|
||||
it('should delete existing config', async () => {
|
||||
const existingConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {},
|
||||
test: {
|
||||
apiProvider: 'anthropic'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
await configManager.DeleteConfig('test')
|
||||
|
||||
const expectedConfig = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {}
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockSecrets.store).toHaveBeenCalledWith(
|
||||
'roo_cline_config_api_config',
|
||||
JSON.stringify(expectedConfig, null, 2)
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when trying to delete non-existent config', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: { default: {} }
|
||||
}))
|
||||
|
||||
await expect(configManager.DeleteConfig('nonexistent')).rejects.toThrow(
|
||||
"Config 'nonexistent' not found"
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when trying to delete last remaining config', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: { default: {} }
|
||||
}))
|
||||
|
||||
await expect(configManager.DeleteConfig('default')).rejects.toThrow(
|
||||
'Cannot delete the last remaining configuration.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoadConfig', () => {
|
||||
it('should load config and update current config name', async () => {
|
||||
const existingConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
test: {
|
||||
apiProvider: 'anthropic',
|
||||
apiKey: 'test-key'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const config = await configManager.LoadConfig('test')
|
||||
|
||||
expect(config).toEqual({
|
||||
apiProvider: 'anthropic',
|
||||
apiKey: 'test-key'
|
||||
})
|
||||
|
||||
const expectedConfig = {
|
||||
currentApiConfigName: 'test',
|
||||
apiConfigs: {
|
||||
test: {
|
||||
apiProvider: 'anthropic',
|
||||
apiKey: 'test-key'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockSecrets.store).toHaveBeenCalledWith(
|
||||
'roo_cline_config_api_config',
|
||||
JSON.stringify(expectedConfig, null, 2)
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when config does not exist', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: { default: {} }
|
||||
}))
|
||||
|
||||
await expect(configManager.LoadConfig('nonexistent')).rejects.toThrow(
|
||||
"Config 'nonexistent' not found"
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error if secrets storage fails', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
test: { apiProvider: 'anthropic' }
|
||||
}
|
||||
}))
|
||||
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
|
||||
|
||||
await expect(configManager.LoadConfig('test')).rejects.toThrow(
|
||||
'Failed to load config: Error: Failed to write config to secrets: Error: Storage failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SetCurrentConfig', () => {
|
||||
it('should set current config', async () => {
|
||||
const existingConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {},
|
||||
test: {
|
||||
apiProvider: 'anthropic'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
await configManager.SetCurrentConfig('test')
|
||||
|
||||
const expectedConfig = {
|
||||
currentApiConfigName: 'test',
|
||||
apiConfigs: {
|
||||
default: {},
|
||||
test: {
|
||||
apiProvider: 'anthropic'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockSecrets.store).toHaveBeenCalledWith(
|
||||
'roo_cline_config_api_config',
|
||||
JSON.stringify(expectedConfig, null, 2)
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when config does not exist', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: { default: {} }
|
||||
}))
|
||||
|
||||
await expect(configManager.SetCurrentConfig('nonexistent')).rejects.toThrow(
|
||||
"Config 'nonexistent' not found"
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error if secrets storage fails', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
test: { apiProvider: 'anthropic' }
|
||||
}
|
||||
}))
|
||||
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
|
||||
|
||||
await expect(configManager.SetCurrentConfig('test')).rejects.toThrow(
|
||||
'Failed to set current config: Error: Failed to write config to secrets: Error: Storage failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HasConfig', () => {
|
||||
it('should return true for existing config', async () => {
|
||||
const existingConfig: ApiConfigData = {
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: {
|
||||
default: {},
|
||||
test: {
|
||||
apiProvider: 'anthropic'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const hasConfig = await configManager.HasConfig('test')
|
||||
expect(hasConfig).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-existent config', async () => {
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify({
|
||||
currentApiConfigName: 'default',
|
||||
apiConfigs: { default: {} }
|
||||
}))
|
||||
|
||||
const hasConfig = await configManager.HasConfig('nonexistent')
|
||||
expect(hasConfig).toBe(false)
|
||||
})
|
||||
|
||||
it('should throw error if secrets storage fails', async () => {
|
||||
mockSecrets.get.mockRejectedValue(new Error('Storage failed'))
|
||||
|
||||
await expect(configManager.HasConfig('test')).rejects.toThrow(
|
||||
'Failed to check config existence: Error: Failed to read config from secrets: Error: Storage failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,9 +12,9 @@ import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
@@ -23,8 +23,10 @@ import { openMention } from "../mentions"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
||||
import { checkExistKey } from "../../shared/checkExistApiConfig"
|
||||
import { enhancePrompt } from "../../utils/enhance-prompt"
|
||||
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
|
||||
import { ConfigManager } from "../config/ConfigManager"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -85,6 +87,9 @@ type GlobalStateKey =
|
||||
| "mcpEnabled"
|
||||
| "alwaysApproveResubmit"
|
||||
| "requestDelaySeconds"
|
||||
| "currentApiConfigName"
|
||||
| "listApiConfigMeta"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
@@ -103,6 +108,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
private workspaceTracker?: WorkspaceTracker
|
||||
mcpHub?: McpHub
|
||||
private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement
|
||||
configManager: ConfigManager
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -112,6 +118,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
ClineProvider.activeInstances.add(this)
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
this.configManager = new ConfigManager(this.context)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -235,7 +242,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
diffEnabled,
|
||||
fuzzyMatchThreshold
|
||||
} = await this.getState()
|
||||
|
||||
|
||||
this.cline = new Cline(
|
||||
this,
|
||||
apiConfiguration,
|
||||
@@ -255,7 +262,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
diffEnabled,
|
||||
fuzzyMatchThreshold
|
||||
} = await this.getState()
|
||||
|
||||
|
||||
this.cline = new Cline(
|
||||
this,
|
||||
apiConfiguration,
|
||||
@@ -321,15 +328,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
// Use a nonce to only allow a specific script to be run.
|
||||
/*
|
||||
content security policy of your webview to only allow scripts that have a specific nonce
|
||||
create a content security policy meta tag so that only loading scripts with a nonce is allowed
|
||||
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
|
||||
<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}';">
|
||||
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.
|
||||
<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
|
||||
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
|
||||
|
||||
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
|
||||
*/
|
||||
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
|
||||
*/
|
||||
const nonce = getNonce()
|
||||
|
||||
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
|
||||
@@ -410,6 +417,55 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.configManager.ListConfig().then(async (listApiConfig) => {
|
||||
|
||||
if (!listApiConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
if (listApiConfig.length === 1) {
|
||||
// check if first time init then sync with exist config
|
||||
if (!checkExistKey(listApiConfig[0])) {
|
||||
const {
|
||||
apiConfiguration,
|
||||
} = await this.getState()
|
||||
await this.configManager.SaveConfig(listApiConfig[0].name ?? "default", apiConfiguration)
|
||||
listApiConfig[0].apiProvider = apiConfiguration.apiProvider
|
||||
}
|
||||
}
|
||||
|
||||
let currentConfigName = await this.getGlobalState("currentApiConfigName") as string
|
||||
|
||||
if (currentConfigName) {
|
||||
if (!await this.configManager.HasConfig(currentConfigName)) {
|
||||
// current config name not valid, get first config in list
|
||||
await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
|
||||
if (listApiConfig?.[0]?.name) {
|
||||
const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name);
|
||||
|
||||
await Promise.all([
|
||||
this.updateGlobalState("listApiConfigMeta", listApiConfig),
|
||||
this.postMessageToWebview({ type: "listApiConfig", listApiConfig }),
|
||||
this.updateApiConfiguration(apiConfig),
|
||||
])
|
||||
await this.postStateToWebview()
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
await this.updateGlobalState("listApiConfigMeta", listApiConfig),
|
||||
await this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
|
||||
]
|
||||
)
|
||||
}).catch(console.error);
|
||||
|
||||
break
|
||||
case "newTask":
|
||||
// Code that should run in response to the hello message command
|
||||
@@ -424,70 +480,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
break
|
||||
case "apiConfiguration":
|
||||
if (message.apiConfiguration) {
|
||||
const {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
glamaModelId,
|
||||
glamaModelInfo,
|
||||
glamaApiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
azureApiVersion,
|
||||
openAiStreamingEnabled,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterUseMiddleOutTransform,
|
||||
} = message.apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
await this.storeSecret("apiKey", apiKey)
|
||||
await this.updateGlobalState("glamaModelId", glamaModelId)
|
||||
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
|
||||
await this.storeSecret("glamaApiKey", glamaApiKey)
|
||||
await this.storeSecret("openRouterApiKey", openRouterApiKey)
|
||||
await this.storeSecret("awsAccessKey", awsAccessKey)
|
||||
await this.storeSecret("awsSecretKey", awsSecretKey)
|
||||
await this.storeSecret("awsSessionToken", awsSessionToken)
|
||||
await this.updateGlobalState("awsRegion", awsRegion)
|
||||
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
|
||||
await this.updateGlobalState("vertexProjectId", vertexProjectId)
|
||||
await this.updateGlobalState("vertexRegion", vertexRegion)
|
||||
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
|
||||
await this.storeSecret("openAiApiKey", openAiApiKey)
|
||||
await this.updateGlobalState("openAiModelId", openAiModelId)
|
||||
await this.updateGlobalState("ollamaModelId", ollamaModelId)
|
||||
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
|
||||
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
|
||||
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
|
||||
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
|
||||
await this.storeSecret("geminiApiKey", geminiApiKey)
|
||||
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
|
||||
await this.storeSecret("deepSeekApiKey", message.apiConfiguration.deepSeekApiKey)
|
||||
await this.updateGlobalState("azureApiVersion", azureApiVersion)
|
||||
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
|
||||
await this.updateGlobalState("openRouterModelId", openRouterModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
|
||||
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(message.apiConfiguration)
|
||||
}
|
||||
await this.updateApiConfiguration(message.apiConfiguration)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
@@ -566,7 +559,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
if (message?.values?.baseUrl && message?.values?.apiKey) {
|
||||
const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey)
|
||||
this.postMessageToWebview({ type: "openAiModels", openAiModels })
|
||||
}
|
||||
}
|
||||
break
|
||||
case "openImage":
|
||||
openImage(message.text!)
|
||||
@@ -805,6 +798,113 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
break
|
||||
}
|
||||
case "upsertApiConfiguration":
|
||||
if (message.text && message.apiConfiguration) {
|
||||
try {
|
||||
await this.configManager.SaveConfig(message.text, message.apiConfiguration);
|
||||
|
||||
let listApiConfig = await this.configManager.ListConfig();
|
||||
|
||||
await Promise.all([
|
||||
this.updateApiConfiguration(message.apiConfiguration),
|
||||
this.updateGlobalState("currentApiConfigName", message.text),
|
||||
this.updateGlobalState("listApiConfigMeta", listApiConfig),
|
||||
])
|
||||
|
||||
this.postStateToWebview()
|
||||
} catch (error) {
|
||||
console.error("Error create new api configuration:", error)
|
||||
vscode.window.showErrorMessage("Failed to create api configuration")
|
||||
}
|
||||
}
|
||||
break
|
||||
case "renameApiConfiguration":
|
||||
if (message.values && message.apiConfiguration) {
|
||||
try {
|
||||
|
||||
const { oldName, newName } = message.values
|
||||
|
||||
await this.configManager.SaveConfig(newName, message.apiConfiguration);
|
||||
|
||||
await this.configManager.DeleteConfig(oldName)
|
||||
|
||||
let listApiConfig = await this.configManager.ListConfig();
|
||||
|
||||
await Promise.all([
|
||||
this.updateGlobalState("currentApiConfigName", newName),
|
||||
this.updateGlobalState("listApiConfigMeta", listApiConfig),
|
||||
])
|
||||
|
||||
this.postStateToWebview()
|
||||
} catch (error) {
|
||||
console.error("Error create new api configuration:", error)
|
||||
vscode.window.showErrorMessage("Failed to create api configuration")
|
||||
}
|
||||
}
|
||||
break
|
||||
case "loadApiConfiguration":
|
||||
if (message.text) {
|
||||
try {
|
||||
const apiConfig = await this.configManager.LoadConfig(message.text);
|
||||
|
||||
await Promise.all([
|
||||
this.updateGlobalState("currentApiConfigName", message.text),
|
||||
this.updateApiConfiguration(apiConfig),
|
||||
])
|
||||
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
console.error("Error load api configuration:", error)
|
||||
vscode.window.showErrorMessage("Failed to load api configuration")
|
||||
}
|
||||
}
|
||||
break
|
||||
case "deleteApiConfiguration":
|
||||
if (message.text) {
|
||||
|
||||
const answer = await vscode.window.showInformationMessage(
|
||||
"Are you sure you want to delete this configuration profile?",
|
||||
{ modal: true },
|
||||
"Yes",
|
||||
)
|
||||
|
||||
if (answer !== "Yes") {
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
await this.configManager.DeleteConfig(message.text);
|
||||
let listApiConfig = await this.configManager.ListConfig()
|
||||
let currentApiConfigName = await this.getGlobalState("currentApiConfigName")
|
||||
|
||||
if (message.text === currentApiConfigName) {
|
||||
await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
|
||||
if (listApiConfig?.[0]?.name) {
|
||||
const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name);
|
||||
|
||||
await Promise.all([
|
||||
this.updateGlobalState("listApiConfigMeta", listApiConfig),
|
||||
this.updateApiConfiguration(apiConfig),
|
||||
])
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error delete api configuration:", error)
|
||||
vscode.window.showErrorMessage("Failed to delete api configuration")
|
||||
}
|
||||
}
|
||||
break
|
||||
case "getListApiConfiguration":
|
||||
try {
|
||||
let listApiConfig = await this.configManager.ListConfig();
|
||||
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
|
||||
this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
|
||||
} catch (error) {
|
||||
console.error("Error get list api configuration:", error)
|
||||
vscode.window.showErrorMessage("Failed to get list api configuration")
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
null,
|
||||
@@ -812,6 +912,74 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private async updateApiConfiguration(apiConfiguration: ApiConfiguration) {
|
||||
const {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
glamaModelId,
|
||||
glamaModelInfo,
|
||||
glamaApiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
azureApiVersion,
|
||||
openAiStreamingEnabled,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterUseMiddleOutTransform,
|
||||
} = apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
await this.storeSecret("apiKey", apiKey)
|
||||
await this.updateGlobalState("glamaModelId", glamaModelId)
|
||||
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
|
||||
await this.storeSecret("glamaApiKey", glamaApiKey)
|
||||
await this.storeSecret("openRouterApiKey", openRouterApiKey)
|
||||
await this.storeSecret("awsAccessKey", awsAccessKey)
|
||||
await this.storeSecret("awsSecretKey", awsSecretKey)
|
||||
await this.storeSecret("awsSessionToken", awsSessionToken)
|
||||
await this.updateGlobalState("awsRegion", awsRegion)
|
||||
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
|
||||
await this.updateGlobalState("vertexProjectId", vertexProjectId)
|
||||
await this.updateGlobalState("vertexRegion", vertexRegion)
|
||||
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
|
||||
await this.storeSecret("openAiApiKey", openAiApiKey)
|
||||
await this.updateGlobalState("openAiModelId", openAiModelId)
|
||||
await this.updateGlobalState("ollamaModelId", ollamaModelId)
|
||||
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
|
||||
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
|
||||
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
|
||||
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
|
||||
await this.storeSecret("geminiApiKey", geminiApiKey)
|
||||
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
|
||||
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
|
||||
await this.updateGlobalState("azureApiVersion", azureApiVersion)
|
||||
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
|
||||
await this.updateGlobalState("openRouterModelId", openRouterModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
|
||||
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(apiConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
async updateCustomInstructions(instructions?: string) {
|
||||
// User may be clearing the field
|
||||
await this.updateGlobalState("customInstructions", instructions || undefined)
|
||||
@@ -1256,8 +1424,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
mcpEnabled,
|
||||
alwaysApproveResubmit,
|
||||
requestDelaySeconds,
|
||||
currentApiConfigName,
|
||||
listApiConfigMeta,
|
||||
} = await this.getState()
|
||||
|
||||
|
||||
const allowedCommands = vscode.workspace
|
||||
.getConfiguration('roo-cline')
|
||||
.get<string[]>('allowedCommands') || []
|
||||
@@ -1290,6 +1460,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
mcpEnabled: mcpEnabled ?? true,
|
||||
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
|
||||
requestDelaySeconds: requestDelaySeconds ?? 5,
|
||||
currentApiConfigName: currentApiConfigName ?? "default",
|
||||
listApiConfigMeta: listApiConfigMeta ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,6 +1569,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
mcpEnabled,
|
||||
alwaysApproveResubmit,
|
||||
requestDelaySeconds,
|
||||
currentApiConfigName,
|
||||
listApiConfigMeta,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("apiModelId") as Promise<string | undefined>,
|
||||
@@ -1449,6 +1623,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
|
||||
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
|
||||
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
@@ -1545,6 +1721,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
mcpEnabled: mcpEnabled ?? true,
|
||||
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
|
||||
requestDelaySeconds: requestDelaySeconds ?? 5,
|
||||
currentApiConfigName: currentApiConfigName ?? "default",
|
||||
listApiConfigMeta: listApiConfigMeta ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
src/shared/checkExistApiConfig.ts
Normal file
19
src/shared/checkExistApiConfig.ts
Normal 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;
|
||||
}
|
||||
@@ -44,9 +44,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { filePaths, apiConfiguration } = useExtensionState()
|
||||
const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [gitCommits, setGitCommits] = useState<any[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (showDropdown) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [showDropdown])
|
||||
|
||||
// Handle enhanced prompt response
|
||||
useEffect(() => {
|
||||
@@ -649,13 +661,62 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
style={{
|
||||
position: "absolute",
|
||||
paddingTop: 4,
|
||||
bottom: 14,
|
||||
bottom: 32,
|
||||
left: 22,
|
||||
right: 67,
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(listApiConfigMeta || []).length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 25,
|
||||
bottom: 14,
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
<select
|
||||
value={currentApiConfigName}
|
||||
disabled={textAreaDisabled}
|
||||
onChange={(e) => vscode.postMessage({
|
||||
type: "loadApiConfiguration",
|
||||
text: e.target.value
|
||||
})}
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
cursor: textAreaDisabled ? "not-allowed" : "pointer",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "var(--vscode-input-foreground)",
|
||||
opacity: textAreaDisabled ? 0.5 : 0.6,
|
||||
outline: "none",
|
||||
paddingLeft: 14,
|
||||
WebkitAppearance: "none",
|
||||
MozAppearance: "none",
|
||||
appearance: "none",
|
||||
backgroundImage: "url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\")",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "left 0px center",
|
||||
backgroundSize: "10px"
|
||||
}}
|
||||
>
|
||||
{(listApiConfigMeta || [])?.map((config) => (
|
||||
<option
|
||||
key={config.name}
|
||||
value={config.name}
|
||||
style={{
|
||||
backgroundColor: "var(--vscode-dropdown-background)",
|
||||
color: "var(--vscode-dropdown-foreground)"
|
||||
}}
|
||||
>
|
||||
{config.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="button-row" style={{ position: "absolute", right: 20, display: "flex", alignItems: "center", height: 31, bottom: 8, zIndex: 2, justifyContent: "flex-end" }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{apiConfiguration?.apiProvider === "openrouter" && (
|
||||
|
||||
@@ -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
|
||||
<div style={{ padding: "0 20px", flexShrink: 0 }}>
|
||||
<h2>What can I do for you?</h2>
|
||||
<p>
|
||||
Thanks to{" "}
|
||||
<VSCodeLink
|
||||
href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
|
||||
style={{ display: "inline" }}>
|
||||
Claude 3.5 Sonnet's agentic coding capabilities,
|
||||
</VSCodeLink>{" "}
|
||||
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
|
||||
|
||||
225
webview-ui/src/components/settings/ApiConfigManager.tsx
Normal file
225
webview-ui/src/components/settings/ApiConfigManager.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
|
||||
|
||||
interface ApiConfigManagerProps {
|
||||
currentApiConfigName?: string
|
||||
listApiConfigMeta?: ApiConfigMeta[]
|
||||
onSelectConfig: (configName: string) => void
|
||||
onDeleteConfig: (configName: string) => void
|
||||
onRenameConfig: (oldName: string, newName: string) => void
|
||||
onUpsertConfig: (configName: string) => void
|
||||
}
|
||||
|
||||
const ApiConfigManager = ({
|
||||
currentApiConfigName = "",
|
||||
listApiConfigMeta = [],
|
||||
onSelectConfig,
|
||||
onDeleteConfig,
|
||||
onRenameConfig,
|
||||
onUpsertConfig,
|
||||
}: ApiConfigManagerProps) => {
|
||||
const [editState, setEditState] = useState<'new' | 'rename' | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
|
||||
// Focus input when entering edit mode
|
||||
useEffect(() => {
|
||||
if (editState) {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [editState]);
|
||||
|
||||
// Reset edit state when current profile changes
|
||||
useEffect(() => {
|
||||
setEditState(null);
|
||||
setInputValue("");
|
||||
}, [currentApiConfigName]);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newConfigName = currentApiConfigName + " (copy)";
|
||||
onUpsertConfig(newConfigName);
|
||||
};
|
||||
|
||||
const handleStartRename = () => {
|
||||
setEditState('rename');
|
||||
setInputValue(currentApiConfigName || "");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditState(null);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
if (!trimmedValue) return;
|
||||
|
||||
if (editState === 'new') {
|
||||
onUpsertConfig(trimmedValue);
|
||||
} else if (editState === 'rename' && currentApiConfigName) {
|
||||
onRenameConfig(currentApiConfigName, trimmedValue);
|
||||
}
|
||||
|
||||
setEditState(null);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return;
|
||||
|
||||
// Let the extension handle both deletion and selection
|
||||
onDeleteConfig(currentApiConfigName);
|
||||
};
|
||||
|
||||
const isOnlyProfile = listApiConfigMeta?.length === 1;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px"
|
||||
}}>
|
||||
<label htmlFor="config-profile">
|
||||
<span style={{ fontWeight: "500" }}>Configuration Profile</span>
|
||||
</label>
|
||||
|
||||
{editState ? (
|
||||
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<VSCodeTextField
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
disabled={!inputValue.trim()}
|
||||
onClick={handleSave}
|
||||
title="Save"
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
minWidth: '28px'
|
||||
}}
|
||||
>
|
||||
<span className="codicon codicon-check" />
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={handleCancel}
|
||||
title="Cancel"
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
minWidth: '28px'
|
||||
}}
|
||||
>
|
||||
<span className="codicon codicon-close" />
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<select
|
||||
id="config-profile"
|
||||
value={currentApiConfigName}
|
||||
onChange={(e) => onSelectConfig(e.target.value)}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
padding: "4px 8px",
|
||||
paddingRight: "24px",
|
||||
backgroundColor: "var(--vscode-dropdown-background)",
|
||||
color: "var(--vscode-dropdown-foreground)",
|
||||
border: "1px solid var(--vscode-dropdown-border)",
|
||||
borderRadius: "2px",
|
||||
height: "28px",
|
||||
cursor: "pointer",
|
||||
outline: "none"
|
||||
}}
|
||||
>
|
||||
{listApiConfigMeta?.map((config) => (
|
||||
<option
|
||||
key={config.name}
|
||||
value={config.name}
|
||||
>
|
||||
{config.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={handleAdd}
|
||||
title="Add profile"
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
minWidth: '28px'
|
||||
}}
|
||||
>
|
||||
<span className="codicon codicon-add" />
|
||||
</VSCodeButton>
|
||||
{currentApiConfigName && (
|
||||
<>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={handleStartRename}
|
||||
title="Rename profile"
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
minWidth: '28px'
|
||||
}}
|
||||
>
|
||||
<span className="codicon codicon-edit" />
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={handleDelete}
|
||||
title={isOnlyProfile ? "Cannot delete the only profile" : "Delete profile"}
|
||||
disabled={isOnlyProfile}
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
minWidth: '28px'
|
||||
}}
|
||||
>
|
||||
<span className="codicon codicon-trash" />
|
||||
</VSCodeButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: "12px",
|
||||
margin: "5px 0 12px",
|
||||
color: "var(--vscode-descriptionForeground)"
|
||||
}}>
|
||||
Save different API configurations to quickly switch between providers and settings
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ApiConfigManager)
|
||||
@@ -43,13 +43,12 @@ import OpenAiModelPicker from "./OpenAiModelPicker"
|
||||
import GlamaModelPicker from "./GlamaModelPicker"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
showModelOptions: boolean
|
||||
apiErrorMessage?: string
|
||||
modelIdErrorMessage?: string
|
||||
}
|
||||
|
||||
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
|
||||
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
|
||||
const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
|
||||
const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState()
|
||||
const [ollamaModels, setOllamaModels] = useState<string[]>([])
|
||||
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
|
||||
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
|
||||
@@ -57,7 +56,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
|
||||
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
|
||||
setApiConfiguration({ ...apiConfiguration, [field]: event.target.value })
|
||||
const apiConfig = { ...apiConfiguration, [field]: event.target.value }
|
||||
onUpdateApiConfig(apiConfig)
|
||||
setApiConfiguration(apiConfig)
|
||||
}
|
||||
|
||||
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
@@ -693,16 +694,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedProvider === "glama" && showModelOptions && <GlamaModelPicker />}
|
||||
{selectedProvider === "glama" && <GlamaModelPicker />}
|
||||
|
||||
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker />}
|
||||
{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
|
||||
|
||||
{selectedProvider !== "glama" &&
|
||||
selectedProvider !== "openrouter" &&
|
||||
selectedProvider !== "openai" &&
|
||||
selectedProvider !== "ollama" &&
|
||||
selectedProvider !== "lmstudio" &&
|
||||
showModelOptions && (
|
||||
selectedProvider !== "lmstudio" && (
|
||||
<>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="model-id">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -34,6 +37,13 @@ const GlamaModelPicker: React.FC = () => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) {
|
||||
setSearchTerm(apiConfiguration?.glamaModelId)
|
||||
}
|
||||
}, [apiConfiguration, searchTerm])
|
||||
|
||||
useMount(() => {
|
||||
vscode.postMessage({ type: "refreshGlamaModels" })
|
||||
})
|
||||
|
||||
@@ -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,13 +18,22 @@ 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)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) {
|
||||
setSearchTerm(apiConfiguration?.openAiModelId)
|
||||
}
|
||||
}, [apiConfiguration, searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -34,6 +37,12 @@ const OpenRouterModelPicker: React.FC = () => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfiguration?.openRouterModelId && apiConfiguration?.openRouterModelId !== searchTerm) {
|
||||
setSearchTerm(apiConfiguration?.openRouterModelId)
|
||||
}
|
||||
}, [apiConfiguration, searchTerm])
|
||||
|
||||
useMount(() => {
|
||||
vscode.postMessage({ type: "refreshOpenRouterModels" })
|
||||
})
|
||||
|
||||
@@ -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,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
setAlwaysApproveResubmit,
|
||||
requestDelaySeconds,
|
||||
setRequestDelaySeconds,
|
||||
currentApiConfigName,
|
||||
listApiConfigMeta,
|
||||
} = useExtensionState()
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
|
||||
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
|
||||
const [commandInput, setCommandInput] = useState("")
|
||||
|
||||
const handleSubmit = () => {
|
||||
const apiValidationResult = validateApiConfiguration(apiConfiguration)
|
||||
const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
|
||||
@@ -89,6 +93,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()
|
||||
}
|
||||
}
|
||||
@@ -152,8 +163,37 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Provider Settings</h3>
|
||||
<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
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ApiOptions
|
||||
showModelOptions={true}
|
||||
apiErrorMessage={apiErrorMessage}
|
||||
modelIdErrorMessage={modelIdErrorMessage}
|
||||
/>
|
||||
@@ -405,10 +445,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowMcp}
|
||||
onChange={(e: any) => {
|
||||
setAlwaysAllowMcp(e.target.checked)
|
||||
vscode.postMessage({ type: "alwaysAllowMcp", bool: e.target.checked })
|
||||
}}>
|
||||
onChange={(e: any) => setAlwaysAllowMcp(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
|
||||
</VSCodeCheckbox>
|
||||
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import ApiConfigManager from '../ApiConfigManager';
|
||||
|
||||
// Mock VSCode components
|
||||
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
|
||||
VSCodeButton: ({ children, onClick, title, disabled }: any) => (
|
||||
<button onClick={onClick} title={title} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => onInput(e)}
|
||||
placeholder={placeholder}
|
||||
ref={undefined} // Explicitly set ref to undefined to avoid warning
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ApiConfigManager', () => {
|
||||
const mockOnSelectConfig = jest.fn();
|
||||
const mockOnDeleteConfig = jest.fn();
|
||||
const mockOnRenameConfig = jest.fn();
|
||||
const mockOnUpsertConfig = jest.fn();
|
||||
|
||||
const defaultProps = {
|
||||
currentApiConfigName: 'Default Config',
|
||||
listApiConfigMeta: [
|
||||
{ name: 'Default Config' },
|
||||
{ name: 'Another Config' }
|
||||
],
|
||||
onSelectConfig: mockOnSelectConfig,
|
||||
onDeleteConfig: mockOnDeleteConfig,
|
||||
onRenameConfig: mockOnRenameConfig,
|
||||
onUpsertConfig: mockOnUpsertConfig,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('immediately creates a copy when clicking add button', () => {
|
||||
render(<ApiConfigManager {...defaultProps} />);
|
||||
|
||||
// Find and click the add button
|
||||
const addButton = screen.getByTitle('Add profile');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Verify that onUpsertConfig was called with the correct name
|
||||
expect(mockOnUpsertConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnUpsertConfig).toHaveBeenCalledWith('Default Config (copy)');
|
||||
});
|
||||
|
||||
it('creates copy with correct name when current config has spaces', () => {
|
||||
render(
|
||||
<ApiConfigManager
|
||||
{...defaultProps}
|
||||
currentApiConfigName="My Test Config"
|
||||
/>
|
||||
);
|
||||
|
||||
const addButton = screen.getByTitle('Add profile');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockOnUpsertConfig).toHaveBeenCalledWith('My Test Config (copy)');
|
||||
});
|
||||
|
||||
it('handles empty current config name gracefully', () => {
|
||||
render(
|
||||
<ApiConfigManager
|
||||
{...defaultProps}
|
||||
currentApiConfigName=""
|
||||
/>
|
||||
);
|
||||
|
||||
const addButton = screen.getByTitle('Add profile');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockOnUpsertConfig).toHaveBeenCalledWith(' (copy)');
|
||||
});
|
||||
|
||||
it('allows renaming the current config', () => {
|
||||
render(<ApiConfigManager {...defaultProps} />);
|
||||
|
||||
// Start rename
|
||||
const renameButton = screen.getByTitle('Rename profile');
|
||||
fireEvent.click(renameButton);
|
||||
|
||||
// Find input and enter new name
|
||||
const input = screen.getByDisplayValue('Default Config');
|
||||
fireEvent.input(input, { target: { value: 'New Name' } });
|
||||
|
||||
// Save
|
||||
const saveButton = screen.getByTitle('Save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(mockOnRenameConfig).toHaveBeenCalledWith('Default Config', 'New Name');
|
||||
});
|
||||
|
||||
it('allows selecting a different config', () => {
|
||||
render(<ApiConfigManager {...defaultProps} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
fireEvent.change(select, { target: { value: 'Another Config' } });
|
||||
|
||||
expect(mockOnSelectConfig).toHaveBeenCalledWith('Another Config');
|
||||
});
|
||||
|
||||
it('allows deleting the current config when not the only one', () => {
|
||||
render(<ApiConfigManager {...defaultProps} />);
|
||||
|
||||
const deleteButton = screen.getByTitle('Delete profile');
|
||||
expect(deleteButton).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(deleteButton);
|
||||
expect(mockOnDeleteConfig).toHaveBeenCalledWith('Default Config');
|
||||
});
|
||||
|
||||
it('disables delete button when only one config exists', () => {
|
||||
render(
|
||||
<ApiConfigManager
|
||||
{...defaultProps}
|
||||
listApiConfigMeta={[{ name: 'Default Config' }]}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTitle('Cannot delete the only profile');
|
||||
expect(deleteButton).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('cancels rename operation when clicking cancel', () => {
|
||||
render(<ApiConfigManager {...defaultProps} />);
|
||||
|
||||
// Start rename
|
||||
const renameButton = screen.getByTitle('Rename profile');
|
||||
fireEvent.click(renameButton);
|
||||
|
||||
// Find input and enter new name
|
||||
const input = screen.getByDisplayValue('Default Config');
|
||||
fireEvent.input(input, { target: { value: 'New Name' } });
|
||||
|
||||
// Cancel
|
||||
const cancelButton = screen.getByTitle('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Verify rename was not called
|
||||
expect(mockOnRenameConfig).not.toHaveBeenCalled();
|
||||
|
||||
// Verify we're back to normal view
|
||||
expect(screen.queryByDisplayValue('New Name')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { validateApiConfiguration } from "../../utils/validate"
|
||||
@@ -24,21 +24,16 @@ const WelcomeView = () => {
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, padding: "0 20px" }}>
|
||||
<h2>Hi, I'm Cline</h2>
|
||||
<p>
|
||||
I can do all kinds of tasks thanks to the latest breakthroughs in{" "}
|
||||
<VSCodeLink
|
||||
href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
|
||||
style={{ display: "inline" }}>
|
||||
Claude 3.5 Sonnet's agentic coding capabilities
|
||||
</VSCodeLink>{" "}
|
||||
I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities
|
||||
and access to tools that let me create & edit files, explore complex projects, use the browser, and
|
||||
execute terminal commands (with your permission, of course). I can even use MCP to create new tools and
|
||||
extend my own capabilities.
|
||||
</p>
|
||||
|
||||
<b>To get started, this extension needs an API provider for Claude 3.5 Sonnet.</b>
|
||||
<b>To get started, this extension needs an API provider.</b>
|
||||
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<ApiOptions showModelOptions={false} />
|
||||
<ApiOptions />
|
||||
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
|
||||
Let's go!
|
||||
</VSCodeButton>
|
||||
|
||||
@@ -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,9 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
setAlwaysApproveResubmit: (value: boolean) => void
|
||||
requestDelaySeconds: number
|
||||
setRequestDelaySeconds: (value: number) => void
|
||||
setCurrentApiConfigName: (value: string) => void
|
||||
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
|
||||
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
|
||||
}
|
||||
|
||||
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||
@@ -72,7 +78,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 +96,24 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
||||
const [openAiModels, setOpenAiModels] = useState<string[]>([])
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
|
||||
|
||||
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) {
|
||||
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 +167,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 +217,10 @@ 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,
|
||||
onUpdateApiConfig
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user