From ac71659d27c480bc80982cf2e60da2d0540b2c8c Mon Sep 17 00:00:00 2001 From: Hai Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:03:08 -0700 Subject: [PATCH 1/2] Update --- .../io/askimo/core/context/AppContext.kt | 24 ------------------- .../core/providers/ProviderInstanceService.kt | 2 +- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt b/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt index 1c55641f2..f7a474f3a 100644 --- a/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt +++ b/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt @@ -257,30 +257,6 @@ class AppContext private constructor( return newInstance.settings } - /** - * Updates the settings of the first instance of [provider] type, or creates a new - * instance if none exists for that type. - * - * When there is an active instance of a different type, this does **not** change - * [params.currentInstanceId] — call [setCurrentInstance] explicitly if needed. - */ - fun setProviderSetting( - provider: ModelProvider, - settings: ProviderSettings, - ) { - val existing = params.providerInstances.firstOrNull { it.providerType == provider } - if (existing != null) { - params.replaceInstance(existing.copy(settings = settings)) - } else { - val newInstance = ProviderInstance.create( - displayName = provider.providerKey(), - providerType = provider, - settings = settings, - ) - params.upsertInstance(newInstance) - } - } - /** * Persists the current [params] to the YAML config file via [AppConfig]. */ diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ProviderInstanceService.kt b/shared/src/main/kotlin/io/askimo/core/providers/ProviderInstanceService.kt index ffb8e24fb..733b4563a 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/ProviderInstanceService.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/ProviderInstanceService.kt @@ -28,7 +28,7 @@ class ProviderInstanceService(private val appContext: AppContext) { get() = appContext.params.activeInstance /** Finds an instance by its stable [id], or null if not found. */ - fun findById(id: String): ProviderInstance? = appContext.params.providerInstances.firstOrNull { it.id == id } + fun findById(id: String): ProviderInstance? = appContext.params.providerInstances.firstOrNull { it.id == id }.also { if (it == null) log.warn("Could not find provider instance with id {}", id) } /** * Returns `true` when [displayName] (trimmed, case-insensitive) is not already taken by From dce0786adee767ae3b86fdaf841b2e59545ea3eb Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:17:46 -0700 Subject: [PATCH 2/2] Update --- .../main/resources/i18n/messages.properties | 1 + .../resources/i18n/messages_de.properties | 1 + .../resources/i18n/messages_es.properties | 1 + .../resources/i18n/messages_fr.properties | 1 + .../resources/i18n/messages_ja_JP.properties | 1 + .../resources/i18n/messages_ko_KR.properties | 1 + .../resources/i18n/messages_pt_BR.properties | 1 + .../resources/i18n/messages_vi_VN.properties | 1 + .../resources/i18n/messages_zh_CN.properties | 1 + .../resources/i18n/messages_zh_TW.properties | 1 + .../src/main/kotlin/io/askimo/desktop/Main.kt | 5 +- .../io/askimo/desktop/di/DesktopModule.kt | 4 +- .../settings/AIProviderSettingsSection.kt | 11 +- .../settings/ProviderSelectionDialog.kt | 21 +- .../settings/ProviderWizardViewModel.kt | 658 ++++++++++++++++++ .../desktop/settings/SettingsConfigDialog.kt | 2 +- .../askimo/desktop/settings/SettingsView.kt | 7 +- .../desktop/settings/SettingsViewModel.kt | 647 ++--------------- .../io/askimo/desktop/di/DesktopModuleTest.kt | 4 +- .../io/askimo/core/context/AppContext.kt | 70 +- .../internal/ProviderInstanceSavedEvent.kt | 30 + 21 files changed, 813 insertions(+), 656 deletions(-) create mode 100644 desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt create mode 100644 shared/src/main/kotlin/io/askimo/core/event/internal/ProviderInstanceSavedEvent.kt diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index bc285251b..748b66a28 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -791,6 +791,7 @@ settings.model.select.title=Select Model settings.model.current=Current Model settings.model.selected=Selected Model settings.model.new=New Model +settings.model.use.default=Use default settings.model.change.description=Choose a model from the list below: settings.model.search=Search Models settings.model.search.placeholder=Type to filter models... diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 98383249b..72d67c25f 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -767,6 +767,7 @@ settings.model.select.title=Modell auswählen settings.model.current=Aktuelles Modell settings.model.selected=Ausgewähltes Modell settings.model.new=Neues Modell +settings.model.use.default=Standard verwenden settings.model.change.description=Wählen Sie ein Modell aus der folgenden Liste aus: settings.model.search=Modelle durchsuchen settings.model.search.placeholder=Eingeben, um Modelle zu filtern... diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index d5ef6b680..a76830c52 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -765,6 +765,7 @@ settings.model.select.title=Seleccionar modelo settings.model.current=Modelo actual settings.model.selected=Modelo seleccionado settings.model.new=Nuevo modelo +settings.model.use.default=Usar predeterminado settings.model.change.description=Elige un modelo de la lista a continuación: settings.model.search=Buscar modelos settings.model.search.placeholder=Escribe para filtrar modelos... diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 073d20138..7f75a4c09 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -765,6 +765,7 @@ settings.model.select.title=Sélection du modèle settings.model.current=Modèle actuel settings.model.selected=Modèle sélectionné settings.model.new=Nouveau modèle +settings.model.use.default=Utiliser par défaut settings.model.change.description=Choisissez un modèle dans la liste ci-dessous : settings.model.search=Rechercher un modèle settings.model.search.placeholder=Filtrer les modèles... diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index 6b7cb1d19..d0af266cc 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -765,6 +765,7 @@ settings.model.select.title=モデルを選択 settings.model.current=現在のモデル settings.model.selected=選択されたモデル settings.model.new=新しいモデル +settings.model.use.default=デフォルトを使用 settings.model.change.description=以下のリストからモデルを選択してください: settings.model.search=モデル検索 settings.model.search.placeholder=モデルの絞り込み... diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index 40d106ee1..27e9e7a09 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -765,6 +765,7 @@ settings.model.select.title=모델 선택 settings.model.current=현재 모델 settings.model.selected=선택된 모델 settings.model.new=새 모델 +settings.model.use.default=기본값 사용 settings.model.change.description=아래 목록에서 모델을 선택하세요: settings.model.search=모델 검색 settings.model.search.placeholder=모델 필터링 입력... diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index 85191b665..8222f5d8c 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -768,6 +768,7 @@ settings.model.select.title=Selecionar Modelo settings.model.current=Modelo Atual settings.model.selected=Modelo Selecionado settings.model.new=Novo modelo +settings.model.use.default=Usar padrão settings.model.change.description=Escolha um modelo na lista abaixo: settings.model.search=Buscar Modelos settings.model.search.placeholder=Digite para filtrar modelos... diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index 0f857b934..f3121ff04 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -764,6 +764,7 @@ settings.model.select.title=Chọn mô hình settings.model.current=Mô hình hiện tại settings.model.selected=Mô hình đã chọn settings.model.new=Mô hình mới +settings.model.use.default=Sử dụng mặc định settings.model.change.description=Chọn một mô hình từ danh sách dưới đây: settings.model.search=Tìm mô hình settings.model.search.placeholder=Nhập để lọc mô hình... diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index 539e998f1..bc0eb89bd 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -765,6 +765,7 @@ settings.model.select.title=选择模型 settings.model.current=当前模型 settings.model.selected=已选模型 settings.model.new=新模型 +settings.model.use.default=使用默认值 settings.model.change.description=从下面的列表中选择一个模型: settings.model.search=搜索模型 settings.model.search.placeholder=输入以筛选模型... diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index 9ef0ba706..794bf65f0 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -765,6 +765,7 @@ settings.model.select.title=選擇模型 settings.model.current=目前模型 settings.model.selected=已選取模型 settings.model.new=新模型 +settings.model.use.default=使用預設值 settings.model.change.description=從下方列表中選擇一個模型: settings.model.search=搜尋模型 settings.model.search.placeholder=輸入以篩選模型... diff --git a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt index 9cfd9102e..352fa9241 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt @@ -1461,8 +1461,9 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = ) } - if (settingsViewModel.showProviderWizard) { - providerWizardDialog(viewModel = settingsViewModel) + val wizard = settingsViewModel.wizardViewModel + if (wizard != null) { + providerWizardDialog(viewModel = wizard) } // About Dialog diff --git a/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt b/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt index a0ced499f..611e78e49 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/di/DesktopModule.kt @@ -17,7 +17,7 @@ import io.askimo.core.providers.ProviderInstanceService import io.askimo.core.tools.ToolProviderImpl import io.askimo.desktop.project.ProjectViewModel import io.askimo.desktop.project.ProjectsViewModel -import io.askimo.desktop.settings.SettingsViewModel +import io.askimo.desktop.settings.AIProviderViewModel import io.askimo.ui.chat.ProjectIndexStateManager import io.askimo.ui.common.monitoring.SystemResourceMonitor import io.askimo.ui.discover.DiscoverViewModel @@ -126,7 +126,7 @@ val desktopModule = module { } factory { (scope: CoroutineScope) -> - SettingsViewModel(scope = scope, appContext = get(), providerInstanceService = get()) + AIProviderViewModel(scope = scope, appContext = get(), providerInstanceService = get()) } // Commands diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt index 31ffed587..e60ba9b2e 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt @@ -68,7 +68,7 @@ import java.awt.Desktop import java.net.URI @Composable -fun aiProviderSettingsSection(viewModel: SettingsViewModel) { +fun aiProviderSettingsSection(viewModel: AIProviderViewModel) { val scrollState = rememberScrollState() Box(modifier = Modifier.fillMaxSize()) { @@ -169,7 +169,7 @@ fun aiProviderSettingsSection(viewModel: SettingsViewModel) { } @Composable -private fun providerModelConfigCard(instance: ProviderInstance, viewModel: SettingsViewModel) { +private fun providerModelConfigCard(instance: ProviderInstance, viewModel: AIProviderViewModel) { val provider = instance.providerType val isLocalProvider = provider in setOf( ModelProvider.OLLAMA, @@ -533,6 +533,13 @@ private fun providerModelTypePickerDialog( null }, actions = { + // "Use default" is only relevant when an override is already set + if (currentValue.isNotBlank()) { + secondaryButton(onClick = { onSelect("") }) { + Text(stringResource("settings.model.use.default")) + } + Spacer(modifier = Modifier.weight(1f)) + } secondaryButton(onClick = onDismiss) { Text(stringResource("action.cancel")) } Spacer(modifier = Modifier.width(Spacing.small)) primaryButton( diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt index 3ba82ddab..5a8fe5465 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.askimo.core.AppConstants.DOMAIN import io.askimo.core.providers.ModelDTO -import io.askimo.core.providers.ModelProvider import io.askimo.core.providers.ProviderConfigField import io.askimo.core.providers.ProviderRegistry import io.askimo.core.providers.filterChatModels @@ -63,7 +62,7 @@ import java.net.URI // ── Dialog: Provider wizard (add & edit) ────────────────────────────────────────────────── @Composable -fun providerWizardDialog(viewModel: SettingsViewModel) { +fun providerWizardDialog(viewModel: ProviderWizardViewModel) { val title = when (viewModel.wizardStep) { WizardStep.MODEL -> stringResource("settings.model.select.title") @@ -251,7 +250,7 @@ fun providerWizardDialog(viewModel: SettingsViewModel) { // ── Screen 2: Provider type picker ──────────────────────────────────────────────────────── @Composable -private fun providerTypePickerScreen(viewModel: SettingsViewModel) { +private fun providerTypePickerScreen(viewModel: ProviderWizardViewModel) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Spacing.small), @@ -303,7 +302,7 @@ private fun providerTypePickerScreen(viewModel: SettingsViewModel) { // ── Screen 3: Instance config form ──────────────────────────────────────────────────────── @Composable -private fun instanceConfigScreen(viewModel: SettingsViewModel) { +private fun instanceConfigScreen(viewModel: ProviderWizardViewModel) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Spacing.large), @@ -451,18 +450,6 @@ private fun instanceConfigScreen(viewModel: SettingsViewModel) { Text(text = viewModel.embeddingModelWarning ?: "", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.9f)) } } - if (viewModel.canPullEmbeddingModel && viewModel.embeddingModelProvider == "OLLAMA") { - primaryButton(onClick = { - val baseUrl = viewModel.providerFieldValues["baseUrl"] ?: "" - if (baseUrl.isNotBlank()) viewModel.pullEmbeddingModel(ModelProvider.OLLAMA, baseUrl) - }, enabled = !viewModel.isCheckingEmbeddingModel) { - if (viewModel.isCheckingEmbeddingModel) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) - Spacer(Modifier.width(8.dp)) - } - Text(stringResource("settings.embedding.download_model")) - } - } } } } @@ -474,7 +461,7 @@ private fun instanceConfigScreen(viewModel: SettingsViewModel) { @Composable private fun modelPickerScreen( - viewModel: SettingsViewModel, + viewModel: ProviderWizardViewModel, filteredModels: List, searchQuery: String, isFiltered: Boolean, diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt new file mode 100644 index 000000000..3cee8c252 --- /dev/null +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt @@ -0,0 +1,658 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.desktop.settings + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.askimo.core.config.AppConfig +import io.askimo.core.context.AppContext +import io.askimo.core.error.AppError +import io.askimo.core.event.EventBus +import io.askimo.core.event.internal.ProviderInstanceSavedEvent +import io.askimo.core.i18n.LocalizationManager +import io.askimo.core.logging.logger +import io.askimo.core.providers.ChatModelFactory +import io.askimo.core.providers.LocalModelValidator +import io.askimo.core.providers.ModelAvailabilityResult +import io.askimo.core.providers.ModelDTO +import io.askimo.core.providers.ModelProvider +import io.askimo.core.providers.ProviderConfigField +import io.askimo.core.providers.ProviderInstance +import io.askimo.core.providers.ProviderInstanceService +import io.askimo.core.providers.ProviderRegistry +import io.askimo.core.providers.ProviderSettings +import io.askimo.core.providers.ProviderTestResult +import io.askimo.core.providers.SettingField +import io.askimo.ui.util.ErrorHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +/** Wizard navigation steps for the provider add/edit wizard. */ +enum class WizardStep { TYPE_PICKER, CONFIG, MODEL } + +/** + * ViewModel for the multi-step provider add/edit wizard (TYPE_PICKER → CONFIG → MODEL). + * + * Created fresh by [AIProviderViewModel] each time the wizard opens via + * [AIProviderViewModel.openAddProviderWizard] or [AIProviderViewModel.openEditProviderWizard]. + * Torn down (set to null) when it signals close via [onClose]. + * + * On successful save, emits [ProviderInstanceSavedEvent] on [EventBus] so that + * [AIProviderViewModel] can refresh its active-configuration display without direct coupling. + * + * @param onClose Called when the wizard should be dismissed (both on cancel and after a + * successful save). The caller ([AIProviderViewModel]) sets its + * [AIProviderViewModel.wizardViewModel] reference to null in response. + */ +class ProviderWizardViewModel( + private val scope: CoroutineScope, + private val appContext: AppContext, + private val providerInstanceService: ProviderInstanceService, + private val onClose: () -> Unit, +) { + private val log = logger() + + // ── Step ───────────────────────────────────────────────────────────────────────────────── + + var wizardStep by mutableStateOf(WizardStep.TYPE_PICKER) + private set + + // ── Feedback ───────────────────────────────────────────────────────────────────────────── + + var showSuccessMessage by mutableStateOf(false) + private set + + var successMessage by mutableStateOf("") + private set + + // ── Provider list (TYPE_PICKER step) ───────────────────────────────────────────────────── + + /** All supported provider types shown in the TYPE_PICKER step. */ + var availableProviders by mutableStateOf>(emptyList()) + private set + + /** All configured instances — used to show existing-count badges in the type picker. */ + var availableInstances by mutableStateOf>(emptyList()) + private set + + // ── Instance being edited ───────────────────────────────────────────────────────────────── + + /** + * Non-null when the wizard is in **edit** mode; null when adding a new instance. + * Derive [isAddingNewInstance] from this. + */ + var editingInstance by mutableStateOf(null) + private set + + /** True when the wizard is in add mode (no instance being edited). */ + val isAddingNewInstance: Boolean get() = editingInstance == null + + // ── Provider type ───────────────────────────────────────────────────────────────────────── + + /** The provider type chosen in the TYPE_PICKER step or taken from the edited instance. */ + var selectedProvider by mutableStateOf(null) + private set + + // ── Display-name fields ─────────────────────────────────────────────────────────────────── + + /** Editable display name for a **new** instance. */ + var newInstanceDisplayName by mutableStateOf("") + private set + + /** Editable display name when **editing** an existing instance. */ + var editingInstanceDisplayName by mutableStateOf("") + private set + + var displayNameError by mutableStateOf(null) + private set + + // ── Config fields ───────────────────────────────────────────────────────────────────────── + + var providerConfigFields by mutableStateOf>(emptyList()) + private set + + var providerFieldValues by mutableStateOf>(emptyMap()) + private set + + // ── Connection / fetch state ────────────────────────────────────────────────────────────── + + var isTestingConnection by mutableStateOf(false) + private set + + var isFetchingModelsForConfig by mutableStateOf(false) + private set + + private var autoFetchJob: Job? = null + + var connectionError by mutableStateOf(null) + private set + + var connectionErrorHelp by mutableStateOf(null) + private set + + var connectionTestSuccess by mutableStateOf(false) + private set + + // ── Model picker ────────────────────────────────────────────────────────────────────────── + + var pendingModelForNewProvider by mutableStateOf(null) + private set + + var availableModels by mutableStateOf>(emptyList()) + private set + + var isLoadingModels by mutableStateOf(false) + private set + + var modelError by mutableStateOf(null) + private set + + var modelErrorHelp by mutableStateOf(null) + private set + + // ── Embedding model check ───────────────────────────────────────────────────────────────── + + var isCheckingEmbeddingModel by mutableStateOf(false) + private set + + var embeddingModelWarning by mutableStateOf(null) + private set + + var embeddingModelProvider by mutableStateOf(null) + private set + + var canPullEmbeddingModel by mutableStateOf(false) + private set + + // ── Init helpers ────────────────────────────────────────────────────────────────────────── + + /** + * Initialises the wizard in **add** mode (starts at TYPE_PICKER step). + * Called immediately after construction by [AIProviderViewModel.openAddProviderWizard]. + */ + fun initAddMode() { + availableInstances = providerInstanceService.all + availableProviders = ProviderRegistry.getSupportedProviders() + .filter { it != ModelProvider.UNKNOWN && it != ModelProvider.ASKIMO_PRO } + .sortedBy { ProviderRegistry.getProviderDisplayName(it) } + editingInstance = null + selectedProvider = null + newInstanceDisplayName = "" + wizardStep = WizardStep.TYPE_PICKER + resetWizardFormState() + } + + /** + * Initialises the wizard in **edit** mode (starts at CONFIG step, pre-populated). + * Called immediately after construction by [AIProviderViewModel.openEditProviderWizard]. + */ + fun initEditMode(instance: ProviderInstance) { + editingInstance = instance + selectedProvider = instance.providerType + editingInstanceDisplayName = instance.displayName + wizardStep = WizardStep.CONFIG + resetWizardFormState() + + providerConfigFields = instance.settings.getConfigFields(LocalizationManager.messageResolver) + providerFieldValues = providerConfigFields.mapNotNull { field -> + when (field) { + is ProviderConfigField.ApiKeyField -> field.name to field.value + is ProviderConfigField.BaseUrlField -> field.name to field.value + is ProviderConfigField.InfoField -> null + } + }.toMap() + + scheduleAutoModelFetch() + } + + // ── Navigation ──────────────────────────────────────────────────────────────────────────── + + /** Resets internal state and notifies the parent via [onClose] to tear this VM down. */ + fun closeProviderWizard() { + autoFetchJob?.cancel() + editingInstance = null + selectedProvider = null + newInstanceDisplayName = "" + editingInstanceDisplayName = "" + wizardStep = WizardStep.TYPE_PICKER + resetWizardFormState() + onClose() + } + + /** + * Navigates back within the wizard: + * MODEL → CONFIG, CONFIG → TYPE_PICKER (add) or close (edit), TYPE_PICKER → close. + */ + fun wizardBack() { + when (wizardStep) { + WizardStep.MODEL -> { + wizardStep = WizardStep.CONFIG + pendingModelForNewProvider = null + } + + WizardStep.CONFIG -> { + if (isAddingNewInstance) { + selectedProvider = null + wizardStep = WizardStep.TYPE_PICKER + resetWizardFormState() + } else { + closeProviderWizard() + } + } + + WizardStep.TYPE_PICKER -> closeProviderWizard() + } + } + + // ── Step actions ────────────────────────────────────────────────────────────────────────── + + /** Called when the user picks a provider type in the TYPE_PICKER step. */ + fun selectProviderTypeForNewInstance(providerType: ModelProvider) { + selectedProvider = providerType + newInstanceDisplayName = ProviderRegistry.getProviderDisplayName(providerType) + wizardStep = WizardStep.CONFIG + resetWizardFormState() + + val defaultSettings = ProviderRegistry.getFactory(providerType)?.defaultSettings() + providerConfigFields = defaultSettings?.getConfigFields(LocalizationManager.messageResolver) ?: emptyList() + providerFieldValues = providerConfigFields.mapNotNull { field -> + when (field) { + is ProviderConfigField.ApiKeyField -> field.name to field.value + is ProviderConfigField.BaseUrlField -> field.name to field.value + is ProviderConfigField.InfoField -> null + } + }.toMap() + + scheduleAutoModelFetch() + } + + fun updateNewInstanceDisplayName(name: String) { + newInstanceDisplayName = name + displayNameError = if (name.isNotBlank() && !providerInstanceService.isDisplayNameAvailable(name)) { + LocalizationManager.getString("provider.instance.name.duplicate", name.trim()) + } else { + null + } + } + + fun updateEditingInstanceDisplayName(name: String) { + editingInstanceDisplayName = name + displayNameError = if (name.isNotBlank() && !providerInstanceService.isDisplayNameAvailable(name, excludingId = editingInstance?.id)) { + LocalizationManager.getString("provider.instance.name.duplicate", name.trim()) + } else { + null + } + } + + fun updateProviderField(fieldName: String, value: String) { + providerFieldValues = providerFieldValues.toMutableMap().apply { put(fieldName, value) } + scheduleAutoModelFetch() + } + + fun selectModelForNewProvider(model: String) { + pendingModelForNewProvider = model + } + + /** Called when the user explicitly clicks "Next" on the CONFIG step (add mode). */ + fun advanceToModelPicker() { + val candidateName = newInstanceDisplayName.ifBlank { + selectedProvider?.let { ProviderRegistry.getProviderDisplayName(it) } ?: "" + } + if (!providerInstanceService.isDisplayNameAvailable(candidateName)) { + displayNameError = LocalizationManager.getString("provider.instance.name.duplicate", candidateName.trim()) + return + } + displayNameError = null + wizardStep = WizardStep.MODEL + } + + /** + * Saves the instance. On success: + * 1. Sets [showSuccessMessage] with a human-readable confirmation. + * 2. Emits [ProviderInstanceSavedEvent] on [EventBus]. + * 3. Calls [closeProviderWizard] → [onClose] to tear this VM down. + */ + fun saveProvider() { + val provider = selectedProvider ?: return + + if (!validateConfigFields(providerFieldValues, providerConfigFields)) { + connectionError = "Please fill in all required fields" + return + } + + val candidateName = if (editingInstance != null) { + editingInstanceDisplayName.ifBlank { editingInstance!!.displayName } + } else { + newInstanceDisplayName.ifBlank { ProviderRegistry.getProviderDisplayName(provider) } + } + val excludingId = editingInstance?.id + if (!providerInstanceService.isDisplayNameAvailable(candidateName, excludingId = excludingId)) { + displayNameError = LocalizationManager.getString("provider.instance.name.duplicate", candidateName.trim()) + return + } + + isTestingConnection = true + connectionError = null + connectionErrorHelp = null + + scope.launch { + val wasAdding = isAddingNewInstance + var savedInstanceId = "" + var savedDisplayName = "" + + val result = withContext(Dispatchers.IO) { + try { + val baseSettings = editingInstance?.settings + ?: ProviderRegistry.getFactory(provider)?.defaultSettings() + + val newSettings = baseSettings?.applyConfigFields(providerFieldValues) + ?: return@withContext ProviderTestResult.Failure("Failed to create settings") + + if (!newSettings.validate()) { + return@withContext ProviderTestResult.Failure( + message = "Cannot connect to ${provider.name.lowercase()} provider", + helpText = newSettings.getSetupHelpText(LocalizationManager.messageResolver), + ) + } + + val pendingModel = pendingModelForNewProvider?.takeIf { it.isNotBlank() } + ?: newSettings.defaultModel + val settingsWithModel = if (pendingModel.isNotBlank()) { + newSettings.updateField(SettingField.DEFAULT_MODEL, pendingModel) + } else { + newSettings + } + + try { + if (editingInstance != null) { + val displayName = editingInstanceDisplayName.ifBlank { editingInstance!!.displayName } + val updated = editingInstance!!.copy(displayName = displayName, settings = settingsWithModel) + providerInstanceService.update(updated).getOrThrow() + savedInstanceId = updated.id + savedDisplayName = displayName + } else { + val displayName = newInstanceDisplayName.ifBlank { ProviderRegistry.getProviderDisplayName(provider) } + val newInstance = ProviderRegistry.createInstance( + providerType = provider, + displayName = displayName, + settings = settingsWithModel, + ) + providerInstanceService.add(newInstance).getOrThrow() + savedInstanceId = newInstance.id + savedDisplayName = displayName + } + ProviderTestResult.Success + } catch (e: Exception) { + log.error("Error saving instance", e) + val appError = (e as? AppError) ?: (e.cause as? AppError) + if (appError is AppError.DuplicateEntry) { + ProviderTestResult.Failure("A provider named \"${appError.value.trim()}\" already exists") + } else { + ProviderTestResult.Failure("Failed to save provider instance") + } + } + } catch (e: Exception) { + log.error("Error saving instance", e) + ProviderTestResult.Failure( + ErrorHandler.getUserFriendlyError(e, "saving instance", "Failed to apply provider settings. Please try again."), + ) + } + } + + isTestingConnection = false + + when (result) { + is ProviderTestResult.Success -> { + successMessage = if (wasAdding) "Provider \"$savedDisplayName\" added" else "Provider settings updated" + showSuccessMessage = true + EventBus.emit( + ProviderInstanceSavedEvent( + instanceId = savedInstanceId, + displayName = savedDisplayName, + isNewInstance = wasAdding, + ), + ) + closeProviderWizard() + } + + is ProviderTestResult.Failure -> { + connectionError = result.message + connectionErrorHelp = result.helpText + } + } + } + } + + // ── Embedding model availability ────────────────────────────────────────────────────────── + + fun checkEmbeddingModelAvailability(provider: ModelProvider, baseUrl: String) { + isCheckingEmbeddingModel = true + embeddingModelWarning = null + embeddingModelProvider = null + canPullEmbeddingModel = false + + scope.launch { + try { + val result = withContext(Dispatchers.IO) { + fun resolveEmbeddingModel(p: ModelProvider): String { + val instanceModel = editingInstance?.settings?.embeddingModel?.takeIf { it.isNotBlank() } + return instanceModel ?: AppConfig.models[p].embeddingModel + } + when (provider) { + ModelProvider.OLLAMA -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.OLLAMA)) + ModelProvider.DOCKER -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.DOCKER)) + ModelProvider.LOCALAI -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.LOCALAI)) + ModelProvider.LMSTUDIO -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.LMSTUDIO)) + ModelProvider.ANTHROPIC -> ModelAvailabilityResult.NotAvailable(reason = LocalizationManager.getString("settings.embedding.anthropic_no_embedding"), canAutoPull = false) + ModelProvider.XAI -> ModelAvailabilityResult.NotAvailable(reason = LocalizationManager.getString("settings.embedding.xai_no_embedding"), canAutoPull = false) + else -> ModelAvailabilityResult.Available + } + } + when (result) { + is ModelAvailabilityResult.Available -> embeddingModelWarning = null + + is ModelAvailabilityResult.NotAvailable -> { + embeddingModelWarning = LocalizationManager.getString("settings.embedding.not_available_rag_only", result.reason) + embeddingModelProvider = provider.name + canPullEmbeddingModel = result.canAutoPull + } + + is ModelAvailabilityResult.ProviderUnreachable -> { + embeddingModelWarning = LocalizationManager.getString("settings.embedding.provider_unreachable", result.error) + embeddingModelProvider = provider.name + canPullEmbeddingModel = false + } + } + } catch (e: Exception) { + log.error("Error checking embedding model availability", e) + embeddingModelWarning = LocalizationManager.getString("settings.embedding.check_failed", e.message ?: "Unknown error") + } finally { + isCheckingEmbeddingModel = false + } + } + } + + fun pullEmbeddingModel(provider: ModelProvider, baseUrl: String) { + if (provider != ModelProvider.OLLAMA) return + isCheckingEmbeddingModel = true + scope.launch { + try { + val modelName = AppConfig.models[ModelProvider.OLLAMA].embeddingModel + val success = withContext(Dispatchers.IO) { LocalModelValidator.pullOllamaModel(baseUrl, modelName) } + if (success) { + embeddingModelWarning = null + successMessage = LocalizationManager.getString("settings.embedding.download_success", modelName) + showSuccessMessage = true + } else { + embeddingModelWarning = LocalizationManager.getString("settings.embedding.download_failed", modelName) + } + } catch (e: Exception) { + log.error("Error pulling embedding model", e) + embeddingModelWarning = LocalizationManager.getString("settings.embedding.download_error", e.message ?: "Unknown error") + } finally { + isCheckingEmbeddingModel = false + } + } + } + + // ── Private helpers ─────────────────────────────────────────────────────────────────────── + + /** Debounced model fetch triggered on field changes; validates connection and loads models. */ + private fun scheduleAutoModelFetch() { + autoFetchJob?.cancel() + + if (!validateConfigFields(providerFieldValues, providerConfigFields)) { + connectionError = null + connectionErrorHelp = null + isFetchingModelsForConfig = false + return + } + + val provider = selectedProvider ?: return + connectionError = null + connectionErrorHelp = null + isFetchingModelsForConfig = true + + autoFetchJob = scope.launch { + delay(1000.milliseconds) + + val result = withContext(Dispatchers.IO) { + try { + val baseSettings = editingInstance?.settings ?: ProviderRegistry.getFactory(provider)?.defaultSettings() + val newSettings = baseSettings?.applyConfigFields(providerFieldValues) + ?: return@withContext ProviderTestResult.Failure("Failed to create settings") + + if (!newSettings.validate()) { + return@withContext ProviderTestResult.Failure( + message = "Cannot connect to ${provider.name.lowercase()} provider", + helpText = newSettings.getSetupHelpText(LocalizationManager.messageResolver), + ) + } + + val factory = ProviderRegistry.getFactory(provider) + ?: return@withContext ProviderTestResult.Failure("No factory found for provider") + + @Suppress("UNCHECKED_CAST") + val models = (factory as ChatModelFactory) + .availableModels((editingInstance?.settings ?: factory.defaultSettings()).applyConfigFields(providerFieldValues)) + + isLoadingModels = false + if (models.isNotEmpty()) { + ProviderTestResult.Success + } else { + ProviderTestResult.Failure(message = LocalizationManager.getString("provider.connection.failed"), helpText = null) + } + } catch (e: Exception) { + log.error("Error auto-fetching models for provider config", e) + ProviderTestResult.Failure( + ErrorHandler.getUserFriendlyError(e, "fetching models", "Could not reach the provider. Please check your settings."), + ) + } + } + + isFetchingModelsForConfig = false + + when (result) { + is ProviderTestResult.Success -> { + connectionError = null + connectionErrorHelp = null + connectionTestSuccess = true + loadModelsForSelectedProvider() + val baseUrl = providerFieldValues[SettingField.BASE_URL] + if (!baseUrl.isNullOrBlank()) checkEmbeddingModelAvailability(provider, baseUrl) + } + + is ProviderTestResult.Failure -> { + connectionError = result.message + connectionErrorHelp = result.helpText + connectionTestSuccess = false + } + } + } + } + + private fun loadModelsForSelectedProvider() { + modelError = null + modelErrorHelp = null + isLoadingModels = true + + scope.launch { + val provider = selectedProvider ?: run { + isLoadingModels = false + availableModels = emptyList() + modelError = "Provider not set" + pendingModelForNewProvider = null + return@launch + } + + withContext(Dispatchers.IO) { + val factory = ProviderRegistry.getFactory(provider) ?: run { + isLoadingModels = false + availableModels = emptyList() + modelError = "No model factory registered for provider: ${provider.name.lowercase()}" + pendingModelForNewProvider = null + return@withContext + } + + @Suppress("UNCHECKED_CAST") + val models = (factory as ChatModelFactory) + .availableModels((editingInstance?.settings ?: factory.defaultSettings()).applyConfigFields(providerFieldValues)) + + isLoadingModels = false + if (models.isEmpty()) { + availableModels = emptyList() + modelError = "No models available for ${provider.name.lowercase()}" + modelErrorHelp = factory.getNoModelsHelpText() + pendingModelForNewProvider = null + } else { + availableModels = models + modelError = null + modelErrorHelp = null + val prev = editingInstance?.settings?.defaultModel ?: "" + pendingModelForNewProvider = prev.takeIf { it.isNotBlank() && models.any { m -> m.modelId == it } } + } + } + } + } + + private fun resetWizardFormState() { + autoFetchJob?.cancel() + connectionError = null + connectionErrorHelp = null + connectionTestSuccess = false + pendingModelForNewProvider = null + availableModels = emptyList() + isLoadingModels = false + modelError = null + modelErrorHelp = null + embeddingModelWarning = null + embeddingModelProvider = null + canPullEmbeddingModel = false + isCheckingEmbeddingModel = false + providerConfigFields = emptyList() + providerFieldValues = emptyMap() + isFetchingModelsForConfig = false + isTestingConnection = false + displayNameError = null + } + + private fun validateConfigFields(fields: Map, configFields: List): Boolean = configFields.all { field -> + if (field.required) { + if (field is ProviderConfigField.ApiKeyField && field.hasExistingValue) { + true + } else { + !fields[field.name].isNullOrBlank() + } + } else { + true + } + } +} diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsConfigDialog.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsConfigDialog.kt index e3dc88057..73a58fb1b 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsConfigDialog.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsConfigDialog.kt @@ -38,7 +38,7 @@ import io.askimo.ui.common.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable fun settingsConfigDialog( - viewModel: SettingsViewModel, + viewModel: AIProviderViewModel, onDismiss: () -> Unit, ) { AppComponents.alertDialog( diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsView.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsView.kt index 2d9b8abcc..759c446f1 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsView.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsView.kt @@ -78,7 +78,7 @@ enum class SettingsSection { @Composable fun settingsViewWithSidebar( onClose: () -> Unit, - settingsViewModel: SettingsViewModel, + settingsViewModel: AIProviderViewModel, selectedSection: SettingsSection = SettingsSection.APPEARANCE, onSectionChange: (SettingsSection) -> Unit = {}, ) { @@ -291,8 +291,9 @@ fun settingsViewWithSidebar( // Dialogs - if (settingsViewModel.showProviderWizard) { - providerWizardDialog(viewModel = settingsViewModel) + val wizard = settingsViewModel.wizardViewModel + if (wizard != null) { + providerWizardDialog(viewModel = wizard) } if (settingsViewModel.showSettingsDialog) { diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsViewModel.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsViewModel.kt index e3661a30e..6b1769e13 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsViewModel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsViewModel.kt @@ -7,55 +7,41 @@ package io.askimo.desktop.settings import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import io.askimo.core.config.AppConfig import io.askimo.core.context.AppContext import io.askimo.core.context.getConfigInfo -import io.askimo.core.error.AppError import io.askimo.core.event.EventBus import io.askimo.core.event.internal.ModelChangedEvent -import io.askimo.core.i18n.LocalizationManager +import io.askimo.core.event.internal.ProviderInstanceSavedEvent import io.askimo.core.logging.logger -import io.askimo.core.providers.ChatModelFactory -import io.askimo.core.providers.LocalModelValidator -import io.askimo.core.providers.ModelAvailabilityResult -import io.askimo.core.providers.ModelDTO import io.askimo.core.providers.ModelProvider -import io.askimo.core.providers.ProviderConfigField import io.askimo.core.providers.ProviderInstance import io.askimo.core.providers.ProviderInstanceService -import io.askimo.core.providers.ProviderRegistry -import io.askimo.core.providers.ProviderSettings -import io.askimo.core.providers.ProviderTestResult import io.askimo.core.providers.SettingField -import io.askimo.ui.util.ErrorHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.time.Duration.Companion.milliseconds - -/** Wizard navigation steps for the provider add/edit wizard. */ -enum class WizardStep { TYPE_PICKER, CONFIG, MODEL } /** - * ViewModel for managing settings state and configuration information. + * ViewModel for the AI Provider settings section, the settings-config dialog, and the + * top-level settings shell that hosts the provider wizard. * - * Provider dialog flow: - * - [showProviderWizard] — wizard for adding or editing a provider instance - * - Add flow: TYPE_PICKER → CONFIG → MODEL - * - Edit flow: CONFIG → MODEL (no type picker) + * Responsibilities: + * - Active-configuration display ([provider], [model], [instanceDisplayName], [activeInstanceState]) + * - Per-instance model overrides ([updateInstanceModelOverride]) + * - Settings-config dialog ([showSettingsDialog], [settingsFields], [updateSettingsField]) + * - Wizard lifecycle: creates a fresh [ProviderWizardViewModel] on open, tears it down on close * - * Domain changes (model switch, save) emit [ModelChangedEvent] on [EventBus]. - * UI navigation (open/close dialogs) is pure ViewModel state — no callbacks. + * Listens for [ModelChangedEvent] and [ProviderInstanceSavedEvent] on [EventBus] to keep + * displayed configuration in sync without direct coupling to other VMs. */ -class SettingsViewModel( +class AIProviderViewModel( private val scope: CoroutineScope, private val appContext: AppContext, private val providerInstanceService: ProviderInstanceService, ) { - private val log = logger() + private val log = logger() // ── Active-configuration display ───────────────────────────────────────────────────────── @@ -76,8 +62,7 @@ class SettingsViewModel( /** * Observable snapshot of the active [ProviderInstance]. - * Updated by [loadConfiguration] and [updateInstanceModelOverride] so that - * Compose can recompose when instance settings change without switching instances. + * Updated by [loadConfiguration] and [updateInstanceModelOverride]. */ var activeInstanceState by mutableStateOf(null) private set @@ -93,21 +78,7 @@ class SettingsViewModel( var successMessage by mutableStateOf("") private set - // ── Model list state (used by wizard model picker) ──────────────────────────────────────── - - var availableModels by mutableStateOf>(emptyList()) - private set - - var isLoadingModels by mutableStateOf(false) - private set - - var modelError by mutableStateOf(null) - private set - - var modelErrorHelp by mutableStateOf(null) - private set - - // ── Settings (advanced fields) dialog ──────────────────────────────────────────────────── + // ── Settings-config dialog ──────────────────────────────────────────────────────────────── var showSettingsDialog by mutableStateOf(false) private set @@ -115,98 +86,35 @@ class SettingsViewModel( var settingsFields by mutableStateOf>(emptyList()) private set - // ── Manage providers dialog ─────────────────────────────────────────────────────────────── - - /** All configured instances shown in the manage dialog. */ - var availableInstances by mutableStateOf>(emptyList()) - private set - - // ── Provider wizard (add & edit) ────────────────────────────────────────────────────────── - - /** True while the add/edit provider wizard is open. */ - var showProviderWizard by mutableStateOf(false) - private set - - /** Current step within the wizard. */ - var wizardStep by mutableStateOf(WizardStep.TYPE_PICKER) - private set - - /** All supported provider types — shown in the TYPE_PICKER step. */ - var availableProviders by mutableStateOf>(emptyList()) - private set + // ── Wizard ──────────────────────────────────────────────────────────────────────────────── /** - * Non-null when the wizard is in **edit** mode; null when adding a new instance. - * Derive [isAddingNewInstance] from this. + * Non-null while the provider add/edit wizard is open. + * Created fresh by [openAddProviderWizard] / [openEditProviderWizard] and set back to null + * when the wizard calls its [onClose] callback. */ - var editingInstance by mutableStateOf(null) - private set - - /** True when the wizard is in add mode (no instance being edited). */ - val isAddingNewInstance: Boolean get() = editingInstance == null && showProviderWizard - - /** The provider type chosen in the wizard (TYPE_PICKER) or taken from the edited instance. */ - var selectedProvider by mutableStateOf(null) - private set - - /** Editable display name for a **new** instance. */ - var newInstanceDisplayName by mutableStateOf("") - private set - - /** Editable display name when **editing** an existing instance. */ - var editingInstanceDisplayName by mutableStateOf("") - private set - - var providerConfigFields by mutableStateOf>(emptyList()) - private set - - var providerFieldValues by mutableStateOf>(emptyMap()) - private set - - var isTestingConnection by mutableStateOf(false) - private set - - var isFetchingModelsForConfig by mutableStateOf(false) - private set - - private var autoFetchJob: Job? = null - - var connectionError by mutableStateOf(null) - private set - - var connectionErrorHelp by mutableStateOf(null) - private set - - var connectionTestSuccess by mutableStateOf(false) - private set - - var pendingModelForNewProvider by mutableStateOf(null) - private set - - var isCheckingEmbeddingModel by mutableStateOf(false) - private set - - var embeddingModelWarning by mutableStateOf(null) - private set - - var embeddingModelProvider by mutableStateOf(null) - private set - - var canPullEmbeddingModel by mutableStateOf(false) - private set - - var displayNameError by mutableStateOf(null) + var wizardViewModel by mutableStateOf(null) private set init { loadConfiguration() scope.launch { - EventBus.internalEvents.collect { event -> - if (event is ModelChangedEvent) { - model = event.newModel - loadConfiguration() + EventBus.internalEvents.filterIsInstance().collect { event -> + model = event.newModel + loadConfiguration() + } + } + + scope.launch { + EventBus.internalEvents.filterIsInstance().collect { event -> + loadConfiguration() + successMessage = if (event.isNewInstance) { + "Provider \"${event.displayName}\" added" + } else { + "Provider settings updated" } + showSuccessMessage = true } } } @@ -223,7 +131,7 @@ class SettingsViewModel( activeInstanceState = providerInstanceService.findById(configInfo.instanceId) } - // ── Instance switching (from manage dialog, no wizard) ─────────────────────────────────── + // ── Model override (from the model-config card) ─────────────────────────────────────────── /** * Persists a per-instance special model override (utility / vision / image / embedding) @@ -248,245 +156,37 @@ class SettingsViewModel( } } - // ── Manage providers dialog ─────────────────────────────────────────────────────────────── + // ── Wizard lifecycle ────────────────────────────────────────────────────────────────────── /** - * Entry point — called when the user clicks the "Change provider" button in settings. - * Always opens the add provider wizard directly. + * Entry point when the user clicks the "Change provider" / "Add" button. + * Always opens the wizard in add mode. */ fun onChangeProvider() { openAddProviderWizard() } - // ── Provider wizard — open ──────────────────────────────────────────────────────────────── - - /** Opens the wizard in add mode (starts at TYPE_PICKER step). */ + /** Opens a fresh [ProviderWizardViewModel] in add mode (starts at TYPE_PICKER step). */ fun openAddProviderWizard() { - availableInstances = providerInstanceService.all - availableProviders = ProviderRegistry.getSupportedProviders() - .filter { it != ModelProvider.UNKNOWN && it != ModelProvider.ASKIMO_PRO } - .sortedBy { ProviderRegistry.getProviderDisplayName(it) } - editingInstance = null - selectedProvider = null - newInstanceDisplayName = "" - wizardStep = WizardStep.TYPE_PICKER - resetWizardFormState() - showProviderWizard = true + wizardViewModel = ProviderWizardViewModel( + scope = scope, + appContext = appContext, + providerInstanceService = providerInstanceService, + onClose = { wizardViewModel = null }, + ).also { it.initAddMode() } } - /** Opens the wizard in edit mode (starts at CONFIG step, pre-populated). */ + /** Opens a fresh [ProviderWizardViewModel] in edit mode (starts at CONFIG step, pre-populated). */ fun openEditProviderWizard(instance: ProviderInstance) { - editingInstance = instance - selectedProvider = instance.providerType - editingInstanceDisplayName = instance.displayName - wizardStep = WizardStep.CONFIG - resetWizardFormState() - - providerConfigFields = instance.settings.getConfigFields(LocalizationManager.messageResolver) - providerFieldValues = providerConfigFields.mapNotNull { field -> - when (field) { - is ProviderConfigField.ApiKeyField -> field.name to field.value - is ProviderConfigField.BaseUrlField -> field.name to field.value - is ProviderConfigField.InfoField -> null - } - }.toMap() - - scheduleAutoModelFetch() - showProviderWizard = true - } - - // ── Provider wizard — close / back ──────────────────────────────────────────────────────── - - /** Closes the wizard and discards unsaved state. */ - fun closeProviderWizard() { - autoFetchJob?.cancel() - showProviderWizard = false - editingInstance = null - selectedProvider = null - newInstanceDisplayName = "" - editingInstanceDisplayName = "" - wizardStep = WizardStep.TYPE_PICKER - resetWizardFormState() - } - - /** - * Navigates back within the wizard: - * MODEL → CONFIG, CONFIG → TYPE_PICKER (add) or close (edit), - * TYPE_PICKER → close. - */ - fun wizardBack() { - when (wizardStep) { - WizardStep.MODEL -> { - wizardStep = WizardStep.CONFIG - pendingModelForNewProvider = null - } - - WizardStep.CONFIG -> { - if (isAddingNewInstance) { - selectedProvider = null - wizardStep = WizardStep.TYPE_PICKER - resetWizardFormState() - } else { - // Edit: back = close wizard - closeProviderWizard() - } - } - - WizardStep.TYPE_PICKER -> { - closeProviderWizard() - } - } - } - - // ── Provider wizard — step actions ──────────────────────────────────────────────────────── - - /** Called when the user picks a provider type in the TYPE_PICKER step. */ - fun selectProviderTypeForNewInstance(providerType: ModelProvider) { - selectedProvider = providerType - newInstanceDisplayName = ProviderRegistry.getProviderDisplayName(providerType) - wizardStep = WizardStep.CONFIG - resetWizardFormState() - - val defaultSettings = ProviderRegistry.getFactory(providerType)?.defaultSettings() - providerConfigFields = defaultSettings?.getConfigFields(LocalizationManager.messageResolver) ?: emptyList() - providerFieldValues = providerConfigFields.mapNotNull { field -> - when (field) { - is ProviderConfigField.ApiKeyField -> field.name to field.value - is ProviderConfigField.BaseUrlField -> field.name to field.value - is ProviderConfigField.InfoField -> null - } - }.toMap() - - scheduleAutoModelFetch() - } - - fun updateNewInstanceDisplayName(name: String) { - newInstanceDisplayName = name - displayNameError = if (name.isNotBlank() && !providerInstanceService.isDisplayNameAvailable(name)) { - LocalizationManager.getString("provider.instance.name.duplicate", name.trim()) - } else { - null - } - } - - fun updateEditingInstanceDisplayName(name: String) { - editingInstanceDisplayName = name - displayNameError = if (name.isNotBlank() && !providerInstanceService.isDisplayNameAvailable(name, excludingId = editingInstance?.id)) { - LocalizationManager.getString("provider.instance.name.duplicate", name.trim()) - } else { - null - } + wizardViewModel = ProviderWizardViewModel( + scope = scope, + appContext = appContext, + providerInstanceService = providerInstanceService, + onClose = { wizardViewModel = null }, + ).also { it.initEditMode(instance) } } - fun updateProviderField(fieldName: String, value: String) { - providerFieldValues = providerFieldValues.toMutableMap().apply { put(fieldName, value) } - scheduleAutoModelFetch() - } - - fun selectModelForNewProvider(model: String) { - pendingModelForNewProvider = model - } - - /** Saves the instance and closes the wizard on success. */ - fun saveProvider() { - val provider = selectedProvider ?: return - - if (!validateConfigFields(providerFieldValues, providerConfigFields)) { - connectionError = "Please fill in all required fields" - return - } - - // Guard: reject duplicate display names before hitting IO - val candidateName = if (editingInstance != null) { - editingInstanceDisplayName.ifBlank { editingInstance!!.displayName } - } else { - newInstanceDisplayName.ifBlank { ProviderRegistry.getProviderDisplayName(provider) } - } - val excludingId = editingInstance?.id - if (!providerInstanceService.isDisplayNameAvailable(candidateName, excludingId = excludingId)) { - displayNameError = LocalizationManager.getString("provider.instance.name.duplicate", candidateName.trim()) - return - } - - isTestingConnection = true - connectionError = null - connectionErrorHelp = null - - scope.launch { - val result = withContext(Dispatchers.IO) { - try { - val baseSettings = editingInstance?.settings - ?: ProviderRegistry.getFactory(provider)?.defaultSettings() - - val newSettings = baseSettings?.applyConfigFields(providerFieldValues) - ?: return@withContext ProviderTestResult.Failure("Failed to create settings") - - if (!newSettings.validate()) { - return@withContext ProviderTestResult.Failure( - message = "Cannot connect to ${provider.name.lowercase()} provider", - helpText = newSettings.getSetupHelpText(LocalizationManager.messageResolver), - ) - } - - val pendingModel = pendingModelForNewProvider?.takeIf { it.isNotBlank() } - ?: newSettings.defaultModel - val settingsWithModel = if (pendingModel.isNotBlank()) { - newSettings.updateField(SettingField.DEFAULT_MODEL, pendingModel) - } else { - newSettings - } - - try { - if (editingInstance != null) { - val displayName = editingInstanceDisplayName.ifBlank { editingInstance!!.displayName } - val updated = editingInstance!!.copy(displayName = displayName, settings = settingsWithModel) - providerInstanceService.update(updated).getOrThrow() - } else { - val displayName = newInstanceDisplayName.ifBlank { ProviderRegistry.getProviderDisplayName(provider) } - val newInstance = ProviderRegistry.createInstance(providerType = provider, displayName = displayName, settings = settingsWithModel) - providerInstanceService.add(newInstance).getOrThrow() - } - ProviderTestResult.Success - } catch (e: Exception) { - log.error("Error saving instance", e) - val appError = (e as? AppError) ?: (e.cause as? AppError) - if (appError is AppError.DuplicateEntry) { - ProviderTestResult.Failure("A provider named \"${appError.value.trim()}\" already exists") - } else { - ProviderTestResult.Failure("Failed to save provider instance") - } - } - } catch (e: Exception) { - log.error("Error saving instance", e) - ProviderTestResult.Failure(ErrorHandler.getUserFriendlyError(e, "saving instance", "Failed to apply provider settings. Please try again.")) - } - } - - isTestingConnection = false - - when (result) { - is ProviderTestResult.Success -> { - val wasAdding = isAddingNewInstance - val savedName = if (wasAdding) { - newInstanceDisplayName.ifBlank { provider.name.lowercase() } - } else { - editingInstanceDisplayName.ifBlank { editingInstance?.displayName ?: "" } - } - closeProviderWizard() - loadConfiguration() - successMessage = if (wasAdding) "Provider \"$savedName\" added" else "Provider settings updated" - showSuccessMessage = true - } - - is ProviderTestResult.Failure -> { - connectionError = result.message - connectionErrorHelp = result.helpText - } - } - } - } - - // ── Advanced settings dialog ────────────────────────────────────────────────────────────── + // ── Settings-config dialog ──────────────────────────────────────────────────────────────── fun onChangeSettings() { provider?.let { @@ -515,242 +215,7 @@ class SettingsViewModel( successMessage = "Settings updated successfully" showSuccessMessage = true } - - // ── Embedding model availability ────────────────────────────────────────────────────────── - - fun checkEmbeddingModelAvailability(provider: ModelProvider, baseUrl: String) { - isCheckingEmbeddingModel = true - embeddingModelWarning = null - embeddingModelProvider = null - canPullEmbeddingModel = false - - scope.launch { - try { - val result = withContext(Dispatchers.IO) { - // Resolve the embedding model: prefer instance-level override, then AppConfig type-level default. - fun resolveEmbeddingModel(p: ModelProvider): String { - val instanceModel = editingInstance?.settings?.embeddingModel?.takeIf { it.isNotBlank() } - return instanceModel ?: AppConfig.models[p].embeddingModel - } - when (provider) { - ModelProvider.OLLAMA -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.OLLAMA)) - ModelProvider.DOCKER -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.DOCKER)) - ModelProvider.LOCALAI -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.LOCALAI)) - ModelProvider.LMSTUDIO -> LocalModelValidator.checkModelExists(provider, baseUrl, resolveEmbeddingModel(ModelProvider.LMSTUDIO)) - ModelProvider.ANTHROPIC -> ModelAvailabilityResult.NotAvailable(reason = LocalizationManager.getString("settings.embedding.anthropic_no_embedding"), canAutoPull = false) - ModelProvider.XAI -> ModelAvailabilityResult.NotAvailable(reason = LocalizationManager.getString("settings.embedding.xai_no_embedding"), canAutoPull = false) - else -> ModelAvailabilityResult.Available - } - } - when (result) { - is ModelAvailabilityResult.Available -> embeddingModelWarning = null - - is ModelAvailabilityResult.NotAvailable -> { - embeddingModelWarning = LocalizationManager.getString("settings.embedding.not_available_rag_only", result.reason) - embeddingModelProvider = provider.name - canPullEmbeddingModel = result.canAutoPull - } - - is ModelAvailabilityResult.ProviderUnreachable -> { - embeddingModelWarning = LocalizationManager.getString("settings.embedding.provider_unreachable", result.error) - embeddingModelProvider = provider.name - canPullEmbeddingModel = false - } - } - } catch (e: Exception) { - log.error("Error checking embedding model availability", e) - embeddingModelWarning = LocalizationManager.getString("settings.embedding.check_failed", e.message ?: "Unknown error") - } finally { - isCheckingEmbeddingModel = false - } - } - } - - fun pullEmbeddingModel(provider: ModelProvider, baseUrl: String) { - if (provider != ModelProvider.OLLAMA) return - isCheckingEmbeddingModel = true - scope.launch { - try { - val modelName = AppConfig.models[ModelProvider.OLLAMA].embeddingModel - val success = withContext(Dispatchers.IO) { LocalModelValidator.pullOllamaModel(baseUrl, modelName) } - if (success) { - embeddingModelWarning = null - successMessage = LocalizationManager.getString("settings.embedding.download_success", modelName) - showSuccessMessage = true - } else { - embeddingModelWarning = LocalizationManager.getString("settings.embedding.download_failed", modelName) - } - } catch (e: Exception) { - log.error("Error pulling embedding model", e) - embeddingModelWarning = LocalizationManager.getString("settings.embedding.download_error", e.message ?: "Unknown error") - } finally { - isCheckingEmbeddingModel = false - } - } - } - - // ── Private helpers ─────────────────────────────────────────────────────────────────────── - - /** Debounced model fetch; on success advances wizard to MODEL step. */ - private fun scheduleAutoModelFetch() { - autoFetchJob?.cancel() - - if (!validateConfigFields(providerFieldValues, providerConfigFields)) { - connectionError = null - connectionErrorHelp = null - isFetchingModelsForConfig = false - return - } - - val provider = selectedProvider ?: return - connectionError = null - connectionErrorHelp = null - isFetchingModelsForConfig = true - - autoFetchJob = scope.launch { - delay(1000.milliseconds) - - val result = withContext(Dispatchers.IO) { - try { - val baseSettings = editingInstance?.settings ?: ProviderRegistry.getFactory(provider)?.defaultSettings() - val newSettings = baseSettings?.applyConfigFields(providerFieldValues) - ?: return@withContext ProviderTestResult.Failure("Failed to create settings") - - if (!newSettings.validate()) { - return@withContext ProviderTestResult.Failure( - message = "Cannot connect to ${provider.name.lowercase()} provider", - helpText = newSettings.getSetupHelpText(LocalizationManager.messageResolver), - ) - } - - val factory = ProviderRegistry.getFactory(provider) - ?: return@withContext ProviderTestResult.Failure("No factory found for provider") - - @Suppress("UNCHECKED_CAST") - val models = (factory as ChatModelFactory) - .availableModels((editingInstance?.settings ?: factory.defaultSettings()).applyConfigFields(providerFieldValues)) - - isLoadingModels = false - if (models.isNotEmpty()) { - ProviderTestResult.Success - } else { - ProviderTestResult.Failure(message = LocalizationManager.getString("provider.connection.failed"), helpText = null) - } - } catch (e: Exception) { - log.error("Error auto-fetching models for provider config", e) - ProviderTestResult.Failure(ErrorHandler.getUserFriendlyError(e, "fetching models", "Could not reach the provider. Please check your settings.")) - } - } - - isFetchingModelsForConfig = false - - when (result) { - is ProviderTestResult.Success -> { - connectionError = null - connectionErrorHelp = null - connectionTestSuccess = true - // Pre-load models in background so they're ready when user clicks Next - loadModelsForSelectedProvider() - val baseUrl = providerFieldValues[SettingField.BASE_URL] - if (!baseUrl.isNullOrBlank()) checkEmbeddingModelAvailability(provider, baseUrl) - } - - is ProviderTestResult.Failure -> { - connectionError = result.message - connectionErrorHelp = result.helpText - connectionTestSuccess = false - } - } - } - } - - /** Called when user explicitly clicks "Next" on the CONFIG step (add mode). */ - fun advanceToModelPicker() { - val candidateName = newInstanceDisplayName.ifBlank { - selectedProvider?.let { ProviderRegistry.getProviderDisplayName(it) } ?: "" - } - if (!providerInstanceService.isDisplayNameAvailable(candidateName)) { - displayNameError = LocalizationManager.getString("provider.instance.name.duplicate", candidateName.trim()) - return - } - displayNameError = null - wizardStep = WizardStep.MODEL - } - - private fun loadModelsForSelectedProvider() { - modelError = null - modelErrorHelp = null - isLoadingModels = true - - scope.launch { - val provider = selectedProvider ?: run { - isLoadingModels = false - availableModels = emptyList() - modelError = "Provider not set" - pendingModelForNewProvider = null - return@launch - } - - withContext(Dispatchers.IO) { - val factory = ProviderRegistry.getFactory(provider) ?: run { - isLoadingModels = false - availableModels = emptyList() - modelError = "No model factory registered for provider: ${provider.name.lowercase()}" - pendingModelForNewProvider = null - return@withContext - } - - @Suppress("UNCHECKED_CAST") - val models = (factory as ChatModelFactory) - .availableModels((editingInstance?.settings ?: factory.defaultSettings()).applyConfigFields(providerFieldValues)) - - isLoadingModels = false - if (models.isEmpty()) { - availableModels = emptyList() - modelError = "No models available for ${provider.name.lowercase()}" - modelErrorHelp = factory.getNoModelsHelpText() - pendingModelForNewProvider = null - } else { - availableModels = models - modelError = null - modelErrorHelp = null - val prev = editingInstance?.settings?.defaultModel ?: "" - pendingModelForNewProvider = prev.takeIf { it.isNotBlank() && models.any { m -> m.modelId == it } } - } - } - } - } - - private fun resetWizardFormState() { - autoFetchJob?.cancel() - connectionError = null - connectionErrorHelp = null - connectionTestSuccess = false - pendingModelForNewProvider = null - availableModels = emptyList() - isLoadingModels = false - modelError = null - modelErrorHelp = null - embeddingModelWarning = null - embeddingModelProvider = null - canPullEmbeddingModel = false - isCheckingEmbeddingModel = false - providerConfigFields = emptyList() - providerFieldValues = emptyMap() - isFetchingModelsForConfig = false - isTestingConnection = false - displayNameError = null - } - - private fun validateConfigFields(fields: Map, configFields: List): Boolean = configFields.all { field -> - if (field.required) { - if (field is ProviderConfigField.ApiKeyField && field.hasExistingValue) { - true - } else { - !fields[field.name].isNullOrBlank() - } - } else { - true - } - } } + +/** Backward-compat alias — prefer [AIProviderViewModel] in new code. */ +typealias SettingsViewModel = AIProviderViewModel diff --git a/desktop/src/test/kotlin/io/askimo/desktop/di/DesktopModuleTest.kt b/desktop/src/test/kotlin/io/askimo/desktop/di/DesktopModuleTest.kt index a512036d7..96eeecd6d 100644 --- a/desktop/src/test/kotlin/io/askimo/desktop/di/DesktopModuleTest.kt +++ b/desktop/src/test/kotlin/io/askimo/desktop/di/DesktopModuleTest.kt @@ -9,7 +9,7 @@ import io.askimo.core.chat.service.ChatSessionExporterService import io.askimo.core.chat.service.ChatSessionService import io.askimo.core.context.AppContext import io.askimo.core.context.ExecutionMode -import io.askimo.desktop.settings.SettingsViewModel +import io.askimo.desktop.settings.AIProviderViewModel import io.askimo.test.extensions.AskimoTestHome import io.askimo.ui.session.SessionManager import io.askimo.ui.session.SessionsViewModel @@ -89,6 +89,6 @@ class DesktopModuleTest : KoinTest { ) }, ) - assertNotNull(koin.get { parametersOf(scope) }) + assertNotNull(koin.get { parametersOf(scope) }) } } diff --git a/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt b/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt index f7a474f3a..ba3ae5e01 100644 --- a/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt +++ b/shared/src/main/kotlin/io/askimo/core/context/AppContext.kt @@ -241,10 +241,19 @@ class AppContext private constructor( ?: ProviderRegistry.getFactory(params.activeProviderType)?.defaultSettings() ?: NoopProviderSettings + /** + * Returns the currently active [ProviderInstance]. + * @throws ProviderNotConfiguredException if no instance is configured/selected. + */ + fun requireActiveInstance(): ProviderInstance = params.activeInstance ?: throw ProviderNotConfiguredException() + /** * Gets (or lazily creates) settings for the first instance of [provider] type. * If no instance of that type exists, a new one is created with default settings and * added to [params.providerInstances]. + * + * Prefer [requireActiveInstance] for active-instance operations. This method is intended + * only for callers that need settings for a *specific* provider type (e.g. CodexAgent, GeminiAgent). */ fun getOrCreateProviderSettings(provider: ModelProvider): ProviderSettings { val existing = params.providerInstances.firstOrNull { it.providerType == provider } @@ -272,23 +281,20 @@ class AppContext private constructor( fun getModelFactory(provider: ModelProvider): ChatModelFactory<*> = ProviderRegistry.getFactory(provider) ?: throw ProviderNotConfiguredException() fun createChatModel(): ChatModel { - val provider = params.activeProviderType - val factory = getModelFactory(provider) - val settings = getOrCreateProviderSettings(provider) + val instance = requireActiveInstance() + val factory = getModelFactory(instance.providerType) @Suppress("UNCHECKED_CAST") - return (factory as ChatModelFactory).createModel(settings) + return (factory as ChatModelFactory).createModel(instance.settings) } fun getStatelessChatClient(): ChatClient { - val provider = params.activeProviderType - val factory = getModelFactory(provider) - - val settings = getOrCreateProviderSettings(provider) + val instance = requireActiveInstance() + val factory = getModelFactory(instance.providerType) @Suppress("UNCHECKED_CAST") return (factory as ChatModelFactory).create( - settings = settings, + settings = instance.settings, executionMode = ExecutionMode.STATELESS_MODE, ) } @@ -314,18 +320,16 @@ class AppContext private constructor( // Double-check after acquiring lock cachedUtilityClient?.let { return it } - val provider = params.activeProviderType - val factory = getModelFactory(provider) - - val settings = getOrCreateProviderSettings(provider) + val instance = requireActiveInstance() + val factory = getModelFactory(instance.providerType) @Suppress("UNCHECKED_CAST") val client = (factory as ChatModelFactory).createUtilityClient( - settings = settings, + settings = instance.settings, ) cachedUtilityClient = client - log.debug("Created and cached utility client for provider {} with model {}", provider, params.model) + log.debug("Created and cached utility client for provider {} with model {}", instance.providerType, params.model) return client } } @@ -337,18 +341,16 @@ class AppContext private constructor( synchronized(this) { cachedImageModel?.let { return it } - val provider = params.activeProviderType - val factory = getModelFactory(provider) - - val settings = getOrCreateProviderSettings(provider) + val instance = requireActiveInstance() + val factory = getModelFactory(instance.providerType) @Suppress("UNCHECKED_CAST") val imageModel = (factory as ChatModelFactory).createImageModel( - settings = settings, + settings = instance.settings, ) cachedImageModel = imageModel - log.debug("Created and cached image model for provider {}", provider) + log.debug("Created and cached image model for provider {}", instance.providerType) return imageModel } } @@ -367,23 +369,21 @@ class AppContext private constructor( synchronized(this) { cachedEmbeddingModel?.let { return it } - val provider = params.activeProviderType - val factory = getModelFactory(provider) + val instance = requireActiveInstance() + val factory = getModelFactory(instance.providerType) if (!factory.supportsEmbedding()) { throw UnsupportedOperationException( - "${provider.name} does not support embedding models. " + + "${instance.providerType.name} does not support embedding models. " + "Please switch to a provider that supports embeddings (OpenAI, Gemini, Ollama, etc.) to use RAG features.", ) } - val settings = getOrCreateProviderSettings(provider) - @Suppress("UNCHECKED_CAST") - val embeddingModel = (factory as ChatModelFactory).createEmbeddingModel(settings) + val embeddingModel = (factory as ChatModelFactory).createEmbeddingModel(instance.settings) cachedEmbeddingModel = embeddingModel - log.debug("Created and cached embedding model for provider {}", provider) + log.debug("Created and cached embedding model for provider {}", instance.providerType) return embeddingModel } } @@ -395,12 +395,11 @@ class AppContext private constructor( * @return Maximum number of tokens the embedding model can handle */ fun getEmbeddingTokenLimit(): Int { - val provider = params.activeProviderType - val factory = ProviderRegistry.getFactory(provider) ?: return 2048 - val settings = getOrCreateProviderSettings(provider) + val instance = params.activeInstance ?: return 2048 + val factory = ProviderRegistry.getFactory(instance.providerType) ?: return 2048 @Suppress("UNCHECKED_CAST") - return (factory as ChatModelFactory).getEmbeddingTokenLimit(settings) + return (factory as ChatModelFactory).getEmbeddingTokenLimit(instance.settings) } /** @@ -424,14 +423,13 @@ class AppContext private constructor( retriever: ContentRetriever? = null, memory: ChatMemory, ): ChatClient { - val provider = params.activeProviderType - val factory = getModelFactory(provider) - val settings = getOrCreateProviderSettings(provider) + val instance = requireActiveInstance() + val factory = getModelFactory(instance.providerType) @Suppress("UNCHECKED_CAST") return (factory as ChatModelFactory).create( sessionId = sessionId, - settings = settings, + settings = instance.settings, toolProvider = getToolProvider(), retriever = retriever, executionMode = executionMode, diff --git a/shared/src/main/kotlin/io/askimo/core/event/internal/ProviderInstanceSavedEvent.kt b/shared/src/main/kotlin/io/askimo/core/event/internal/ProviderInstanceSavedEvent.kt new file mode 100644 index 000000000..3a1182406 --- /dev/null +++ b/shared/src/main/kotlin/io/askimo/core/event/internal/ProviderInstanceSavedEvent.kt @@ -0,0 +1,30 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.event.internal + +import io.askimo.core.event.Event +import io.askimo.core.event.EventSource +import io.askimo.core.event.EventType +import java.time.Instant + +/** + * Emitted after a provider instance is successfully saved (created or updated). + * + * Consumers (e.g. [io.askimo.desktop.settings.AIProviderViewModel]) use this to refresh + * their active-configuration display without direct coupling to the wizard. + */ +data class ProviderInstanceSavedEvent( + /** Stable ID of the saved [io.askimo.core.providers.ProviderInstance]. */ + val instanceId: String, + /** User-visible display name of the saved instance. */ + val displayName: String, + /** True if the instance was newly created; false if an existing one was updated. */ + val isNewInstance: Boolean, + override val timestamp: Instant = Instant.now(), + override val source: EventSource = EventSource.SYSTEM, +) : Event { + override val type = EventType.INTERNAL + override fun getDetails() = "Provider instance saved: $displayName (new=$isNewInstance, id=$instanceId)" +}