From 163758a2be89dfd734b33a79f54e8b4f8b0606b7 Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Tue, 16 Sep 2025 23:44:34 +0100 Subject: [PATCH] fix: migrate Custom OpenAI services to use UUIDs and several other fixes --- .../custom/CustomServiceFormTabbedPane.java | 25 +- .../chat/ui/textarea/ModelComboBoxAction.java | 6 +- .../CodeCompletionRequestFactory.kt | 3 +- .../codecompletions/CodeCompletionService.kt | 12 +- .../factory/CustomOpenAIRequestFactory.kt | 6 +- .../codegpt/credentials/CredentialsStore.kt | 7 +- .../codegpt/settings/Placeholder.kt | 20 +- .../migration/LegacySettingsMigration.kt | 48 +-- .../codegpt/settings/models/ModelRegistry.kt | 38 +-- .../codegpt/settings/models/ModelSettings.kt | 62 ++-- .../settings/models/ModelSettingsState.kt | 4 +- .../settings/remote/ConfigSyncService.kt | 0 .../service/ModelReplacementDialog.kt | 116 ++++---- .../settings/service/ModelSelectionService.kt | 28 +- .../custom/CustomServiceConfigurable.kt | 15 +- .../service/custom/CustomServiceSettings.kt | 43 ++- .../form/CustomServiceChatCompletionForm.kt | 76 +++-- .../form/CustomServiceCodeCompletionForm.kt | 55 +++- ...erviceListForm.kt => CustomServiceForm.kt} | 276 +++++++++++------- .../form/CustomServiceNameListRenderer.kt | 19 +- .../form/model/CustomServiceSettingsData.kt | 2 + .../form/model/CustomServicesStateData.kt | 5 +- .../custom/form/model/DataToStateMapper.kt | 2 + .../custom/form/model/StateToDataMapper.kt | 17 +- .../ee/carlrobert/codegpt/util/GitUtil.kt | 9 +- .../settings/models/ModelSettingsTest.kt | 13 +- .../RemoteSettingsFormIntegrationTest.kt | 0 27 files changed, 527 insertions(+), 380 deletions(-) create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/remote/ConfigSyncService.kt rename src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/{CustomServiceListForm.kt => CustomServiceForm.kt} (65%) create mode 100644 src/test/kotlin/ee/carlrobert/codegpt/settings/remote/RemoteSettingsFormIntegrationTest.kt diff --git a/src/main/java/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.java b/src/main/java/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.java index e732221c..acb49790 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.java @@ -58,13 +58,32 @@ public class CustomServiceFormTabbedPane extends JBTabbedPane { private void setTableData(JBTable table, Map values) { DefaultTableModel model = (DefaultTableModel) table.getModel(); - model.setRowCount(0); - for (var entry : values.entrySet()) { - model.addRow(new Object[]{entry.getKey(), entry.getValue()}); + if (hasTableDataChanged(model, values)) { + model.setRowCount(0); + for (var entry : values.entrySet()) { + model.addRow(new Object[]{entry.getKey(), entry.getValue()}); + } } } + private boolean hasTableDataChanged(DefaultTableModel model, Map newValues) { + if (model.getRowCount() != newValues.size()) { + return true; + } + + for (int i = 0; i < model.getRowCount(); i++) { + String key = (String) model.getValueAt(i, 0); + Object value = model.getValueAt(i, 1); + + if (!newValues.containsKey(key) || !java.util.Objects.equals(newValues.get(key), value)) { + return true; + } + } + + return false; + } + private Map getTableData(JBTable table) { var model = (DefaultTableModel) table.getModel(); var data = new HashMap(); diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java index ccdf9f08..f255d761 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java @@ -312,11 +312,11 @@ public class ModelComboBoxAction extends ComboBoxAction { break; case CUSTOM_OPENAI: ModelRegistry.getInstance().getCustomOpenAIModels().stream() - .filter(it -> Objects.requireNonNull(modelCode).equals(it.getModel())) + .filter(it -> it.getModel().equals(modelCode)) .findFirst() .ifPresent(selection -> { templatePresentation.setIcon(Icons.OpenAI); - templatePresentation.setText(selection.getModel()); + templatePresentation.setText(selection.getDisplayName()); }); break; case ANTHROPIC: @@ -463,7 +463,7 @@ public class ModelComboBoxAction extends ComboBoxAction { Icons.OpenAI, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(featureType, model.getName(), CUSTOM_OPENAI)); + .setModel(featureType, model.getId(), CUSTOM_OPENAI)); } private AnAction createGoogleModelAction(GoogleModel model, Presentation comboBoxPresentation) { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionRequestFactory.kt index 1f82f788..6c119dc9 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionRequestFactory.kt @@ -139,8 +139,7 @@ object CodeCompletionRequestFactory { val activeService = service() .customServiceStateForFeatureType(FeatureType.CODE_COMPLETION) val settings = activeService.codeCompletionSettings - val credential = - getCredential(CredentialKey.CustomServiceApiKey(activeService.name.orEmpty())) + val credential = getCredential(CredentialKey.CustomServiceApiKeyById(requireNotNull(activeService.id))) return buildCustomRequest( details, settings.url!!, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.kt b/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.kt index a225c26a..2a693580 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.kt @@ -3,7 +3,6 @@ package ee.carlrobert.codegpt.codecompletions import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import ee.carlrobert.codegpt.codecompletions.CodeCompletionRequestFactory.buildChatBasedFIMRequest import ee.carlrobert.codegpt.codecompletions.CodeCompletionRequestFactory.buildChatBasedFIMHttpRequest import ee.carlrobert.codegpt.codecompletions.CodeCompletionRequestFactory.buildCustomRequest import ee.carlrobert.codegpt.codecompletions.CodeCompletionRequestFactory.buildLlamaRequest @@ -68,13 +67,13 @@ class CodeCompletionService(private val project: Project) { } CUSTOM_OPENAI -> { - val activeService = service().state.active + val activeService = + service().customServiceStateForFeatureType(FeatureType.CODE_COMPLETION) val customSettings = activeService.codeCompletionSettings - val isChatBasedFIM = customSettings.infillTemplate == InfillPromptTemplate.CHAT_COMPLETION - + val isChatBasedFIM = + customSettings.infillTemplate == InfillPromptTemplate.CHAT_COMPLETION if (isChatBasedFIM) { - // Use chat completion endpoint for chat-based FIM with proper API key substitution - val credential = getCredential(CredentialKey.CustomServiceApiKey(activeService.name.orEmpty())) + val credential = getCredential(CredentialKey.CustomServiceApiKeyById(requireNotNull(activeService.id))) createFactory( CompletionClientProvider.getDefaultClientBuilder().build() ).newEventSource( @@ -88,7 +87,6 @@ class CodeCompletionService(private val project: Project) { OpenAIChatCompletionEventSourceListener(eventListener) ) } else { - // Use traditional completion endpoint createFactory( CompletionClientProvider.getDefaultClientBuilder().build() ).newEventSource( diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt index d48ed6f4..ff2eab7b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt @@ -33,7 +33,7 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() { params.psiStructure ), true, - getCredential(CredentialKey.CustomServiceApiKey(service.name.orEmpty())) + getCredential(CredentialKey.CustomServiceApiKeyById(requireNotNull(service.id))) ) return CustomOpenAIRequest(request) } @@ -54,7 +54,7 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() { OpenAIChatCompletionStandardMessage("user", userPrompt) ), stream, - getCredential(CredentialKey.CustomServiceApiKey(service.name.orEmpty())) + getCredential(CredentialKey.CustomServiceApiKeyById(requireNotNull(service.id))) ) return CustomOpenAIRequest(request) } @@ -67,7 +67,7 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() { service.chatCompletionSettings, messages, true, - getCredential(CredentialKey.CustomServiceApiKey(service.name.orEmpty())) + getCredential(CredentialKey.CustomServiceApiKeyById(requireNotNull(service.id))) ) return CustomOpenAIRequest(request) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/credentials/CredentialsStore.kt b/src/main/kotlin/ee/carlrobert/codegpt/credentials/CredentialsStore.kt index adc3e907..2a28fc13 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/credentials/CredentialsStore.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/credentials/CredentialsStore.kt @@ -48,10 +48,15 @@ object CredentialsStore { override val value: String = "OPENAI_API_KEY" } + @Deprecated("Only for migration") data class CustomServiceApiKey(val name: String) : CredentialKey() { override val value: String = "CUSTOM_SERVICE_API_KEY:$name" } + data class CustomServiceApiKeyById(val id: String) : CredentialKey() { + override val value: String = "CUSTOM_SERVICE_API_KEY_ID:$id" + } + @Deprecated("Only for migration") data object CustomServiceApiKeyLegacy : CredentialKey() { override val value: String = "CUSTOM_SERVICE_API_KEY" @@ -73,4 +78,4 @@ object CredentialsStore { override val value: String = "MISTRAL_API_KEY" } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/Placeholder.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/Placeholder.kt index 8a09deba..a486290c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/Placeholder.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/Placeholder.kt @@ -1,7 +1,8 @@ package ee.carlrobert.codegpt.settings +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project -import git4idea.GitUtil +import ee.carlrobert.codegpt.util.GitUtil import git4idea.branch.GitBranchUtil import java.time.LocalDate @@ -16,7 +17,10 @@ enum class Placeholder(val description: String, val code: String) { "The complete source code contents of all files currently open in the IDE editor tabs, maintaining their formatting and structure.", "$" + "OPEN_FILES" ), - ACTIVE_CONVERSATION("The complete conversation history with the AI assistant, including the most recent response and any relevant context from the current interaction.", "$" + "ACTIVE_CONVERSATION"), + ACTIVE_CONVERSATION( + "The complete conversation history with the AI assistant, including the most recent response and any relevant context from the current interaction.", + "$" + "ACTIVE_CONVERSATION" + ), PREFIX("Code before the cursor.", "$" + "PREFIX"), SUFFIX("Code after the cursor.", "$" + "SUFFIX"), FIM_PROMPT( @@ -36,15 +40,19 @@ class DatePlaceholderStrategy : PlaceholderStrategy { } class BranchNamePlaceholderStrategy(val project: Project) : PlaceholderStrategy { + private val logger = thisLogger() + override fun getReplacementValue(): String { return try { - val repositories = GitUtil.getRepositoryManager(project).repositories - if (repositories.isEmpty() || repositories.size != 1) { + val repository = GitUtil.getProjectRepository(project) + if (repository == null) { + logger.error("Couldn't find repository for project") return "BRANCH-UNKNOWN" } - GitBranchUtil.getBranchNameOrRev(repositories[0]) - } catch (ignore: Exception) { + GitBranchUtil.getBranchNameOrRev(repository) + } catch (ex: Exception) { + logger.error("Couldn't get git branch name replacement value", ex) "BRANCH-UNKNOWN" } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt index ad118cbe..1c0c43fc 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt @@ -24,7 +24,7 @@ object LegacySettingsMigration { return try { val generalState = GeneralSettings.getCurrentState() val selectedService = generalState.selectedService - + if (selectedService != null) { generalState.selectedService = null createMigratedState(selectedService) @@ -40,7 +40,7 @@ object LegacySettingsMigration { private fun createMigratedState(selectedService: ServiceType): ModelSettingsState { return ModelSettingsState().apply { val chatModel = getLegacyChatModelForService(selectedService) - + setModelSelection(FeatureType.CHAT, chatModel, selectedService) setModelSelection(FeatureType.AUTO_APPLY, chatModel, selectedService) setModelSelection(FeatureType.COMMIT_MESSAGE, chatModel, selectedService) @@ -49,7 +49,7 @@ object LegacySettingsMigration { val codeModel = getLegacyCodeModelForService(selectedService) setModelSelection(FeatureType.CODE_COMPLETION, codeModel, selectedService) - + if (selectedService == ServiceType.PROXYAI) { setModelSelection(FeatureType.NEXT_EDIT, ModelRegistry.ZETA, ServiceType.PROXYAI) } else { @@ -71,7 +71,8 @@ object LegacySettingsMigration { } ServiceType.ANTHROPIC -> { - AnthropicSettings.getCurrentState().model ?: ModelRegistry.CLAUDE_SONNET_4_20250514 + AnthropicSettings.getCurrentState().model + ?: ModelRegistry.CLAUDE_SONNET_4_20250514 } ServiceType.GOOGLE -> { @@ -94,15 +95,10 @@ object LegacySettingsMigration { } ServiceType.CUSTOM_OPENAI -> { - val customServicesSettings = service() - val services = customServicesSettings.state.services - - val activeServiceName = customServicesSettings.state.active.name - if (!activeServiceName.isNullOrBlank()) { - activeServiceName - } else { - services.map { it.name }.lastOrNull()?.takeIf { it.isNotBlank() } ?: "Default" - } + service().state.services + .map { it.name } + .lastOrNull() + ?.takeIf { it.isNotBlank() } ?: "Default" } ServiceType.MISTRAL -> { @@ -111,7 +107,7 @@ object LegacySettingsMigration { } } catch (e: Exception) { logger.warn("Failed to get legacy chat model for $serviceType", e) - getDefaultModelForService(serviceType) + throw e } } @@ -162,28 +158,4 @@ object LegacySettingsMigration { null } } - - private fun getDefaultModelForService(serviceType: ServiceType): String { - return when (serviceType) { - ServiceType.PROXYAI -> ModelRegistry.GEMINI_FLASH_2_5 - ServiceType.OPENAI -> ModelRegistry.GPT_4O - ServiceType.ANTHROPIC -> ModelRegistry.CLAUDE_SONNET_4_20250514 - ServiceType.GOOGLE -> ModelRegistry.GEMINI_2_0_FLASH - ServiceType.MISTRAL -> ModelRegistry.DEVSTRAL_MEDIUM_2507 - ServiceType.OLLAMA -> ModelRegistry.LLAMA_3_2 - ServiceType.LLAMA_CPP -> ModelRegistry.LLAMA_3_2_3B_INSTRUCT - ServiceType.CUSTOM_OPENAI -> { - // For Custom OpenAI, try to use the active service name if available - // If not available, use a placeholder that won't break model selection - try { - val customServicesSettings = service() - val activeService = customServicesSettings.state.active - activeService?.name?.takeIf { it.isNotBlank() } ?: "Custom OpenAI" - } catch (e: Exception) { - logger.warn("Could not access CustomServicesSettings for default model, using placeholder", e) - "Custom OpenAI" - } - } - } - } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt index 2df3b07b..9fc07dfa 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt @@ -286,17 +286,15 @@ class ModelRegistry { return try { val customServicesSettings = service() customServicesSettings.state.services.mapNotNull { service -> - if (service.name.isNullOrBlank()) { - return@mapNotNull null - } + val serviceId = service.id ?: return@mapNotNull null + val serviceName = service.name ?: "" + val modelFromBody = service.codeCompletionSettings.body["model"] + val modelName = (modelFromBody as? String) + val displayName = if (!modelName.isNullOrEmpty()) { + if (modelName.length > 20) "$serviceName (...${modelName.takeLast(20)})" else "$serviceName ($modelName)" + } else serviceName - service.name?.let { serviceName -> - val modelFromBody = service.codeCompletionSettings.body["model"] - val modelName = (modelFromBody as? String) ?: "Unknown Model" - val displayName = "$serviceName ($modelName)" - - ModelSelection(ServiceType.CUSTOM_OPENAI, serviceName, displayName) - } + ModelSelection(ServiceType.CUSTOM_OPENAI, serviceId, displayName) } } catch (e: Exception) { logger.error("Failed to get Custom OpenAI code models", e) @@ -524,20 +522,14 @@ class ModelRegistry { return try { val customServicesSettings = service() customServicesSettings.state.services.mapNotNull { service -> - if (service.name.isNullOrBlank()) { - return@mapNotNull null - } + val serviceId = service.id ?: return@mapNotNull null + val serviceName = service.name ?: "" + val modelName = service.chatCompletionSettings.body["model"] as? String + val displayName = if (!modelName.isNullOrEmpty()) { + if (modelName.length > 20) "$serviceName (...${modelName.takeLast(20)})" else "$serviceName ($modelName)" + } else serviceName - service.name?.let { serviceName -> - val modelName = service.chatCompletionSettings.body["model"] as? String - val displayName = if (modelName != null) { - "$serviceName ($modelName)" - } else { - serviceName - } - - ModelSelection(ServiceType.CUSTOM_OPENAI, serviceName, displayName) - } + ModelSelection(ServiceType.CUSTOM_OPENAI, serviceId, displayName) } } catch (e: Exception) { logger.error("Failed to get Custom OpenAI models", e) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt index 5dbc5d04..ba06d35a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt @@ -5,6 +5,7 @@ import com.intellij.openapi.components.* import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings @Service @State( @@ -57,6 +58,7 @@ class ModelSettings : SimplePersistentStateComponent(ModelSe val oldState = this.state super.loadState(state) + migrateCustomOpenAIModelCodesToIds() migrateMissingProviderInformation() notifyIfChanged(oldState, this.state) } @@ -74,29 +76,15 @@ class ModelSettings : SimplePersistentStateComponent(ModelSe notifyModelChange(featureType, model, serviceType) } - fun getModelSelection(featureType: FeatureType): ModelSelection { + fun getModelSelection(featureType: FeatureType): ModelSelection? { val details = getModelDetailsState(featureType) - if (details == null) { - val defaultModel = ModelRegistry.getInstance().getDefaultModelForFeature(featureType) - state.setModelSelection(featureType, defaultModel.model, defaultModel.provider) - return defaultModel - } - - return details.model?.let { model -> + return details?.model?.let { model -> details.provider?.let { provider -> ModelRegistry.getInstance().findModel(provider, model) } - } ?: run { - val defaultModel = ModelRegistry.getInstance().getDefaultModelForFeature(featureType) - state.setModelSelection(featureType, defaultModel.model, defaultModel.provider) - defaultModel } } - fun getOrCreateModelSelection(featureType: FeatureType): ModelSelection { - return getModelSelection(featureType) - } - fun getModelForFeature(featureType: FeatureType): String? { return getModelDetailsState(featureType)?.model } @@ -136,19 +124,7 @@ class ModelSettings : SimplePersistentStateComponent(ModelSe } private fun findServiceTypeForModel(featureType: FeatureType, modelId: String?): ServiceType { - if (modelId == null) return ServiceType.PROXYAI - - val provider = getProviderForFeature(featureType) - val models = getModelsForFeatureType(featureType) - - if (provider != null) { - val modelWithProvider = models.find { it.model == modelId && it.provider == provider } - if (modelWithProvider != null) { - return modelWithProvider.provider - } - } - - return models.find { it.model == modelId }?.provider ?: ServiceType.PROXYAI + return ServiceType.CUSTOM_OPENAI } private fun migrateMissingProviderInformation() { @@ -172,7 +148,33 @@ class ModelSettings : SimplePersistentStateComponent(ModelSe return models.find { it.model == modelCode }?.provider } + private fun migrateCustomOpenAIModelCodesToIds() { + val servicesByName: Map> = try { + CustomServicesSettings::class.java + val settings = service() + settings.state.services.groupBy({ it.name ?: "" }, { it.id ?: "" }).filterKeys { it.isNotEmpty() } + } catch (_: Exception) { + emptyMap() + } + + if (servicesByName.isEmpty()) return + + FeatureType.entries.forEach { featureType -> + val details = state.getModelSelection(featureType) ?: return@forEach + if (details.provider == ServiceType.CUSTOM_OPENAI && !details.model.isNullOrBlank()) { + val current = details.model!! + val ids = servicesByName[current] + if (ids != null && ids.size == 1) { + val id = ids.first() + if (id.isNotBlank() && id != current) { + state.setModelSelection(featureType, id, ServiceType.CUSTOM_OPENAI) + } + } + } + } + } + companion object { fun getInstance(): ModelSettings = service() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsState.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsState.kt index 11bf0be6..da35ac48 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsState.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsState.kt @@ -17,7 +17,9 @@ class ModelSettingsState : BaseState() { val registry = ModelRegistry.getInstance() FeatureType.entries.forEach { featureType -> val defaultModel = registry.getDefaultModelForFeature(featureType) - setModelSelection(featureType, defaultModel.model, defaultModel.provider) + if (defaultModel != null) { + setModelSelection(featureType, defaultModel.model, defaultModel.provider) + } } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/remote/ConfigSyncService.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/remote/ConfigSyncService.kt new file mode 100644 index 00000000..e69de29b diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelReplacementDialog.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelReplacementDialog.kt index 526425b1..0bdde91d 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelReplacementDialog.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelReplacementDialog.kt @@ -9,15 +9,13 @@ import ee.carlrobert.codegpt.settings.models.ModelRegistry import ee.carlrobert.codegpt.settings.models.ModelSelection import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.settings.models.ModelSettingsForm -import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTAvailableModels -import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTModel import ee.carlrobert.codegpt.util.ApplicationUtil -import ee.carlrobert.llm.client.codegpt.PricingPlan import javax.swing.JComponent class ModelReplacementDialog( private val project: Project?, - serviceType: ServiceType + serviceType: ServiceType, + private val preferredCustomServiceId: String? = null ) : DialogWrapper(project) { private val modelSettingsForm = @@ -67,19 +65,28 @@ class ModelReplacementDialog( super.dispose() } - private fun generateInitialModelSelections(serviceType: ServiceType): Map? { + private fun generateInitialModelSelections(serviceType: ServiceType): Map { val registry = ModelRegistry.getInstance() - - return when (serviceType) { - ServiceType.PROXYAI -> { - val userDetails = project?.let { CODEGPT_USER_DETAILS.get(it) } - FeatureType.entries.associateWith { featureType -> + return FeatureType.entries.associateWith { featureType -> + when (serviceType) { + ServiceType.PROXYAI -> { + val userDetails = project?.let { CODEGPT_USER_DETAILS.get(it) } registry.getDefaultModelForFeature(featureType, userDetails?.pricingPlan) } - } - else -> { - FeatureType.entries.associateWith { featureType -> + ServiceType.CUSTOM_OPENAI -> { + val models = registry.getAllModelsForFeature(featureType) + .filter { it.provider == serviceType } + + if (!preferredCustomServiceId.isNullOrBlank()) { + models.firstOrNull { it.model == preferredCustomServiceId } + ?: models.firstOrNull() + } else { + models.firstOrNull() + } + } + + else -> { registry.getAllModelsForFeature(featureType) .firstOrNull { it.provider == serviceType } } @@ -95,70 +102,59 @@ class ModelReplacementDialog( } companion object { - fun showDialog(serviceType: ServiceType): DialogResult { - val dialog = ModelReplacementDialog(ApplicationUtil.findCurrentProject(), serviceType) + fun showDialog( + serviceType: ServiceType, + preferredCustomServiceId: String? = null + ): DialogResult { + val dialog = ModelReplacementDialog( + ApplicationUtil.findCurrentProject(), + serviceType, + preferredCustomServiceId + ) dialog.show() return dialog.result } - fun showDialogIfNeeded(serviceType: ServiceType): DialogResult { - return if (shouldShowDialog(serviceType)) { - showDialog(serviceType) + fun showDialogIfNeeded( + serviceType: ServiceType, + preferredCustomServiceId: String? = null + ): DialogResult { + return if (shouldShowDialog(serviceType, preferredCustomServiceId)) { + showDialog(serviceType, preferredCustomServiceId) } else { DialogResult.KEEP_MODELS } } - private fun shouldShowDialog(serviceType: ServiceType): Boolean { - if (serviceType == ServiceType.PROXYAI) { - val project = ApplicationUtil.findCurrentProject() - val userDetails = project?.let { CODEGPT_USER_DETAILS.get(it) } - val modelSettings = service() - val registry = service() - - return FeatureType.entries.any { featureType -> - val currentSelection = modelSettings.getModelSelection(featureType) - val suggestedSelection = - registry.getDefaultModelForFeature(featureType, userDetails?.pricingPlan) - - when { - currentSelection == null -> true - currentSelection.provider != serviceType -> true - currentSelection.model != suggestedSelection.model -> { - val suggestedModelAccessible = - CodeGPTAvailableModels.findByCode(suggestedSelection.model)?.let { - isModelAccessible(it, userDetails?.pricingPlan) - } == true - val currentModelNotAccessible = - CodeGPTAvailableModels.findByCode(currentSelection.model)?.let { - !isModelAccessible(it, userDetails?.pricingPlan) - } == true - - suggestedModelAccessible || currentModelNotAccessible - } - - else -> false - } - } - } - + private fun shouldShowDialog( + serviceType: ServiceType, + preferredCustomServiceId: String? = null + ): Boolean { return FeatureType.entries.any { featureType -> + val registry = service() + if (!registry.isFeatureSupportedByProvider( + featureType, + serviceType + ) + ) return@any false + val currentSelection = service().getModelSelection(featureType) - val availableModels = service().getAllModelsForFeature(featureType) + val availableModels = registry.getAllModelsForFeature(featureType) .filter { it.provider == serviceType } + if (availableModels.isEmpty()) return@any false + when { - currentSelection == null -> true - currentSelection.provider != serviceType -> true - availableModels.none { it.model == currentSelection.model } -> true + currentSelection?.provider != null && currentSelection.provider != serviceType -> true + + serviceType == ServiceType.CUSTOM_OPENAI && !preferredCustomServiceId.isNullOrBlank() && + currentSelection != null && currentSelection.model != preferredCustomServiceId -> true + + currentSelection != null && availableModels.none { it.model == currentSelection.model } -> true + else -> false } } } - - private fun isModelAccessible(model: CodeGPTModel, userPricingPlan: PricingPlan?): Boolean { - if (userPricingPlan == null) return false - return userPricingPlan.ordinal >= model.pricingPlan.ordinal - } } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelSelectionService.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelSelectionService.kt index 73e08171..8f71767b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelSelectionService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ModelSelectionService.kt @@ -59,6 +59,32 @@ class ModelSelectionService { } } + fun syncWithAvailableCustomOpenAIModels(preferredServiceId: String? = null) { + val registry = service() + val settings = service() + + FeatureType.entries.forEach { featureType -> + if (!registry.isFeatureSupportedByProvider(featureType, ServiceType.CUSTOM_OPENAI)) return@forEach + + val current = settings.getModelSelection(featureType) + if (current?.provider != ServiceType.CUSTOM_OPENAI) return@forEach + + val available = registry.getAllModelsForFeature(featureType) + .filter { it.provider == ServiceType.CUSTOM_OPENAI } + + val isCurrentValid = available.any { it.model == current.model } + + if (!isCurrentValid) { + val newId = when { + !preferredServiceId.isNullOrBlank() && available.any { it.model == preferredServiceId } -> preferredServiceId + available.isNotEmpty() -> available.first().model + else -> null + } + settings.setModelWithProvider(featureType, newId, ServiceType.CUSTOM_OPENAI) + } + } + } + companion object { private val logger = thisLogger() @@ -68,4 +94,4 @@ class ModelSelectionService { return ApplicationManager.getApplication().getService(ModelSelectionService::class.java) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceConfigurable.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceConfigurable.kt index ef10e755..161f978a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceConfigurable.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceConfigurable.kt @@ -2,9 +2,8 @@ package ee.carlrobert.codegpt.settings.service.custom import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable -import ee.carlrobert.codegpt.settings.service.ModelReplacementDialog -import ee.carlrobert.codegpt.settings.service.ServiceType -import ee.carlrobert.codegpt.settings.service.custom.form.CustomServiceListForm +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.settings.service.custom.form.CustomServiceForm import ee.carlrobert.codegpt.util.coroutines.EdtDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -14,14 +13,14 @@ import javax.swing.JComponent class CustomServiceConfigurable : Configurable { private val coroutineScope = CoroutineScope(SupervisorJob() + EdtDispatchers.Default) - private lateinit var component: CustomServiceListForm + private lateinit var component: CustomServiceForm override fun getDisplayName(): String { return "ProxyAI: Custom Service" } override fun createComponent(): JComponent { - component = CustomServiceListForm(service(), coroutineScope) + component = CustomServiceForm(service(), coroutineScope) return component.getForm() } @@ -29,8 +28,8 @@ class CustomServiceConfigurable : Configurable { override fun apply() { component.applyChanges() - - ModelReplacementDialog.showDialogIfNeeded(ServiceType.CUSTOM_OPENAI) + ModelSelectionService.getInstance() + .syncWithAvailableCustomOpenAIModels(component.getSelectedServiceId()) } override fun reset() { @@ -40,4 +39,4 @@ class CustomServiceConfigurable : Configurable { override fun disposeUIResources() { coroutineScope.cancel() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceSettings.kt index fb7f9758..ecb293a7 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceSettings.kt @@ -13,6 +13,7 @@ import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceChatC import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceCodeCompletionTemplate import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceTemplate import ee.carlrobert.codegpt.util.BaseConverter +import java.util.UUID import ee.carlrobert.codegpt.util.MapConverter private const val DEFAULT_SERVICE_SETTINGS_NANE = "Default" @@ -63,13 +64,8 @@ class CustomServicesSettings : val migrated = CustomServiceSettingsState().apply { copyFrom(oldSettingsService.state) } state.services.clear() state.services.add(migrated) - state.active = migrated CredentialsStore.setCredential(CredentialsStore.CredentialKey.CustomServiceApiKeyLegacy, null) - CredentialsStore.setCredential( - CredentialsStore.CredentialKey.CustomServiceApiKey(state.active.name.orEmpty()), - oldApiKey - ) oldSettingsService.state.apply { template = CustomServiceTemplate.OPENAI @@ -91,21 +87,49 @@ class CustomServicesSettings : headers = mutableMapOf() } } + + state.services.forEach { svc -> + if (svc.id.isNullOrBlank()) { + svc.id = UUID.randomUUID().toString() + } + } + + runCatching { + val services = state.services.filter { !it.id.isNullOrBlank() } + val groups = services.groupBy { it.name ?: "" } + services.forEach { svc -> + val id = svc.id ?: return@forEach + val name = svc.name ?: return@forEach + val unique = name.isNotEmpty() && (groups[name]?.size == 1) + if (unique) { + val idKey = CredentialsStore.CredentialKey.CustomServiceApiKeyById(id) + val hasId = !CredentialsStore.getCredential(idKey).isNullOrEmpty() + if (!hasId) { + val legacy = CredentialsStore.getCredential( + CredentialsStore.CredentialKey.CustomServiceApiKey(name) + ) + if (!legacy.isNullOrEmpty()) { + CredentialsStore.setCredential(idKey, legacy) + } + } + } + } + } } fun customServiceStateForFeatureType(featureType: FeatureType): CustomServiceSettingsState { val modelSelection = service() val featureSelection = modelSelection.getModelSelectionForFeature(featureType) - if (featureSelection.provider != ServiceType.CUSTOM_OPENAI) + if (featureSelection?.provider != ServiceType.CUSTOM_OPENAI) throw IllegalStateException( "Current selected ServiceType (${featureSelection}) is not of type 'CUSTOM_OPENAI'. " + "This function should not be called in this context!" ) return this.state.services - .find { it.name == featureSelection.model } - ?: throw IllegalStateException("Unable to find custom service with name '${featureSelection.model}'.") + .find { it.id == featureSelection.model } + ?: throw IllegalStateException("Unable to find custom service with id '${featureSelection.model}'.") } } @@ -120,8 +144,6 @@ class CustomServicesState( @get:OptionTag(converter = CustomServiceSettingsListConverter::class) var services by list() - var active by property(initialState) - init { services.add(initialState) } @@ -129,6 +151,7 @@ class CustomServicesState( @JsonIgnoreProperties(ignoreUnknown = true) class CustomServiceSettingsState : BaseState() { + var id by string(UUID.randomUUID().toString()) var name by string(DEFAULT_SERVICE_SETTINGS_NANE) var template by enum(CustomServiceTemplate.OPENAI) var chatCompletionSettings by property(CustomServiceChatCompletionSettingsState()) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceChatCompletionForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceChatCompletionForm.kt index c7ceec77..dd627136 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceChatCompletionForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceChatCompletionForm.kt @@ -1,7 +1,5 @@ package ee.carlrobert.codegpt.settings.service.custom.form -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.observable.util.whenTextChanged import com.intellij.openapi.ui.MessageType import com.intellij.util.ui.FormBuilder import ee.carlrobert.codegpt.CodeGPTBundle @@ -31,7 +29,9 @@ class CustomServiceChatCompletionForm( ) init { - testConnectionButton.addActionListener { testConnection() } + testConnectionButton.addActionListener { + testConnection() + } } var url: String @@ -73,42 +73,70 @@ class CustomServiceChatCompletionForm( } private fun testConnection() { + testConnectionButton.isEnabled = false + testConnectionButton.text = "Testing..." + + val request = CustomOpenAIRequestFactory.buildCustomOpenAICompletionRequest( + "Test", + urlField.text, + tabbedPane.headers, + tabbedPane.body, + getApiKey.invoke() + + ) + CompletionRequestService.getInstance().getCustomOpenAIChatCompletionAsync( - CustomOpenAIRequestFactory.buildCustomOpenAICompletionRequest( - "Test", - urlField.text, - tabbedPane.headers, - tabbedPane.body, - getApiKey.invoke() - ), + request, TestConnectionEventListener() ) } internal inner class TestConnectionEventListener : CompletionEventListener { + private var responseReceived = false + override fun onMessage(value: String?, eventSource: EventSource) { - if (!value.isNullOrEmpty()) { - runInEdt { - OverlayUtil.showBalloon( - CodeGPTBundle.get("settingsConfigurable.service.custom.openai.connectionSuccess"), - MessageType.INFO, - testConnectionButton - ) - eventSource.cancel() - } + if (!responseReceived) { + responseReceived = true + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") + OverlayUtil.showBalloon( + "Connection successful!", + MessageType.INFO, + testConnectionButton + ) + eventSource.cancel() } } override fun onError(error: ErrorDetails, ex: Throwable) { - runInEdt { + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") + OverlayUtil.showBalloon( + "Connection failed: ${error.message}", + MessageType.ERROR, + testConnectionButton + ) + } + + override fun onComplete(messageBuilder: StringBuilder) { + if (!responseReceived) { + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") OverlayUtil.showBalloon( - CodeGPTBundle.get("settingsConfigurable.service.custom.openai.connectionFailed") - + "\n\n" - + error.message, - MessageType.ERROR, + "Connection successful!", + MessageType.INFO, testConnectionButton ) } } + + override fun onCancelled(messageBuilder: StringBuilder) { + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") + } } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceCodeCompletionForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceCodeCompletionForm.kt index 6826caee..b8ae9c7d 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceCodeCompletionForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceCodeCompletionForm.kt @@ -2,7 +2,6 @@ package ee.carlrobert.codegpt.settings.service.custom.form import com.intellij.icons.AllIcons.General import com.intellij.ide.HelpTooltip -import com.intellij.openapi.application.runInEdt import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.MessageType import com.intellij.openapi.ui.panel.ComponentPanelBuilder @@ -162,6 +161,9 @@ class CustomServiceCodeCompletionForm( } private fun testConnection() { + testConnectionButton.isEnabled = false + testConnectionButton.text = "Testing..." + val selectedTemplate = promptTemplateComboBox.selectedItem as InfillPromptTemplate val testRequest = InfillRequest.Builder("Hello", "!", 0).build() @@ -193,31 +195,54 @@ class CustomServiceCodeCompletionForm( } } + internal inner class TestConnectionEventListener : CompletionEventListener { + private var responseReceived = false + override fun onMessage(value: String?, eventSource: EventSource) { - if (!value.isNullOrEmpty()) { - runInEdt { - OverlayUtil.showBalloon( - CodeGPTBundle.get("settingsConfigurable.service.custom.openai.connectionSuccess"), - MessageType.INFO, - testConnectionButton - ) - eventSource.cancel() - } + if (!responseReceived) { + responseReceived = true + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") + OverlayUtil.showBalloon( + "Connection successful!", + MessageType.INFO, + testConnectionButton + ) + eventSource.cancel() } } override fun onError(error: ErrorDetails, ex: Throwable) { - runInEdt { + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") + OverlayUtil.showBalloon( + "Connection failed: ${error.message}", + MessageType.ERROR, + testConnectionButton + ) + } + + override fun onComplete(messageBuilder: StringBuilder) { + if (!responseReceived) { + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") OverlayUtil.showBalloon( - CodeGPTBundle.get("settingsConfigurable.service.custom.openai.connectionFailed") - + "\n\n" - + error.message, - MessageType.ERROR, + "Connection successful!", + MessageType.INFO, testConnectionButton ) } } + + override fun onCancelled(messageBuilder: StringBuilder) { + testConnectionButton.isEnabled = true + testConnectionButton.text = + CodeGPTBundle.get("settingsConfigurable.service.custom.openai.testConnection.label") + } } private fun updatePromptTemplateHelpTooltip(template: InfillPromptTemplate) { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceListForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceForm.kt similarity index 65% rename from src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceListForm.kt rename to src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceForm.kt index 601b8e08..ac1b5ab1 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceListForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceForm.kt @@ -31,22 +31,26 @@ import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceTempl import ee.carlrobert.codegpt.ui.OverlayUtil import ee.carlrobert.codegpt.ui.UIUtil import ee.carlrobert.codegpt.util.ApplicationUtil -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import okhttp3.internal.toImmutableList import java.awt.BorderLayout import java.awt.Dimension import java.awt.FlowLayout +import java.awt.event.ItemEvent import java.net.MalformedURLException import java.net.URL import javax.swing.* -class CustomServiceListForm( +class CustomServiceForm( private val service: CustomServicesSettings, - coroutineScope: CoroutineScope + private val coroutineScope: CoroutineScope ) { private val formState = MutableStateFlow(service.state.mapToData()) @@ -55,6 +59,9 @@ class CustomServiceListForm( private val customSettingsFileProvider = CustomSettingsFileProvider() private var lastSelectedIndex = 0 + private var selectedServiceId: String? = null + private var pendingSelectedId: String? = null + private var suppressSelectionEvents: Boolean = false private val customProvidersJBList = JBList(formState.value.services) .apply { @@ -62,6 +69,7 @@ class CustomServiceListForm( selectionMode = ListSelectionModel.SINGLE_SELECTION addListSelectionListener { _ -> + if (suppressSelectionEvents) return@addListSelectionListener val localSelectedIndex = selectedIndex if (localSelectedIndex != -1) { if (lastSelectedIndex != -1) { @@ -69,6 +77,7 @@ class CustomServiceListForm( } lastSelectedIndex = localSelectedIndex + selectedServiceId = model.getElementAt(localSelectedIndex).id updateFormData(lastSelectedIndex) } } @@ -76,9 +85,36 @@ class CustomServiceListForm( init { formState - .onEach { - customProvidersJBList.setListData(it.services.toTypedArray()) - customProvidersJBList.repaint() + .onEach { newState -> + val model = customProvidersJBList.model + val current = (0 until model.size).map { model.getElementAt(it) } + val currentIds = current.map { it.id } + val newIds = newState.services.map { it.id } + val idsChanged = currentIds != newIds + val namesChanged = !idsChanged && current.indices.any { i -> + i < newState.services.size && current[i].name != newState.services[i].name + } + if (idsChanged || namesChanged) { + SwingUtilities.invokeLater { + suppressSelectionEvents = true + try { + customProvidersJBList.setListData(newState.services.toTypedArray()) + val targetId = pendingSelectedId ?: selectedServiceId + val idx = newState.services.indexOfFirst { it.id == targetId } + val targetIndex = if (idx >= 0) idx else 0 + if (newState.services.isNotEmpty()) { + customProvidersJBList.selectedIndex = targetIndex + lastSelectedIndex = targetIndex + selectedServiceId = newState.services[targetIndex].id + updateFormDataSilently(newState.services[targetIndex]) + } + pendingSelectedId = null + customProvidersJBList.repaint() + } finally { + suppressSelectionEvents = false + } + } + } } .launchIn(coroutineScope) } @@ -99,10 +135,7 @@ class CustomServiceListForm( init { val selectedItem = formState.value.services.first() - - apiKeyField.text = runBlocking(Dispatchers.IO) { - getCredential(CredentialKey.CustomServiceApiKey(selectedItem.name.orEmpty())) - } + apiKeyField.text = getCredential(CredentialKey.CustomServiceApiKeyById(selectedItem.id)) chatCompletionsForm = CustomServiceChatCompletionForm(selectedItem.chatCompletionSettings, this::getApiKey) codeCompletionsForm = @@ -110,31 +143,8 @@ class CustomServiceListForm( tabbedPane = JBTabbedPane().apply { add(CodeGPTBundle.get("shared.chatCompletions"), chatCompletionsForm.form) add(CodeGPTBundle.get("shared.codeCompletions"), codeCompletionsForm.form) - templateComboBox.selectedItem = selectedItem.template - } - nameField.text = selectedItem.name - templateComboBox.addItemListener { - val template = it.item as CustomServiceTemplate - updateTemplateHelpTextTooltip(template) - chatCompletionsForm.run { - url = template.chatCompletionTemplate.url - headers = template.chatCompletionTemplate.headers - body = template.chatCompletionTemplate.body - } - if (template.codeCompletionTemplate != null) { - codeCompletionsForm.run { - url = template.codeCompletionTemplate.url - headers = template.codeCompletionTemplate.headers - body = template.codeCompletionTemplate.body - parseResponseAsChatCompletions = - template.codeCompletionTemplate.parseResponseAsChatCompletions - } - tabbedPane.setEnabledAt(1, true) - } else { - tabbedPane.selectedIndex = 0 - tabbedPane.setEnabledAt(1, false) - } } + exportButton = JButton(CodeGPTBundle.get("settingsConfigurable.service.custom.openai.exportSettings")).apply { addActionListener { exportSettingsToFile() } @@ -143,34 +153,83 @@ class CustomServiceListForm( JButton(CodeGPTBundle.get("settingsConfigurable.service.custom.openai.importSettings")).apply { addActionListener { importSettingsFromFile() } } - updateTemplateHelpTextTooltip(selectedItem.template) + + templateComboBox.addItemListener { event -> + if (event.stateChange == ItemEvent.SELECTED) { + val template = event.item as CustomServiceTemplate + updateTemplateHelpTextTooltip(template) + + chatCompletionsForm.run { + url = template.chatCompletionTemplate.url + headers = template.chatCompletionTemplate.headers + body = template.chatCompletionTemplate.body + } + if (template.codeCompletionTemplate != null) { + codeCompletionsForm.run { + url = template.codeCompletionTemplate.url + headers = template.codeCompletionTemplate.headers + body = template.codeCompletionTemplate.body + parseResponseAsChatCompletions = + template.codeCompletionTemplate.parseResponseAsChatCompletions + } + tabbedPane.setEnabledAt(1, true) + } else { + tabbedPane.selectedIndex = 0 + tabbedPane.setEnabledAt(1, false) + } + } + } + + updateFormDataSilently(selectedItem) + SwingUtilities.invokeLater { + if (customProvidersJBList.model.size > 0) { + customProvidersJBList.selectedIndex = 0 + lastSelectedIndex = 0 + selectedServiceId = customProvidersJBList.model.getElementAt(0).id + } + } } private fun updateFormData(index: Int) { val selectedItem = formState.value.services[index] + SwingUtilities.invokeLater { + updateFormDataSilently(selectedItem) + } + } - chatCompletionsForm.apply { - val chatCompletionSettings = selectedItem.chatCompletionSettings - url = chatCompletionSettings.url.orEmpty() - body = chatCompletionSettings.body.toMutableMap() - headers = chatCompletionSettings.headers.toMutableMap() + private fun updateFormDataSilently(selectedItem: CustomServiceSettingsData) { + val templateListener = templateComboBox.itemListeners.firstOrNull() + templateListener?.let { templateComboBox.removeItemListener(it) } + + try { + chatCompletionsForm.apply { + val chatCompletionSettings = selectedItem.chatCompletionSettings + url = chatCompletionSettings.url.orEmpty() + body = chatCompletionSettings.body.toMutableMap() + headers = chatCompletionSettings.headers.toMutableMap() + } + codeCompletionsForm.apply { + val codeCompletionSettings = selectedItem.codeCompletionSettings + url = codeCompletionSettings.url.orEmpty() + body = codeCompletionSettings.body.toMutableMap() + headers = codeCompletionSettings.headers.toMutableMap() + infillTemplate = codeCompletionSettings.infillTemplate + codeCompletionsEnabled = codeCompletionSettings.codeCompletionsEnabled + parseResponseAsChatCompletions = + codeCompletionSettings.parseResponseAsChatCompletions + } + + apiKeyField.text = getCredential(CredentialKey.CustomServiceApiKeyById(selectedItem.id)) + nameField.text = selectedItem.name + templateComboBox.selectedItem = selectedItem.template + updateTemplateHelpTextTooltip(selectedItem.template) + } finally { + templateListener?.let { templateComboBox.addItemListener(it) } } - codeCompletionsForm.apply { - val codeCompletionSettings = selectedItem.codeCompletionSettings - url = codeCompletionSettings.url.orEmpty() - body = codeCompletionSettings.body.toMutableMap() - headers = codeCompletionSettings.headers.toMutableMap() - infillTemplate = codeCompletionSettings.infillTemplate - codeCompletionsEnabled = codeCompletionSettings.codeCompletionsEnabled - parseResponseAsChatCompletions = codeCompletionSettings.parseResponseAsChatCompletions - } - apiKeyField.text = selectedItem.apiKey - nameField.text = selectedItem.name - templateComboBox.selectedItem = selectedItem.template - updateTemplateHelpTextTooltip(selectedItem.template) } private fun updateStateFromForm(editedIndex: Int) { + if (editedIndex < 0 || editedIndex >= formState.value.services.size) return formState.update { state -> val editedItem = state.services[editedIndex] @@ -252,41 +311,48 @@ class CustomServiceListForm( private fun handleRemoveAction() { val prevSelectedIndex = customProvidersJBList.selectedIndex + + // Update form state before deletion to ensure current edits are saved + if (lastSelectedIndex != -1 && lastSelectedIndex < formState.value.services.size) { + updateStateFromForm(lastSelectedIndex) + } + + val current = formState.value.services + val targetNeighborId = when { + current.isEmpty() -> null + prevSelectedIndex <= 0 && current.size >= 2 -> current[1].id + prevSelectedIndex > 0 -> current[prevSelectedIndex - 1].id + else -> null + } + formState.update { state -> - state.copy(services = state.services.filterIndexed { index, _ -> - index != customProvidersJBList.selectedIndex - }) - } - val newSelectedIndex = if (prevSelectedIndex == 0) { - 0 - } else { - prevSelectedIndex - 1 + state.copy(services = state.services.filterIndexed { index, _ -> index != prevSelectedIndex }) } + + pendingSelectedId = targetNeighborId lastSelectedIndex = -1 - updateFormData(newSelectedIndex) - customProvidersJBList.selectedIndex = newSelectedIndex } private fun handleDuplicateAction() { formState.update { val selectedIndex = customProvidersJBList.selectedIndex - val copiedService = - it.services[selectedIndex].copy(name = it.services[selectedIndex].name + "Copied") + val src = it.services[selectedIndex] + val copiedService = src.copy( + id = java.util.UUID.randomUUID().toString(), + name = src.name + "Copied" + ) it.copy( services = it.services + copiedService ) } - customProvidersJBList.selectedIndex = formState.value.services.lastIndex + pendingSelectedId = formState.value.services.last().id } private fun handleAddAction() { - formState.update { - it.copy( - services = it.services + CustomServiceSettingsState().apply { name += it.services.size } - .mapToData() - ) - } - customProvidersJBList.selectedIndex = formState.value.services.lastIndex + val newData = CustomServiceSettingsState().apply { name += formState.value.services.size } + .mapToData() + formState.update { it.copy(services = it.services + newData) } + pendingSelectedId = newData.id } private fun createContentPanel(): JPanel = FormBuilder.createFormBuilder() @@ -317,7 +383,9 @@ class CustomServiceListForm( fun getApiKey() = String(apiKeyField.password).ifEmpty { null } fun isModified(): Boolean { - updateStateFromForm(lastSelectedIndex) + if (lastSelectedIndex >= 0 && lastSelectedIndex < formState.value.services.size) { + updateStateFromForm(lastSelectedIndex) + } return service.state.mapToData() != formState.value } @@ -376,12 +444,8 @@ class CustomServiceListForm( .inSmartMode(it) .finishOnUiThread(ModalityState.defaultModalityState()) { settings -> if (settings != null) { - val newActualService = - settings.firstOrNull { it.name == formState.value.active.name } - ?: settings.first() - formState.update { state -> - state.copy(services = settings, active = newActualService) + state.copy(services = settings) } updateFormData(0) } @@ -432,51 +496,41 @@ class CustomServiceListForm( } fun applyChanges() { - if (!validateServiceNames()) { - OverlayUtil.showBalloon( - "Service names must be unique", - MessageType.ERROR, - customProvidersJBList, - ) - return + if (lastSelectedIndex != -1 && lastSelectedIndex < formState.value.services.size) { + updateStateFromForm(lastSelectedIndex) } val formStateValue = formState.value - val newActualService = - formStateValue.services.firstOrNull { it.name == formStateValue.active.name } - ?: formStateValue.services.first() - - // Cleanup saved api keys - val savedServicesName = service.state.services.mapNotNull { it.name } - val deletedServices = - savedServicesName.subtract(formStateValue.services.mapNotNull { it.name }.toSet()) - deletedServices.forEach { deletedServiceName -> - CredentialsStore.setCredential( - CredentialKey.CustomServiceApiKey(deletedServiceName), - null - ) + val prevById = service.state.services.associateBy { it.id } + val savedIds = prevById.keys.filterNotNull().toSet() + val newIds = formStateValue.services.map { it.id }.toSet() + val deletedIds = savedIds.subtract(newIds) + deletedIds.forEach { deletedId -> + CredentialsStore.setCredential(CredentialKey.CustomServiceApiKeyById(deletedId), null) } - // Save apiKeys formStateValue.services.forEach { - CredentialsStore.setCredential( - CredentialKey.CustomServiceApiKey(it.name.orEmpty()), - it.apiKey - ) + if (it.id.isNotBlank()) { + CredentialsStore.setCredential( + CredentialKey.CustomServiceApiKeyById(it.id), + it.apiKey + ) + } } - // Save settings service.state.run { services = formStateValue.services.mapTo(mutableListOf()) { it.mapToState() } - active = newActualService.mapToState() } formState.value = service.state.mapToData() } - private fun validateServiceNames(): Boolean { - val serviceNames = formState.value.services.mapNotNull { it.name } - val uniqueNames = serviceNames.toSet() - return serviceNames.size == uniqueNames.size + fun getSelectedServiceId(): String? { + val idx = customProvidersJBList.selectedIndex + return if (idx >= 0 && idx < formState.value.services.size) { + formState.value.services[idx].id + } else { + selectedServiceId + } } fun resetForm() { @@ -503,4 +557,4 @@ class CustomServiceListForm( throw RuntimeException(e) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceNameListRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceNameListRenderer.kt index 5424f0fb..6e094089 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceNameListRenderer.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceNameListRenderer.kt @@ -1,14 +1,12 @@ package ee.carlrobert.codegpt.settings.service.custom.form -import com.intellij.ui.render.LabelBasedRenderer import ee.carlrobert.codegpt.settings.service.custom.form.model.CustomServiceSettingsData import java.awt.Component import javax.swing.JLabel import javax.swing.JList import javax.swing.ListCellRenderer -internal class CustomServiceNameListRenderer : LabelBasedRenderer(), ListCellRenderer { - private val delegate = List() +internal class CustomServiceNameListRenderer : JLabel(), ListCellRenderer { override fun getListCellRendererComponent( list: JList, @@ -16,9 +14,12 @@ internal class CustomServiceNameListRenderer : LabelBasedRenderer(), ListCellRen index: Int, isSelected: Boolean, cellHasFocus: Boolean - ): Component = - delegate.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) - .also { component -> - (component as? JLabel)?.text = value?.name ?: "" - } -} \ No newline at end of file + ): Component { + text = value?.name ?: "" + isOpaque = true + background = if (isSelected) list.selectionBackground else list.background + foreground = if (isSelected) list.selectionForeground else list.foreground + font = list.font + return this + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServiceSettingsData.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServiceSettingsData.kt index d3be0470..76761d28 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServiceSettingsData.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServiceSettingsData.kt @@ -1,8 +1,10 @@ package ee.carlrobert.codegpt.settings.service.custom.form.model import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceTemplate +import java.util.UUID data class CustomServiceSettingsData( + val id: String = UUID.randomUUID().toString(), val name: String?, val template: CustomServiceTemplate, val apiKey: String?, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServicesStateData.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServicesStateData.kt index 97d52d73..e4e7d09c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServicesStateData.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/CustomServicesStateData.kt @@ -1,6 +1,3 @@ package ee.carlrobert.codegpt.settings.service.custom.form.model -data class CustomServicesStateData( - val services: List, - val active: CustomServiceSettingsData -) +data class CustomServicesStateData(val services: List) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/DataToStateMapper.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/DataToStateMapper.kt index 5c451ea7..21ad4902 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/DataToStateMapper.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/DataToStateMapper.kt @@ -3,10 +3,12 @@ package ee.carlrobert.codegpt.settings.service.custom.form.model import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState import ee.carlrobert.codegpt.settings.service.custom.CustomServiceCodeCompletionSettingsState import ee.carlrobert.codegpt.settings.service.custom.CustomServiceSettingsState +import java.util.UUID fun CustomServiceSettingsData.mapToState(): CustomServiceSettingsState = CustomServiceSettingsState().also { serviceState -> + serviceState.id = if (id.isBlank()) UUID.randomUUID().toString() else id serviceState.name = name serviceState.template = template serviceState.chatCompletionSettings = chatCompletionSettings.mapToState() diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/StateToDataMapper.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/StateToDataMapper.kt index 3bcb3ff7..76ebf851 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/StateToDataMapper.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/model/StateToDataMapper.kt @@ -5,25 +5,26 @@ import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletion import ee.carlrobert.codegpt.settings.service.custom.CustomServiceCodeCompletionSettingsState import ee.carlrobert.codegpt.settings.service.custom.CustomServiceSettingsState import ee.carlrobert.codegpt.settings.service.custom.CustomServicesState +import java.util.UUID fun CustomServicesState.mapToData(): CustomServicesStateData = - CustomServicesStateData( - services = services.map { it.mapToData() }, - active = active.mapToData() - ) + CustomServicesStateData(services.map { it.mapToData() }) fun CustomServiceSettingsState.mapToData(): CustomServiceSettingsData = CustomServiceSettingsData( + id = id ?: UUID.randomUUID().toString(), name = name, template = template, - apiKey = CredentialsStore.getCredential(CredentialsStore.CredentialKey.CustomServiceApiKey(name.orEmpty())), + apiKey = if (!id.isNullOrEmpty()) + CredentialsStore.getCredential(CredentialsStore.CredentialKey.CustomServiceApiKeyById(id!!)) + else null, chatCompletionSettings = chatCompletionSettings.mapToData(), codeCompletionSettings = codeCompletionSettings.mapToData() ) fun CustomServiceChatCompletionSettingsState.mapToData(): CustomServiceChatCompletionSettingsData = CustomServiceChatCompletionSettingsData( - url = url, + url = url ?: "", headers = headers, body = body ) @@ -33,7 +34,7 @@ fun CustomServiceCodeCompletionSettingsState.mapToData(): CustomServiceCodeCompl codeCompletionsEnabled = codeCompletionsEnabled, parseResponseAsChatCompletions = parseResponseAsChatCompletions, infillTemplate = infillTemplate, - url = url, + url = url ?: "", headers = headers, body = body - ) \ No newline at end of file + ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt index 59af03d6..51887178 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt @@ -26,8 +26,13 @@ object GitUtil { @JvmStatic fun getProjectRepository(project: Project): GitRepository? { val repositoryManager = project.service() - return repositoryManager.getRepositoryForFile(project.guessProjectDir()) - ?: repositoryManager.repositories.firstOrNull() + return try { + repositoryManager.getRepositoryForFile(project.guessProjectDir()) + ?: repositoryManager.repositories.firstOrNull() + } catch (e: Exception) { + logger.warn("Failed to get git repository", e) + repositoryManager.repositories.firstOrNull() + } } fun getCurrentChanges(project: Project): String? { diff --git a/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt index 0bf47497..ecda81f3 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt @@ -173,23 +173,14 @@ class ModelSettingsTest : IntegrationTest() { assertThat(notification.serviceType).isEqualTo(ServiceType.ANTHROPIC) } - fun `test getOrCreateModelSelection with existing selection returns stored model`() { - modelSettings.setModelWithProvider(FeatureType.CHAT, "gpt-4o", ServiceType.OPENAI) - - val result = modelSettings.getOrCreateModelSelection(FeatureType.CHAT) - - assertThat(result.provider).isEqualTo(ServiceType.OPENAI) - assertThat(result.model).isEqualTo("gpt-4o") - } - fun `test getModelSelection with valid feature returns model selection`() { modelSettings.setModelWithProvider(FeatureType.CHAT, "gpt-4o", ServiceType.OPENAI) val result = modelSettings.getModelSelection(FeatureType.CHAT) assertThat(result).isNotNull - assertThat(result.provider).isEqualTo(ServiceType.OPENAI) - assertThat(result.model).isEqualTo("gpt-4o") + assertThat(result?.provider).isEqualTo(ServiceType.OPENAI) + assertThat(result?.model).isEqualTo("gpt-4o") } fun `test getModelForFeature returns stored model`() { diff --git a/src/test/kotlin/ee/carlrobert/codegpt/settings/remote/RemoteSettingsFormIntegrationTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/settings/remote/RemoteSettingsFormIntegrationTest.kt new file mode 100644 index 00000000..e69de29b