diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt index 60920cf1..e9162d1b 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt @@ -108,13 +108,16 @@ import kotlin.time.TimeSource * [state]'s AI settings. Every change is saved as it is made. Changes to the model or search * rebuild the discovery engine; page access and content filter changes keep it, so running * searches carry on, asking by the new rules, and the next search filters by the new setting. - * Test calls the provider with the saved settings. + * Test calls the provider with the saved settings. While discovery is switched off, everything + * but its switch is disabled, keeping what was entered for when it is switched back on. */ @Composable fun AiDiscoverySettings(state: AppState) { val ai = state.aiSettings val settings = ai.settings val supported = ai.supported + // Everything under the switch follows it. + val editable = supported && settings.enabled val connectionTest = ai.connectionTest val onChange = { changed: AiSettings -> ai.save(changed) } val focusManager = LocalFocusManager.current @@ -141,18 +144,18 @@ fun AiDiscoverySettings(state: AppState) { title = stringResource(Res.string.settings_ai_content_filter), description = stringResource(Res.string.settings_ai_content_filter_hint), checked = settings.contentFilter, - enabled = supported, + enabled = editable, onCheckedChange = { onChange(settings.copy(contentFilter = it)) }, ) } - PageAccessGroup(access = settings.access, enabled = supported, onChange = ai::saveAccess) + PageAccessGroup(access = settings.access, enabled = editable, onChange = ai::saveAccess) SettingsGroup(title = stringResource(Res.string.settings_ai_model_group)) { SettingsRow( title = stringResource(Res.string.settings_ai_provider), description = providerHint(llm.provider).resolve(), - enabled = supported, + enabled = editable, ) { FlowRow( modifier = Modifier.fillMaxWidth(), @@ -163,7 +166,7 @@ fun AiDiscoverySettings(state: AppState) { KetchChip( label = provider.buttonLabel.resolve(), selected = provider == llm.provider, - enabled = supported, + enabled = editable, // Model and endpoint are provider-specific, so switching falls back to the new // provider's defaults. The token is kept: clearing a secret on a stray tap is // worse than a token the connection test will reject. @@ -186,7 +189,7 @@ fun AiDiscoverySettings(state: AppState) { } else { stringResource(Res.string.settings_ai_api_key_plain) }, - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = llm.apiKey, @@ -194,7 +197,7 @@ fun AiDiscoverySettings(state: AppState) { placeholder = tokenPlaceholder(llm.provider), secret = true, mono = true, - enabled = supported, + enabled = editable, ) } } @@ -205,7 +208,7 @@ fun AiDiscoverySettings(state: AppState) { } else { stringResource(Res.string.settings_ai_model_any) }, - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = llm.model, @@ -213,7 +216,7 @@ fun AiDiscoverySettings(state: AppState) { placeholder = llm.provider.defaultModel .ifBlank { stringResource(Res.string.settings_ai_model_placeholder) }, mono = true, - enabled = supported, + enabled = editable, ) val suggestions = modelSuggestions(llm.provider) if (suggestions.isNotEmpty()) { @@ -232,7 +235,7 @@ fun AiDiscoverySettings(state: AppState) { KetchButtonVariant.Ghost }, size = KetchButtonSize.Small, - enabled = supported, + enabled = editable, ) } } @@ -250,14 +253,14 @@ fun AiDiscoverySettings(state: AppState) { } else { null }, - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = llm.baseUrl, onCommit = { onChange(settings.copy(llm = llm.copy(baseUrl = it))) }, placeholder = llm.provider.defaultBaseUrl.ifBlank { "https://openrouter.ai/api/v1" }, mono = true, - enabled = supported, + enabled = editable, ) } // How long the last test took, measured from the click. @@ -275,7 +278,7 @@ fun AiDiscoverySettings(state: AppState) { title = stringResource(Res.string.settings_ai_test), description = testMessage.resolve(), descriptionColor = testColor, - enabled = supported, + enabled = editable, trailing = { KetchButton( text = if (testing) { @@ -292,7 +295,7 @@ fun AiDiscoverySettings(state: AppState) { }, variant = KetchButtonVariant.Secondary, size = KetchButtonSize.Small, - enabled = supported && effective.llm.isComplete && !testing, + enabled = editable && effective.llm.isComplete && !testing, ) }, ) @@ -308,13 +311,13 @@ fun AiDiscoverySettings(state: AppState) { value = search.provider, options = SearchProvider.entries, label = { it.displayName }, - enabled = supported, + enabled = editable, onSelect = { onChange(settings.copy(search = search.copy(provider = it))) }, ) if (search.provider.requiresApiKey) { SettingsRow( title = stringResource(Res.string.settings_ai_search_api_key), - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = search.apiKey, @@ -322,7 +325,7 @@ fun AiDiscoverySettings(state: AppState) { placeholder = stringResource(Res.string.settings_ai_api_key), secret = true, mono = true, - enabled = supported, + enabled = editable, ) } } @@ -330,14 +333,14 @@ fun AiDiscoverySettings(state: AppState) { SettingsRow( title = stringResource(Res.string.settings_ai_engine_id), description = stringResource(Res.string.settings_ai_engine_id_hint), - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = search.cx, onCommit = { onChange(settings.copy(search = search.copy(cx = it))) }, placeholder = stringResource(Res.string.settings_ai_engine_id_placeholder), mono = true, - enabled = supported, + enabled = editable, ) } } @@ -506,11 +509,12 @@ private fun discoveryStatus( val colors = KetchTheme.colors return when { !supported -> Res.string.settings_ai_status_unsupported.text() to colors.textTertiary + // Switched off, nothing below can be changed, so it says nothing of what is missing. + !settings.enabled -> Res.string.settings_ai_status_off.text() to colors.textSecondary !effective.llm.isComplete -> Res.string.settings_ai_status_needs_provider.text() to colors.status.paused.color !effective.search.isComplete -> Res.string.settings_ai_status_needs_search.text() to colors.status.paused.color - !settings.enabled -> Res.string.settings_ai_status_off.text() to colors.textSecondary else -> Res.string.settings_ai_status_ready.text( effective.llm.provider.displayName, effective.llm.effectiveModel, diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt index 4083d925..ec04b27f 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt @@ -859,9 +859,9 @@ internal fun integrationSummary( * @param effective [settings] with the credentials the platform supplies. */ internal fun discoverSummary(settings: AiSettings, effective: AiSettings): UiText = when { + !settings.enabled -> Res.string.settings_summary_off.text() !effective.llm.isComplete || !effective.search.isComplete -> Res.string.settings_summary_not_set_up.text() - !settings.enabled -> Res.string.settings_summary_off.text() effective.search.provider == SearchProvider.None -> effective.llm.provider.displayName else -> Res.string.settings_summary_discover_search.text( effective.llm.provider.displayName, diff --git a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt index 9b5505f1..394fa687 100644 --- a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt @@ -110,6 +110,12 @@ class SettingsPageTest { assertEquals("Not set up", discoverSummary(saved, saved).load()) assertEquals("OpenAI", discoverSummary(saved, effective).load()) } + @Test + fun discoverSummary_switchedOffWithoutKeys_saysOff() = runTest { + val off = AiSettings(enabled = false) + assertEquals("Off", discoverSummary(off, off).load()) + } + @Test fun downloadsSummary_namesFolderAndQueue() = runTest { diff --git a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt index 025e6e0d..c522f188 100644 --- a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt +++ b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt @@ -153,6 +153,16 @@ class SettingsSnapshots { } } + @Test + fun discoverPage_switchedOff_disablesEverythingButTheSwitch() { + withSettings(SnapshotTheme.Light, PhoneTall.density) { + captureApp("settings-discover-off", PhoneTall, SnapshotTheme.Light, it) { + state.aiSettings.setEnabled(false) + openSettings(SettingsTarget.Page.Discover) + } + } + } + @Test fun app_mediumWindow_showsSettingsAsTwoPanes() { for (theme in SnapshotTheme.entries) { diff --git a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt index 066c6588..b16cee31 100644 --- a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt +++ b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt @@ -21,6 +21,7 @@ import com.linroid.ketch.config.PageAccessMode import com.linroid.ketch.config.PageAccessSettings import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -80,6 +81,26 @@ class AiDiscoverySettingsRenderTest { } } + @Test + fun switchOff_disablesEverythingButTheSwitch() { + runDiscoverPage(trusted = listOf("ubuntu.com")) { ai -> + ai.setEnabled(false) + frames(FRAMES) + + assertTrue(field(PLACEHOLDER).isDisabled(), "The page access field follows the switch") + assertTrue(control("Gemini").isDisabled(), "The provider chips follow the switch") + assertTrue(control("Ask for each new site").isDisabled(), "So does the page access menu") + assertFalse(discoverSwitch().isDisabled(), "The switch itself stays usable") + + discoverSwitch().click() + frames(FRAMES) + + assertTrue(ai.settings.enabled) + assertFalse(field(PLACEHOLDER).isDisabled()) + assertEquals(listOf("ubuntu.com"), ai.settings.access.trustedSites, "Nothing is lost") + } + } + /** Renders the Discover page with [trusted] sites and runs [test] on it. */ private fun runDiscoverPage( trusted: List = emptyList(), @@ -108,6 +129,33 @@ class AiDiscoverySettingsRenderTest { SemanticsActions.SetText in node.config && node.subtree().any { it.ownText() == PLACEHOLDER } } + /** + * The field showing [placeholder], usable or not: a disabled field cannot be edited, so it + * has no SetText. + */ + private fun ImageComposeScene.field(placeholder: String): SemanticsNode = nodes().first { node -> + (SemanticsActions.SetText in node.config || SemanticsProperties.Disabled in node.config) && + node.subtree().any { it.ownText() == placeholder } + } + + /** The row that switches discovery on and off. */ + private fun ImageComposeScene.discoverSwitch(): SemanticsNode = nodes().first { node -> + SemanticsProperties.ToggleableState in node.config && + node.subtree().any { it.ownText() == "AI discovery" } + } + + /** The control around the text [label]. */ + private fun ImageComposeScene.control(label: String): SemanticsNode = + checkNotNull(nodes().first { it.ownText() == label }.clickableAround()) + + private fun SemanticsNode.clickableAround(): SemanticsNode? { + var node: SemanticsNode? = this + while (node != null && SemanticsActions.OnClick !in node.config) node = node.parent + return node + } + + private fun SemanticsNode.isDisabled(): Boolean = SemanticsProperties.Disabled in config + /** Puts the keyboard in the add field and types [text] into it. */ private suspend fun ImageComposeScene.typeIntoAddField(text: String) { val field = addField() diff --git a/docs/ai-discovery.md b/docs/ai-discovery.md index 99472934..47d8543a 100644 --- a/docs/ai-discovery.md +++ b/docs/ai-discovery.md @@ -364,8 +364,9 @@ Rules: The settings page judges the form the way the engine will: a blank API key that the environment supplies is noted under the **API key** field and counts as filled in the status line, and **Test** works with the -token left empty. It also works with the switch off, so you can check a -key before turning discovery on. +token left empty. While the switch is off, everything under it is +disabled and the status line just says so; what you entered is kept for +when you switch it back on. When the provider fails during a search or a **Test**, such as a rejected API key, an unknown model or an unreachable endpoint, Discover shows why