From b1a7410e6d1911e22b3502ceb3f8999dcab59553 Mon Sep 17 00:00:00 2001 From: Alexandr Gavrishev Date: Sat, 30 Mar 2024 22:36:50 +0300 Subject: [PATCH 1/5] OpenAI Integration --- 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 | 332 ++++++++++++++++++ .../feeder/ui/compose/settings/Settings.kt | 31 ++ .../ui/compose/settings/SettingsViewModel.kt | 58 +++ .../settings/VisualTransformationApiKey.kt | 19 + app/src/main/res/values/strings.xml | 9 + settings.gradle.kts | 6 + 13 files changed, 840 insertions(+), 1 deletion(-) 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 b8a8f7f97..ea4a2d55d 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 c1582d099..0f31401bc 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 21d6bd074..7b1de3aba 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 f161c4600..001659434 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 000000000..0c89ef1e4 --- /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(): ModelsResult { + if (openAISettings.key.isEmpty()) { + return ModelsResult.MissingToken + } + if (openAISettings.isAzure) { + if (openAISettings.azureApiVersion.isBlank()) { + return ModelsResult.AzureApiVersionRequired + } + if (openAISettings.azureDeploymentId.isBlank()) { + return ModelsResult.AzureDeploymentIdRequired + } + } + return try { + openAI.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 6eadf992f..b99058e99 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 68ac13009..80ea3200d 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 000000000..6a9797184 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/OpenAISection.kt @@ -0,0 +1,332 @@ +package com.nononsenseapps.feeder.ui.compose.settings + +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Save +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.text.AnnotatedString +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.openai.toOpenAIHost +import com.nononsenseapps.feeder.openai.toUrl +import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens + +@Composable +fun OpenAISection( + openAISettings: OpenAISettings, + openAIModels: OpenAIModelsState, + openAIEdit: Boolean, + onEvent: (OpenAISettingsEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .padding(start = 64.dp, bottom = 16.dp) + .width(LocalDimens.current.maxContentWidth), + ) { + if (openAIEdit) { + OpenAISectionEdit( + modifier = Modifier, + settings = openAISettings, + models = openAIModels, + onEvent = onEvent, + ) + } else { + OpenAISectionReadOnly( + modifier = Modifier, + settings = openAISettings, + onEvent = onEvent, + ) + } + } +} + +@Composable +private fun OpenAISectionReadOnly( + settings: OpenAISettings, + modifier: Modifier = Modifier, + onEvent: (OpenAISettingsEvent) -> Unit, +) { + Column( + modifier = modifier, + ) { + val transformedKey = remember(settings.key) { VisualTransformationApiKey().filter(AnnotatedString(settings.key)) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.api_key), + style = MaterialTheme.typography.titleMedium, + ) + IconButton(onClick = { onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = true)) }) { + Icon(Icons.Outlined.Edit, contentDescription = "Edit") + } + } + Text( + text = transformedKey.text, + style = MaterialTheme.typography.bodySmall, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.model_id), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = settings.modelId, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.url), + style = MaterialTheme.typography.titleMedium, + ) + val url = remember(settings) { settings.toOpenAIHost(withAzureDeploymentId = true).toUrl().buildString() } + Text( + text = url, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +fun OpenAISectionEdit( + settings: OpenAISettings, + models: OpenAIModelsState, + onEvent: (OpenAISettingsEvent) -> Unit, + modifier: Modifier = Modifier, +) { + var current by remember(settings) { mutableStateOf(settings) } + val latestOnEvent by rememberUpdatedState(onEvent) + LaunchedEffect(true) { + latestOnEvent(OpenAISettingsEvent.LoadModels) + } + + var modelsMenuExpanded by remember { mutableStateOf(false) } + Column( + modifier = modifier, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Text( + text = stringResource(R.string.api_key), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.weight(1.0f)) + IconButton(onClick = { + onEvent(OpenAISettingsEvent.UpdateSettings(current)) + onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = false)) + }) { + Icon(Icons.Outlined.Save, contentDescription = stringResource(R.string.save)) + } + + IconButton(onClick = { onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = false)) }) { + Icon(Icons.Outlined.Close, contentDescription = stringResource(android.R.string.cancel)) + } + } + TextField( + modifier = Modifier.fillMaxWidth(), + value = current.key, + onValueChange = { + current = current.copy(key = it) + onEvent(OpenAISettingsEvent.LoadModels) + }, + visualTransformation = VisualTransformationApiKey(), + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.model_id), + style = MaterialTheme.typography.titleMedium, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = current.modelId, + onValueChange = { + current = current.copy(modelId = it) + onEvent(OpenAISettingsEvent.LoadModels) + }, + 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)) + } + } + }, + ) + OpenAIModelsSection( + menuExpanded = modelsMenuExpanded, + state = models, + onValueChange = { + current = current.copy(modelId = it) + }, + onDismissRequest = { modelsMenuExpanded = false }, + ) + + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.url), + style = MaterialTheme.typography.titleMedium, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = current.baseUrl, + placeholder = { + Text(OpenAIHost.OpenAI.baseUrl) + }, + onValueChange = { + current = current.copy(baseUrl = it) + onEvent(OpenAISettingsEvent.LoadModels) + }, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.azure_deployment_id), + style = MaterialTheme.typography.titleMedium, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = current.azureDeploymentId, + onValueChange = { + current = current.copy(azureDeploymentId = it) + onEvent(OpenAISettingsEvent.LoadModels) + }, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.azure_api_version), + style = MaterialTheme.typography.titleMedium, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = current.azureApiVersion, + onValueChange = { + current = current.copy(azureApiVersion = it) + onEvent(OpenAISettingsEvent.LoadModels) + }, + ) + } +} + +@Composable +private fun OpenAIModelsSection( + menuExpanded: Boolean, + state: OpenAIModelsState, + onValueChange: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + when (state) { + is OpenAIModelsState.Success -> { + if (state.ids.isEmpty()) { + OutlinedCard { + Text( + text = stringResource(R.string.no_models_were_found), + modifier = Modifier.padding(top = 4.dp), + ) + } + } else { + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissRequest, + ) { + state.ids.forEach { id -> + DropdownMenuItem( + text = { Text(text = id) }, + onClick = { + onValueChange(id) + onDismissRequest() + }, + ) + } + } + } + } + + is OpenAIModelsState.Error -> { + OutlinedCard { + Text( + text = stringResource(R.string.unable_to_load_models) + " " + state.message, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + + OpenAIModelsState.Loading -> {} + OpenAIModelsState.None -> {} + } +} + +@Preview +@Composable +private fun OpenAISectionReadPreview() { + Surface { + OpenAISection( + openAISettings = + OpenAISettings( + key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + modelId = "gpt-4o-mini", + ), + openAIModels = OpenAIModelsState.None, + openAIEdit = false, + onEvent = { }, + ) + } +} + +@Preview +@Composable +private fun OpenAISectionEditPreview() { + Surface { + 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 58613d33c..efa7a7063 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()) } } 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 23e15f951..1b268051d 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) { + OpenAISettingsEvent.LoadModels -> loadOpenAIModels() + 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() { + viewModelScope.launch(Dispatchers.IO) { + openAIModelsState.value = OpenAIModelsState.Loading + openAIModelsState.value = + openAIApi.listModelIds().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 object LoadModels : 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 000000000..23ce828df --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/settings/VisualTransformationApiKey.kt @@ -0,0 +1,19 @@ +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 prefixLength = 3 + val suffixLength = 4 + val stars = "*".repeat(text.length - prefixLength - suffixLength) + val transformed = "${text.subSequence(0..prefixLength)}$stars${text.subSequence(text.length - prefixLength, text.length)}" + return TransformedText(AnnotatedString(transformed), OffsetMapping.Identity) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5e999e44..148ab642f 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 d37b65997..90872da94 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") From 9a3bee47d202798fa5b1e238017e2ef6f7c5ed57 Mon Sep 17 00:00:00 2001 From: Alex Gavrishev Date: Thu, 14 Nov 2024 19:43:25 +0200 Subject: [PATCH 2/5] Make OpenAI section dialog --- .../ui/compose/settings/OpenAISection.kt | 260 +++++++++--------- .../feeder/ui/compose/settings/Settings.kt | 5 +- 2 files changed, 131 insertions(+), 134 deletions(-) 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 6a9797184..e703645a7 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 @@ -1,18 +1,20 @@ 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.Spacer 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.material.icons.outlined.Close -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Save +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 @@ -33,14 +35,17 @@ 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.openai.toOpenAIHost -import com.nononsenseapps.feeder.openai.toUrl import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens @Composable @@ -51,74 +56,84 @@ fun OpenAISection( onEvent: (OpenAISettingsEvent) -> Unit, modifier: Modifier = Modifier, ) { - Box( - modifier = - modifier - .padding(start = 64.dp, bottom = 16.dp) - .width(LocalDimens.current.maxContentWidth), - ) { - if (openAIEdit) { - OpenAISectionEdit( - modifier = Modifier, - settings = openAISettings, - models = openAIModels, - onEvent = onEvent, - ) - } else { - OpenAISectionReadOnly( - modifier = Modifier, - settings = openAISettings, - onEvent = onEvent, - ) - } + 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 OpenAISectionReadOnly( +private fun OpenAISectionItem( settings: OpenAISettings, modifier: Modifier = Modifier, onEvent: (OpenAISettingsEvent) -> Unit, ) { - Column( - modifier = modifier, + 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)) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.api_key), - style = MaterialTheme.typography.titleMedium, - ) - IconButton(onClick = { onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = true)) }) { - Icon(Icons.Outlined.Edit, contentDescription = "Edit") - } - } - Text( - text = transformedKey.text, - style = MaterialTheme.typography.bodySmall, - ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = stringResource(R.string.model_id), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = settings.modelId, - style = MaterialTheme.typography.bodyMedium, - ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = stringResource(R.string.url), - style = MaterialTheme.typography.titleMedium, - ) - val url = remember(settings) { settings.toOpenAIHost(withAzureDeploymentId = true).toUrl().buildString() } - Text( - text = url, - style = MaterialTheme.typography.bodyMedium, + TitleAndSubtitle( + title = { + Text( + text = stringResource(R.string.api_key), + ) + }, + subtitle = { + Text( + text = transformedKey.text, + style = MaterialTheme.typography.bodySmall, + ) + }, ) } } @@ -130,56 +145,38 @@ fun OpenAISectionEdit( onEvent: (OpenAISettingsEvent) -> Unit, modifier: Modifier = Modifier, ) { - var current by remember(settings) { mutableStateOf(settings) } val latestOnEvent by rememberUpdatedState(onEvent) LaunchedEffect(true) { latestOnEvent(OpenAISettingsEvent.LoadModels) } var modelsMenuExpanded by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() Column( - modifier = modifier, + modifier = modifier.verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - Text( - text = stringResource(R.string.api_key), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.weight(1.0f)) - IconButton(onClick = { - onEvent(OpenAISettingsEvent.UpdateSettings(current)) - onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = false)) - }) { - Icon(Icons.Outlined.Save, contentDescription = stringResource(R.string.save)) - } - - IconButton(onClick = { onEvent(OpenAISettingsEvent.SwitchEditMode(enabled = false)) }) { - Icon(Icons.Outlined.Close, contentDescription = stringResource(android.R.string.cancel)) - } - } TextField( modifier = Modifier.fillMaxWidth(), - value = current.key, + value = settings.key, + label = { + Text(stringResource(R.string.api_key)) + }, onValueChange = { - current = current.copy(key = it) + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(key = it))) onEvent(OpenAISettingsEvent.LoadModels) }, visualTransformation = VisualTransformationApiKey(), ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = stringResource(R.string.model_id), - style = MaterialTheme.typography.titleMedium, - ) + TextField( modifier = Modifier.fillMaxWidth(), - value = current.modelId, + value = settings.modelId, + label = { + Text(stringResource(R.string.model_id)) + }, onValueChange = { - current = current.copy(modelId = it) + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) onEvent(OpenAISettingsEvent.LoadModels) }, trailingIcon = { @@ -195,54 +192,53 @@ fun OpenAISectionEdit( } }, ) + OpenAIModelsSection( menuExpanded = modelsMenuExpanded, state = models, onValueChange = { - current = current.copy(modelId = it) + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) }, onDismissRequest = { modelsMenuExpanded = false }, ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = stringResource(R.string.url), - style = MaterialTheme.typography.titleMedium, - ) TextField( modifier = Modifier.fillMaxWidth(), - value = current.baseUrl, + value = settings.toOpenAIHost(withAzureDeploymentId = false).baseUrl, placeholder = { Text(OpenAIHost.OpenAI.baseUrl) }, + label = { + Text(stringResource(R.string.url)) + }, onValueChange = { - current = current.copy(baseUrl = it) + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(baseUrl = it))) onEvent(OpenAISettingsEvent.LoadModels) }, ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = stringResource(R.string.azure_deployment_id), - style = MaterialTheme.typography.titleMedium, - ) TextField( modifier = Modifier.fillMaxWidth(), - value = current.azureDeploymentId, + value = settings.azureDeploymentId, + label = { + Text(stringResource(R.string.azure_deployment_id)) + }, onValueChange = { - current = current.copy(azureDeploymentId = it) + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureDeploymentId = it))) onEvent(OpenAISettingsEvent.LoadModels) }, ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = stringResource(R.string.azure_api_version), - style = MaterialTheme.typography.titleMedium, - ) + TextField( modifier = Modifier.fillMaxWidth(), - value = current.azureApiVersion, + value = settings.azureApiVersion, + placeholder = { + Text("2024-02-15-preview") + }, + label = { + Text(stringResource(R.string.azure_api_version)) + }, onValueChange = { - current = current.copy(azureApiVersion = it) + onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureApiVersion = it))) onEvent(OpenAISettingsEvent.LoadModels) }, ) @@ -287,7 +283,7 @@ private fun OpenAIModelsSection( OutlinedCard { Text( text = stringResource(R.string.unable_to_load_models) + " " + state.message, - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier.padding(8.dp), ) } } @@ -297,7 +293,8 @@ private fun OpenAIModelsSection( } } -@Preview +@Preview("OpenAI section item tablet", device = Devices.PIXEL_C) +@Preview("OpenAI section item phone", device = Devices.PIXEL_7) @Composable private fun OpenAISectionReadPreview() { Surface { @@ -314,19 +311,18 @@ private fun OpenAISectionReadPreview() { } } -@Preview +@Preview("OpenAI section dialog tablet", device = Devices.PIXEL_C) +@Preview("OpenAI section dialog phone", device = Devices.PIXEL_7) @Composable private fun OpenAISectionEditPreview() { - Surface { - OpenAISection( - openAISettings = - OpenAISettings( - key = "sk-test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - modelId = "gpt-4o-mini", - ), - openAIModels = OpenAIModelsState.None, - openAIEdit = true, - onEvent = { }, - ) - } + 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 efa7a7063..36e8a43ec 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 @@ -1265,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) { From 1300734f45e0acbe3d7abe221bd45d2064634205 Mon Sep 17 00:00:00 2001 From: Alex Gavrishev Date: Thu, 14 Nov 2024 19:53:57 +0200 Subject: [PATCH 3/5] Apply suggestions from code review Co-authored-by: Jonas Kalderstam --- .../ui/compose/settings/VisualTransformationApiKey.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index 23ce828df..4f3da7498 100644 --- 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 @@ -10,10 +10,13 @@ class VisualTransformationApiKey : VisualTransformation { if (text.isBlank() || text.length < 10) { return VisualTransformation.None.filter(text) } - val prefixLength = 3 - val suffixLength = 4 - val stars = "*".repeat(text.length - prefixLength - suffixLength) - val transformed = "${text.subSequence(0..prefixLength)}$stars${text.subSequence(text.length - prefixLength, text.length)}" + 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 + } } From 087187dd1aa7bb0d8a71e0ac2a15bb508cbf87e0 Mon Sep 17 00:00:00 2001 From: Alex Gavrishev Date: Thu, 14 Nov 2024 20:20:32 +0200 Subject: [PATCH 4/5] Fix refresh models uses stored settings, fix ui jumping --- .../nononsenseapps/feeder/openai/OpenAIApi.kt | 12 ++-- .../ui/compose/settings/OpenAISection.kt | 71 ++++++++++--------- .../ui/compose/settings/SettingsViewModel.kt | 8 +-- 3 files changed, 47 insertions(+), 44 deletions(-) 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 0c89ef1e4..d8ac405c1 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt @@ -92,20 +92,20 @@ class OpenAIApi( private val openAI: OpenAI get() = OpenAI(config = openAISettings.toOpenAIConfig()) - suspend fun listModelIds(): ModelsResult { - if (openAISettings.key.isEmpty()) { + suspend fun listModelIds(settings: OpenAISettings): ModelsResult { + if (settings.key.isEmpty()) { return ModelsResult.MissingToken } - if (openAISettings.isAzure) { - if (openAISettings.azureApiVersion.isBlank()) { + if (settings.isAzure) { + if (settings.azureApiVersion.isBlank()) { return ModelsResult.AzureApiVersionRequired } - if (openAISettings.azureDeploymentId.isBlank()) { + if (settings.azureDeploymentId.isBlank()) { return ModelsResult.AzureDeploymentIdRequired } } return try { - openAI.models() + OpenAI(config = settings.toOpenAIConfig()).models() .sortedByDescending { it.created } .map { it.id.id }.let { ModelsResult.Success(it) } } catch (e: Exception) { 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 e703645a7..af1fd5159 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 @@ -146,8 +146,8 @@ fun OpenAISectionEdit( modifier: Modifier = Modifier, ) { val latestOnEvent by rememberUpdatedState(onEvent) - LaunchedEffect(true) { - latestOnEvent(OpenAISettingsEvent.LoadModels) + LaunchedEffect(settings) { + latestOnEvent(OpenAISettingsEvent.LoadModels(settings = settings)) } var modelsMenuExpanded by remember { mutableStateOf(false) } @@ -164,7 +164,6 @@ fun OpenAISectionEdit( }, onValueChange = { onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(key = it))) - onEvent(OpenAISettingsEvent.LoadModels) }, visualTransformation = VisualTransformationApiKey(), ) @@ -177,7 +176,6 @@ fun OpenAISectionEdit( }, onValueChange = { onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) - onEvent(OpenAISettingsEvent.LoadModels) }, trailingIcon = { IconButton( @@ -188,23 +186,26 @@ fun OpenAISectionEdit( 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 }, + ) + } } } }, ) - OpenAIModelsSection( - menuExpanded = modelsMenuExpanded, - state = models, - onValueChange = { - onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(modelId = it))) - }, - onDismissRequest = { modelsMenuExpanded = false }, - ) + OpenAIModelsStatus(state = models) TextField( modifier = Modifier.fillMaxWidth(), - value = settings.toOpenAIHost(withAzureDeploymentId = false).baseUrl, + value = settings.baseUrl, placeholder = { Text(OpenAIHost.OpenAI.baseUrl) }, @@ -213,7 +214,6 @@ fun OpenAISectionEdit( }, onValueChange = { onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(baseUrl = it))) - onEvent(OpenAISettingsEvent.LoadModels) }, ) TextField( @@ -224,7 +224,6 @@ fun OpenAISectionEdit( }, onValueChange = { onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureDeploymentId = it))) - onEvent(OpenAISettingsEvent.LoadModels) }, ) @@ -239,18 +238,37 @@ fun OpenAISectionEdit( }, onValueChange = { onEvent(OpenAISettingsEvent.UpdateSettings(settings.copy(azureApiVersion = it))) - onEvent(OpenAISettingsEvent.LoadModels) }, ) } } @Composable -private fun OpenAIModelsSection( +private fun OpenAIModelsDropdown( menuExpanded: Boolean, - state: OpenAIModelsState, + 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 -> { @@ -258,24 +276,9 @@ private fun OpenAIModelsSection( OutlinedCard { Text( text = stringResource(R.string.no_models_were_found), - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier.padding(8.dp), ) } - } else { - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = onDismissRequest, - ) { - state.ids.forEach { id -> - DropdownMenuItem( - text = { Text(text = id) }, - onClick = { - onValueChange(id) - onDismissRequest() - }, - ) - } - } } } 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 1b268051d..4ec1451de 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 @@ -157,7 +157,7 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { fun onOpenAISettingsEvent(event: OpenAISettingsEvent) { when (event) { - OpenAISettingsEvent.LoadModels -> loadOpenAIModels() + is OpenAISettingsEvent.LoadModels -> loadOpenAIModels(event.settings) is OpenAISettingsEvent.UpdateSettings -> repository.setOpenAiSettings(event.settings) is OpenAISettingsEvent.SwitchEditMode -> { _viewState.value = _viewState.value.copy(openAIEdit = event.enabled) @@ -262,11 +262,11 @@ class SettingsViewModel(di: DI) : DIAwareViewModel(di) { } } - private fun loadOpenAIModels() { + private fun loadOpenAIModels(settings: OpenAISettings) { viewModelScope.launch(Dispatchers.IO) { openAIModelsState.value = OpenAIModelsState.Loading openAIModelsState.value = - openAIApi.listModelIds().let { res -> + openAIApi.listModelIds(settings).let { res -> when (res) { is OpenAIApi.ModelsResult.Error -> OpenAIModelsState.Error(res.message ?: "") OpenAIApi.ModelsResult.MissingToken -> OpenAIModelsState.None @@ -337,7 +337,7 @@ sealed interface OpenAIModelsState { sealed interface OpenAISettingsEvent { data class UpdateSettings(val settings: OpenAISettings) : OpenAISettingsEvent - data object LoadModels : OpenAISettingsEvent + data class LoadModels(val settings: OpenAISettings) : OpenAISettingsEvent data class SwitchEditMode(val enabled: Boolean) : OpenAISettingsEvent } From c10360536604450fc2f350a713588565255202d0 Mon Sep 17 00:00:00 2001 From: Alex Gavrishev Date: Thu, 14 Nov 2024 20:35:34 +0200 Subject: [PATCH 5/5] ktlint format --- .../feeder/ui/compose/settings/OpenAISection.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 af1fd5159..792641aa4 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 @@ -45,7 +45,6 @@ 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.openai.toOpenAIHost import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens @Composable @@ -267,9 +266,7 @@ private fun OpenAIModelsDropdown( } @Composable -private fun OpenAIModelsStatus( - state: OpenAIModelsState, -) { +private fun OpenAIModelsStatus(state: OpenAIModelsState) { when (state) { is OpenAIModelsState.Success -> { if (state.ids.isEmpty()) {