From 2f575b4854e3d7a18a46374d3936f92155ef04a2 Mon Sep 17 00:00:00 2001 From: Alexandr Gavrishev Date: Sat, 23 Nov 2024 15:04:38 +0200 Subject: [PATCH 1/2] Perplexity support --- .../feeder/di/ArchModelModule.kt | 9 +- .../nononsenseapps/feeder/openai/OpenAIApi.kt | 75 +++---- .../feeder/openai/OpenAIClient.kt | 63 ++++++ .../ui/compose/settings/OpenAISection.kt | 190 +++++++++++++----- .../feeder/ui/compose/settings/Settings.kt | 17 +- .../ui/compose/settings/SettingsViewModel.kt | 28 ++- .../feeder/openai/OpenAIApiTest.kt | 101 ++++++++++ 7 files changed, 366 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIClient.kt create mode 100644 app/src/test/java/com/nononsenseapps/feeder/openai/OpenAIApiTest.kt diff --git a/app/src/main/java/com/nononsenseapps/feeder/di/ArchModelModule.kt b/app/src/main/java/com/nononsenseapps/feeder/di/ArchModelModule.kt index 0016594342..413fbe6935 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/di/ArchModelModule.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/di/ArchModelModule.kt @@ -2,6 +2,7 @@ package com.nononsenseapps.feeder.di import com.nononsenseapps.feeder.archmodel.FeedItemStore import com.nononsenseapps.feeder.archmodel.FeedStore +import com.nononsenseapps.feeder.archmodel.OpenAISettings import com.nononsenseapps.feeder.archmodel.Repository import com.nononsenseapps.feeder.archmodel.SessionStore import com.nononsenseapps.feeder.archmodel.SettingsStore @@ -11,6 +12,8 @@ import com.nononsenseapps.feeder.base.bindWithComposableViewModelScope import com.nononsenseapps.feeder.model.OPMLParserHandler import com.nononsenseapps.feeder.model.opml.OPMLImporter import com.nononsenseapps.feeder.openai.OpenAIApi +import com.nononsenseapps.feeder.openai.OpenAIClient +import com.nononsenseapps.feeder.openai.OpenAIClientDefault import com.nononsenseapps.feeder.ui.CommonActivityViewModel import com.nononsenseapps.feeder.ui.MainActivityViewModel import com.nononsenseapps.feeder.ui.NavigationDeepLinkViewModel @@ -23,7 +26,8 @@ import com.nononsenseapps.feeder.ui.compose.searchfeed.SearchFeedViewModel import com.nononsenseapps.feeder.ui.compose.settings.SettingsViewModel import org.kodein.di.DI import org.kodein.di.bind -import org.kodein.di.compose.instance +import org.kodein.di.bindFactory +import org.kodein.di.factory import org.kodein.di.instance import org.kodein.di.singleton import java.util.Locale @@ -37,7 +41,8 @@ val archModelModule = bind() with singleton { FeedItemStore(di) } bind() with singleton { SyncRemoteStore(di) } bind() with singleton { OPMLImporter(di) } - bind() with singleton { OpenAIApi(instance(), appLang = Locale.getDefault().getISO3Language()) } + bindFactory { settings -> OpenAIClientDefault(settings) } + bind() with singleton { OpenAIApi(instance(), appLang = Locale.getDefault().getISO3Language(), factory()) } bindWithActivityViewModelScope() bindWithActivityViewModelScope() diff --git a/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt index d8ac405c15..1ef954d6b6 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt @@ -5,54 +5,19 @@ import com.aallam.openai.api.chat.ChatMessage import com.aallam.openai.api.chat.ChatResponseFormat import com.aallam.openai.api.chat.ChatRole import com.aallam.openai.api.chat.TextContent -import com.aallam.openai.api.logging.LogLevel import com.aallam.openai.api.model.ModelId -import com.aallam.openai.client.LoggingConfig -import com.aallam.openai.client.OpenAI -import com.aallam.openai.client.OpenAIConfig import com.aallam.openai.client.OpenAIHost -import com.nononsenseapps.feeder.BuildConfig import com.nononsenseapps.feeder.archmodel.OpenAISettings import com.nononsenseapps.feeder.archmodel.Repository -import io.ktor.client.plugins.HttpSend -import io.ktor.client.plugins.plugin -import io.ktor.client.request.url import io.ktor.http.URLBuilder import io.ktor.http.appendPathSegments import io.ktor.http.takeFrom import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -private fun OpenAISettings.toOpenAIConfig(): OpenAIConfig = - OpenAIConfig( - token = key, - logging = LoggingConfig(logLevel = LogLevel.Headers, sanitize = !BuildConfig.DEBUG), - host = toOpenAIHost(withAzureDeploymentId = false), - httpClientConfig = { - if (isAzure) { - install(HttpSend) - install("azure-interceptor") { - plugin(HttpSend).intercept { request -> - request.headers.remove("Authorization") - request.headers.append("api-key", key) - // models path doesn't include azureDeploymentId - val path = request.url.pathSegments.takeLastWhile { it != "openai" || it.isEmpty() } - val url = - toOpenAIHost(withAzureDeploymentId = path.last() != "models") - .toUrl() - .appendPathSegments(path) - .build() - request.url(url) - execute(request) - } - } - } - }, - ) class OpenAIApi( private val repository: Repository, private val appLang: String, + private val openAIClientFactory: (OpenAISettings) -> OpenAIClient, ) { @Serializable data class SummaryResponse(val lang: String, val content: String) @@ -86,16 +51,23 @@ class OpenAIApi( data class Error(val message: String?) : ModelsResult } + companion object { + private val LANG_REGEX = Regex("^Lang: \"?([a-zA-Z]+)\"?$") + } + private val openAISettings: OpenAISettings get() = repository.openAISettings.value - private val openAI: OpenAI - get() = OpenAI(config = openAISettings.toOpenAIConfig()) + private val openAI: OpenAIClient + get() = openAIClientFactory(openAISettings) suspend fun listModelIds(settings: OpenAISettings): ModelsResult { if (settings.key.isEmpty()) { return ModelsResult.MissingToken } + if (settings.isPerplexity) { + return ModelsResult.Success(ids = emptyList()) + } if (settings.isAzure) { if (settings.azureApiVersion.isBlank()) { return ModelsResult.AzureApiVersionRequired @@ -105,9 +77,9 @@ class OpenAIApi( } } return try { - OpenAI(config = settings.toOpenAIConfig()).models() + openAIClientFactory(settings).models() .sortedByDescending { it.created } - .map { it.id.id }.let { ModelsResult.Success(it) } + .map { it.id.id }.let { ModelsResult.Success(ids = it) } } catch (e: Exception) { ModelsResult.Error(message = e.message ?: e.cause?.message) } @@ -121,10 +93,9 @@ class OpenAIApi( requestOptions = null, ) val summaryResponse: SummaryResponse = - response.choices.firstOrNull()?.message?.content?.let { text -> - Json.decodeFromString(text) - } ?: throw IllegalStateException("Response content is null") - + parseSummaryResponse( + response.choices.firstOrNull()?.message?.content ?: throw IllegalStateException("Response content is null"), + ) return SummaryResult.Success( id = response.id, model = response.model.id, @@ -140,6 +111,15 @@ class OpenAIApi( } } + private fun parseSummaryResponse(content: String): SummaryResponse { + val firstLine = content.lineSequence().firstOrNull() ?: "" + val result = LANG_REGEX.find(firstLine) + return SummaryResponse( + lang = result?.groupValues?.getOrNull(1) ?: "", + content = content.replaceFirst(firstLine, "").trim(), + ) + } + private fun summaryRequest(content: String): ChatCompletionRequest { return ChatCompletionRequest( model = ModelId(id = openAISettings.modelId), @@ -153,7 +133,7 @@ class OpenAIApi( "You are an assistant in an RSS reader app, summarizing article content.", "The app language is '$appLang'.", "Provide summaries in the article's language if 99% recognizable; otherwise, use the app language.", - "Format response as JSON: { \"lang\": \"ISO code\", \"content\": \"summary\" }.", + "First line must be: 'Lang: \"ISO code\"'", "Keep summaries up to 100 words, 3 paragraphs, with up to 3 bullet points per paragraph.", "For readability use bullet points, titles, quotes and new lines using plain text only.", "Use only single language.", @@ -166,7 +146,7 @@ class OpenAIApi( messageContent = TextContent("Summarize:\n\n$content"), ), ), - responseFormat = ChatResponseFormat.JsonObject, + responseFormat = ChatResponseFormat.Text, ) } } @@ -174,6 +154,9 @@ class OpenAIApi( val OpenAISettings.isAzure: Boolean get() = baseUrl.contains("openai.azure.com", ignoreCase = true) +val OpenAISettings.isPerplexity: Boolean + get() = baseUrl.contains("api.perplexity.ai", ignoreCase = true) + val OpenAISettings.isValid: Boolean get() = modelId.isNotEmpty() && diff --git a/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIClient.kt b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIClient.kt new file mode 100644 index 0000000000..427a00715e --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIClient.kt @@ -0,0 +1,63 @@ +package com.nononsenseapps.feeder.openai + +import com.aallam.openai.api.chat.ChatCompletion +import com.aallam.openai.api.chat.ChatCompletionRequest +import com.aallam.openai.api.core.RequestOptions +import com.aallam.openai.api.logging.LogLevel +import com.aallam.openai.api.model.Model +import com.aallam.openai.client.LoggingConfig +import com.aallam.openai.client.OpenAI +import com.aallam.openai.client.OpenAIConfig +import com.nononsenseapps.feeder.BuildConfig +import com.nononsenseapps.feeder.archmodel.OpenAISettings +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.plugin +import io.ktor.client.request.url +import io.ktor.http.appendPathSegments + +interface OpenAIClient { + suspend fun models(requestOptions: RequestOptions? = null): List + + suspend fun chatCompletion( + request: ChatCompletionRequest, + requestOptions: RequestOptions?, + ): ChatCompletion +} + +class OpenAIClientDefault(settings: OpenAISettings) : OpenAIClient { + private val client = OpenAI(config = settings.toOpenAIConfig()) + + override suspend fun models(requestOptions: RequestOptions?): List = client.models(requestOptions) + + override suspend fun chatCompletion( + request: ChatCompletionRequest, + requestOptions: RequestOptions?, + ): ChatCompletion = client.chatCompletion(request, requestOptions) +} + +private fun OpenAISettings.toOpenAIConfig(): OpenAIConfig = + OpenAIConfig( + token = key, + logging = LoggingConfig(logLevel = LogLevel.Headers, sanitize = !BuildConfig.DEBUG), + host = toOpenAIHost(withAzureDeploymentId = false), + httpClientConfig = { + if (isAzure) { + install(HttpSend) + install("azure-interceptor") { + plugin(HttpSend).intercept { request -> + request.headers.remove("Authorization") + request.headers.append("api-key", key) + // models path doesn't include azureDeploymentId + val path = request.url.pathSegments.takeLastWhile { it != "openai" || it.isEmpty() } + val url = + toOpenAIHost(withAzureDeploymentId = path.last() != "models") + .toUrl() + .appendPathSegments(path) + .build() + request.url(url) + execute(request) + } + } + } + }, + ) diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt index 792641aa46..5df175d063 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -49,20 +51,18 @@ import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens @Composable fun OpenAISection( - openAISettings: OpenAISettings, - openAIModels: OpenAIModelsState, - openAIEdit: Boolean, + state: OpenAISettingsState, onEvent: (OpenAISettingsEvent) -> Unit, modifier: Modifier = Modifier, ) { OpenAISectionItem( modifier = modifier, - settings = openAISettings, + settings = state.settings, onEvent = onEvent, ) - if (openAIEdit) { - var current by remember(openAISettings) { mutableStateOf(openAISettings) } + if (state.isEditMode) { + var current by remember(state.settings) { mutableStateOf(state.settings) } AlertDialog( confirmButton = { Button(onClick = { @@ -86,8 +86,8 @@ fun OpenAISection( text = { OpenAISectionEdit( modifier = modifier, - settings = current, - models = openAIModels, + state = state, + current = current, onEvent = { if (it is OpenAISettingsEvent.UpdateSettings) { current = it.settings @@ -139,14 +139,14 @@ private fun OpenAISectionItem( @Composable fun OpenAISectionEdit( - settings: OpenAISettings, - models: OpenAIModelsState, + state: OpenAISettingsState, + current: OpenAISettings, onEvent: (OpenAISettingsEvent) -> Unit, modifier: Modifier = Modifier, ) { val latestOnEvent by rememberUpdatedState(onEvent) - LaunchedEffect(settings) { - latestOnEvent(OpenAISettingsEvent.LoadModels(settings = settings)) + LaunchedEffect(current) { + latestOnEvent(OpenAISettingsEvent.LoadModels(settings = current)) } var modelsMenuExpanded by remember { mutableStateOf(false) } @@ -157,40 +157,40 @@ fun OpenAISectionEdit( ) { TextField( modifier = Modifier.fillMaxWidth(), - value = settings.key, + value = current.key, label = { Text(stringResource(R.string.api_key)) }, onValueChange = { - onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(key = it))) + onEvent(OpenAISettingsEvent.UpdateSettings(current.copy(key = it))) }, visualTransformation = VisualTransformationApiKey(), ) TextField( modifier = Modifier.fillMaxWidth(), - value = settings.modelId, + value = current.modelId, label = { Text(stringResource(R.string.model_id)) }, onValueChange = { - onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) + onEvent(OpenAISettingsEvent.UpdateSettings(current.copy(modelId = it))) }, trailingIcon = { IconButton( onClick = { modelsMenuExpanded = true }, - enabled = models is OpenAIModelsState.Success, + enabled = state.modelsResult is OpenAIModelsState.Success, ) { - if (models is OpenAIModelsState.Loading) { + if (state.modelsResult is OpenAIModelsState.Loading) { CircularProgressIndicator() } else { Icon(Icons.Filled.ExpandMore, contentDescription = stringResource(R.string.list_of_available_models)) - if (models is OpenAIModelsState.Success) { + if (state.modelsResult is OpenAIModelsState.Success) { OpenAIModelsDropdown( menuExpanded = modelsMenuExpanded, - state = models, + state = state.modelsResult, onValueChange = { - onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) + onEvent(OpenAISettingsEvent.UpdateSettings(current.copy(modelId = it))) }, onDismissRequest = { modelsMenuExpanded = false }, ) @@ -200,11 +200,15 @@ fun OpenAISectionEdit( }, ) - OpenAIModelsStatus(state = models) + OpenAIModelsStatus( + state = state.modelsResult, + showError = state.showModelsError, + onEvent = onEvent, + ) TextField( modifier = Modifier.fillMaxWidth(), - value = settings.baseUrl, + value = current.baseUrl, placeholder = { Text(OpenAIHost.OpenAI.baseUrl) }, @@ -212,23 +216,23 @@ fun OpenAISectionEdit( Text(stringResource(R.string.url)) }, onValueChange = { - onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(baseUrl = it))) + onEvent(OpenAISettingsEvent.UpdateSettings(current.copy(baseUrl = it))) }, ) TextField( modifier = Modifier.fillMaxWidth(), - value = settings.azureDeploymentId, + value = current.azureDeploymentId, label = { Text(stringResource(R.string.azure_deployment_id)) }, onValueChange = { - onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureDeploymentId = it))) + onEvent(OpenAISettingsEvent.UpdateSettings(current.copy(azureDeploymentId = it))) }, ) TextField( modifier = Modifier.fillMaxWidth(), - value = settings.azureApiVersion, + value = current.azureApiVersion, placeholder = { Text("2024-02-15-preview") }, @@ -236,7 +240,7 @@ fun OpenAISectionEdit( Text(stringResource(R.string.azure_api_version)) }, onValueChange = { - onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureApiVersion = it))) + onEvent(OpenAISettingsEvent.UpdateSettings(current.copy(azureApiVersion = it))) }, ) } @@ -266,7 +270,11 @@ private fun OpenAIModelsDropdown( } @Composable -private fun OpenAIModelsStatus(state: OpenAIModelsState) { +private fun OpenAIModelsStatus( + state: OpenAIModelsState, + showError: Boolean, + onEvent: (OpenAISettingsEvent) -> Unit, +) { when (state) { is OpenAIModelsState.Success -> { if (state.ids.isEmpty()) { @@ -280,11 +288,43 @@ private fun OpenAIModelsStatus(state: OpenAIModelsState) { } is OpenAIModelsState.Error -> { - OutlinedCard { - Text( - text = stringResource(R.string.unable_to_load_models) + " " + state.message, + val hasError by remember(state.message) { mutableStateOf(state.message.isNotEmpty()) } + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { onEvent(OpenAISettingsEvent.ShowModelsError(show = !showError)) }, + ) { + Column( modifier = Modifier.padding(8.dp), - ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + ) + Text( + text = stringResource(R.string.unable_to_load_models), + modifier = + Modifier + .padding(start = 4.dp) + .weight(1f), + ) + if (hasError) { + Icon( + imageVector = if (showError) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = "Show message", + ) + } + } + + if (hasError && showError) { + Text( + text = state.message, + modifier = Modifier.padding(8.dp), + ) + } + } } } @@ -293,36 +333,88 @@ private fun OpenAIModelsStatus(state: OpenAIModelsState) { } } -@Preview("OpenAI section item tablet", device = Devices.PIXEL_C) -@Preview("OpenAI section item phone", device = Devices.PIXEL_7) +@Preview("tablet", device = Devices.PIXEL_C) +@Preview("phone", device = Devices.PIXEL_7) @Composable private fun OpenAISectionReadPreview() { Surface { OpenAISection( - openAISettings = - OpenAISettings( - key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - modelId = "gpt-4o-mini", + state = + OpenAISettingsState( + settings = + OpenAISettings( + key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + modelId = "gpt-4o-mini", + ), + modelsResult = OpenAIModelsState.None, + isEditMode = false, ), - openAIModels = OpenAIModelsState.None, - openAIEdit = false, onEvent = { }, ) } } -@Preview("OpenAI section dialog tablet", device = Devices.PIXEL_C) -@Preview("OpenAI section dialog phone", device = Devices.PIXEL_7) +@Preview("tablet", device = Devices.PIXEL_C) +@Preview("phone", device = Devices.PIXEL_7) @Composable private fun OpenAISectionEditPreview() { OpenAISection( - openAISettings = - OpenAISettings( - key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - modelId = "gpt-4o-mini", + state = + OpenAISettingsState( + settings = + OpenAISettings( + key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + modelId = "gpt-4o-mini", + ), + modelsResult = OpenAIModelsState.None, + isEditMode = true, + ), + onEvent = { }, + ) +} + +@Preview("tablet", device = Devices.PIXEL_C) +@Preview("phone", device = Devices.PIXEL_7) +@Composable +private fun OpenAISectionErrorCollapsedPreview() { + OpenAISection( + state = + OpenAISettingsState( + settings = + OpenAISettings( + key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + modelId = "gpt-4o-mini", + ), + modelsResult = + OpenAIModelsState.Error( + message = "A sample error message", + ), + isEditMode = true, + showModelsError = false, + ), + onEvent = { }, + ) +} + +@Preview("tablet", device = Devices.PIXEL_C) +@Preview("phone", device = Devices.PIXEL_7) +@Composable +private fun OpenAISectionErrorExpandedPreview() { + OpenAISection( + state = + OpenAISettingsState( + settings = + OpenAISettings( + key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + modelId = "gpt-4o-mini", + ), + modelsResult = + OpenAIModelsState.Error( + message = "A sample error message", + ), + isEditMode = true, + showModelsError = true, ), - openAIModels = OpenAIModelsState.None, - openAIEdit = true, onEvent = { }, ) } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/Settings.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/Settings.kt index 36e8a43ece..eceb4d0930 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/Settings.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/Settings.kt @@ -83,7 +83,6 @@ import com.nononsenseapps.feeder.archmodel.DarkThemePreferences import com.nononsenseapps.feeder.archmodel.FeedItemStyle import com.nononsenseapps.feeder.archmodel.ItemOpener import com.nononsenseapps.feeder.archmodel.LinkOpener -import com.nononsenseapps.feeder.archmodel.OpenAISettings import com.nononsenseapps.feeder.archmodel.SortingOptions import com.nononsenseapps.feeder.archmodel.SwipeAsRead import com.nononsenseapps.feeder.archmodel.SyncFrequency @@ -207,9 +206,7 @@ fun SettingsScreen( onStartActivity = { intent -> activityLauncher.startActivity(false, intent) }, - openAISettings = viewState.openAISettings, - openAIModels = viewState.openAIModels, - openAIEdit = viewState.openAIEdit, + openAIState = viewState.openAIState, onOpenAIEvent = settingsViewModel::onOpenAISettingsEvent, modifier = Modifier.padding(padding), ) @@ -278,9 +275,7 @@ private fun SettingsScreenPreview() { showTitleUnreadCount = false, onShowTitleUnreadCountChange = {}, onStartActivity = {}, - openAISettings = OpenAISettings(), - openAIModels = OpenAIModelsState.None, - openAIEdit = false, + openAIState = OpenAISettingsState(), onOpenAIEvent = { _ -> }, modifier = Modifier, ) @@ -345,9 +340,7 @@ fun SettingsList( showTitleUnreadCount: Boolean, onShowTitleUnreadCountChange: (Boolean) -> Unit, onStartActivity: (intent: Intent) -> Unit, - openAISettings: OpenAISettings, - openAIModels: OpenAIModelsState, - openAIEdit: Boolean, + openAIState: OpenAISettingsState, onOpenAIEvent: (OpenAISettingsEvent) -> Unit, modifier: Modifier = Modifier, ) { @@ -712,9 +705,7 @@ fun SettingsList( } OpenAISection( - openAISettings = openAISettings, - openAIModels = openAIModels, - openAIEdit = openAIEdit, + state = openAIState, onEvent = onOpenAIEvent, ) diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/SettingsViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/SettingsViewModel.kt index 4ec1451de5..3bf9edb37e 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/SettingsViewModel.kt @@ -160,7 +160,12 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { is OpenAISettingsEvent.LoadModels -> loadOpenAIModels(event.settings) is OpenAISettingsEvent.UpdateSettings -> repository.setOpenAiSettings(event.settings) is OpenAISettingsEvent.SwitchEditMode -> { - _viewState.value = _viewState.value.copy(openAIEdit = event.enabled) + val current = _viewState.value.openAIState + _viewState.value = _viewState.value.copy(openAIState = current.copy(isEditMode = event.enabled)) + } + is OpenAISettingsEvent.ShowModelsError -> { + val current = _viewState.value.openAIState + _viewState.value = _viewState.value.copy(openAIState = current.copy(showModelsError = event.show)) } } } @@ -252,9 +257,11 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { isOpenAdjacent = params[24] as Boolean, showReadingTime = params[25] as Boolean, showTitleUnreadCount = params[26] as Boolean, - openAISettings = params[27] as OpenAISettings, - openAIModels = params[28] as OpenAIModelsState, - openAIEdit = _viewState.value.openAIEdit, + openAIState = + _viewState.value.openAIState.copy( + settings = params[27] as OpenAISettings, + modelsResult = params[28] as OpenAIModelsState, + ), ) }.collect { _viewState.value = it @@ -311,9 +318,7 @@ data class SettingsViewState( val maxLines: Int = 2, val showOnlyTitle: Boolean = false, val isOpenAdjacent: Boolean = true, - val openAISettings: OpenAISettings = OpenAISettings(), - val openAIModels: OpenAIModelsState = OpenAIModelsState.None, - val openAIEdit: Boolean = false, + val openAIState: OpenAISettingsState = OpenAISettingsState(), val showReadingTime: Boolean = false, val showTitleUnreadCount: Boolean = false, ) @@ -324,6 +329,13 @@ data class UIFeedSettings( val notify: Boolean, ) +data class OpenAISettingsState( + val settings: OpenAISettings = OpenAISettings(), + val modelsResult: OpenAIModelsState = OpenAIModelsState.None, + val isEditMode: Boolean = false, + val showModelsError: Boolean = false, +) + sealed interface OpenAIModelsState { data object None : OpenAIModelsState @@ -340,4 +352,6 @@ sealed interface OpenAISettingsEvent { data class LoadModels(val settings: OpenAISettings) : OpenAISettingsEvent data class SwitchEditMode(val enabled: Boolean) : OpenAISettingsEvent + + data class ShowModelsError(val show: Boolean) : OpenAISettingsEvent } diff --git a/app/src/test/java/com/nononsenseapps/feeder/openai/OpenAIApiTest.kt b/app/src/test/java/com/nononsenseapps/feeder/openai/OpenAIApiTest.kt new file mode 100644 index 0000000000..53076e6067 --- /dev/null +++ b/app/src/test/java/com/nononsenseapps/feeder/openai/OpenAIApiTest.kt @@ -0,0 +1,101 @@ +package com.nononsenseapps.feeder.openai + +import com.aallam.openai.api.chat.ChatChoice +import com.aallam.openai.api.chat.ChatCompletion +import com.aallam.openai.api.chat.ChatCompletionRequest +import com.aallam.openai.api.chat.ChatMessage +import com.aallam.openai.api.chat.ChatRole +import com.aallam.openai.api.core.RequestOptions +import com.aallam.openai.api.model.Model +import com.aallam.openai.api.model.ModelId +import com.nononsenseapps.feeder.archmodel.OpenAISettings +import com.nononsenseapps.feeder.archmodel.Repository +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +class OpenAIClientMock( + private val chatCompletion: ChatCompletion, +) : OpenAIClient { + override suspend fun models(requestOptions: RequestOptions?): List = emptyList() + + override suspend fun chatCompletion( + request: ChatCompletionRequest, + requestOptions: RequestOptions?, + ): ChatCompletion = chatCompletion +} + +class OpenAIApiTest { + @MockK + private lateinit var repository: Repository + + @Before + fun setup() { + MockKAnnotations.init(this) + + every { repository.openAISettings } returns MutableStateFlow(OpenAISettings()) + } + + @Test + fun testSummaryResponseLangNoQuotes() = + runTest { + val chatCompletion = createResponse("Lang: eng\n\nMy summary") + val api = createApi(response = chatCompletion) + val actual = api.summarize("My content") + val expected = createResult("My summary", "eng") + assertEquals(expected, actual) + } + + @Test + fun testSummaryResponseLangWithQuotes() = + runTest { + val chatCompletion = createResponse("Lang: \"FR\"\nMy summary") + val api = createApi(response = chatCompletion) + val actual = api.summarize("My content") + val expected = createResult("My summary", "FR") + assertEquals(expected, actual) + } + + private fun createApi(response: ChatCompletion) = OpenAIApi(repository, "lang", { OpenAIClientMock(response) }) + + private fun createResponse(message: String) = + ChatCompletion( + id = "test", + model = ModelId(id = "test-model-id"), + created = 0, + choices = + listOf( + ChatChoice( + index = 0, + message = + ChatMessage( + role = ChatRole.Assistant, + content = message, + ), + finishReason = null, + logprobs = null, + ), + ), + usage = null, + systemFingerprint = null, + ) + + private fun createResult( + content: String, + lang: String, + ) = OpenAIApi.SummaryResult.Success( + id = "test", + created = 0, + model = "test-model-id", + content = content, + promptTokens = 0, + completeTokens = 0, + totalTokens = 0, + detectedLanguage = lang, + ) +} From 52c0a049429858d492d78cc245c749af5deb00fc Mon Sep 17 00:00:00 2001 From: Alex Gavrishev Date: Tue, 26 Nov 2024 10:58:47 +0200 Subject: [PATCH 2/2] Extract string --- .../nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt index 5df175d063..88a1d0316c 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt @@ -313,7 +313,7 @@ private fun OpenAIModelsStatus( if (hasError) { Icon( imageVector = if (showError) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = "Show message", + contentDescription = stringResource(R.string.show_message), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 148ab642f8..96064d9c36 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,4 +273,5 @@ No models were found Azure deployment id List of available models + Show message