diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 75f5510d37..137fc6b564 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -39,6 +39,7 @@ fun IdeaBottomToolbar( currentConfigName: String? = null, onConfigSelect: (NamedModelConfig) -> Unit = {}, onConfigureClick: () -> Unit = {}, + onAddNewConfig: () -> Unit = {}, modifier: Modifier = Modifier ) { Row( @@ -59,7 +60,8 @@ fun IdeaBottomToolbar( availableConfigs = availableConfigs, currentConfigName = currentConfigName, onConfigSelect = onConfigSelect, - onConfigureClick = onConfigureClick + onConfigureClick = onConfigureClick, + onAddNewConfig = onAddNewConfig ) // Token usage indicator (subtle) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt index 1c54a7d10f..05b41cd2df 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt @@ -35,6 +35,7 @@ fun IdeaModelSelector( currentConfigName: String?, onConfigSelect: (NamedModelConfig) -> Unit, onConfigureClick: () -> Unit, + onAddNewConfig: () -> Unit, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(false) } @@ -131,7 +132,32 @@ fun IdeaModelSelector( separator() } - // Configure button + // Add New Config button + selectableItem( + selected = false, + onClick = { + onAddNewConfig() + expanded = false + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + Text( + text = "Add New Config", + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp) + ) + } + } + + // Configure button (edit current config) selectableItem( selected = false, onClick = { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/SwingBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/SwingBottomToolbar.kt index caa55a89a9..887263c60d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/SwingBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/SwingBottomToolbar.kt @@ -32,6 +32,7 @@ class SwingBottomToolbar( private var availableConfigs: List = emptyList() private var onConfigSelect: (NamedModelConfig) -> Unit = {} private var onConfigureClick: () -> Unit = {} + private var onAddNewConfig: () -> Unit = {} private var isProcessing = false private var isEnhancing = false @@ -52,6 +53,16 @@ class SwingBottomToolbar( } add(modelComboBox) + // Add New Config button + val addConfigButton = JButton(AllIcons.General.Add).apply { + toolTipText = "Add New Config" + preferredSize = Dimension(28, 28) + isBorderPainted = false + isContentAreaFilled = false + addActionListener { onAddNewConfig() } + } + add(addConfigButton) + tokenLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() add(tokenLabel) } @@ -138,5 +149,9 @@ class SwingBottomToolbar( fun setOnConfigureClick(callback: () -> Unit) { onConfigureClick = callback } + + fun setOnAddNewConfig(callback: () -> Unit) { + onAddNewConfig = callback + } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index d38a6f408f..b50c9407b1 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -65,6 +65,7 @@ fun IdeaAgentApp( var isExecuting by remember { mutableStateOf(false) } var currentPlan by remember { mutableStateOf(null) } var showConfigDialog by remember { mutableStateOf(false) } + var isNewConfig by remember { mutableStateOf(false) } var mcpPreloadingMessage by remember { mutableStateOf("") } var configWrapper by remember { mutableStateOf(null) } var currentModelConfig by remember { mutableStateOf(null) } @@ -89,6 +90,9 @@ fun IdeaAgentApp( IdeaLaunchedEffect(viewModel, project = project) { viewModel.showConfigDialog.collect { showConfigDialog = it } } + IdeaLaunchedEffect(viewModel, project = project) { + viewModel.isNewConfig.collect { isNewConfig = it } + } IdeaLaunchedEffect(viewModel, project = project) { viewModel.mcpPreloadingMessage.collect { mcpPreloadingMessage = it } } @@ -217,7 +221,8 @@ fun IdeaAgentApp( viewModel.setActiveConfig(config.name) }, currentPlan = currentPlan, - onConfigureClick = { viewModel.setShowConfigDialog(true) } + onConfigureClick = { viewModel.showEditConfigDialog() }, + onAddNewConfig = { viewModel.showAddNewConfigDialog() } ) } ) @@ -266,7 +271,8 @@ fun IdeaAgentApp( onConfigSelect = { config -> viewModel.setActiveConfig(config.name) }, - onConfigureClick = { viewModel.setShowConfigDialog(true) } + onConfigureClick = { viewModel.showEditConfigDialog() }, + onAddNewConfig = { viewModel.showAddNewConfigDialog() } ) } ) @@ -304,12 +310,13 @@ fun IdeaAgentApp( // DialogWrapper must be created on EDT, so we use invokeLater DisposableEffect(showConfigDialog) { if (showConfigDialog) { - val dialogConfig = currentModelConfig ?: ModelConfig() + val dialogConfig = if (isNewConfig) ModelConfig() else (currentModelConfig ?: ModelConfig()) + val dialogConfigName = if (isNewConfig) null else currentConfigName com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { IdeaModelConfigDialogWrapper.show( project = project, currentConfig = dialogConfig, - currentConfigName = currentConfigName, + currentConfigName = dialogConfigName, onSave = { configName, newModelConfig -> // If creating a new config (not editing current), ensure unique name val existingNames = availableConfigs.map { it.name } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index c4635a92c3..222bc1e5ff 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -81,6 +81,8 @@ class IdeaAgentViewModel( // Show config dialog private val _showConfigDialog = MutableStateFlow(false) val showConfigDialog: StateFlow = _showConfigDialog.asStateFlow() + private val _isNewConfig = MutableStateFlow(false) + val isNewConfig: StateFlow = _isNewConfig.asStateFlow() // Current execution job (for cancellation) private var currentJob: Job? = null @@ -587,6 +589,22 @@ class IdeaAgentViewModel( _showConfigDialog.value = show } + /** + * Show config dialog for adding a new config. + */ + fun showAddNewConfigDialog() { + _isNewConfig.value = true + _showConfigDialog.value = true + } + + /** + * Show config dialog for editing current config. + */ + fun showEditConfigDialog() { + _isNewConfig.value = false + _showConfigDialog.value = true + } + /** * Set the active configuration by name */ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 1f8482969d..1b76238025 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -80,6 +80,7 @@ fun IdeaDevInInputArea( currentConfigName: String? = null, onConfigSelect: (NamedModelConfig) -> Unit = {}, onConfigureClick: () -> Unit = {}, + onAddNewConfig: () -> Unit = {}, currentPlan: AgentPlan? = null ) { val scope = rememberIdeaCoroutineScope(project) @@ -116,6 +117,11 @@ fun IdeaDevInInputArea( onDispose { } } + DisposableEffect(onAddNewConfig) { + swingInputArea?.setOnAddNewConfig(onAddNewConfig) + onDispose { } + } + DisposableEffect(currentPlan) { swingInputArea?.setCurrentPlan(currentPlan) onDispose { } @@ -140,6 +146,7 @@ fun IdeaDevInInputArea( it.setCurrentConfigName(currentConfigName) it.setOnConfigSelect(onConfigSelect) it.setOnConfigureClick(onConfigureClick) + it.setOnAddNewConfig(onAddNewConfig) it.setCurrentPlan(currentPlan) } }, @@ -327,6 +334,10 @@ class SwingDevInInputArea( bottomToolbar.setOnConfigureClick(callback) } + fun setOnAddNewConfig(callback: () -> Unit) { + bottomToolbar.setOnAddNewConfig(callback) + } + fun setCurrentPlan(plan: AgentPlan?) { currentPlan = plan // TODO: Add plan summary bar support diff --git a/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_en.properties b/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_en.properties index f77bf0ce99..495d7044c0 100644 --- a/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_en.properties +++ b/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_en.properties @@ -7,6 +7,7 @@ common.back=Back common.confirm=Confirm common.configure=Configure common.loading=Loading +common.add=Add # Chat UI chat.title=AutoDev @@ -25,6 +26,7 @@ modelConfig.temperature=Temperature modelConfig.maxTokens=Max Tokens modelConfig.advancedParameters=Advanced Parameters modelConfig.configureModel=Configure Model +modelConfig.addNewConfig=Add New Config modelConfig.noSavedConfigs=No saved configurations modelConfig.enterModel=Enter or select model name modelConfig.modelHint=Select from list or type custom model name diff --git a/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_zh.properties b/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_zh.properties index e7bf457831..6a7c0eb3cb 100644 --- a/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_zh.properties +++ b/mpp-ui/src/commonMain/i18n/cc/unitmesh/devins/ui/i18n/AutoDevStrings_zh.properties @@ -7,6 +7,7 @@ common.back=返回 common.confirm=确认 common.configure=配置 common.loading=加载中 +common.add=添加 # Chat UI chat.title=AutoDev @@ -25,6 +26,7 @@ modelConfig.temperature=温度 modelConfig.maxTokens=最大令牌数 modelConfig.advancedParameters=高级参数 modelConfig.configureModel=配置模型... +modelConfig.addNewConfig=新增配置 modelConfig.noSavedConfigs=没有保存的配置 modelConfig.enterModel=输入或选择模型名称 modelConfig.modelHint=从列表中选择或输入自定义模型名称 diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/ModelSelector.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/ModelSelector.kt index 1cd7571616..be6de05eb6 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/ModelSelector.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/ModelSelector.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch fun ModelSelector(onConfigChange: (ModelConfig) -> Unit = {}) { var expanded by remember { mutableStateOf(false) } var showConfigDialog by remember { mutableStateOf(false) } + var isNewConfig by remember { mutableStateOf(false) } var availableConfigs by remember { mutableStateOf>(emptyList()) } var currentConfigName by remember { mutableStateOf(null) } @@ -135,10 +136,28 @@ fun ModelSelector(onConfigChange: (ModelConfig) -> Unit = {}) { HorizontalDivider() } - // Configure button + // Add New Config button + DropdownMenuItem( + text = { Text(Strings.addNewConfig) }, + onClick = { + isNewConfig = true + showConfigDialog = true + expanded = false + }, + leadingIcon = { + Icon( + imageVector = AutoDevComposeIcons.Add, + contentDescription = Strings.add, + modifier = Modifier.size(18.dp) + ) + } + ) + + // Configure button (edit current config) DropdownMenuItem( text = { Text(Strings.configureModel) }, onClick = { + isNewConfig = false showConfigDialog = true expanded = false }, @@ -154,9 +173,12 @@ fun ModelSelector(onConfigChange: (ModelConfig) -> Unit = {}) { if (showConfigDialog) { ModelConfigDialog( - currentConfig = currentConfig?.toModelConfig() ?: ModelConfig(), - currentConfigName = currentConfigName, - onDismiss = { showConfigDialog = false }, + currentConfig = if (isNewConfig) ModelConfig() else (currentConfig?.toModelConfig() ?: ModelConfig()), + currentConfigName = if (isNewConfig) null else currentConfigName, + onDismiss = { + showConfigDialog = false + isNewConfig = false + }, onSave = { configName, newModelConfig -> scope.launch { try { diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/i18n/I18n.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/i18n/I18n.kt index cd3415c975..ab7caa49c1 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/i18n/I18n.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/i18n/I18n.kt @@ -66,6 +66,7 @@ object Strings { val confirm: String get() = AutoDevStrings.common_confirm.toString() val configure: String get() = AutoDevStrings.common_configure.toString() val loading: String get() = AutoDevStrings.common_loading.toString() + val add: String get() = AutoDevStrings.common_add.toString() // Chat UI val chatTitle: String get() = AutoDevStrings.chat_title.toString() @@ -85,6 +86,7 @@ object Strings { val maxTokens: String get() = AutoDevStrings.modelConfig_maxTokens.toString() val advancedParameters: String get() = AutoDevStrings.modelConfig_advancedParameters.toString() val configureModel: String get() = AutoDevStrings.modelConfig_configureModel.toString() + val addNewConfig: String get() = AutoDevStrings.modelConfig_addNewConfig.toString() val noSavedConfigs: String get() = AutoDevStrings.modelConfig_noSavedConfigs.toString() val enterModel: String get() = AutoDevStrings.modelConfig_enterModel.toString() val enterApiKey: String get() = AutoDevStrings.modelConfig_enterApiKey.toString() diff --git a/mpp-vscode/src/providers/chat-view.ts b/mpp-vscode/src/providers/chat-view.ts index 5e7d70a467..72dcd64f95 100644 --- a/mpp-vscode/src/providers/chat-view.ts +++ b/mpp-vscode/src/providers/chat-view.ts @@ -174,6 +174,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disp message.data?.completionIndex as number ); break; + case 'saveModelConfig': + // Save model configuration + await this.handleSaveModelConfig(message.data); + break; } }); @@ -192,6 +196,47 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disp } } + /** + * Save model configuration + */ + private async handleSaveModelConfig(data: any): Promise { + try { + const config: LLMConfig = { + name: data.name as string, + provider: data.provider as LLMProvider, + apiKey: data.apiKey as string, + model: data.model as string, + baseUrl: data.baseUrl as string | undefined, + temperature: data.temperature as number | undefined, + maxTokens: data.maxTokens as number | undefined, + }; + + // Check for duplicate names if it's a new config + if (data.isNewConfig) { + const wrapper = await ConfigManager.load(); + const existingNames = wrapper.getAllConfigs().map(c => c.name); + + if (existingNames.includes(config.name)) { + // Generate unique name + config.name = ConfigManager.generateUniqueConfigName(config.name, existingNames); + } + } + + // Save config + await ConfigManager.saveConfig(config, true); + + // Reload config and notify webview + this.configWrapper = await ConfigManager.load(); + this.sendConfigUpdate(); + + vscode.window.showInformationMessage(`Configuration "${config.name}" saved successfully`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to save configuration: ${errorMessage}`); + this.log(`Failed to save model config: ${errorMessage}`); + } + } + /** * Select a different config */ diff --git a/mpp-vscode/src/services/config-manager.ts b/mpp-vscode/src/services/config-manager.ts index 53cfff2dcf..c72d8c7d20 100644 --- a/mpp-vscode/src/services/config-manager.ts +++ b/mpp-vscode/src/services/config-manager.ts @@ -173,5 +173,68 @@ export class ConfigManager { configs: [] }); } + + /** + * Save a single configuration + */ + static async saveConfig(config: LLMConfig, setActive: boolean = false): Promise { + try { + // Ensure directory exists + if (!fs.existsSync(this.CONFIG_DIR)) { + fs.mkdirSync(this.CONFIG_DIR, { recursive: true }); + } + + // Load existing config + let wrapper = await this.load(); + const configFile = wrapper.getConfigFile(); + + // Check if config with same name exists + const existingIndex = configFile.configs.findIndex(c => c.name === config.name); + + if (existingIndex >= 0) { + // Update existing config + configFile.configs[existingIndex] = config; + } else { + // Add new config + configFile.configs.push(config); + } + + // Set as active if requested + if (setActive) { + configFile.active = config.name; + } + + // Ensure active is set if no active config + if (!configFile.active && configFile.configs.length > 0) { + configFile.active = configFile.configs[0].name; + } + + // Write to file + const yamlContent = yaml.stringify(configFile, { + indent: 2, + lineWidth: 0 + }); + fs.writeFileSync(this.CONFIG_FILE, yamlContent, 'utf-8'); + } catch (error) { + throw new Error(`Failed to save config: ${error}`); + } + } + + /** + * Generate unique config name if name already exists + */ + static generateUniqueConfigName(baseName: string, existingNames: string[]): string { + if (!existingNames.includes(baseName)) { + return baseName; + } + + let counter = 1; + let newName = `${baseName}-${counter}`; + while (existingNames.includes(newName)) { + counter++; + newName = `${baseName}-${counter}`; + } + return newName; + } } diff --git a/mpp-vscode/webview/src/App.tsx b/mpp-vscode/webview/src/App.tsx index ad10404a5b..63067264d0 100644 --- a/mpp-vscode/webview/src/App.tsx +++ b/mpp-vscode/webview/src/App.tsx @@ -9,6 +9,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Timeline } from './components/Timeline'; import { ChatInput } from './components/ChatInput'; import { ModelConfig } from './components/ModelSelector'; +import { ModelConfigDialog } from './components/ModelConfigDialog'; import { SelectedFile } from './components/FileChip'; import { CompletionItem } from './components/CompletionPopup'; import { PlanData } from './components/plan'; @@ -74,6 +75,11 @@ const App: React.FC = () => { // Omnibar state const [showOmnibar, setShowOmnibar] = useState(false); + // Model config dialog state + const [showModelConfigDialog, setShowModelConfigDialog] = useState(false); + const [modelConfigDialogConfig, setModelConfigDialogConfig] = useState(null); + const [isNewConfig, setIsNewConfig] = useState(false); + const { postMessage, onMessage, isVSCode } = useVSCode(); // Handle messages from extension @@ -327,8 +333,22 @@ const App: React.FC = () => { // Handle open config const handleOpenConfig = useCallback(() => { - postMessage({ type: 'openConfig' }); - }, [postMessage]); + const currentConfig = configState.availableConfigs.find(c => c.name === configState.currentConfigName); + if (currentConfig) { + setModelConfigDialogConfig(currentConfig); + setIsNewConfig(false); + setShowModelConfigDialog(true); + } else { + postMessage({ type: 'openConfig' }); + } + }, [postMessage, configState]); + + // Handle add new config + const handleAddNewConfig = useCallback(() => { + setModelConfigDialogConfig(null); + setIsNewConfig(true); + setShowModelConfigDialog(true); + }, []); // Handle stop execution const handleStop = useCallback(() => { @@ -470,6 +490,7 @@ const App: React.FC = () => { onStop={handleStop} onConfigSelect={handleConfigSelect} onConfigureClick={handleOpenConfig} + onAddNewConfig={handleAddNewConfig} onMcpConfigClick={handleMcpConfigClick} onPromptOptimize={handlePromptOptimize} onGetCompletions={handleGetCompletions} @@ -484,6 +505,20 @@ const App: React.FC = () => { totalTokens={totalTokens} currentPlan={currentPlan} /> + + {/* Model Config Dialog */} + {showModelConfigDialog && ( + { + setShowModelConfigDialog(false); + setIsNewConfig(false); + setModelConfigDialogConfig(null); + }} + currentConfig={modelConfigDialogConfig || undefined} + isNewConfig={isNewConfig} + /> + )} ); }; diff --git a/mpp-vscode/webview/src/components/ChatInput.tsx b/mpp-vscode/webview/src/components/ChatInput.tsx index 4932df666c..26ee083224 100644 --- a/mpp-vscode/webview/src/components/ChatInput.tsx +++ b/mpp-vscode/webview/src/components/ChatInput.tsx @@ -13,6 +13,7 @@ interface ChatInputProps { onStop?: () => void; onConfigSelect?: (config: ModelConfig) => void; onConfigureClick?: () => void; + onAddNewConfig?: () => void; onMcpConfigClick?: () => void; onPromptOptimize?: (prompt: string) => Promise; onGetCompletions?: (text: string, cursorPosition: number) => void; @@ -34,6 +35,7 @@ export const ChatInput: React.FC = ({ onStop, onConfigSelect, onConfigureClick, + onAddNewConfig, onMcpConfigClick, onPromptOptimize, onGetCompletions, @@ -225,6 +227,7 @@ export const ChatInput: React.FC = ({ currentConfigName={currentConfigName} onConfigSelect={onConfigSelect || (() => {})} onConfigureClick={onConfigureClick || (() => {})} + onAddNewConfig={onAddNewConfig} /> {/* Token usage indicator */} {totalTokens != null && totalTokens > 0 && ( diff --git a/mpp-vscode/webview/src/components/ModelConfigDialog.css b/mpp-vscode/webview/src/components/ModelConfigDialog.css new file mode 100644 index 0000000000..756e6bf2bd --- /dev/null +++ b/mpp-vscode/webview/src/components/ModelConfigDialog.css @@ -0,0 +1,178 @@ +.model-config-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.model-config-dialog { + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + width: 90%; + max-width: 600px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.model-config-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.model-config-dialog-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.close-button { + background: none; + border: none; + color: var(--vscode-foreground); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.close-button:hover { + background: var(--vscode-list-hoverBackground); +} + +.model-config-dialog-content { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.form-field { + margin-bottom: 16px; +} + +.form-field label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.form-field input, +.form-field select { + width: 100%; + padding: 8px 12px; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: 2px; + color: var(--vscode-input-foreground); + font-size: 13px; + box-sizing: border-box; +} + +.form-field input:focus, +.form-field select:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.input-with-icon { + position: relative; + display: flex; + align-items: center; +} + +.input-with-icon input { + padding-right: 40px; +} + +.toggle-visibility { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 4px; + font-size: 16px; +} + +.toggle-advanced { + background: none; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 8px 0; + font-size: 13px; + text-align: left; + width: 100%; +} + +.toggle-advanced:hover { + color: var(--vscode-textLink-foreground); +} + +.model-config-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid var(--vscode-panel-border); +} + +.cancel-button, +.save-button { + padding: 8px 16px; + border: none; + border-radius: 2px; + font-size: 13px; + cursor: pointer; + font-weight: 500; +} + +.cancel-button { + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +.cancel-button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.save-button { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.save-button:hover { + background: var(--vscode-button-hoverBackground); +} + +.form-field input.error, +.form-field select.error { + border-color: var(--vscode-inputValidation-errorBorder); +} + +.error-message { + color: var(--vscode-errorForeground); + font-size: 12px; + margin-top: 4px; +} + diff --git a/mpp-vscode/webview/src/components/ModelConfigDialog.tsx b/mpp-vscode/webview/src/components/ModelConfigDialog.tsx new file mode 100644 index 0000000000..d03ed8ceda --- /dev/null +++ b/mpp-vscode/webview/src/components/ModelConfigDialog.tsx @@ -0,0 +1,266 @@ +/** + * ModelConfigDialog Component + * + * Dialog for configuring LLM models in VSCode webview. + * Communicates with extension via postMessage. + */ + +import React, { useState, useEffect } from 'react'; +import { useVSCode } from '../hooks/useVSCode'; +import './ModelConfigDialog.css'; + +interface ModelConfigDialogProps { + isOpen: boolean; + onClose: () => void; + currentConfig?: { + name?: string; + provider?: string; + model?: string; + apiKey?: string; + baseUrl?: string; + temperature?: number; + maxTokens?: number; + } | null; + isNewConfig?: boolean; +} + +const PROVIDERS = [ + { value: 'openai', label: 'OpenAI' }, + { value: 'anthropic', label: 'Anthropic' }, + { value: 'google', label: 'Google' }, + { value: 'deepseek', label: 'DeepSeek' }, + { value: 'ollama', label: 'Ollama' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'glm', label: 'GLM' }, + { value: 'qwen', label: 'Qwen' }, + { value: 'kimi', label: 'Kimi' }, + { value: 'custom-openai-base', label: 'Custom OpenAI-compatible' }, +]; + +export const ModelConfigDialog: React.FC = ({ + isOpen, + onClose, + currentConfig, + isNewConfig = false +}) => { + const [configName, setConfigName] = useState(currentConfig?.name || ''); + const [provider, setProvider] = useState(currentConfig?.provider || 'openai'); + const [model, setModel] = useState(currentConfig?.model || ''); + const [apiKey, setApiKey] = useState(currentConfig?.apiKey || ''); + const [baseUrl, setBaseUrl] = useState(currentConfig?.baseUrl || ''); + const [temperature, setTemperature] = useState(currentConfig?.temperature?.toString() || '0.7'); + const [maxTokens, setMaxTokens] = useState(currentConfig?.maxTokens?.toString() || '8192'); + const [showApiKey, setShowApiKey] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [errors, setErrors] = useState>({}); + const { postMessage } = useVSCode(); + + useEffect(() => { + if (isOpen && currentConfig) { + setConfigName(currentConfig.name || ''); + setProvider(currentConfig.provider || 'openai'); + setModel(currentConfig.model || ''); + setApiKey(currentConfig.apiKey || ''); + setBaseUrl(currentConfig.baseUrl || ''); + setTemperature(currentConfig.temperature?.toString() || '0.7'); + setMaxTokens(currentConfig.maxTokens?.toString() || '8192'); + } else if (isOpen && isNewConfig) { + // Reset to defaults for new config + setConfigName(''); + setProvider('openai'); + setModel(''); + setApiKey(''); + setBaseUrl(''); + setTemperature('0.7'); + setMaxTokens('8192'); + } + }, [isOpen, currentConfig, isNewConfig]); + + if (!isOpen) return null; + + const needsBaseUrl = ['ollama', 'glm', 'qwen', 'kimi', 'custom-openai-base'].includes(provider); + const needsApiKey = provider !== 'ollama'; + + const handleSave = () => { + const newErrors: Record = {}; + + if (!configName.trim()) { + newErrors.configName = 'Please enter a configuration name'; + } + if (!model.trim()) { + newErrors.model = 'Please enter a model name'; + } + if (needsApiKey && !apiKey.trim()) { + newErrors.apiKey = 'Please enter an API key'; + } + if (needsBaseUrl && !baseUrl.trim()) { + newErrors.baseUrl = 'Please enter a base URL'; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setErrors({}); + + postMessage({ + type: 'saveModelConfig', + data: { + name: configName.trim(), + provider, + model: model.trim(), + apiKey: apiKey.trim(), + baseUrl: baseUrl.trim() || undefined, + temperature: parseFloat(temperature) || 0.7, + maxTokens: parseInt(maxTokens) || 8192, + isNewConfig + } + }); + + onClose(); + }; + + return ( +
+
e.stopPropagation()}> +
+

{isNewConfig ? 'Add New Model Config' : 'Configure Model'}

+ +
+ +
+
+ + { + setConfigName(e.target.value); + if (errors.configName) setErrors({ ...errors, configName: '' }); + }} + placeholder="e.g., my-glm, work-gpt4" + className={errors.configName ? 'error' : ''} + /> + {errors.configName &&
{errors.configName}
} +
+ +
+ + +
+ +
+ + { + setModel(e.target.value); + if (errors.model) setErrors({ ...errors, model: '' }); + }} + placeholder="Enter model name" + className={errors.model ? 'error' : ''} + /> + {errors.model &&
{errors.model}
} +
+ + {needsApiKey && ( +
+ +
+ { + setApiKey(e.target.value); + if (errors.apiKey) setErrors({ ...errors, apiKey: '' }); + }} + placeholder="Enter API key" + className={errors.apiKey ? 'error' : ''} + /> + +
+ {errors.apiKey &&
{errors.apiKey}
} +
+ )} + + {needsBaseUrl && ( +
+ + { + setBaseUrl(e.target.value); + if (errors.baseUrl) setErrors({ ...errors, baseUrl: '' }); + }} + placeholder={ + provider === 'ollama' ? 'http://localhost:11434' : + provider === 'glm' ? 'https://open.bigmodel.cn/api/paas/v4' : + provider === 'qwen' ? 'https://dashscope.aliyuncs.com/api/v1' : + provider === 'kimi' ? 'https://api.moonshot.cn/v1' : + 'https://api.example.com/v1' + } + className={errors.baseUrl ? 'error' : ''} + /> + {errors.baseUrl &&
{errors.baseUrl}
} +
+ )} + +
+ +
+ + {showAdvanced && ( + <> +
+ + setTemperature(e.target.value)} + placeholder="0.7" + /> +
+ +
+ + setMaxTokens(e.target.value)} + placeholder="8192" + /> +
+ + )} +
+ +
+ + +
+
+
+ ); +}; + diff --git a/mpp-vscode/webview/src/components/ModelSelector.tsx b/mpp-vscode/webview/src/components/ModelSelector.tsx index 2be82112ed..d90a5beeab 100644 --- a/mpp-vscode/webview/src/components/ModelSelector.tsx +++ b/mpp-vscode/webview/src/components/ModelSelector.tsx @@ -19,13 +19,15 @@ interface ModelSelectorProps { currentConfigName: string | null; onConfigSelect: (config: ModelConfig) => void; onConfigureClick: () => void; + onAddNewConfig?: () => void; } export const ModelSelector: React.FC = ({ availableConfigs, currentConfigName, onConfigSelect, - onConfigureClick + onConfigureClick, + onAddNewConfig }) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -93,6 +95,24 @@ export const ModelSelector: React.FC = ({ )} + {onAddNewConfig && ( + <> + +
+ + )} +