diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/CodeViewerBlock.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/CodeViewerBlock.kt index 30240144..a0af6407 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/CodeViewerBlock.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/CodeViewerBlock.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -111,21 +110,18 @@ fun codeViewerBlock( .background(dividerColor), ) - // ── Code / text (no wrap, horizontal scroll) ────────────────────────── Box(modifier = Modifier.weight(1f)) { - SelectionContainer { - Text( - text = highlightedCode, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = contentColor, - softWrap = false, - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(hScrollState) - .padding(top = 12.dp, bottom = 20.dp, start = 12.dp, end = 12.dp), - ) - } + Text( + text = highlightedCode, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = contentColor, + softWrap = false, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(hScrollState) + .padding(top = 12.dp, bottom = 20.dp, start = 12.dp, end = 12.dp), + ) } } } diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 1fd84de8..59043671 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -658,9 +658,9 @@ settings.model.filtered=Showing {0} of {1} models settings.model.no.match=No models match your search settings.model.no.results=No models match your search settings.configure.title=Configure {0} Settings -settings.test.connection=Test Connection settings.test.connection.testing=Testing... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=Could not connect to the provider. Please verify your API key is correct (for cloud providers) or your base URL is reachable (for local providers). You can find setup instructions using the help link above. # Directives Dialog directive.manage.title=Manage Directives @@ -785,6 +785,10 @@ provider.lmstudio.baseurl.description=The base URL for your LMStudio instance (d provider.lmstudio.setup.help=💡To use LMStudio, you need to have it running locally.\n\n1. Install LMStudio from: https://lmstudio.ai/\n2. Start the local server\n3. Optionally configure the base URL if not using default provider.lmstudio.openai.compat.info=Askimo uses OpenAI-compatible endpoints (/v1), not the native LMStudio API (/api/v1/). +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=The base URL for your OpenAI Compatible instance +provider.openai_compatible.apikey.stored=API key already stored securely. Leave blank to keep existing key, or enter a new one to update. + # System Resources system.memory=Memory system.cpu=CPU diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 72c2ed90..49d3529b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -662,9 +662,9 @@ settings.model.filtered={0} von {1} Modellen angezeigt settings.model.no.match=Keine passenden Modelle settings.model.no.results=Keine passenden Modelle settings.configure.title={0} konfigurieren -settings.test.connection=Verbindung testen settings.test.connection.testing=Teste... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=Verbindung zum Anbieter konnte nicht hergestellt werden. Bitte überprüfen Sie, ob Ihr API-Schlüssel korrekt ist (für Cloud-Anbieter) oder Ihre Basis-URL erreichbar ist (für lokale Anbieter). Installationsanweisungen finden Sie über den obigen Hilfe-Link. # Directives Dialog directive.manage.title=Direktiven verwalten @@ -789,6 +789,10 @@ provider.lmstudio.baseurl.description=LMStudio Basis-URL (Standard: http://local provider.lmstudio.setup.help=💡 So verwenden Sie LMStudio:\n\n1. Installieren: https://lmstudio.ai/\n2. Lokalen Server starten\n3. Basis-URL bei Bedarf ändern provider.lmstudio.openai.compat.info=Askimo verwendet OpenAI-kompatible Endpunkte (/v1), nicht die native LMStudio-API (/api/v1/). +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=Die Basis-URL für Ihre OpenAI-kompatible Instanz +provider.openai_compatible.apikey.stored=Der API-Schlüssel ist bereits sicher gespeichert. Lassen Sie das Feld leer, um den bestehenden Schlüssel zu behalten, oder geben Sie einen neuen ein, um ihn zu aktualisieren. + # System Resources system.memory=Speicher system.cpu=CPU diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index 8b996e7d..628a25c2 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -661,9 +661,9 @@ settings.model.filtered=Mostrando {0} de {1} modelos settings.model.no.match=No hay coincidencias settings.model.no.results=No hay coincidencias settings.configure.title=Configurar {0} -settings.test.connection=Probar conexión settings.test.connection.testing=Probando... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=No se pudo conectar al proveedor. Verifique que su clave API sea correcta (para proveedores en la nube) o que su URL base sea accesible (para proveedores locales). Puede encontrar instrucciones de configuración usando el enlace de ayuda de arriba. # Directives Dialog directive.manage.title=Gestionar directivas @@ -788,6 +788,10 @@ provider.lmstudio.baseurl.description=Base URL de LMStudio (por defecto: http:// provider.lmstudio.setup.help=💡 Para usar LMStudio:\n\n1. Instálalo desde: https://lmstudio.ai/\n2. Ejecuta el servidor local\n3. Ajusta la URL base si es necesario provider.lmstudio.openai.compat.info=Askimo utiliza los endpoints compatibles con OpenAI (/v1), no la API nativa de LMStudio (/api/v1/). +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=La URL base para su instancia compatible con OpenAI +provider.openai_compatible.apikey.stored=La clave API ya está almacenada de forma segura. Déjelo en blanco para mantener la clave existente o introduzca una nueva para actualizarla. + # System Resources system.memory=Memoria system.cpu=CPU diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 2bc42f0a..f4b78fee 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -663,9 +663,9 @@ settings.model.filtered=Affichage de {0} sur {1} modèles settings.model.no.match=Aucun modèle correspondant settings.model.no.results=Aucun modèle correspondant settings.configure.title=Configurer {0} -settings.test.connection=Tester la connexion settings.test.connection.testing=Test en cours... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=Impossible de se connecter au fournisseur. Veuillez vérifier que votre clé API est correcte (pour les fournisseurs cloud) ou que votre URL de base est accessible (pour les fournisseurs locaux). Vous pouvez trouver des instructions de configuration en utilisant le lien d'aide ci-dessus. # Directives Dialog directive.manage.title=Gérer les directives @@ -790,6 +790,10 @@ provider.lmstudio.baseurl.description=Base URL LMStudio (défaut : http://localh provider.lmstudio.setup.help=💡 Pour utiliser LMStudio :\n\n1. Installez : https://lmstudio.ai/\n2. Lancez le serveur local\n3. Modifier l’URL de base si nécessaire provider.lmstudio.openai.compat.info=Askimo utilise les points de terminaison compatibles OpenAI (/v1), pas l’API LMStudio native (/api/v1/). +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=L'URL de base pour votre instance compatible OpenAI +provider.openai_compatible.apikey.stored=La clé API est déjà stockée en toute sécurité. Laissez vide pour conserver la clé existante, ou saisissez-en une nouvelle pour la mettre à jour. + # System Resources system.memory=Mémoire system.cpu=CPU @@ -822,7 +826,7 @@ telemetry.llm.col.provider=Fournisseur telemetry.llm.col.model=Modèle telemetry.llm.col.calls=Appels telemetry.llm.col.tokens=Tokens -telemetry.llm.col.avg.duration=Durée Moy. +telemetry.llm.col.avg.duration=Durée Moy telemetry.llm.col.errors=Erreurs telemetry.llm.col.total=Total 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 957944b5..f957c4ea 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -662,9 +662,9 @@ settings.model.filtered={0}/{1} モデル表示中 settings.model.no.match=一致するモデルなし settings.model.no.results=一致するモデルなし settings.configure.title={0} 設定構成 -settings.test.connection=接続テスト settings.test.connection.testing=テスト中... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=プロバイダーに接続できませんでした。APIキーが正しいこと(クラウドプロバイダーの場合)、またはベースURLに到達可能であること(ローカルプロバイダーの場合)を確認してください。セットアップ手順については、上のヘルプリンクを使用してください。 # Directives Dialog directive.manage.title=ディレクティブ管理 @@ -789,6 +789,10 @@ provider.lmstudio.baseurl.description=LMStudio ベース URL (デフォルト: h provider.lmstudio.setup.help=💡 LMStudio の利用方法:\n\n1. https://lmstudio.ai/ でインストール\n2. ローカルサーバーを起動\n3. 必要に応じてベース URL を変更 provider.lmstudio.openai.compat.info=Askimo はネイティブ LMStudio API(/api/v1/)ではなく、OpenAI 互換エンドポイント(/v1)を使用します。 +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=OpenAI互換インスタンスのベースURL +provider.openai_compatible.apikey.stored=APIキーはすでに安全に保存されています。既存のキーを保持する場合は空白のままにし、更新する場合は新しいキーを入力してください。 + # System Resources system.memory=メモリ system.cpu=CPU 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 5824108b..95f28811 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -661,9 +661,9 @@ settings.model.filtered={0}/{1} 모델 표시 중 settings.model.no.match=일치하는 모델 없음 settings.model.no.results=일치하는 모델 없음 settings.configure.title={0} 설정 구성 -settings.test.connection=연결 테스트 settings.test.connection.testing=테스트 중... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=제공자에 연결할 수 없습니다. API 키가 올바른지(클라우드 제공자의 경우) 또는 기본 URL에 도달할 수 있는지(로컬 제공자의 경우) 확인하십시오. 위의 도움말 링크를 사용하여 설정 지침을 찾을 수 있습니다. # Directives Dialog directive.manage.title=지시문 관리 @@ -788,6 +788,10 @@ provider.lmstudio.baseurl.description=LMStudio 기본 URL (기본값: http://loc provider.lmstudio.setup.help=💡 LMStudio 사용 방법:\n\n1. https://lmstudio.ai/ 에서 설치\n2. 로컬 서버 실행\n3. 필요 시 기본 URL 변경 provider.lmstudio.openai.compat.info=Askimo는 기본 LMStudio API(/api/v1/)가 아닌 OpenAI 호환 엔드포인트(/v1)를 사용합니다. +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=OpenAI 호환 인스턴스의 기본 URL +provider.openai_compatible.apikey.stored=API 키가 이미 안전하게 저장되었습니다. 기존 키를 유지하려면 비워두고, 업데이트하려면 새 키를 입력하세요. + # System Resources system.memory=메모리 system.cpu=CPU 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 2ebcfb45..8711b10c 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -663,9 +663,9 @@ settings.model.filtered=Mostrando {0} de {1} modelos settings.model.no.match=Nenhum modelo corresponde à pesquisa settings.model.no.results=Nenhum modelo corresponde à pesquisa settings.configure.title=Configurar {0} -settings.test.connection=Testar Conexão settings.test.connection.testing=Testando... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=Não foi possível conectar ao provedor. Verifique se a sua chave API está correta (para provedores em nuvem) ou se a sua URL base está acessível (para provedores locais). Você pode encontrar instruções de configuração usando o link de ajuda acima. # Directives Dialog directive.manage.title=Gerenciar Diretivas @@ -790,6 +790,10 @@ provider.lmstudio.baseurl.description=Base URL do LMStudio (padrão: http://loca provider.lmstudio.setup.help=💡 Para usar o LMStudio:\n\n1. Instale: https://lmstudio.ai/\n2. Inicie o servidor local\n3. Configure o Base URL se necessário provider.lmstudio.openai.compat.info=O Askimo usa os endpoints compatíveis com OpenAI (/v1), não a API nativa do LMStudio (/api/v1/). +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=A URL base para a sua instância compatível com OpenAI +provider.openai_compatible.apikey.stored=Chave API já armazenada com segurança. Deixe em branco para manter a chave existente ou insira uma nova para atualizar. + # System Resources system.memory=Memória system.cpu=CPU 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 29a8631c..9d7622da 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -661,9 +661,9 @@ settings.model.filtered=Hiển thị {0}/{1} mô hình settings.model.no.match=Không có mô hình phù hợp settings.model.no.results=Không có mô hình phù hợp settings.configure.title=Cấu hình {0} -settings.test.connection=Kiểm tra kết nối settings.test.connection.testing=Đang kiểm tra... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=Não foi possível conectar ao provedor. Verifique se a sua chave API está correta (para provedores em nuvem) ou se a sua URL base está acessível (para provedores locais). Você pode encontrar instruções de configuração usando o link de ajuda acima. # Directives Dialog directive.manage.title=Quản lý chỉ thị @@ -788,6 +788,10 @@ provider.lmstudio.baseurl.description=Base URL LMStudio (mặc định http://lo provider.lmstudio.setup.help=💡 Để dùng LMStudio:\n\n1. Cài từ https://lmstudio.ai/\n2. Chạy server cục bộ\n3. Tùy chọn thay đổi base URL provider.lmstudio.openai.compat.info=Askimo sử dụng các endpoint tương thích OpenAI (/v1), không phải API LMStudio gốc (/api/v1/). +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=URL cơ sở cho phiên bản tương thích OpenAI của bạn +provider.openai_compatible.apikey.stored=Khóa API đã được lưu trữ an toàn. Để trống để giữ khóa hiện có hoặc nhập khóa mới để cập nhật. + # System Resources system.memory=Bộ nhớ system.cpu=CPU 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 e59a83c7..4ba4c554 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -662,9 +662,9 @@ settings.model.filtered=显示 {0}/{1} 个模型 settings.model.no.match=无匹配模型 settings.model.no.results=无匹配模型 settings.configure.title=配置 {0} -settings.test.connection=测试连接 settings.test.connection.testing=正在测试... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=无法连接到提供商。请验证您的 API 密钥是否正确(对于云提供商)或您的基础 URL 是否可达(对于本地提供商)。您可以使用上面的帮助链接找到设置说明。 # Directives Dialog directive.manage.title=管理指令 @@ -789,6 +789,10 @@ provider.lmstudio.baseurl.description=LMStudio 基础 URL(默认 http://localh provider.lmstudio.setup.help=使用 LMStudio:\n\n1. 安装:https://lmstudio.ai/\n2. 启动本地服务\n3. 可选更改基础 URL provider.lmstudio.openai.compat.info=Askimo 使用 OpenAI 兼容端点(/v1),而非 LMStudio 原生 API(/api/v1/)。 +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=您的OpenAI兼容实例的基础URL +provider.openai_compatible.apikey.stored=API密钥已安全存储。留空以保留现有密钥,或输入新密钥进行更新。 + # System Resources system.memory=内存 system.cpu=CPU 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 55b2f82e..67ae7308 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -662,9 +662,9 @@ settings.model.filtered=顯示 {0} / {1} 個模型 settings.model.no.match=沒有符合的模型 settings.model.no.results=沒有符合的模型 settings.configure.title=設定 {0} -settings.test.connection=測試連線 settings.test.connection.testing=測試中... settings.placeholder.baseurl=http://localhost:11434 +provider.connection.failed=無法連接到提供商。請驗證您的 API 金鑰是否正確(對於雲端提供商)或您的基礎 URL 是否可達(對於本地提供商)。您可以使用上面的幫助連結找到設定說明。 # Directives Dialog directive.manage.title=管理指令 @@ -789,6 +789,10 @@ provider.lmstudio.baseurl.description=您的 LMStudio 基本 URL(預設:http provider.lmstudio.setup.help=💡 若要使用 LMStudio:\n\n1. 安裝:https://lmstudio.ai/\n2. 啟動本機伺服器\n3. 若需要,調整基本 URL provider.lmstudio.openai.compat.info=Askimo 使用 OpenAI 相容端點(/v1),而非 LMStudio 原生 API(/api/v1/)。 +# Provider Configuration - Open AI Compatible +provider.openai_compatible.baseurl.description=您的OpenAI相容執行個體的基礎URL +provider.openai_compatible.apikey.stored=API金鑰已安全儲存。留空以保留現有金鑰,或輸入新金鑰進行更新。 + # System Resources system.memory=記憶體 system.cpu=CPU 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 964f3bdb..2c3c6957 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt @@ -4,6 +4,11 @@ */ package io.askimo.desktop.settings +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -78,511 +83,516 @@ fun providerSelectionDialog( ) }, text = { - if (viewModel.showModelSelectionInProviderDialog) { - var searchQuery by remember { mutableStateOf("") } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + if (viewModel.showModelSelectionInProviderDialog) { + var searchQuery by remember { mutableStateOf("") } - val filteredModels = remember(viewModel.availableModels, searchQuery) { - if (searchQuery.isBlank()) { - viewModel.availableModels - } else { - viewModel.availableModels.filter { - it.displayName.contains(searchQuery, ignoreCase = true) || - it.modelId.contains(searchQuery, ignoreCase = true) + val filteredModels = remember(viewModel.availableModels, searchQuery) { + if (searchQuery.isBlank()) { + viewModel.availableModels + } else { + viewModel.availableModels.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.modelId.contains(searchQuery, ignoreCase = true) + } } } - } - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.large), - ) { - when { - viewModel.isLoadingModels -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource("settings.model.loading"), - modifier = Modifier.padding(start = Spacing.large), - ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.large), + ) { + when { + viewModel.isLoadingModels -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource("settings.model.loading"), + modifier = Modifier.padding(start = Spacing.large), + ) + } } - } - viewModel.modelError != null -> { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.small)) { - Text( - text = viewModel.modelError ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - viewModel.modelErrorHelp?.let { helpText -> - Card(colors = AppComponents.surfaceVariantCardColors()) { - Text( - text = helpText, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(Spacing.medium), - ) + viewModel.modelError != null -> { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.small)) { + Text( + text = viewModel.modelError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + viewModel.modelErrorHelp?.let { helpText -> + Card(colors = AppComponents.surfaceVariantCardColors()) { + Text( + text = helpText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(Spacing.medium), + ) + } } } } - } - viewModel.availableModels.isEmpty() -> { - Text( - text = stringResource("settings.model.none"), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + viewModel.availableModels.isEmpty() -> { + Text( + text = stringResource("settings.model.none"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - else -> { - Text( - text = stringResource("settings.model.select", viewModel.selectedProvider?.name ?: ""), - style = MaterialTheme.typography.bodyMedium, - ) + else -> { + Text( + text = stringResource("settings.model.select", viewModel.selectedProvider?.name ?: ""), + style = MaterialTheme.typography.bodyMedium, + ) - // Selected model display (if any) - if (viewModel.pendingModelForNewProvider != null) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = AppComponents.surfaceVariantCardColors(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.large), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + // Selected model display (if any) + if (viewModel.pendingModelForNewProvider != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = AppComponents.surfaceVariantCardColors(), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource("settings.model.selected"), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = viewModel.pendingModelForNewProvider ?: "", - style = MaterialTheme.typography.bodyLarge, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.large), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource("settings.model.selected"), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = viewModel.pendingModelForNewProvider ?: "", + style = MaterialTheme.typography.bodyLarge, + ) + } } } } - } - // Simple search field - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(stringResource("settings.model.search.placeholder")) }, - label = { Text(stringResource("settings.model.search")) }, - singleLine = true, - colors = AppComponents.outlinedTextFieldColors(), - ) + // Simple search field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource("settings.model.search.placeholder")) }, + label = { Text(stringResource("settings.model.search")) }, + singleLine = true, + colors = AppComponents.outlinedTextFieldColors(), + ) - // Filtered models list - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 400.dp) - .verticalScroll(rememberScrollState()), - ) { - if (filteredModels.isEmpty()) { - Text( - text = stringResource("settings.model.no.match"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(Spacing.large), - ) - } else { - if (searchQuery.isNotBlank()) { + // Filtered models list + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()), + ) { + if (filteredModels.isEmpty()) { Text( - text = stringResource("settings.model.filtered", filteredModels.size, viewModel.availableModels.size), + text = stringResource("settings.model.no.match"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = Spacing.small), + modifier = Modifier.padding(Spacing.large), ) - } + } else { + if (searchQuery.isNotBlank()) { + Text( + text = stringResource("settings.model.filtered", filteredModels.size, viewModel.availableModels.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = Spacing.small), + ) + } - // Display grouped models using shared component - groupedModelListAsCards( - models = filteredModels, - selectedModelId = viewModel.pendingModelForNewProvider, - onModelClick = { model -> - viewModel.selectModelForNewProvider(model) - }, - showHeaders = true, - ) + // Display grouped models using shared component + groupedModelListAsCards( + models = filteredModels, + selectedModelId = viewModel.pendingModelForNewProvider, + onModelClick = { model -> + viewModel.selectModelForNewProvider(model) + }, + showHeaders = true, + ) + } } } } } - } - } else { - // Steps 1 & 2: Provider selection and configuration - var providerDropdownExpanded by remember { mutableStateOf(false) } + } else { + // Steps 1 & 2: Provider selection and configuration + var providerDropdownExpanded by remember { mutableStateOf(false) } - Card( - modifier = Modifier.fillMaxWidth(), - colors = AppComponents.bannerCardColors(), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.large), - verticalArrangement = Arrangement.spacedBy(Spacing.large), + Card( + modifier = Modifier.fillMaxWidth(), + colors = AppComponents.bannerCardColors(), ) { - // Step 1: Provider selection dropdown Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.large), + verticalArrangement = Arrangement.spacedBy(Spacing.large), ) { - Text( - text = stringResource("provider.select.prompt"), - style = MaterialTheme.typography.titleMedium, - ) + // Step 1: Provider selection dropdown + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), + ) { + Text( + text = stringResource("provider.select.prompt"), + style = MaterialTheme.typography.titleMedium, + ) - Box(modifier = Modifier.fillMaxWidth()) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickableCard { providerDropdownExpanded = true }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - Row( + Box(modifier = Modifier.fillMaxWidth()) { + Card( modifier = Modifier .fillMaxWidth() - .padding(Spacing.medium), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + .clickableCard { providerDropdownExpanded = true }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) { - Text( - text = viewModel.selectedProvider?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: stringResource("provider.choose.placeholder"), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Icon( - Icons.Default.Edit, - contentDescription = "Select provider", - tint = MaterialTheme.colorScheme.onSurface, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = viewModel.selectedProvider?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: stringResource("provider.choose.placeholder"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + Icons.Default.Edit, + contentDescription = "Select provider", + tint = MaterialTheme.colorScheme.onSurface, + ) + } } - } - AppComponents.dropdownMenu( - expanded = providerDropdownExpanded, - onDismissRequest = { providerDropdownExpanded = false }, - ) { - viewModel.availableProviders.forEach { provider -> - DropdownMenuItem( - text = { - Text( - text = provider.name.lowercase().replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.bodyLarge, - ) - }, - onClick = { - viewModel.selectProviderForChange(provider) - providerDropdownExpanded = false - }, - leadingIcon = if (viewModel.selectedProvider == provider) { - { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.onSurface, + AppComponents.dropdownMenu( + expanded = providerDropdownExpanded, + onDismissRequest = { providerDropdownExpanded = false }, + ) { + viewModel.availableProviders.forEach { provider -> + DropdownMenuItem( + text = { + Text( + text = provider.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyLarge, ) - } - } else { - null - }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), - ) + }, + onClick = { + viewModel.selectProviderForChange(provider) + providerDropdownExpanded = false + }, + leadingIcon = if (viewModel.selectedProvider == provider) { + { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } else { + null + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) + } } } } - } - // Help link for provider setup instructions - if (viewModel.selectedProvider != null) { - linkButton( - onClick = { - try { - val providerName = viewModel.selectedProvider?.name?.lowercase() ?: return@linkButton - Desktop.getDesktop().browse( - URI("https://$DOMAIN/docs/desktop/providers/$providerName/"), - ) - } catch (_: Exception) { - // Silently fail if browser cannot be opened - } - }, - modifier = Modifier.padding(0.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.Help, - contentDescription = "Help", - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(Spacing.extraSmall)) - Text( - text = stringResource("provider.setup.guide", viewModel.selectedProvider?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: ""), - style = MaterialTheme.typography.bodySmall, - ) + // Help link for provider setup instructions + if (viewModel.selectedProvider != null) { + linkButton( + onClick = { + try { + val providerName = viewModel.selectedProvider?.name?.lowercase() ?: return@linkButton + Desktop.getDesktop().browse( + URI("https://$DOMAIN/docs/desktop/providers/$providerName/"), + ) + } catch (_: Exception) { + // Silently fail if browser cannot be opened + } + }, + modifier = Modifier.padding(0.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.Help, + contentDescription = "Help", + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(Spacing.extraSmall)) + Text( + text = stringResource("provider.setup.guide", viewModel.selectedProvider?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: ""), + style = MaterialTheme.typography.bodySmall, + ) + } } - } - // Step 2: Configuration fields (shown after provider is selected) - if (viewModel.selectedProvider != null && viewModel.providerConfigFields.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = Spacing.small)) + // Step 2: Configuration fields (shown after provider is selected) + if (viewModel.selectedProvider != null && viewModel.providerConfigFields.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = Spacing.small)) - Text( - text = stringResource("provider.configure.prompt"), - style = MaterialTheme.typography.titleMedium, - ) + Text( + text = stringResource("provider.configure.prompt"), + style = MaterialTheme.typography.titleMedium, + ) - viewModel.providerConfigFields.forEach { field -> - when (field) { - is ProviderConfigField.InfoField -> { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - verticalAlignment = Alignment.Top, + viewModel.providerConfigFields.forEach { field -> + when (field) { + is ProviderConfigField.InfoField -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { - Icon( - Icons.Default.Info, - contentDescription = "Info", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.Top, + ) { + Icon( + Icons.Default.Info, + contentDescription = "Info", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = field.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), + ) { + Text( + text = field.label + if (field.required) " *" else "", + style = MaterialTheme.typography.labelLarge, ) Text( - text = field.message, + text = field.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - } - } - - else -> { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), - ) { - Text( - text = field.label + if (field.required) " *" else "", - style = MaterialTheme.typography.labelLarge, - ) - Text( - text = field.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - when (field) { - is ProviderConfigField.ApiKeyField -> { - OutlinedTextField( - value = viewModel.providerFieldValues[field.name] ?: "", - onValueChange = { viewModel.updateProviderField(field.name, it) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - placeholder = { - Text( - if (field.hasExistingValue) { - stringResource("provider.apikey.stored") - } else { - stringResource("provider.apikey.enter") - }, - ) - }, - trailingIcon = { - Row { - if (field.hasExistingValue) { - Icon( - Icons.Default.CheckCircle, - contentDescription = stringResource("provider.apikey.already.stored"), - tint = MaterialTheme.colorScheme.onSurface, - ) + when (field) { + is ProviderConfigField.ApiKeyField -> { + OutlinedTextField( + value = viewModel.providerFieldValues[field.name] ?: "", + onValueChange = { viewModel.updateProviderField(field.name, it) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + placeholder = { + Text( + if (field.hasExistingValue) { + stringResource("provider.apikey.stored") + } else { + stringResource("provider.apikey.enter") + }, + ) + }, + trailingIcon = { + Row { + if (field.hasExistingValue) { + Icon( + Icons.Default.CheckCircle, + contentDescription = stringResource("provider.apikey.already.stored"), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Icon(Icons.Default.Lock, contentDescription = "Password") } - Spacer(modifier = Modifier.width(8.dp)) - Icon(Icons.Default.Lock, contentDescription = "Password") - } - }, - colors = AppComponents.outlinedTextFieldColors(), - ) + }, + colors = AppComponents.outlinedTextFieldColors(), + ) - // Security assurance message - Card( - modifier = Modifier - .fillMaxWidth() - .padding(top = Spacing.small), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column( + // Security assurance message + Card( modifier = Modifier .fillMaxWidth() - .padding(Spacing.medium), - verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), + .padding(top = Spacing.small), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { - Text( - text = stringResource("provider.apikey.security.message"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - linkButton( - onClick = { - try { - Desktop.getDesktop().browse( - URI("https://$DOMAIN/security/"), - ) - } catch (_: Exception) { - // Silently fail if browser cannot be opened - } - }, - modifier = Modifier.padding(0.dp), + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), ) { Text( - text = stringResource("provider.apikey.security.link"), + text = stringResource("provider.apikey.security.message"), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + linkButton( + onClick = { + try { + Desktop.getDesktop().browse( + URI("https://$DOMAIN/security/"), + ) + } catch (_: Exception) { + // Silently fail if browser cannot be opened + } + }, + modifier = Modifier.padding(0.dp), + ) { + Text( + text = stringResource("provider.apikey.security.link"), + style = MaterialTheme.typography.bodySmall, + ) + } } } } - } - is ProviderConfigField.BaseUrlField -> { - OutlinedTextField( - value = viewModel.providerFieldValues[field.name] ?: "", - onValueChange = { viewModel.updateProviderField(field.name, it) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text(stringResource("settings.placeholder.baseurl")) }, - colors = AppComponents.outlinedTextFieldColors(), - ) + is ProviderConfigField.BaseUrlField -> { + OutlinedTextField( + value = viewModel.providerFieldValues[field.name] ?: "", + onValueChange = { viewModel.updateProviderField(field.name, it) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(stringResource("settings.placeholder.baseurl")) }, + colors = AppComponents.outlinedTextFieldColors(), + ) + } } } } - } - } // when (field) - } + } // when (field) + } - // Connection error display - if (viewModel.connectionError != null) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - verticalArrangement = Arrangement.spacedBy(Spacing.small), + // Connection error display + if (viewModel.connectionError != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), ) { - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - verticalAlignment = Alignment.Top, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + verticalArrangement = Arrangement.spacedBy(Spacing.small), ) { - Icon( - Icons.Default.Warning, - contentDescription = "Error", - tint = MaterialTheme.colorScheme.error, - ) - Column { - Text( - text = viewModel.connectionError ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.Top, + ) { + Icon( + Icons.Default.Warning, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, ) - if (viewModel.connectionErrorHelp != null) { - Spacer(modifier = Modifier.height(Spacing.extraSmall)) + Column { Text( - text = viewModel.connectionErrorHelp ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + text = viewModel.connectionError ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, ) + if (viewModel.connectionErrorHelp != null) { + Spacer(modifier = Modifier.height(Spacing.extraSmall)) + Text( + text = viewModel.connectionErrorHelp ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + ) + } } } } } } - } - // Embedding model warning (only relevant for RAG features) - if (viewModel.embeddingModelWarning != null) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - verticalArrangement = Arrangement.spacedBy(Spacing.small), + // Embedding model warning (only relevant for RAG features) + if (viewModel.embeddingModelWarning != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), ) { - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - verticalAlignment = Alignment.Top, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + verticalArrangement = Arrangement.spacedBy(Spacing.small), ) { - Icon( - Icons.Default.Info, - contentDescription = "Info", - tint = MaterialTheme.colorScheme.tertiary, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource("settings.embedding.rag_feature_only"), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - Spacer(modifier = Modifier.height(Spacing.extraSmall)) - Text( - text = viewModel.embeddingModelWarning ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.9f), + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.Top, + ) { + Icon( + Icons.Default.Info, + contentDescription = "Info", + tint = MaterialTheme.colorScheme.tertiary, ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource("settings.embedding.rag_feature_only"), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Spacer(modifier = Modifier.height(Spacing.extraSmall)) + Text( + text = viewModel.embeddingModelWarning ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.9f), + ) + } } - } - // Show download button for Ollama if model can be pulled - if (viewModel.canPullEmbeddingModel && viewModel.embeddingModelProvider == "OLLAMA") { - primaryButton( - onClick = { - val baseUrl = viewModel.providerFieldValues["baseUrl"] ?: "" - if (baseUrl.isNotBlank()) { - viewModel.pullEmbeddingModel(ModelProvider.OLLAMA, baseUrl) + // Show download button for Ollama if model can be pulled + 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 = Modifier.width(8.dp)) } - }, - enabled = !viewModel.isCheckingEmbeddingModel, - ) { - if (viewModel.isCheckingEmbeddingModel) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource("settings.embedding.download_model")) } - Text(stringResource("settings.embedding.download_model")) } } } @@ -590,12 +600,13 @@ fun providerSelectionDialog( } } } - } - } // End of else block for provider configuration + } // End of else block for provider configuration + } // end Column }, confirmButton = { Row( horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.CenterVertically, ) { if (viewModel.showModelSelectionInProviderDialog) { secondaryButton( @@ -603,25 +614,6 @@ fun providerSelectionDialog( ) { Text(stringResource("action.back")) } - } else { - if (viewModel.selectedProvider != null && viewModel.providerConfigFields.isNotEmpty()) { - secondaryButton( - onClick = { viewModel.testConnection() }, - enabled = !viewModel.isTestingConnection, - ) { - if (viewModel.isTestingConnection) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.width(Spacing.small)) - Text(stringResource("settings.test.connection.testing")) - } else { - Text(stringResource("settings.test.connection")) - } - } - } } // Save button @@ -638,10 +630,31 @@ fun providerSelectionDialog( } }, dismissButton = { - secondaryButton( - onClick = onDismiss, + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.medium), + verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource("settings.cancel")) + if (!viewModel.showModelSelectionInProviderDialog && viewModel.isFetchingModelsForConfig) { + val infiniteTransition = rememberInfiniteTransition(label = "dots") + val tick by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 4f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1_200, easing = LinearEasing), + ), + label = "dots_tick", + ) + val dots = ".".repeat((tick.toInt() % 4).coerceAtLeast(1)) + Text( + text = stringResource("settings.test.connection.testing") + dots, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + secondaryButton(onClick = onDismiss) { + Text(stringResource("settings.cancel")) + } } }, ) 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 5798d300..615399b1 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsViewModel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/SettingsViewModel.kt @@ -27,9 +27,12 @@ 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.text.isNotBlank +import kotlin.time.Duration.Companion.milliseconds /** * ViewModel for managing settings state and configuration information. @@ -104,6 +107,15 @@ class SettingsViewModel( var isTestingConnection by mutableStateOf(false) private set + /** + * True while the config screen is auto-fetching models in the background. + * Used to show a loading indicator on the config screen instead of an explicit "Test Connection" button. + */ + var isFetchingModelsForConfig by mutableStateOf(false) + private set + + private var autoFetchJob: Job? = null + var connectionError by mutableStateOf(null) private set @@ -244,45 +256,55 @@ class SettingsViewModel( is ProviderConfigField.InfoField -> null } }.toMap() + + // Auto-fetch models if pre-existing fields already satisfy all requirements + scheduleAutoModelFetch() } /** - * Update a provider configuration field value. + * Update a provider configuration field value and reschedule the auto model fetch. */ fun updateProviderField(fieldName: String, value: String) { providerFieldValues = providerFieldValues.toMutableMap().apply { put(fieldName, value) } + scheduleAutoModelFetch() } /** - * Test connection to the provider with current configuration. + * Schedule a debounced model fetch when all required fields are filled. + * Cancels any in-flight fetch and waits 600ms after the last field change before + * attempting to load models. On success, automatically advances to model selection. */ - fun testConnection() { - val provider = selectedProvider ?: return + private fun scheduleAutoModelFetch() { + autoFetchJob?.cancel() - // Validate all required fields are filled + // Only attempt if all required config fields are satisfied if (!validateConfigFields(providerFieldValues, providerConfigFields)) { - connectionError = "Please fill in all required fields" + // Fields not complete yet — reset any previous error so the UI stays clean + connectionError = null + connectionErrorHelp = null + isFetchingModelsForConfig = false return } - isTestingConnection = true + val provider = selectedProvider ?: return + connectionError = null connectionErrorHelp = null + isFetchingModelsForConfig = true + + autoFetchJob = scope.launch { + delay(1000.milliseconds) - scope.launch { val result = withContext(Dispatchers.IO) { try { - // Get existing settings if available val existingSettings = appContext.params.providerSettings[provider] ?: ProviderRegistry.getFactory(provider)?.defaultSettings() - // Create updated settings val newSettings = existingSettings?.applyConfigFields(providerFieldValues) ?: return@withContext ProviderTestResult.Failure("Failed to create settings") - // First validate the settings format if (!newSettings.validate()) { return@withContext ProviderTestResult.Failure( message = "Cannot connect to ${provider.name.lowercase()} provider", @@ -290,46 +312,43 @@ class SettingsViewModel( ) } - // Actually test the connection by trying to fetch models val factory = ProviderRegistry.getFactory(provider) ?: return@withContext ProviderTestResult.Failure("No factory found for provider") @Suppress("UNCHECKED_CAST") val models = (factory as ChatModelFactory).availableModels(newSettings) - // If we got models, the connection works if (models.isNotEmpty()) { ProviderTestResult.Success } else { ProviderTestResult.Failure( - message = "No models available for ${provider.name.lowercase()}", - helpText = factory.getNoModelsHelpText(), + message = LocalizationManager.getString("provider.connection.failed"), + helpText = null, ) } } catch (e: Exception) { - log.error("Error testing provider connection", e) - val errorMsg = ErrorHandler.getUserFriendlyError( - e, - "testing provider connection", - "Failed to test connection. Please check your settings.", + 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.", + ), ) - ProviderTestResult.Failure(errorMsg) } } - isTestingConnection = false + isFetchingModelsForConfig = false when (result) { is ProviderTestResult.Success -> { connectionError = null connectionErrorHelp = null connectionTestSuccess = true - // Automatically load models for provider selection flow showModelSelectionInProviderDialog = true loadModelsForSelectedProvider() - // Check embedding model availability for local providers - val baseUrl = providerFieldValues["baseUrl"] + val baseUrl = providerFieldValues[SettingField.BASE_URL] if (baseUrl != null && baseUrl.isNotBlank()) { checkEmbeddingModelAvailability(provider, baseUrl) } @@ -420,8 +439,12 @@ class SettingsViewModel( * Go back from model selection to provider configuration. */ fun backToProviderConfiguration() { + autoFetchJob?.cancel() + isFetchingModelsForConfig = false showModelSelectionInProviderDialog = false connectionTestSuccess = false + connectionError = null + connectionErrorHelp = null pendingModelForNewProvider = null } @@ -516,6 +539,8 @@ class SettingsViewModel( * Close the provider selection dialog. */ fun closeProviderDialog() { + autoFetchJob?.cancel() + isFetchingModelsForConfig = false showProviderDialog = false selectedProvider = null providerConfigFields = emptyList()