From 29f4f77cea61297bcde03cf59d38db810ea48343 Mon Sep 17 00:00:00 2001 From: Hai Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:31:41 -0700 Subject: [PATCH] feat(shell): Integrate provider configuration flow into error handling - Enhances error dialogs to support secondary actions for user resolution. - Global error handler now automatically triggers provider setup wizard upon configuration errors. - Introduced `NavigateToProviderSettingsEvent` to decouple UI navigation from core logic. - Updated internationalization resources for provider setup and error actions across multiple languages. - Added stable `installId` getter to the Analytics module for better tracking. --- .../io/askimo/ui/common/dialog/ErrorDialog.kt | 13 +++++- .../io/askimo/ui/shell/GlobalErrorHandler.kt | 33 ++++++++++++++- .../main/resources/i18n/messages.properties | 3 ++ .../resources/i18n/messages_de.properties | 3 ++ .../resources/i18n/messages_es.properties | 3 ++ .../resources/i18n/messages_fr.properties | 3 ++ .../resources/i18n/messages_ja_JP.properties | 3 ++ .../resources/i18n/messages_ko_KR.properties | 3 ++ .../resources/i18n/messages_pt_BR.properties | 3 ++ .../resources/i18n/messages_vi_VN.properties | 3 ++ .../resources/i18n/messages_zh_CN.properties | 3 ++ .../resources/i18n/messages_zh_TW.properties | 3 ++ .../src/main/kotlin/io/askimo/desktop/Main.kt | 26 ++++++++++-- .../io/askimo/desktop/shell/FooterBar.kt | 40 ++++++++++++++++++- .../desktop/shell/ProviderModelPanel.kt | 25 ++++++++++++ .../io/askimo/core/analytics/Analytics.kt | 7 ++++ .../NavigateToProviderSettingsEvent.kt | 21 ++++++++++ .../OpenAiCompatibleTemplate.kt | 2 +- 18 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 shared/src/main/kotlin/io/askimo/core/event/internal/NavigateToProviderSettingsEvent.kt diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/dialog/ErrorDialog.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/dialog/ErrorDialog.kt index 317387b3..3a9f2b62 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/dialog/ErrorDialog.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/dialog/ErrorDialog.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import io.askimo.ui.common.components.primaryButton +import io.askimo.ui.common.components.secondaryButton import io.askimo.ui.common.i18n.stringResource import io.askimo.ui.common.theme.AppComponents import io.askimo.ui.common.theme.AppTextStyles @@ -54,6 +55,8 @@ fun errorDialog( linkText: String? = null, linkUrl: String? = null, details: String? = null, + actionLabel: String? = null, + action: (() -> Unit)? = null, ) { val linkColor = MaterialTheme.colorScheme.onSurface var showDetails by remember { mutableStateOf(false) } @@ -200,8 +203,16 @@ fun errorDialog( confirmButton = { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(Spacing.small, Alignment.End), ) { + if (actionLabel != null && action != null) { + secondaryButton(onClick = { + onDismiss() + action() + }) { + Text(actionLabel) + } + } primaryButton(onClick = onDismiss) { Text(stringResource("action.ok")) } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/GlobalErrorHandler.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/GlobalErrorHandler.kt index d9c664bc..5a53ecf2 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/GlobalErrorHandler.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/GlobalErrorHandler.kt @@ -11,7 +11,11 @@ import io.askimo.core.event.error.IndexingErrorEvent import io.askimo.core.event.error.IndexingErrorType import io.askimo.core.event.error.ModelNotAvailableEvent import io.askimo.core.event.error.SendMessageErrorEvent +import io.askimo.core.event.internal.NavigateToProviderSettingsEvent +import io.askimo.core.exception.AuthenticationException import io.askimo.core.exception.ExceptionMapper +import io.askimo.core.exception.ModelNotFoundChatException +import io.askimo.core.exception.ProviderNotConfiguredException import io.askimo.core.i18n.LocalizationManager /** @@ -24,13 +28,22 @@ data class ErrorDialogState( val linkText: String? = null, val linkUrl: String? = null, val details: String? = null, + /** Label for an optional in-dialog action button (e.g. "Configure Provider"). */ + val actionLabel: String? = null, + /** Callback invoked when the user clicks the action button. The dialog is dismissed before this runs. */ + val action: (() -> Unit)? = null, ) /** * Listens to [EventBus.errorEvents] and surfaces errors through [ErrorDialogState]. * - * Call this composable once at the top-level [app] scope and pass the returned - * state (along with the dismiss callback) down to wherever the error dialog is rendered. + * Call this composable once at the top-level app scope. + * + * For errors the user can fix by reconfiguring their provider + * ([AuthenticationException], [ProviderNotConfiguredException], [ModelNotFoundChatException]), + * the resulting [ErrorDialogState] includes an action button that posts + * [NavigateToProviderSettingsEvent] on the internal event bus. + * The top-level composable should listen for that event and open the provider wizard. * * @param onStateChange called whenever a new [ErrorDialogState] should be applied. */ @@ -136,11 +149,27 @@ fun globalErrorHandler(onStateChange: (ErrorDialogState) -> Unit) { mapped.getMessageKey(), *mapped.getMessageArgs().values.toTypedArray(), ) + val isProviderConfigError = mapped is AuthenticationException || + mapped is ProviderNotConfiguredException || + mapped is ModelNotFoundChatException + onStateChange( ErrorDialogState( show = true, title = LocalizationManager.getString("error.send_message.title"), message = localizedMsg, + actionLabel = if (isProviderConfigError) { + LocalizationManager.getString("error.action.configure_provider") + } else { + null + }, + // Posts a navigation event so the UI layer can open the wizard + // without this component holding any reference to the UI. + action = if (isProviderConfigError) { + { EventBus.post(NavigateToProviderSettingsEvent()) } + } else { + null + }, ), ) } diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index dd84bcdd..8fb41f32 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -951,6 +951,8 @@ provider.instance.name.duplicate=A provider named "{0}" already exists provider.setup.required.title=Setup AI Provider provider.setup.required.message=Before you can start chatting, you need to set up an AI provider. Please select a provider from the list and configure it. provider.setup.required.button=Set Up Provider +provider.setup.required.skip=Set up later +provider.setup.empty.state.button=Add Provider provider.configure.prompt=Configure provider: provider.setup.guide=How to set up {0} provider.apikey.stored=API key stored securely @@ -1116,6 +1118,7 @@ error.app.message=An unexpected error occurred: {0} # Send Message Errors error.send_message.title=Failed to Send Message +error.action.configure_provider=Configure Provider # Indexing Errors error.indexing.model_not_found.title=Embedding Model Not Found diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index f6bec459..1f520372 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate=Ein Anbieter mit dem Namen "{0}" existiert bere provider.setup.required.title=AI-Anbieter einrichten provider.setup.required.message=Bevor Sie mit dem Chatten beginnen können, müssen Sie einen AI-Anbieter einrichten. Bitte wählen Sie einen Anbieter aus der Liste aus und konfigurieren Sie ihn. provider.setup.required.button=Anbieter einrichten +provider.setup.required.skip=Später einrichten +provider.setup.empty.state.button=Anbieter hinzufügen provider.configure.prompt=Anbieter konfigurieren: provider.setup.guide=So richten Sie {0} ein provider.apikey.stored=API-Schlüssel sicher gespeichert @@ -1115,6 +1117,7 @@ error.app.message=Ein unerwarteter Fehler ist aufgetreten: {0} # Send Message Errors error.send_message.title=Nachricht konnte nicht gesendet werden +error.action.configure_provider=Anbieter konfigurieren # Indexing Errors error.indexing.model_not_found.title=Einbettungsmodell nicht gefunden diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index e777ebf4..723185c9 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate=Ya existe un proveedor llamado "{0}" provider.setup.required.title=Configurar proveedor de AI provider.setup.required.message=Antes de comenzar a chatear, debe configurar un proveedor de AI. Seleccione un proveedor de la lista y configúrelo. provider.setup.required.button=Configurar proveedor +provider.setup.required.skip=Configurar más tarde +provider.setup.empty.state.button=Agregar proveedor provider.configure.prompt=Configurar proveedor: provider.setup.guide=Cómo configurar {0} provider.apikey.stored=Clave API guardada de forma segura @@ -1115,6 +1117,7 @@ error.app.message=Ocurrió un error inesperado: {0} # Send Message Errors error.send_message.title=No se pudo enviar el mensaje +error.action.configure_provider=Configurar proveedor # Indexing Errors error.indexing.model_not_found.title=Modelo de incrustación no encontrado diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index b8e808a0..4443e05a 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate=Un fournisseur nommé "{0}" existe déjà provider.setup.required.title=Configurer le fournisseur d'AI provider.setup.required.message=Avant de pouvoir commencer à discuter, vous devez configurer un fournisseur d'AI. Veuillez sélectionner un fournisseur dans la liste et le configurer. provider.setup.required.button=Configurer le fournisseur +provider.setup.required.skip=Configurer plus tard +provider.setup.empty.state.button=Ajouter un fournisseur provider.configure.prompt=Configurer le fournisseur : provider.setup.guide=Comment configurer {0} provider.apikey.stored=Clé API stockée de manière sécurisée @@ -1115,6 +1117,7 @@ error.app.message=Une erreur inattendue s’est produite : {0} # Send Message Errors error.send_message.title=Échec de l’envoi du message +error.action.configure_provider=Configurer le fournisseur # Indexing Errors error.indexing.model_not_found.title=Modèle d’embedding introuvable 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 793a5bd9..f20581a2 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate="{0}" という名前のプロバイダーは provider.setup.required.title=AIプロバイダーのセットアップ provider.setup.required.message=チャットを開始する前に、AIプロバイダーをセットアップする必要があります。リストからプロバイダーを選択し、設定を行ってください。 provider.setup.required.button=プロバイダーをセットアップ +provider.setup.required.skip=後で設定する +provider.setup.empty.state.button=プロバイダーを追加 provider.configure.prompt=プロバイダーの設定: provider.setup.guide={0} のセットアップ方法 provider.apikey.stored=APIキーは安全に保存されています @@ -1115,6 +1117,7 @@ error.app.message=予期しないエラーが発生しました: {0} # Send Message Errors error.send_message.title=メッセージの送信に失敗しました +error.action.configure_provider=プロバイダーを設定 # Indexing Errors error.indexing.model_not_found.title=埋め込みモデルが見つかりません 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 49b987ac..37f49c43 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate="{0}" 이름의 프로바이더가 이미 존 provider.setup.required.title=AI 제공업체 설정 provider.setup.required.message=채팅을 시작하려면 먼저 AI 제공업체를 설정해야 합니다. 목록에서 제공업체를 선택하고 구성해 주세요. provider.setup.required.button=제공업체 설정 +provider.setup.required.skip=나중에 설정 +provider.setup.empty.state.button=공급자 추가 provider.configure.prompt=제공업체 구성: provider.setup.guide={0} 설정 방법 provider.apikey.stored=API 키가 안전하게 저장됨 @@ -1115,6 +1117,7 @@ error.app.message=예기치 않은 오류가 발생했습니다: {0} # Send Message Errors error.send_message.title=메시지 전송 실패 +error.action.configure_provider=공급자 구성 # Indexing Errors error.indexing.model_not_found.title=임베딩 모델을 찾을 수 없음 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 b78a2e37..898bf32f 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate=Já existe um provedor chamado "{0}" provider.setup.required.title=Configurar provedor de AI provider.setup.required.message=Antes de começar a conversar, você precisa configurar um provedor de AI. Selecione um provedor da lista e configure-o. provider.setup.required.button=Configurar provedor +provider.setup.required.skip=Configurar mais tarde +provider.setup.empty.state.button=Adicionar provedor provider.configure.prompt=Configurar provedor: provider.setup.guide=Como configurar o {0} provider.apikey.stored=Chave API armazenada de forma segura @@ -1115,6 +1117,7 @@ error.app.message=Ocorreu um erro inesperado: {0} # Send Message Errors error.send_message.title=Falha ao enviar mensagem +error.action.configure_provider=Configurar provedor # Indexing Errors error.indexing.model_not_found.title=Modelo de embedding não encontrado 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 470e4ffd..93084cea 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate=Nhà cung cấp có tên "{0}" đã tồn tại provider.setup.required.title=Thiết lập nhà cung cấp AI provider.setup.required.message=Trước khi có thể bắt đầu trò chuyện, bạn cần thiết lập một nhà cung cấp AI. Vui lòng chọn một nhà cung cấp từ danh sách và cấu hình nó. provider.setup.required.button=Thiết lập nhà cung cấp +provider.setup.required.skip=Thiết lập sau +provider.setup.empty.state.button=Thêm nhà cung cấp provider.configure.prompt=Cấu hình nhà cung cấp: provider.setup.guide=Cách thiết lập {0} provider.apikey.stored=Khóa API được lưu trữ an toàn @@ -1115,6 +1117,7 @@ error.app.message=Đã xảy ra lỗi không mong muốn: {0} # Send Message Errors error.send_message.title=Gửi tin nhắn thất bại +error.action.configure_provider=Cấu hình nhà cung cấp # Indexing Errors error.indexing.model_not_found.title=Không tìm thấy mô hình embedding 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 24840e6c..9d7128d7 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate=名为"{0}"的提供商已存在 provider.setup.required.title=设置 AI 服务商 provider.setup.required.message=在开始聊天之前,您需要设置一个 AI 服务商。请从列表中选择一个服务商并进行配置。 provider.setup.required.button=设置服务商 +provider.setup.required.skip=稍后设置 +provider.setup.empty.state.button=添加提供商 provider.configure.prompt=配置服务商: provider.setup.guide=如何设置 {0} provider.apikey.stored=API 密钥已安全存储 @@ -1115,6 +1117,7 @@ error.app.message=发生了一个意外错误:{0} # Send Message Errors error.send_message.title=发送消息失败 +error.action.configure_provider=配置提供程序 # Indexing Errors error.indexing.model_not_found.title=未找到嵌入模型 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 1d45f8ed..46396168 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -950,6 +950,8 @@ provider.instance.name.duplicate=名為「{0}」的提供商已存在 provider.setup.required.title=設定 AI 服務商 provider.setup.required.message=在開始聊天之前,您需要設定 AI 服務商。請從清單中選擇一個服務商並進行設定。 provider.setup.required.button=設定服務商 +provider.setup.required.skip=稍後設定 +provider.setup.empty.state.button=新增提供商 provider.configure.prompt=設定服務商: provider.setup.guide=如何設定 {0} provider.apikey.stored=API 金鑰已安全儲存 @@ -1115,6 +1117,7 @@ error.app.message=發生未預期的錯誤:{0} # Send Message Errors error.send_message.title=傳送訊息失敗 +error.action.configure_provider=設定提供者 # Indexing Errors error.indexing.model_not_found.title=找不到嵌入模型 diff --git a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt index 37e6718b..56954f1e 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt @@ -70,6 +70,7 @@ import io.askimo.core.db.DatabaseManager import io.askimo.core.event.Event import io.askimo.core.event.EventBus import io.askimo.core.event.internal.LanguageDirectiveChangedEvent +import io.askimo.core.event.internal.NavigateToProviderSettingsEvent import io.askimo.core.event.internal.RunCodeEvent import io.askimo.core.event.system.InvalidateCacheEvent import io.askimo.core.i18n.LocalizationManager @@ -411,9 +412,6 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = } } - // Listen for errors – handled by the dedicated GlobalErrorHandler - globalErrorHandler { state -> errorDialogState = state } - val scope = rememberCoroutineScope() // Backup and restore helper functions @@ -508,6 +506,17 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = val settingsViewModel = remember { koin.get { parametersOf(scope) } } val updateViewModel = remember { koin.get { parametersOf(scope) } } + // Listen for errors via the GlobalErrorHandler. + globalErrorHandler { state -> errorDialogState = state } + + // React to NavigateToProviderSettingsEvent posted by GlobalErrorHandler (and any other + // component) to open the provider wizard without the emitter holding a UI reference. + LaunchedEffect(Unit) { + EventBus.internalEvents + .filterIsInstance() + .collect { settingsViewModel.onChangeProvider() } + } + val deleteSessionCommand = remember { koin.get { parametersOf(scope) } } @@ -1435,7 +1444,7 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = // Provider setup required dialog if (showProviderSetupDialog) { alertDialog( - onDismissRequest = { }, + onDismissRequest = { showProviderSetupDialog = false }, title = { Text( text = stringResource("provider.setup.required.title"), @@ -1458,6 +1467,13 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = Text(stringResource("provider.setup.required.button")) } }, + dismissButton = { + secondaryButton( + onClick = { showProviderSetupDialog = false }, + ) { + Text(stringResource("provider.setup.required.skip")) + } + }, ) } @@ -1857,6 +1873,8 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = linkText = errorDialogState.linkText, linkUrl = errorDialogState.linkUrl, details = errorDialogState.details, + actionLabel = errorDialogState.actionLabel, + action = errorDialogState.action, onDismiss = { errorDialogState = ErrorDialogState() }, diff --git a/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt b/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt index e8261165..6b4e5837 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/shell/FooterBar.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -45,6 +46,7 @@ import io.askimo.core.context.AppContext import io.askimo.core.context.getConfigInfo import io.askimo.core.event.EventBus import io.askimo.core.event.internal.ModelChangedEvent +import io.askimo.core.providers.ModelProvider import io.askimo.core.providers.ProviderInstanceService import io.askimo.core.providers.ProviderRegistry import io.askimo.ui.common.i18n.stringResource @@ -67,7 +69,6 @@ private fun aiConfigInfo( val scope = rememberCoroutineScope() var configInfo by remember { mutableStateOf(appContext.getConfigInfo()) } - // Keep configInfo in sync with model/instance changes broadcast on the event bus. LaunchedEffect(Unit) { EventBus.internalEvents.collect { event -> if (event is ModelChangedEvent) { @@ -76,6 +77,43 @@ private fun aiConfigInfo( } } + val noProvider = configInfo.provider == ModelProvider.UNKNOWN + + // When no provider is configured, show a prominent "Add Provider" CTA so + // users have an obvious entry point directly in the footer. + if (noProvider) { + themedTooltip(text = stringResource("provider.setup.required.title")) { + Card( + modifier = Modifier + .clickableCard { onAddProvider() } + .pointerHoverIcon(PointerIcon.Hand) + .widthIn(min = 120.dp, max = 320.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + text = stringResource("provider.setup.empty.state.button"), + style = AppTextStyles.caption, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + return + } + val panelState = remember(appContext) { ProviderModelPanelState(scope, appContext, providerInstanceService) } var panelExpanded by remember { mutableStateOf(false) } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt b/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt index 1ecca2f9..e0c35e16 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt @@ -187,6 +187,7 @@ internal fun providerModelPanel( Text( text = stringResource("provider.no.instances.hint"), style = AppTextStyles.caption, + modifier = Modifier.padding(horizontal = 12.dp), ) } } else { @@ -226,6 +227,30 @@ internal fun providerModelPanel( ) } } + + // ── Pinned "Add provider" row ───────────────────────────────────────────── + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onAddProvider() } + .pointerHoverIcon(PointerIcon.Hand) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(Spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource("provider.add.new"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource("provider.add.new"), + style = AppTextStyles.caption, + color = MaterialTheme.colorScheme.primary, + ) + } } // ── Vertical divider ────────────────────────────────────────────────────────── diff --git a/shared/src/main/kotlin/io/askimo/core/analytics/Analytics.kt b/shared/src/main/kotlin/io/askimo/core/analytics/Analytics.kt index b5ca5da9..67a975d8 100644 --- a/shared/src/main/kotlin/io/askimo/core/analytics/Analytics.kt +++ b/shared/src/main/kotlin/io/askimo/core/analytics/Analytics.kt @@ -43,6 +43,13 @@ object Analytics { /** True when the user has opted in and analytics is actively collecting. */ val isEnabled: Boolean get() = enabled + /** + * Anonymous stable install-scoped identifier sourced from [AnalyticsDeviceInfo.installId]. + * A random UUID v4 persisted in `~/.askimo/.install_id`, stable across sessions. + * Safe to use as a device identifier in sync protocols. + */ + val installId: String get() = AnalyticsDeviceInfo.installId + /** * Returns `"local"` for self-hosted providers (Ollama, LMStudio, LocalAI, Docker), * `"cloud"` for all managed / API-key providers. diff --git a/shared/src/main/kotlin/io/askimo/core/event/internal/NavigateToProviderSettingsEvent.kt b/shared/src/main/kotlin/io/askimo/core/event/internal/NavigateToProviderSettingsEvent.kt new file mode 100644 index 00000000..1d1c1568 --- /dev/null +++ b/shared/src/main/kotlin/io/askimo/core/event/internal/NavigateToProviderSettingsEvent.kt @@ -0,0 +1,21 @@ +/* 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 when a component requests the UI to navigate to the provider settings wizard. + */ +data class NavigateToProviderSettingsEvent( + override val timestamp: Instant = Instant.now(), + override val source: EventSource = EventSource.SYSTEM, +) : Event { + override val type = EventType.INTERNAL + override fun getDetails(): String = "Navigate to provider settings requested" +} diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt index afcfc5e9..19e91fe2 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt @@ -83,7 +83,7 @@ enum class OpenAiCompatibleTemplate( OPENROUTER( displayName = "OpenRouter", initials = "OR", - tagline = "Access 300+ models — GPT-4o, Claude, Llama, Gemini, Mistral and more via one API.", + tagline = "Access 300+ models — GPT, Claude, Llama, Gemini, Mistral and more via one API.", baseUrl = "https://openrouter.ai/api/v1", apiKeyRequired = true, apiKeyUrl = "https://openrouter.ai/keys",