From ca13fd4776ade8afed34c67fe1d0f9c7940d1d4e Mon Sep 17 00:00:00 2001 From: Alex Gavrishev Date: Sat, 16 Nov 2024 20:57:27 +0200 Subject: [PATCH] added article summary with OpenAI integration (#399) * OpenAI Integration * Make OpenAI section dialog * Apply suggestions from code review Co-authored-by: Jonas Kalderstam * Fix refresh models uses stored settings, fix ui jumping * ktlint format --------- Co-authored-by: Jonas Kalderstam --- app/build.gradle.kts | 3 + .../feeder/archmodel/Repository.kt | 4 + .../feeder/archmodel/SettingsStore.kt | 40 +++ .../feeder/di/ArchModelModule.kt | 5 + .../nononsenseapps/feeder/openai/OpenAIApi.kt | 209 +++++++++++ .../ui/compose/feedarticle/ArticleScreen.kt | 53 ++- .../compose/feedarticle/ArticleViewModel.kt | 72 ++++ .../ui/compose/settings/OpenAISection.kt | 328 ++++++++++++++++++ .../feeder/ui/compose/settings/Settings.kt | 36 +- .../ui/compose/settings/SettingsViewModel.kt | 58 ++++ .../settings/VisualTransformationApiKey.kt | 22 ++ app/src/main/res/values/strings.xml | 9 + settings.gradle.kts | 6 + 13 files changed, 842 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt create mode 100644 app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt create mode 100644 app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/VisualTransformationApiKey.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8a8f7f975..ea4a2d55de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,6 +214,7 @@ dependencies { implementation(platform(libs.okhttp.bom)) implementation(platform(libs.coil.bom)) implementation(platform(libs.compose.bom)) + implementation(platform(libs.openai.client.bom)) // Dependencies implementation(libs.bundles.android) @@ -221,6 +222,8 @@ dependencies { implementation(libs.bundles.jvm) implementation(libs.bundles.okhttp.android) implementation(libs.bundles.kotlin) + implementation(libs.openai.client) + implementation(libs.ktor.client.okhttp) // Only for debug debugImplementation("com.squareup.leakcanary:leakcanary-android:3.0-alpha-1") diff --git a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt index c1582d0997..0f31401bc9 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt @@ -284,6 +284,10 @@ class Repository(override val di: DI) : DIAware { sessionStore.setResumeTime(value) } + val openAISettings = settingsStore.openAiSettings + + fun setOpenAiSettings(value: OpenAISettings) = settingsStore.setOpenAiSettings(value) + val showTitleUnreadCount = settingsStore.showTitleUnreadCount fun setShowTitleUnreadCount(value: Boolean) = settingsStore.setShowTitleUnreadCount(value) diff --git a/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt b/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt index 21d6bd0741..7b1de3abac 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt @@ -481,6 +481,29 @@ class SettingsStore(override val di: DI) : DIAware { } } + private val _openAiSettings = + MutableStateFlow( + OpenAISettings( + key = sp.getStringNonNull(PREF_OPENAI_KEY, ""), + modelId = sp.getStringNonNull(PREF_OPENAI_MODEL_ID, "gpt-4o-mini"), + baseUrl = sp.getStringNonNull(PREF_OPENAI_URL, ""), + azureApiVersion = sp.getStringNonNull(PREF_OPENAI_AZURE_VERSION, ""), + azureDeploymentId = sp.getStringNonNull(PREF_OPENAI_AZURE_DEPLOYMENT_ID, ""), + ), + ) + val openAiSettings = _openAiSettings.asStateFlow() + + fun setOpenAiSettings(value: OpenAISettings) { + _openAiSettings.value = value + sp.edit() + .putString(PREF_OPENAI_KEY, value.key) + .putString(PREF_OPENAI_MODEL_ID, value.modelId) + .putString(PREF_OPENAI_URL, value.baseUrl) + .putString(PREF_OPENAI_AZURE_VERSION, value.azureApiVersion) + .putString(PREF_OPENAI_AZURE_DEPLOYMENT_ID, value.azureDeploymentId) + .apply() + } + private val _showTitleUnreadCount = MutableStateFlow(sp.getBoolean(PREF_SHOW_TITLE_UNREAD_COUNT, false)) val showTitleUnreadCount = _showTitleUnreadCount.asStateFlow() @@ -586,6 +609,15 @@ const val PREF_LIST_SHOW_READING_TIME = "pref_show_reading_time" */ const val PREF_READALOUD_USE_DETECT_LANGUAGE = "pref_readaloud_detect_lang" +/** + * OpenAI integration + */ +const val PREF_OPENAI_KEY = "pref_openai_key" +const val PREF_OPENAI_MODEL_ID = "pref_openai_model_id" +const val PREF_OPENAI_URL = "pref_openai_url" +const val PREF_OPENAI_AZURE_VERSION = "pref_openai_azure_version" +const val PREF_OPENAI_AZURE_DEPLOYMENT_ID = "pref_openai_azure_deployment_id" + /** * Appearance settings */ @@ -702,6 +734,14 @@ enum class SwipeAsRead( FROM_ANYWHERE(R.string.from_anywhere), } +data class OpenAISettings( + val modelId: String = "", + val baseUrl: String = "", + val azureApiVersion: String = "", + val azureDeploymentId: String = "", + val key: String = "", +) + fun String.dropEnds( starting: Int, ending: Int, 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 f161c46000..0016594342 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/di/ArchModelModule.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/di/ArchModelModule.kt @@ -10,6 +10,7 @@ import com.nononsenseapps.feeder.base.bindWithActivityViewModelScope 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.ui.CommonActivityViewModel import com.nononsenseapps.feeder.ui.MainActivityViewModel import com.nononsenseapps.feeder.ui.NavigationDeepLinkViewModel @@ -22,7 +23,10 @@ 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.instance import org.kodein.di.singleton +import java.util.Locale val archModelModule = DI.Module(name = "arch models") { @@ -33,6 +37,7 @@ 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()) } 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 new file mode 100644 index 0000000000..d8ac405c15 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt @@ -0,0 +1,209 @@ +package com.nononsenseapps.feeder.openai + +import com.aallam.openai.api.chat.ChatCompletionRequest +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, +) { + @Serializable + data class SummaryResponse(val lang: String, val content: String) + + sealed interface SummaryResult { + val content: String + + data class Success( + val id: String, + val created: Long, + val model: String, + override val content: String, + val promptTokens: Int, + val completeTokens: Int, + val totalTokens: Int, + val detectedLanguage: String, + ) : SummaryResult + + data class Error(override val content: String) : SummaryResult + } + + sealed interface ModelsResult { + data object MissingToken : ModelsResult + + data object AzureApiVersionRequired : ModelsResult + + data object AzureDeploymentIdRequired : ModelsResult + + data class Success(val ids: List) : ModelsResult + + data class Error(val message: String?) : ModelsResult + } + + private val openAISettings: OpenAISettings + get() = repository.openAISettings.value + + private val openAI: OpenAI + get() = OpenAI(config = openAISettings.toOpenAIConfig()) + + suspend fun listModelIds(settings: OpenAISettings): ModelsResult { + if (settings.key.isEmpty()) { + return ModelsResult.MissingToken + } + if (settings.isAzure) { + if (settings.azureApiVersion.isBlank()) { + return ModelsResult.AzureApiVersionRequired + } + if (settings.azureDeploymentId.isBlank()) { + return ModelsResult.AzureDeploymentIdRequired + } + } + return try { + OpenAI(config = settings.toOpenAIConfig()).models() + .sortedByDescending { it.created } + .map { it.id.id }.let { ModelsResult.Success(it) } + } catch (e: Exception) { + ModelsResult.Error(message = e.message ?: e.cause?.message) + } + } + + suspend fun summarize(content: String): SummaryResult { + try { + val response = + openAI.chatCompletion( + request = summaryRequest(content), + requestOptions = null, + ) + val summaryResponse: SummaryResponse = + response.choices.firstOrNull()?.message?.content?.let { text -> + Json.decodeFromString(text) + } ?: throw IllegalStateException("Response content is null") + + return SummaryResult.Success( + id = response.id, + model = response.model.id, + content = summaryResponse.content, + created = response.created, + promptTokens = response.usage?.promptTokens ?: 0, + completeTokens = response.usage?.completionTokens ?: 0, + totalTokens = response.usage?.completionTokens ?: 0, + detectedLanguage = summaryResponse.lang, + ) + } catch (e: Exception) { + return SummaryResult.Error(content = e.message ?: e.cause?.message ?: "") + } + } + + private fun summaryRequest(content: String): ChatCompletionRequest { + return ChatCompletionRequest( + model = ModelId(id = openAISettings.modelId), + messages = + listOf( + ChatMessage( + role = ChatRole.System, + messageContent = + TextContent( + listOf( + "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\" }.", + "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.", + "Keep full quotes if any.", + ).joinToString(separator = " "), + ), + ), + ChatMessage( + role = ChatRole.User, + messageContent = TextContent("Summarize:\n\n$content"), + ), + ), + responseFormat = ChatResponseFormat.JsonObject, + ) + } +} + +val OpenAISettings.isAzure: Boolean + get() = baseUrl.contains("openai.azure.com", ignoreCase = true) + +val OpenAISettings.isValid: Boolean + get() = + modelId.isNotEmpty() && + key.isNotEmpty() && + if (isAzure) azureApiVersion.isNotBlank() && azureDeploymentId.isNotBlank() else true + +fun OpenAISettings.toOpenAIHost(withAzureDeploymentId: Boolean): OpenAIHost = + baseUrl.let { baseUrl -> + if (baseUrl.isEmpty()) { + OpenAIHost.OpenAI + } else { + OpenAIHost( + baseUrl = + URLBuilder() + .takeFrom(baseUrl).also { + it.appendPathSegments("openai") + if (withAzureDeploymentId && azureDeploymentId.isNotBlank()) { + it.appendPathSegments("deployments", azureDeploymentId) + } + }.buildString(), + queryParams = + azureApiVersion.let { apiVersion -> + if (apiVersion.isEmpty()) emptyMap() else mapOf("api-version" to apiVersion) + }, + ) + } + } + +fun OpenAIHost.toUrl(): URLBuilder = + URLBuilder() + .takeFrom(baseUrl).also { + queryParams.forEach { (k, v) -> it.parameters.append(k, v) } + } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt index 6eadf992f7..b99058e992 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only @@ -19,6 +20,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Share @@ -29,7 +31,9 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -130,6 +134,9 @@ fun ArticleScreen( }, articleListState = articleListState, onNavigateUp = onNavigateUp, + onSummarize = { + viewModel.summarize() + }, ) } @@ -152,8 +159,9 @@ fun ArticleScreen( ttsOnSelectLanguage: (LocaleOverride) -> Unit, onToggleBookmark: () -> Unit, articleListState: LazyListState, - modifier: Modifier = Modifier, onNavigateUp: () -> Unit, + onSummarize: () -> Unit, + modifier: Modifier = Modifier, ) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() @@ -252,6 +260,24 @@ fun ArticleScreen( }, ) + if (viewState.showSummarize) { + DropdownMenuItem( + onClick = { + onShowToolbarMenu(false) + onSummarize() + }, + leadingIcon = { + Icon( + Icons.Default.AutoFixHigh, + contentDescription = null, + ) + }, + text = { + Text(stringResource(id = R.string.summarize)) + }, + ) + } + DropdownMenuItem( onClick = { onShowToolbarMenu(false) @@ -412,6 +438,11 @@ fun ArticleContent( modifier = modifier, articleListState = articleListState, ) { + if (viewState.openAiSummary !is OpenAISummaryState.Empty) { + item { + SummarySection(viewState.openAiSummary) + } + } // Can take a composition or two before viewstate is set to its actual values if (viewState.articleId > ID_UNSET) { when (viewState.textToDisplay) { @@ -459,6 +490,26 @@ fun ArticleContent( } } +@Composable +private fun SummarySection(summary: OpenAISummaryState) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + ) { + when (summary) { + OpenAISummaryState.Empty -> {} + OpenAISummaryState.Loading -> + LinearProgressIndicator( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) + is OpenAISummaryState.Result -> + Text( + modifier = Modifier.padding(8.dp), + text = summary.value.content, + ) + } + } +} + @Suppress("FunctionName") private fun LazyListScope.LoadingItem() { item { diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt index 68ac130096..80ea3200df 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleViewModel.kt @@ -8,6 +8,7 @@ import com.nononsenseapps.feeder.ApplicationCoroutineScope import com.nononsenseapps.feeder.archmodel.Article import com.nononsenseapps.feeder.archmodel.Enclosure import com.nononsenseapps.feeder.archmodel.LinkOpener +import com.nononsenseapps.feeder.archmodel.OpenAISettings import com.nononsenseapps.feeder.archmodel.Repository import com.nononsenseapps.feeder.archmodel.TextToDisplay import com.nononsenseapps.feeder.base.DIAwareViewModel @@ -29,6 +30,8 @@ import com.nononsenseapps.feeder.model.ThumbnailImage import com.nononsenseapps.feeder.model.UnsupportedContentType import com.nononsenseapps.feeder.model.html.HtmlLinearizer import com.nononsenseapps.feeder.model.html.LinearArticle +import com.nononsenseapps.feeder.openai.OpenAIApi +import com.nononsenseapps.feeder.openai.isValid import com.nononsenseapps.feeder.ui.compose.text.htmlToAnnotatedString import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.FilePathProvider @@ -44,6 +47,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jsoup.Jsoup import org.kodein.di.DI import org.kodein.di.instance import java.io.FileNotFoundException @@ -58,6 +62,7 @@ class ArticleViewModel( private val ttsStateHolder: TTSStateHolder by instance() private val fullTextParser: FullTextParser by instance() private val filePathProvider: FilePathProvider by instance() + private val openAIApi: OpenAIApi by instance() // Use this for actions which should complete even if app goes off screen private val applicationCoroutineScope: ApplicationCoroutineScope by instance() @@ -108,6 +113,8 @@ class ArticleViewModel( private val toolbarVisible: MutableStateFlow = MutableStateFlow(state["toolbarMenuVisible"] ?: false) + private val openAiSummary: MutableStateFlow = MutableStateFlow(OpenAISummaryState.Empty) + val viewState: StateFlow = combine( articleFlow, @@ -118,6 +125,8 @@ class ArticleViewModel( repository.useDetectLanguage, ttsStateHolder.ttsState, ttsStateHolder.availableLanguages, + repository.openAISettings, + openAiSummary, ) { params -> val article = params[0] as Article? val textToDisplay = params[1] as TextToDisplay @@ -130,6 +139,9 @@ class ArticleViewModel( @Suppress("UNCHECKED_CAST") val ttsLanguages = params[7] as List + val showSummarize = (params[8] as OpenAISettings).isValid && !article?.link.isNullOrEmpty() + val openAiSummary = (params[9] as OpenAISummaryState) + ArticleState( useDetectLanguage = useDetectLanguage, isBottomBarVisible = ttsState != PlaybackStatus.STOPPED, @@ -155,6 +167,8 @@ class ArticleViewModel( article?.wordCount ?: 0 }, image = article?.image, + showSummarize = showSummarize, + openAiSummary = openAiSummary, articleContent = articleContent, ) } @@ -349,6 +363,52 @@ class ArticleViewModel( ttsStateHolder.setLanguage(lang) } + fun summarize() { + viewModelScope.launch(Dispatchers.IO) { + try { + openAiSummary.value = OpenAISummaryState.Loading + val content = loadArticleContent() + openAiSummary.value = + OpenAISummaryState.Result( + value = openAIApi.summarize(content), + ) + } catch (e: Exception) { + openAiSummary.value = + OpenAISummaryState.Result( + value = OpenAIApi.SummaryResult.Error(content = e.message ?: "Unknown error"), + ) + } + } + } + + private suspend fun loadArticleContent(): String { + val viewState = viewState.value + val blobFile = blobFullFile(viewState.articleId, filePathProvider.fullArticleDir) + val contentStream = + if (blobFile.isFile) { + blobFullInputStream(viewState.articleId, filePathProvider.fullArticleDir) + } else { + fullTextParser.parseFullArticleIfMissing( + object : FeedItemForFetching { + override val id = viewState.articleId + override val link = viewState.articleLink + }, + ).let { + val error = it.leftOrNull() + if (error == null) { + blobFullInputStream(viewState.articleId, filePathProvider.fullArticleDir) + } else { + throw IllegalStateException("Cannot load article: ${error.description}", error.throwable) + } + } + } + + val content = + Jsoup.parse(contentStream, null, viewState.articleFeedUrl ?: "")?.body()?.text() + ?: throw IllegalStateException("Cannot parse content") + return content + } + companion object { private const val LOG_TAG = "FEEDER_ArticleVM" } @@ -375,6 +435,8 @@ private data class ArticleState( override val keyHolder: ArticleItemKeyHolder = RotatingArticleItemKeyHolder, override val wordCount: Int = 0, override val image: ThumbnailImage? = null, + override val showSummarize: Boolean = false, + override val openAiSummary: OpenAISummaryState = OpenAISummaryState.Empty, override val articleContent: LinearArticle = LinearArticle(emptyList()), ) : ArticleScreenViewState @@ -400,9 +462,19 @@ interface ArticleScreenViewState { val keyHolder: ArticleItemKeyHolder val wordCount: Int val image: ThumbnailImage? + val showSummarize: Boolean + val openAiSummary: OpenAISummaryState val articleContent: LinearArticle } +sealed interface OpenAISummaryState { + data object Empty : OpenAISummaryState + + data object Loading : OpenAISummaryState + + data class Result(val value: OpenAIApi.SummaryResult) : OpenAISummaryState +} + interface ArticleItemKeyHolder { fun getAndIncrementKey(): Any } 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 new file mode 100644 index 0000000000..792641aa46 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt @@ -0,0 +1,328 @@ +package com.nononsenseapps.feeder.ui.compose.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.aallam.openai.client.OpenAIHost +import com.nononsenseapps.feeder.R +import com.nononsenseapps.feeder.archmodel.OpenAISettings +import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens + +@Composable +fun OpenAISection( + openAISettings: OpenAISettings, + openAIModels: OpenAIModelsState, + openAIEdit: Boolean, + onEvent: (OpenAISettingsEvent) -> Unit, + modifier: Modifier = Modifier, +) { + OpenAISectionItem( + modifier = modifier, + settings = openAISettings, + onEvent = onEvent, + ) + + if (openAIEdit) { + var current by remember(openAISettings) { mutableStateOf(openAISettings) } + AlertDialog( + confirmButton = { + Button(onClick = { + onEvent(OpenAISettingsEvent.UpdateSettings(current)) + onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = false)) + }) { + Text(text = stringResource(R.string.save)) + } + }, + dismissButton = { + Button(onClick = { + onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = false)) + }) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + onDismissRequest = { onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = false)) }, + title = { + Text(text = stringResource(R.string.openai_settings)) + }, + text = { + OpenAISectionEdit( + modifier = modifier, + settings = current, + models = openAIModels, + onEvent = { + if (it is OpenAISettingsEvent.UpdateSettings) { + current = it.settings + } else { + onEvent(it) + } + }, + ) + }, + ) + } +} + +@Composable +private fun OpenAISectionItem( + settings: OpenAISettings, + modifier: Modifier = Modifier, + onEvent: (OpenAISettingsEvent) -> Unit, +) { + Row( + modifier = + modifier + .width(LocalDimens.current.maxContentWidth) + .clickable { onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = true)) } + .semantics { role = Role.Button }, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(64.dp), + contentAlignment = Alignment.Center, + ) { } + + val transformedKey = remember(settings.key) { VisualTransformationApiKey().filter(AnnotatedString(settings.key)) } + TitleAndSubtitle( + title = { + Text( + text = stringResource(R.string.api_key), + ) + }, + subtitle = { + Text( + text = transformedKey.text, + style = MaterialTheme.typography.bodySmall, + ) + }, + ) + } +} + +@Composable +fun OpenAISectionEdit( + settings: OpenAISettings, + models: OpenAIModelsState, + onEvent: (OpenAISettingsEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val latestOnEvent by rememberUpdatedState(onEvent) + LaunchedEffect(settings) { + latestOnEvent(OpenAISettingsEvent.LoadModels(settings = settings)) + } + + var modelsMenuExpanded by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + Column( + modifier = modifier.verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextField( + modifier = Modifier.fillMaxWidth(), + value = settings.key, + label = { + Text(stringResource(R.string.api_key)) + }, + onValueChange = { + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(key = it))) + }, + visualTransformation = VisualTransformationApiKey(), + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = settings.modelId, + label = { + Text(stringResource(R.string.model_id)) + }, + onValueChange = { + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) + }, + trailingIcon = { + IconButton( + onClick = { modelsMenuExpanded = true }, + enabled = models is OpenAIModelsState.Success, + ) { + if (models is OpenAIModelsState.Loading) { + CircularProgressIndicator() + } else { + Icon(Icons.Filled.ExpandMore, contentDescription = stringResource(R.string.list_of_available_models)) + if (models is OpenAIModelsState.Success) { + OpenAIModelsDropdown( + menuExpanded = modelsMenuExpanded, + state = models, + onValueChange = { + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) + }, + onDismissRequest = { modelsMenuExpanded = false }, + ) + } + } + } + }, + ) + + OpenAIModelsStatus(state = models) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = settings.baseUrl, + placeholder = { + Text(OpenAIHost.OpenAI.baseUrl) + }, + label = { + Text(stringResource(R.string.url)) + }, + onValueChange = { + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(baseUrl = it))) + }, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = settings.azureDeploymentId, + label = { + Text(stringResource(R.string.azure_deployment_id)) + }, + onValueChange = { + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureDeploymentId = it))) + }, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = settings.azureApiVersion, + placeholder = { + Text("2024-02-15-preview") + }, + label = { + Text(stringResource(R.string.azure_api_version)) + }, + onValueChange = { + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureApiVersion = it))) + }, + ) + } +} + +@Composable +private fun OpenAIModelsDropdown( + menuExpanded: Boolean, + state: OpenAIModelsState.Success, + onValueChange: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissRequest, + ) { + state.ids.forEach { id -> + DropdownMenuItem( + text = { Text(text = id) }, + onClick = { + onValueChange(id) + onDismissRequest() + }, + ) + } + } +} + +@Composable +private fun OpenAIModelsStatus(state: OpenAIModelsState) { + when (state) { + is OpenAIModelsState.Success -> { + if (state.ids.isEmpty()) { + OutlinedCard { + Text( + text = stringResource(R.string.no_models_were_found), + modifier = Modifier.padding(8.dp), + ) + } + } + } + + is OpenAIModelsState.Error -> { + OutlinedCard { + Text( + text = stringResource(R.string.unable_to_load_models) + " " + state.message, + modifier = Modifier.padding(8.dp), + ) + } + } + + OpenAIModelsState.Loading -> {} + OpenAIModelsState.None -> {} + } +} + +@Preview("OpenAI section item tablet", device = Devices.PIXEL_C) +@Preview("OpenAI section item phone", device = Devices.PIXEL_7) +@Composable +private fun OpenAISectionReadPreview() { + Surface { + OpenAISection( + openAISettings = + OpenAISettings( + key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + modelId = "gpt-4o-mini", + ), + openAIModels = OpenAIModelsState.None, + openAIEdit = false, + onEvent = { }, + ) + } +} + +@Preview("OpenAI section dialog tablet", device = Devices.PIXEL_C) +@Preview("OpenAI section dialog phone", device = Devices.PIXEL_7) +@Composable +private fun OpenAISectionEditPreview() { + OpenAISection( + openAISettings = + OpenAISettings( + key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + modelId = "gpt-4o-mini", + ), + 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 58613d33c4..36e8a43ece 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,6 +83,7 @@ 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 @@ -206,6 +207,10 @@ fun SettingsScreen( onStartActivity = { intent -> activityLauncher.startActivity(false, intent) }, + openAISettings = viewState.openAISettings, + openAIModels = viewState.openAIModels, + openAIEdit = viewState.openAIEdit, + onOpenAIEvent = settingsViewModel::onOpenAISettingsEvent, modifier = Modifier.padding(padding), ) } @@ -273,6 +278,10 @@ private fun SettingsScreenPreview() { showTitleUnreadCount = false, onShowTitleUnreadCountChange = {}, onStartActivity = {}, + openAISettings = OpenAISettings(), + openAIModels = OpenAIModelsState.None, + openAIEdit = false, + onOpenAIEvent = { _ -> }, modifier = Modifier, ) } @@ -336,6 +345,10 @@ fun SettingsList( showTitleUnreadCount: Boolean, onShowTitleUnreadCountChange: (Boolean) -> Unit, onStartActivity: (intent: Intent) -> Unit, + openAISettings: OpenAISettings, + openAIModels: OpenAIModelsState, + openAIEdit: Boolean, + onOpenAIEvent: (OpenAISettingsEvent) -> Unit, modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() @@ -689,6 +702,24 @@ fun SettingsList( onCheckedChange = onUseDetectLanguageChange, ) + HorizontalDivider(modifier = Modifier.width(dimens.maxContentWidth)) + + GroupTitle { innerModifier -> + Text( + stringResource(id = R.string.openai_settings), + modifier = innerModifier, + ) + } + + OpenAISection( + openAISettings = openAISettings, + openAIModels = openAIModels, + openAIEdit = openAIEdit, + onEvent = onOpenAIEvent, + ) + + HorizontalDivider(modifier = Modifier.width(dimens.maxContentWidth)) + Spacer(modifier = Modifier.navigationBarsPadding()) } } @@ -1234,12 +1265,13 @@ fun ScaleSetting( } @Composable -private fun RowScope.TitleAndSubtitle( +fun RowScope.TitleAndSubtitle( title: @Composable () -> Unit, + modifier: Modifier = Modifier, subtitle: (@Composable () -> Unit)? = null, ) { Column( - modifier = Modifier.weight(1f), + modifier = modifier.weight(1f), verticalArrangement = Arrangement.Center, ) { ProvideTextStyle(value = MaterialTheme.typography.titleMedium) { 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 23e15f9512..4ec1451de5 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 @@ -10,12 +10,15 @@ 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.Repository import com.nononsenseapps.feeder.archmodel.SortingOptions import com.nononsenseapps.feeder.archmodel.SwipeAsRead import com.nononsenseapps.feeder.archmodel.SyncFrequency import com.nononsenseapps.feeder.archmodel.ThemeOptions import com.nononsenseapps.feeder.base.DIAwareViewModel +import com.nononsenseapps.feeder.openai.OpenAIApi +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,6 +36,7 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { private val repository: Repository by instance() private val context: Application by instance() private val applicationCoroutineScope: ApplicationCoroutineScope by instance() + private val openAIApi: OpenAIApi by instance() fun setCurrentTheme(value: ThemeOptions) { repository.setCurrentTheme(value) @@ -151,6 +155,18 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { repository.setShowTitleUnreadCount(value) } + fun onOpenAISettingsEvent(event: OpenAISettingsEvent) { + when (event) { + is OpenAISettingsEvent.LoadModels -> loadOpenAIModels(event.settings) + is OpenAISettingsEvent.UpdateSettings -> repository.setOpenAiSettings(event.settings) + is OpenAISettingsEvent.SwitchEditMode -> { + _viewState.value = _viewState.value.copy(openAIEdit = event.enabled) + } + } + } + + private val openAIModelsState = MutableStateFlow(OpenAIModelsState.None) + @OptIn(ExperimentalCoroutinesApi::class) private val immutableFeedsSettings = repository.feedNotificationSettings @@ -204,6 +220,8 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { repository.isOpenAdjacent, repository.showReadingTime, repository.showTitleUnreadCount, + repository.openAISettings, + openAIModelsState, ) { params: Array -> @Suppress("UNCHECKED_CAST") SettingsViewState( @@ -234,6 +252,9 @@ 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, ) }.collect { _viewState.value = it @@ -241,6 +262,22 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { } } + private fun loadOpenAIModels(settings: OpenAISettings) { + viewModelScope.launch(Dispatchers.IO) { + openAIModelsState.value = OpenAIModelsState.Loading + openAIModelsState.value = + openAIApi.listModelIds(settings).let { res -> + when (res) { + is OpenAIApi.ModelsResult.Error -> OpenAIModelsState.Error(res.message ?: "") + OpenAIApi.ModelsResult.MissingToken -> OpenAIModelsState.None + is OpenAIApi.ModelsResult.Success -> OpenAIModelsState.Success(res.ids) + OpenAIApi.ModelsResult.AzureApiVersionRequired -> OpenAIModelsState.None + OpenAIApi.ModelsResult.AzureDeploymentIdRequired -> OpenAIModelsState.None + } + } + } + } + companion object { @Suppress("unused") private const val LOG_TAG = "FEEDER_SETTINGSVM" @@ -274,6 +311,9 @@ 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 showReadingTime: Boolean = false, val showTitleUnreadCount: Boolean = false, ) @@ -283,3 +323,21 @@ data class UIFeedSettings( val title: String, val notify: Boolean, ) + +sealed interface OpenAIModelsState { + data object None : OpenAIModelsState + + data object Loading : OpenAIModelsState + + data class Success(val ids: List) : OpenAIModelsState + + data class Error(val message: String) : OpenAIModelsState +} + +sealed interface OpenAISettingsEvent { + data class UpdateSettings(val settings: OpenAISettings) : OpenAISettingsEvent + + data class LoadModels(val settings: OpenAISettings) : OpenAISettingsEvent + + data class SwitchEditMode(val enabled: Boolean) : OpenAISettingsEvent +} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/VisualTransformationApiKey.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/VisualTransformationApiKey.kt new file mode 100644 index 0000000000..4f3da7498a --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/VisualTransformationApiKey.kt @@ -0,0 +1,22 @@ +package com.nononsenseapps.feeder.ui.compose.settings + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class VisualTransformationApiKey : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + if (text.isBlank() || text.length < 10) { + return VisualTransformation.None.filter(text) + } + val stars = "*".repeat(text.length - PREFIX_LENGTH - SUFFIX_LENGTH) + val transformed = "${text.subSequence(0..PREFIX_LENGTH)}$stars${text.subSequence(text.length - PREFIX_LENGTH, text.length)}" + return TransformedText(AnnotatedString(transformed), OffsetMapping.Identity) + } + + companion object { + const val PREFIX_LENGTH = 3 + const val SUFFIX_LENGTH = 4 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5e999e445..148ab642f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -261,7 +261,16 @@ Close menu Skip duplicate articles Articles with links or titles identical to existing articles are ignored + Summarize + OpenAI integration + API Key + Model Id + Azure API version Touch to play audio (%1$d) Show unread article count in title + Unable to load models. + No models were found + Azure deployment id + List of available models diff --git a/settings.gradle.kts b/settings.gradle.kts index d37b65997b..90872da944 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -57,6 +57,7 @@ dependencyResolutionManagement { version("testRunner", "1.4.0") version("lifecycle", "2.6.2") version("room", "2.5.2") + version("openai-client", "3.8.2") // Compose related below version("compose", "2024.04.00") val activityCompose = "1.7.0" @@ -88,6 +89,11 @@ dependencyResolutionManagement { library("coil-bom", "io.coil-kt", "coil-bom").versionRef("coil") library("compose-bom", "androidx.compose", "compose-bom").versionRef("compose") + // OpenAI + library("openai-client-bom", "com.aallam.openai", "openai-client-bom").versionRef("openai-client") + library("openai-client", "com.aallam.openai", "openai-client").withoutVersion() + library("ktor-client-okhttp", "io.ktor", "ktor-client-okhttp").withoutVersion() + // Libraries library("ktlint-compose", "io.nlopez.compose.rules", "ktlint").versionRef("ktlint-compose") library("room", "androidx.room", "room-compiler").versionRef("room")