diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 57630fc71..f52cb777c 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -338,6 +338,11 @@ dependencies { // Google services model the translate text playImplementation libs.google.mlkit.translate + // On-device AI writing assistance (Gemini Nano via AICore) + playImplementation libs.google.mlkit.genai.proofreading + playImplementation libs.google.mlkit.genai.prompt + playImplementation libs.google.mlkit.genai.rewriting + // PushNotifications playImplementation platform(libs.firebase.bom) playImplementation libs.firebase.messaging diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/NoOpWritingAssistant.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/NoOpWritingAssistant.kt new file mode 100644 index 000000000..665cce0c3 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/NoOpWritingAssistant.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +class NoOpWritingAssistant : WritingAssistant { + override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Unavailable + + override suspend fun transform( + text: String, + tone: WritingTone, + ): WritingResult = WritingResult(text, text, tone) + + override fun close() {} +} diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt new file mode 100644 index 000000000..bc5e51758 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import android.content.Context + +object WritingAssistantFactory { + @Suppress("UNUSED_PARAMETER") + fun create(context: Context): WritingAssistant = NoOpWritingAssistant() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/WritingAssistant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/WritingAssistant.kt new file mode 100644 index 000000000..e136ecba1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/WritingAssistant.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import androidx.compose.runtime.Immutable + +interface WritingAssistant { + suspend fun checkAvailability(): WritingAssistantStatus + + suspend fun transform( + text: String, + tone: WritingTone, + ): WritingResult + + fun close() +} + +enum class WritingTone { + CORRECT, + REPHRASE, + SHORTER, + ELABORATE, + FRIENDLY, + PROFESSIONAL, + MORE_DIRECT, + PUNCHY, + EMOJIFY, +} + +sealed class WritingAssistantStatus { + data object Available : WritingAssistantStatus() + + data object Unavailable : WritingAssistantStatus() + + data object Downloading : WritingAssistantStatus() +} + +@Immutable +data class WritingResult( + val originalText: String, + val transformedText: String, + val tone: WritingTone, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 94a5248af..8647f9c01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -73,7 +73,8 @@ class MarmotGroupEventsEoseManager( metadata ?.relays ?.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer.normalizeOrNull(it) + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(it) }?.toSet() val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays for (relay in relaysForGroup) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpButton.kt new file mode 100644 index 000000000..c2e2a7b61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpButton.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.aihelp + +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun AiWritingHelpButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + Icon( + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = stringRes(R.string.ai_writing_help), + modifier = Modifier.height(22.dp), + tint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt new file mode 100644 index 000000000..1d93cc6ed --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.aihelp + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.ai.WritingResult +import com.vitorpamplona.amethyst.service.ai.WritingTone +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun AiWritingHelpPanel( + isVisible: Boolean, + isProcessing: Boolean, + result: WritingResult?, + onToneSelected: (WritingTone) -> Unit, + onApply: () -> Unit, + onDismiss: () -> Unit, +) { + AnimatedVisibility( + visible = isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + if (isProcessing) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + + result?.let { + AiResultCard( + result = it, + onApply = onApply, + onDismiss = onDismiss, + ) + } + + ToneChipRow(onToneSelected = onToneSelected) + } + } +} + +@Composable +private fun AiResultCard( + result: WritingResult, + onApply: () -> Unit, + onDismiss: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + ) { + Text( + text = result.transformedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 150.dp) + .verticalScroll(rememberScrollState()), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.ai_writing_dismiss)) + } + OutlinedButton(onClick = onApply) { + Text(stringRes(R.string.ai_writing_use_this)) + } + } + } +} + +@Composable +private fun ToneChipRow(onToneSelected: (WritingTone) -> Unit) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + WritingTone.entries.forEach { tone -> + FilterChip( + selected = false, + onClick = { onToneSelected(tone) }, + label = { Text(toneDisplayName(tone)) }, + ) + } + } +} + +@Composable +private fun toneDisplayName(tone: WritingTone): String = + when (tone) { + WritingTone.CORRECT -> stringRes(R.string.ai_tone_correct) + WritingTone.REPHRASE -> stringRes(R.string.ai_tone_rephrase) + WritingTone.SHORTER -> stringRes(R.string.ai_tone_shorter) + WritingTone.ELABORATE -> stringRes(R.string.ai_tone_elaborate) + WritingTone.FRIENDLY -> stringRes(R.string.ai_tone_friendly) + WritingTone.PROFESSIONAL -> stringRes(R.string.ai_tone_professional) + WritingTone.MORE_DIRECT -> stringRes(R.string.ai_tone_more_direct) + WritingTone.PUNCHY -> stringRes(R.string.ai_tone_punchy) + WritingTone.EMOJIFY -> stringRes(R.string.ai_tone_emojify) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 8791601c1..c6f8fbf35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -496,6 +496,10 @@ class GroupEventHandler( Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}") } + is GroupEventResult.Duplicate -> { + Log.d("GroupEventHandler") { "Duplicate event for group ${result.groupId}" } + } + is GroupEventResult.Error -> { Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index eed62305a..c0a116dfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -65,6 +65,7 @@ import androidx.core.net.toUri import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS @@ -82,6 +83,8 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.aihelp.AiWritingHelpButton +import com.vitorpamplona.amethyst.ui.note.creators.aihelp.AiWritingHelpPanel import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -144,6 +147,10 @@ fun ShortNotePostScreen( val context = LocalContext.current val activity = context.getActivity() + LaunchedEffect(Unit) { + postViewModel.initWritingAssistant(context) + } + LaunchedEffect(postViewModel, accountViewModel) { val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) } val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) } @@ -556,6 +563,15 @@ private fun NewPostScreenBody( ) } + AiWritingHelpPanel( + isVisible = postViewModel.wantsAiHelp, + isProcessing = postViewModel.isAiProcessing, + result = postViewModel.aiResult, + onToneSelected = postViewModel::requestAiTransform, + onApply = postViewModel::applyAiResult, + onDismiss = postViewModel::dismissAiResult, + ) + BottomRowActions(postViewModel) } } @@ -651,6 +667,12 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + if (postViewModel.aiStatus is WritingAssistantStatus.Available) { + AiWritingHelpButton(postViewModel.wantsAiHelp) { + postViewModel.wantsAiHelp = !postViewModel.wantsAiHelp + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 6adf94284..2ec52b771 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -44,6 +44,11 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.ai.WritingAssistant +import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory +import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus +import com.vitorpamplona.amethyst.service.ai.WritingResult +import com.vitorpamplona.amethyst.service.ai.WritingTone import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor @@ -291,6 +296,53 @@ open class ShortNotePostViewModel : // Anonymous Reply var wantsAnonymousPost by mutableStateOf(false) + // AI Writing Help + var wantsAiHelp by mutableStateOf(false) + var aiResult by mutableStateOf(null) + var aiStatus by mutableStateOf(WritingAssistantStatus.Unavailable) + var isAiProcessing by mutableStateOf(false) + private var writingAssistant: WritingAssistant? = null + + fun initWritingAssistant(context: android.content.Context) { + if (writingAssistant == null) { + writingAssistant = WritingAssistantFactory.create(context) + viewModelScope.launch(Dispatchers.IO) { + aiStatus = writingAssistant?.checkAvailability() ?: WritingAssistantStatus.Unavailable + } + } + } + + fun requestAiTransform(tone: WritingTone) { + val text = message.text.toString() + if (text.isBlank() || isAiProcessing) return + + isAiProcessing = true + aiResult = null + viewModelScope.launch(Dispatchers.IO) { + try { + val result = writingAssistant?.transform(text, tone) + aiResult = result + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + aiResult = null + } finally { + isAiProcessing = false + } + } + } + + fun applyAiResult() { + aiResult?.let { + message.setTextAndPlaceCursorAtEnd(it.transformedText) + aiResult = null + draftTag.newVersion() + } + } + + fun dismissAiResult() { + aiResult = null + } + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -1372,6 +1424,8 @@ open class ShortNotePostViewModel : override fun onCleared() { super.onCleared() + writingAssistant?.close() + writingAssistant = null Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 985b4f915..3548f1e30 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2199,4 +2199,17 @@ %1$d members removed from relay Relay join request Relay leave request + + AI Writing Help + Use This + Dismiss + Correct + Rephrase + Shorter + Elaborate + Friendly + Professional + More Direct + Punchy + + Emoji diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitWritingAssistant.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitWritingAssistant.kt new file mode 100644 index 000000000..00d04436d --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitWritingAssistant.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import android.content.Context +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.proofreading.ProofreaderOptions +import com.google.mlkit.genai.proofreading.Proofreading +import com.google.mlkit.genai.proofreading.ProofreadingRequest +import com.google.mlkit.genai.rewriting.Rewriter +import com.google.mlkit.genai.rewriting.RewriterOptions +import com.google.mlkit.genai.rewriting.Rewriting +import com.google.mlkit.genai.rewriting.RewritingRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MLKitWritingAssistant( + private val context: Context, +) : WritingAssistant { + private var rewriters = mutableMapOf() + private var proofreader = + Proofreading.getClient( + ProofreaderOptions + .builder(context) + .setInputType(ProofreaderOptions.InputType.KEYBOARD) + .setLanguage(ProofreaderOptions.Language.ENGLISH) + .build(), + ) + + private fun getRewriter( + @RewriterOptions.OutputType outputType: Int, + ): Rewriter = + rewriters.getOrPut(outputType) { + Rewriting.getClient( + RewriterOptions + .builder(context) + .setOutputType(outputType) + .setLanguage(RewriterOptions.Language.ENGLISH) + .build(), + ) + } + + override suspend fun checkAvailability(): WritingAssistantStatus = + withContext(Dispatchers.IO) { + try { + val status = getRewriter(RewriterOptions.OutputType.REPHRASE).checkFeatureStatus().get() + when (status) { + FeatureStatus.AVAILABLE -> { + WritingAssistantStatus.Available + } + + FeatureStatus.DOWNLOADING -> { + WritingAssistantStatus.Downloading + } + + else -> { + WritingAssistantStatus.Unavailable + } + } + } catch (e: Exception) { + WritingAssistantStatus.Unavailable + } + } + + override suspend fun transform( + text: String, + tone: WritingTone, + ): WritingResult { + val transformedText = + when (tone) { + WritingTone.CORRECT -> { + proofread(text) + } + + WritingTone.REPHRASE -> { + rewrite(text, RewriterOptions.OutputType.REPHRASE) + } + + WritingTone.SHORTER -> { + rewrite(text, RewriterOptions.OutputType.SHORTEN) + } + + WritingTone.ELABORATE -> { + rewrite(text, RewriterOptions.OutputType.ELABORATE) + } + + WritingTone.FRIENDLY -> { + rewrite(text, RewriterOptions.OutputType.FRIENDLY) + } + + WritingTone.PROFESSIONAL -> { + rewrite(text, RewriterOptions.OutputType.PROFESSIONAL) + } + + WritingTone.EMOJIFY -> { + rewrite(text, RewriterOptions.OutputType.EMOJIFY) + } + + WritingTone.MORE_DIRECT -> { + rewrite(text, RewriterOptions.OutputType.PROFESSIONAL) + } + + WritingTone.PUNCHY -> { + rewrite(text, RewriterOptions.OutputType.SHORTEN) + } + } + + return WritingResult( + originalText = text, + transformedText = transformedText, + tone = tone, + ) + } + + private suspend fun rewrite( + text: String, + @RewriterOptions.OutputType outputType: Int, + ): String = + withContext(Dispatchers.IO) { + val rewriter = getRewriter(outputType) + val request = RewritingRequest.builder(text).build() + val result = rewriter.runInference(request).get() + result.results.firstOrNull()?.text ?: text + } + + private suspend fun proofread(text: String): String = + withContext(Dispatchers.IO) { + val request = ProofreadingRequest.builder(text).build() + val result = proofreader.runInference(request).get() + result.results.firstOrNull()?.text ?: text + } + + override fun close() { + rewriters.values.forEach { it.close() } + rewriters.clear() + proofreader.close() + } +} diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt new file mode 100644 index 000000000..86525c2d9 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import android.content.Context + +object WritingAssistantFactory { + fun create(context: Context): WritingAssistant = MLKitWritingAssistant(context) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60485652c..0c0cc62f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,9 @@ kotlin = "2.3.20" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.10.0" +genaiProofreading = "1.0.0-beta1" +genaiPrompt = "1.0.0-beta2" +genaiRewriting = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.8.1" @@ -134,6 +137,9 @@ jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "jetbrainsCompose" } +google-mlkit-genai-proofreading = { group = "com.google.mlkit", name = "genai-proofreading", version.ref = "genaiProofreading" } +google-mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "genaiPrompt" } +google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewriting", version.ref = "genaiRewriting" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }