Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand All @@ -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.
Expand All @@ -186,15 +189,15 @@ fun AiDiscoverySettings(state: AppState) {
} else {
stringResource(Res.string.settings_ai_api_key_plain)
},
enabled = supported,
enabled = editable,
) {
SettingsTextInput(
value = llm.apiKey,
onCommit = { onChange(settings.copy(llm = llm.copy(apiKey = it))) },
placeholder = tokenPlaceholder(llm.provider),
secret = true,
mono = true,
enabled = supported,
enabled = editable,
)
}
}
Expand All @@ -205,15 +208,15 @@ fun AiDiscoverySettings(state: AppState) {
} else {
stringResource(Res.string.settings_ai_model_any)
},
enabled = supported,
enabled = editable,
) {
SettingsTextInput(
value = llm.model,
onCommit = { onChange(settings.copy(llm = llm.copy(model = it))) },
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()) {
Expand All @@ -232,7 +235,7 @@ fun AiDiscoverySettings(state: AppState) {
KetchButtonVariant.Ghost
},
size = KetchButtonSize.Small,
enabled = supported,
enabled = editable,
)
}
}
Expand All @@ -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.
Expand All @@ -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) {
Expand All @@ -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,
)
},
)
Expand All @@ -308,36 +311,36 @@ 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,
onCommit = { onChange(settings.copy(search = search.copy(apiKey = it))) },
placeholder = stringResource(Res.string.settings_ai_api_key),
secret = true,
mono = true,
enabled = supported,
enabled = editable,
)
}
}
if (search.provider.requiresCx) {
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,
)
}
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String> = emptyList(),
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions docs/ai-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading