mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 20:31:37 -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
|
- Drag and drop images into chats
|
||||||
- Delete messages from chats
|
- Delete messages from chats
|
||||||
- @-mention Git commits to include their context in the chat
|
- @-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)
|
- "Enhance prompt" button (OpenRouter models only for now)
|
||||||
- Sound effects for feedback
|
- Sound effects for feedback
|
||||||
- Option to use browsers of different sizes and adjust screenshot quality
|
- 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
|
// Clean conversation history by:
|
||||||
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => ({ role, content }))
|
// 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 stream = this.api.createMessage(systemPrompt, cleanConversationHistory)
|
||||||
const iterator = stream[Symbol.asyncIterator]()
|
const iterator = stream[Symbol.asyncIterator]()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Cline } from '../Cline';
|
import { Cline } from '../Cline';
|
||||||
import { ClineProvider } from '../webview/ClineProvider';
|
import { ClineProvider } from '../webview/ClineProvider';
|
||||||
import { ApiConfiguration } from '../../shared/api';
|
import { ApiConfiguration, ModelInfo } from '../../shared/api';
|
||||||
import { ApiStreamChunk } from '../../api/transform/stream';
|
import { ApiStreamChunk } from '../../api/transform/stream';
|
||||||
|
import { Anthropic } from '@anthropic-ai/sdk';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
// Mock all MCP-related modules
|
// Mock all MCP-related modules
|
||||||
@@ -498,6 +499,133 @@ describe('Cline', () => {
|
|||||||
expect(passedMessage).not.toHaveProperty('ts');
|
expect(passedMessage).not.toHaveProperty('ts');
|
||||||
expect(passedMessage).not.toHaveProperty('extraProp');
|
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 { 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
|
||||||
@@ -85,6 +87,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 +108,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 +118,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -235,7 +242,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||||||
diffEnabled,
|
diffEnabled,
|
||||||
fuzzyMatchThreshold
|
fuzzyMatchThreshold
|
||||||
} = await this.getState()
|
} = await this.getState()
|
||||||
|
|
||||||
this.cline = new Cline(
|
this.cline = new Cline(
|
||||||
this,
|
this,
|
||||||
apiConfiguration,
|
apiConfiguration,
|
||||||
@@ -255,7 +262,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||||||
diffEnabled,
|
diffEnabled,
|
||||||
fuzzyMatchThreshold
|
fuzzyMatchThreshold
|
||||||
} = await this.getState()
|
} = await this.getState()
|
||||||
|
|
||||||
this.cline = new Cline(
|
this.cline = new Cline(
|
||||||
this,
|
this,
|
||||||
apiConfiguration,
|
apiConfiguration,
|
||||||
@@ -321,15 +328,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 +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
|
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 +480,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
|
||||||
@@ -566,7 +559,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||||||
if (message?.values?.baseUrl && message?.values?.apiKey) {
|
if (message?.values?.baseUrl && message?.values?.apiKey) {
|
||||||
const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey)
|
const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey)
|
||||||
this.postMessageToWebview({ type: "openAiModels", openAiModels })
|
this.postMessageToWebview({ type: "openAiModels", openAiModels })
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "openImage":
|
case "openImage":
|
||||||
openImage(message.text!)
|
openImage(message.text!)
|
||||||
@@ -805,6 +798,113 @@ 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.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,
|
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) {
|
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,8 +1424,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||||||
mcpEnabled,
|
mcpEnabled,
|
||||||
alwaysApproveResubmit,
|
alwaysApproveResubmit,
|
||||||
requestDelaySeconds,
|
requestDelaySeconds,
|
||||||
|
currentApiConfigName,
|
||||||
|
listApiConfigMeta,
|
||||||
} = await this.getState()
|
} = await this.getState()
|
||||||
|
|
||||||
const allowedCommands = vscode.workspace
|
const allowedCommands = vscode.workspace
|
||||||
.getConfiguration('roo-cline')
|
.getConfiguration('roo-cline')
|
||||||
.get<string[]>('allowedCommands') || []
|
.get<string[]>('allowedCommands') || []
|
||||||
@@ -1290,6 +1460,8 @@ 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 ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1397,6 +1569,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||||||
mcpEnabled,
|
mcpEnabled,
|
||||||
alwaysApproveResubmit,
|
alwaysApproveResubmit,
|
||||||
requestDelaySeconds,
|
requestDelaySeconds,
|
||||||
|
currentApiConfigName,
|
||||||
|
listApiConfigMeta,
|
||||||
] = 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 +1623,8 @@ 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>,
|
||||||
])
|
])
|
||||||
|
|
||||||
let apiProvider: ApiProvider
|
let apiProvider: ApiProvider
|
||||||
@@ -1545,6 +1721,8 @@ 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 ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { filePaths, apiConfiguration } = useExtensionState()
|
const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState()
|
||||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||||
const [gitCommits, setGitCommits] = useState<any[]>([])
|
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
|
// Handle enhanced prompt response
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -649,13 +661,62 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
paddingTop: 4,
|
paddingTop: 4,
|
||||||
bottom: 14,
|
bottom: 32,
|
||||||
left: 22,
|
left: 22,
|
||||||
right: 67,
|
right: 67,
|
||||||
zIndex: 2,
|
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" }}>
|
<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 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
{apiConfiguration?.apiProvider === "openrouter" && (
|
{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 debounce from "debounce"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
|
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
|
||||||
@@ -868,12 +868,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||||||
<div style={{ padding: "0 20px", flexShrink: 0 }}>
|
<div style={{ padding: "0 20px", flexShrink: 0 }}>
|
||||||
<h2>What can I do for you?</h2>
|
<h2>What can I do for you?</h2>
|
||||||
<p>
|
<p>
|
||||||
Thanks to{" "}
|
Thanks to the latest breakthroughs in agentic coding capabilities,
|
||||||
<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 handle complex software development tasks step-by-step. With tools that let me create
|
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
|
& 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
|
(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"
|
import GlamaModelPicker from "./GlamaModelPicker"
|
||||||
|
|
||||||
interface ApiOptionsProps {
|
interface ApiOptionsProps {
|
||||||
showModelOptions: boolean
|
|
||||||
apiErrorMessage?: string
|
apiErrorMessage?: string
|
||||||
modelIdErrorMessage?: string
|
modelIdErrorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
|
const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
|
||||||
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
|
const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState()
|
||||||
const [ollamaModels, setOllamaModels] = useState<string[]>([])
|
const [ollamaModels, setOllamaModels] = useState<string[]>([])
|
||||||
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
|
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
|
||||||
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
|
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
|
||||||
@@ -57,7 +56,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||||
|
|
||||||
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
|
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(() => {
|
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||||
@@ -693,16 +694,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedProvider === "glama" && showModelOptions && <GlamaModelPicker />}
|
{selectedProvider === "glama" && <GlamaModelPicker />}
|
||||||
|
|
||||||
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker />}
|
{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
|
||||||
|
|
||||||
{selectedProvider !== "glama" &&
|
{selectedProvider !== "glama" &&
|
||||||
selectedProvider !== "openrouter" &&
|
selectedProvider !== "openrouter" &&
|
||||||
selectedProvider !== "openai" &&
|
selectedProvider !== "openai" &&
|
||||||
selectedProvider !== "ollama" &&
|
selectedProvider !== "ollama" &&
|
||||||
selectedProvider !== "lmstudio" &&
|
selectedProvider !== "lmstudio" && (
|
||||||
showModelOptions && (
|
|
||||||
<>
|
<>
|
||||||
<div className="dropdown-container">
|
<div className="dropdown-container">
|
||||||
<label htmlFor="model-id">
|
<label htmlFor="model-id">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView"
|
|||||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||||
|
|
||||||
const GlamaModelPicker: React.FC = () => {
|
const GlamaModelPicker: React.FC = () => {
|
||||||
const { apiConfiguration, setApiConfiguration, glamaModels } = useExtensionState()
|
const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState()
|
||||||
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId)
|
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId)
|
||||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
@@ -22,11 +22,14 @@ const GlamaModelPicker: React.FC = () => {
|
|||||||
|
|
||||||
const handleModelChange = (newModelId: string) => {
|
const handleModelChange = (newModelId: string) => {
|
||||||
// could be setting invalid model id/undefined info but validation will catch it
|
// could be setting invalid model id/undefined info but validation will catch it
|
||||||
setApiConfiguration({
|
const apiConfig = {
|
||||||
...apiConfiguration,
|
...apiConfiguration,
|
||||||
glamaModelId: newModelId,
|
glamaModelId: newModelId,
|
||||||
glamaModelInfo: glamaModels[newModelId],
|
glamaModelInfo: glamaModels[newModelId],
|
||||||
})
|
}
|
||||||
|
setApiConfiguration(apiConfig)
|
||||||
|
onUpdateApiConfig(apiConfig)
|
||||||
|
|
||||||
setSearchTerm(newModelId)
|
setSearchTerm(newModelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,13 @@ const GlamaModelPicker: React.FC = () => {
|
|||||||
return normalizeApiConfiguration(apiConfiguration)
|
return normalizeApiConfiguration(apiConfiguration)
|
||||||
}, [apiConfiguration])
|
}, [apiConfiguration])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) {
|
||||||
|
setSearchTerm(apiConfiguration?.glamaModelId)
|
||||||
|
}
|
||||||
|
}, [apiConfiguration, searchTerm])
|
||||||
|
|
||||||
useMount(() => {
|
useMount(() => {
|
||||||
vscode.postMessage({ type: "refreshGlamaModels" })
|
vscode.postMessage({ type: "refreshGlamaModels" })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { vscode } from "../../utils/vscode"
|
|||||||
import { highlight } from "../history/HistoryView"
|
import { highlight } from "../history/HistoryView"
|
||||||
|
|
||||||
const OpenAiModelPicker: React.FC = () => {
|
const OpenAiModelPicker: React.FC = () => {
|
||||||
const { apiConfiguration, setApiConfiguration, openAiModels } = useExtensionState()
|
const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
|
||||||
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "")
|
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "")
|
||||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
@@ -18,13 +18,22 @@ const OpenAiModelPicker: React.FC = () => {
|
|||||||
|
|
||||||
const handleModelChange = (newModelId: string) => {
|
const handleModelChange = (newModelId: string) => {
|
||||||
// could be setting invalid model id/undefined info but validation will catch it
|
// could be setting invalid model id/undefined info but validation will catch it
|
||||||
setApiConfiguration({
|
const apiConfig = {
|
||||||
...apiConfiguration,
|
...apiConfiguration,
|
||||||
openAiModelId: newModelId,
|
openAiModelId: newModelId,
|
||||||
})
|
}
|
||||||
|
setApiConfiguration(apiConfig)
|
||||||
|
onUpdateApiConfig(apiConfig)
|
||||||
|
|
||||||
setSearchTerm(newModelId)
|
setSearchTerm(newModelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) {
|
||||||
|
setSearchTerm(apiConfiguration?.openAiModelId)
|
||||||
|
}
|
||||||
|
}, [apiConfiguration, searchTerm])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
|
if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView"
|
|||||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||||
|
|
||||||
const OpenRouterModelPicker: React.FC = () => {
|
const OpenRouterModelPicker: React.FC = () => {
|
||||||
const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState()
|
const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState()
|
||||||
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
|
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
|
||||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
@@ -22,11 +22,14 @@ const OpenRouterModelPicker: React.FC = () => {
|
|||||||
|
|
||||||
const handleModelChange = (newModelId: string) => {
|
const handleModelChange = (newModelId: string) => {
|
||||||
// could be setting invalid model id/undefined info but validation will catch it
|
// could be setting invalid model id/undefined info but validation will catch it
|
||||||
setApiConfiguration({
|
const apiConfig = {
|
||||||
...apiConfiguration,
|
...apiConfiguration,
|
||||||
openRouterModelId: newModelId,
|
openRouterModelId: newModelId,
|
||||||
openRouterModelInfo: openRouterModels[newModelId],
|
openRouterModelInfo: openRouterModels[newModelId],
|
||||||
})
|
}
|
||||||
|
|
||||||
|
setApiConfiguration(apiConfig)
|
||||||
|
onUpdateApiConfig(apiConfig)
|
||||||
setSearchTerm(newModelId)
|
setSearchTerm(newModelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,12 @@ const OpenRouterModelPicker: React.FC = () => {
|
|||||||
return normalizeApiConfiguration(apiConfiguration)
|
return normalizeApiConfiguration(apiConfiguration)
|
||||||
}, [apiConfiguration])
|
}, [apiConfiguration])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (apiConfiguration?.openRouterModelId && apiConfiguration?.openRouterModelId !== searchTerm) {
|
||||||
|
setSearchTerm(apiConfiguration?.openRouterModelId)
|
||||||
|
}
|
||||||
|
}, [apiConfiguration, searchTerm])
|
||||||
|
|
||||||
useMount(() => {
|
useMount(() => {
|
||||||
vscode.postMessage({ type: "refreshOpenRouterModels" })
|
vscode.postMessage({ type: "refreshOpenRouterModels" })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,13 @@ 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 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 +93,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,8 +163,37 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
|||||||
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 }}>
|
<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>
|
||||||
|
<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
|
<ApiOptions
|
||||||
showModelOptions={true}
|
|
||||||
apiErrorMessage={apiErrorMessage}
|
apiErrorMessage={apiErrorMessage}
|
||||||
modelIdErrorMessage={modelIdErrorMessage}
|
modelIdErrorMessage={modelIdErrorMessage}
|
||||||
/>
|
/>
|
||||||
@@ -405,10 +445,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
|||||||
<div style={{ marginBottom: 5 }}>
|
<div style={{ marginBottom: 5 }}>
|
||||||
<VSCodeCheckbox
|
<VSCodeCheckbox
|
||||||
checked={alwaysAllowMcp}
|
checked={alwaysAllowMcp}
|
||||||
onChange={(e: any) => {
|
onChange={(e: any) => setAlwaysAllowMcp(e.target.checked)}>
|
||||||
setAlwaysAllowMcp(e.target.checked)
|
|
||||||
vscode.postMessage({ type: "alwaysAllowMcp", bool: e.target.checked })
|
|
||||||
}}>
|
|
||||||
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
|
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
|
||||||
</VSCodeCheckbox>
|
</VSCodeCheckbox>
|
||||||
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
|
<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
|
// 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()
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from "react"
|
||||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||||
import { validateApiConfiguration } from "../../utils/validate"
|
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" }}>
|
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, padding: "0 20px" }}>
|
||||||
<h2>Hi, I'm Cline</h2>
|
<h2>Hi, I'm Cline</h2>
|
||||||
<p>
|
<p>
|
||||||
I can do all kinds of tasks thanks to the latest breakthroughs in{" "}
|
I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities
|
||||||
<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>{" "}
|
|
||||||
and access to tools that let me create & edit files, explore complex projects, use the browser, and
|
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
|
execute terminal commands (with your permission, of course). I can even use MCP to create new tools and
|
||||||
extend my own capabilities.
|
extend my own capabilities.
|
||||||
</p>
|
</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" }}>
|
<div style={{ marginTop: "10px" }}>
|
||||||
<ApiOptions showModelOptions={false} />
|
<ApiOptions />
|
||||||
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
|
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
|
||||||
Let's go!
|
Let's go!
|
||||||
</VSCodeButton>
|
</VSCodeButton>
|
||||||
|
|||||||
@@ -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,9 @@ 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
|
||||||
|
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||||
@@ -72,7 +78,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 +96,24 @@ 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 onUpdateApiConfig = useCallback((apiConfig: ApiConfiguration) => {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: "upsertApiConfiguration",
|
||||||
|
text: state.currentApiConfigName,
|
||||||
|
apiConfiguration: apiConfig,
|
||||||
|
})
|
||||||
|
}, [state])
|
||||||
|
|
||||||
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 +167,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 +217,10 @@ 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,
|
||||||
|
onUpdateApiConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||||
|
|||||||
Reference in New Issue
Block a user