Merge pull request #2176 from vitorpamplona/claude/ai-post-writing-help-CDPPw
Add AI writing assistance with ML Kit integration
This commit is contained in:
@@ -338,6 +338,11 @@ dependencies {
|
|||||||
// Google services model the translate text
|
// Google services model the translate text
|
||||||
playImplementation libs.google.mlkit.translate
|
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
|
// PushNotifications
|
||||||
playImplementation platform(libs.firebase.bom)
|
playImplementation platform(libs.firebase.bom)
|
||||||
playImplementation libs.firebase.messaging
|
playImplementation libs.firebase.messaging
|
||||||
|
|||||||
+32
@@ -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() {}
|
||||||
|
}
|
||||||
+28
@@ -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()
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ data class UiSettings(
|
|||||||
val dontAskForNotificationPermissions: Boolean = false,
|
val dontAskForNotificationPermissions: Boolean = false,
|
||||||
val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
|
val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
|
||||||
val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC,
|
val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC,
|
||||||
|
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ThemeType(
|
enum class ThemeType(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class UiSettingsFlow(
|
|||||||
val dontAskForNotificationPermissions: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
val dontAskForNotificationPermissions: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||||
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
|
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
|
||||||
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
|
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
|
||||||
|
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||||
) {
|
) {
|
||||||
val listOfFlows: List<Flow<Any?>> =
|
val listOfFlows: List<Flow<Any?>> =
|
||||||
listOf<Flow<Any?>>(
|
listOf<Flow<Any?>>(
|
||||||
@@ -52,6 +53,7 @@ class UiSettingsFlow(
|
|||||||
dontAskForNotificationPermissions,
|
dontAskForNotificationPermissions,
|
||||||
featureSet,
|
featureSet,
|
||||||
gallerySet,
|
gallerySet,
|
||||||
|
automaticallyProposeAiImprovements,
|
||||||
)
|
)
|
||||||
|
|
||||||
// emits at every change in any of the propertyes.
|
// emits at every change in any of the propertyes.
|
||||||
@@ -69,6 +71,7 @@ class UiSettingsFlow(
|
|||||||
flows[8] as Boolean,
|
flows[8] as Boolean,
|
||||||
flows[9] as FeatureSetType,
|
flows[9] as FeatureSetType,
|
||||||
flows[10] as ProfileGalleryType,
|
flows[10] as ProfileGalleryType,
|
||||||
|
flows[11] as BooleanType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ class UiSettingsFlow(
|
|||||||
dontAskForNotificationPermissions.value,
|
dontAskForNotificationPermissions.value,
|
||||||
featureSet.value,
|
featureSet.value,
|
||||||
gallerySet.value,
|
gallerySet.value,
|
||||||
|
automaticallyProposeAiImprovements.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun update(torSettings: UiSettings): Boolean {
|
fun update(torSettings: UiSettings): Boolean {
|
||||||
@@ -134,6 +138,10 @@ class UiSettingsFlow(
|
|||||||
gallerySet.tryEmit(torSettings.gallerySet)
|
gallerySet.tryEmit(torSettings.gallerySet)
|
||||||
any = true
|
any = true
|
||||||
}
|
}
|
||||||
|
if (automaticallyProposeAiImprovements.value != torSettings.automaticallyProposeAiImprovements) {
|
||||||
|
automaticallyProposeAiImprovements.tryEmit(torSettings.automaticallyProposeAiImprovements)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
return any
|
return any
|
||||||
}
|
}
|
||||||
@@ -164,6 +172,7 @@ class UiSettingsFlow(
|
|||||||
MutableStateFlow(uiSettings.dontAskForNotificationPermissions),
|
MutableStateFlow(uiSettings.dontAskForNotificationPermissions),
|
||||||
MutableStateFlow(uiSettings.featureSet),
|
MutableStateFlow(uiSettings.featureSet),
|
||||||
MutableStateFlow(uiSettings.gallerySet),
|
MutableStateFlow(uiSettings.gallerySet),
|
||||||
|
MutableStateFlow(uiSettings.automaticallyProposeAiImprovements),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -102,6 +102,7 @@ class UiSharedPreferences(
|
|||||||
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
|
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
|
||||||
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
|
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
|
||||||
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
|
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
|
||||||
|
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
|
||||||
|
|
||||||
suspend fun uiPreferences(context: Context): UiSettings? =
|
suspend fun uiPreferences(context: Context): UiSettings? =
|
||||||
try {
|
try {
|
||||||
@@ -120,6 +121,7 @@ class UiSharedPreferences(
|
|||||||
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
|
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
|
||||||
featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED,
|
featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED,
|
||||||
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
|
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
|
||||||
|
automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
@@ -155,6 +157,7 @@ class UiSharedPreferences(
|
|||||||
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
|
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
|
||||||
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
|
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
|
||||||
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
|
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
|
||||||
|
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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 kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Remove before shipping. Debug-only mock for testing the AI writing help UI
|
||||||
|
* on devices without Gemini Nano / AICore support.
|
||||||
|
*/
|
||||||
|
class MockWritingAssistant : WritingAssistant {
|
||||||
|
override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Available
|
||||||
|
|
||||||
|
override suspend fun transform(
|
||||||
|
text: String,
|
||||||
|
tone: WritingTone,
|
||||||
|
): WritingResult {
|
||||||
|
delay(800)
|
||||||
|
|
||||||
|
val transformed =
|
||||||
|
when (tone) {
|
||||||
|
WritingTone.CORRECT -> {
|
||||||
|
correctMock(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.REPHRASE -> {
|
||||||
|
"Here's another way to put it: $text"
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.SHORTER -> {
|
||||||
|
text
|
||||||
|
.split(".")
|
||||||
|
.firstOrNull()
|
||||||
|
?.trim()
|
||||||
|
?.plus(".") ?: text
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.ELABORATE -> {
|
||||||
|
"$text Furthermore, this point deserves deeper consideration and nuance."
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.FRIENDLY -> {
|
||||||
|
"Hey! $text Hope that makes sense! :)"
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.PROFESSIONAL -> {
|
||||||
|
"I would like to bring to your attention the following: $text"
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.MORE_DIRECT -> {
|
||||||
|
text.replace("I think ", "").replace("maybe ", "").replace("perhaps ", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.PUNCHY -> {
|
||||||
|
text.uppercase().replace(".", "!")
|
||||||
|
}
|
||||||
|
|
||||||
|
WritingTone.EMOJIFY -> {
|
||||||
|
"$text \uD83D\uDE80\uD83D\uDD25\u2728"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return WritingResult(
|
||||||
|
originalText = text,
|
||||||
|
transformedText = transformed,
|
||||||
|
tone = tone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun correctMock(text: String): String =
|
||||||
|
text
|
||||||
|
.replace("teh ", "the ")
|
||||||
|
.replace("dont ", "don't ")
|
||||||
|
.replace("cant ", "can't ")
|
||||||
|
.replace("wont ", "won't ")
|
||||||
|
.replace("i ", "I ")
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
+55
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.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,
|
||||||
|
readyResults: Map<WritingTone, WritingResult>,
|
||||||
|
selectedResult: WritingResult?,
|
||||||
|
onToneSelected: (WritingTone) -> Unit,
|
||||||
|
onApply: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
selectedResult?.let {
|
||||||
|
AiResultCard(
|
||||||
|
result = it,
|
||||||
|
onApply = onApply,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToneChipRow(
|
||||||
|
readyTones = readyResults.keys,
|
||||||
|
selectedTone = selectedResult?.tone,
|
||||||
|
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(
|
||||||
|
readyTones: Set<WritingTone>,
|
||||||
|
selectedTone: WritingTone?,
|
||||||
|
onToneSelected: (WritingTone) -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
WritingTone.entries.forEach { tone ->
|
||||||
|
if (tone in readyTones) {
|
||||||
|
FilterChip(
|
||||||
|
selected = tone == selectedTone,
|
||||||
|
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)
|
||||||
|
}
|
||||||
+14
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
|||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||||
|
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.ContentSensitivityExplainer
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||||
@@ -144,6 +145,10 @@ fun ShortNotePostScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context.getActivity()
|
val activity = context.getActivity()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
postViewModel.initWritingAssistant(context)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(postViewModel, accountViewModel) {
|
LaunchedEffect(postViewModel, accountViewModel) {
|
||||||
val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) }
|
val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) }
|
||||||
val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) }
|
val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) }
|
||||||
@@ -556,6 +561,15 @@ private fun NewPostScreenBody(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AiWritingHelpPanel(
|
||||||
|
isVisible = postViewModel.showAiPanel,
|
||||||
|
readyResults = postViewModel.aiResults,
|
||||||
|
selectedResult = postViewModel.aiSelectedResult,
|
||||||
|
onToneSelected = postViewModel::selectAiResult,
|
||||||
|
onApply = postViewModel::applyAiResult,
|
||||||
|
onDismiss = postViewModel::dismissAiResult,
|
||||||
|
)
|
||||||
|
|
||||||
BottomRowActions(postViewModel)
|
BottomRowActions(postViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
@@ -41,9 +41,16 @@ import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
|
|||||||
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
|
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.BooleanType
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.service.ai.MockWritingAssistant
|
||||||
|
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.location.LocationState
|
||||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||||
@@ -140,7 +147,13 @@ import com.vitorpamplona.quartz.utils.Log
|
|||||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -291,6 +304,96 @@ open class ShortNotePostViewModel :
|
|||||||
// Anonymous Reply
|
// Anonymous Reply
|
||||||
var wantsAnonymousPost by mutableStateOf(false)
|
var wantsAnonymousPost by mutableStateOf(false)
|
||||||
|
|
||||||
|
// AI Writing Help for testing
|
||||||
|
private val useMockAi = false
|
||||||
|
|
||||||
|
var aiResults by mutableStateOf<Map<WritingTone, WritingResult>>(emptyMap())
|
||||||
|
var aiSelectedResult by mutableStateOf<WritingResult?>(null)
|
||||||
|
var aiStatus by mutableStateOf<WritingAssistantStatus>(WritingAssistantStatus.Unavailable)
|
||||||
|
private var writingAssistant: WritingAssistant? = null
|
||||||
|
private var aiComputeJob: Job? = null
|
||||||
|
private var lastComputedText: String = ""
|
||||||
|
|
||||||
|
val showAiPanel: Boolean
|
||||||
|
get() {
|
||||||
|
val prefEnabled =
|
||||||
|
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
|
||||||
|
BooleanType.ALWAYS
|
||||||
|
return prefEnabled && aiStatus is WritingAssistantStatus.Available && aiResults.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initWritingAssistant(context: android.content.Context) {
|
||||||
|
if (writingAssistant == null) {
|
||||||
|
writingAssistant =
|
||||||
|
if (useMockAi) {
|
||||||
|
MockWritingAssistant()
|
||||||
|
} else {
|
||||||
|
WritingAssistantFactory.create(context)
|
||||||
|
}
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
aiStatus = writingAssistant?.checkAvailability() ?: WritingAssistantStatus.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun precomputeAiResults() {
|
||||||
|
val assistant = writingAssistant ?: return
|
||||||
|
if (aiStatus !is WritingAssistantStatus.Available) return
|
||||||
|
val prefEnabled =
|
||||||
|
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
|
||||||
|
BooleanType.ALWAYS
|
||||||
|
if (!prefEnabled) return
|
||||||
|
|
||||||
|
val text = message.text.toString().trim()
|
||||||
|
if (text.isBlank() || text == lastComputedText) return
|
||||||
|
|
||||||
|
aiComputeJob?.cancel()
|
||||||
|
aiResults = emptyMap()
|
||||||
|
aiSelectedResult = null
|
||||||
|
|
||||||
|
aiComputeJob =
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
delay(1000)
|
||||||
|
lastComputedText = text
|
||||||
|
|
||||||
|
val results =
|
||||||
|
coroutineScope {
|
||||||
|
WritingTone.entries
|
||||||
|
.map { tone ->
|
||||||
|
async {
|
||||||
|
try {
|
||||||
|
assistant.transform(text, tone)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
.filterNotNull()
|
||||||
|
.associateBy { it.tone }
|
||||||
|
}
|
||||||
|
aiResults = results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAiResult(tone: WritingTone) {
|
||||||
|
aiSelectedResult = aiResults[tone]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyAiResult() {
|
||||||
|
aiSelectedResult?.let {
|
||||||
|
message.setTextAndPlaceCursorAtEnd(it.transformedText)
|
||||||
|
aiSelectedResult = null
|
||||||
|
aiResults = emptyMap()
|
||||||
|
lastComputedText = ""
|
||||||
|
draftTag.newVersion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAiResult() {
|
||||||
|
aiSelectedResult = null
|
||||||
|
}
|
||||||
|
|
||||||
fun lnAddress(): String? = account.userProfile().lnAddress()
|
fun lnAddress(): String? = account.userProfile().lnAddress()
|
||||||
|
|
||||||
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
|
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
|
||||||
@@ -1131,6 +1234,7 @@ open class ShortNotePostViewModel :
|
|||||||
emojiSuggestions?.processCurrentWord(lastWord)
|
emojiSuggestions?.processCurrentWord(lastWord)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
precomputeAiResults()
|
||||||
draftTag.newVersion()
|
draftTag.newVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,6 +1476,8 @@ open class ShortNotePostViewModel :
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
writingAssistant?.close()
|
||||||
|
writingAssistant = null
|
||||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
@@ -121,6 +121,7 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
|
|||||||
ImmersiveScrollingChoice(sharedPrefs)
|
ImmersiveScrollingChoice(sharedPrefs)
|
||||||
FeatureSetChoice(sharedPrefs)
|
FeatureSetChoice(sharedPrefs)
|
||||||
GalleryChoice(sharedPrefs)
|
GalleryChoice(sharedPrefs)
|
||||||
|
AiWritingHelpChoice(sharedPrefs)
|
||||||
PushNotificationSettingsRow(sharedPrefs)
|
PushNotificationSettingsRow(sharedPrefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,6 +367,26 @@ fun GalleryChoice(sharedPrefs: UiSettingsFlow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AiWritingHelpChoice(sharedPrefs: UiSettingsFlow) {
|
||||||
|
val aiIndex by sharedPrefs.automaticallyProposeAiImprovements.collectAsState()
|
||||||
|
|
||||||
|
val booleanItems =
|
||||||
|
persistentListOf(
|
||||||
|
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
|
||||||
|
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsRow(
|
||||||
|
R.string.ai_writing_setting_title,
|
||||||
|
R.string.ai_writing_setting_description,
|
||||||
|
booleanItems,
|
||||||
|
aiIndex.screenCode,
|
||||||
|
) {
|
||||||
|
sharedPrefs.automaticallyProposeAiImprovements.tryEmit(parseBooleanType(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRow(
|
fun SettingsRow(
|
||||||
name: Int,
|
name: Int,
|
||||||
|
|||||||
@@ -2199,4 +2199,19 @@
|
|||||||
<string name="relay_members_removed">%1$d members removed from relay</string>
|
<string name="relay_members_removed">%1$d members removed from relay</string>
|
||||||
<string name="relay_join_request">Relay join request</string>
|
<string name="relay_join_request">Relay join request</string>
|
||||||
<string name="relay_leave_request">Relay leave request</string>
|
<string name="relay_leave_request">Relay leave request</string>
|
||||||
|
|
||||||
|
<string name="ai_writing_help">AI Writing Help</string>
|
||||||
|
<string name="ai_writing_setting_title">Propose text improvements</string>
|
||||||
|
<string name="ai_writing_setting_description">Uses an on-device AI model to propose text corrections and tone changes.</string>
|
||||||
|
<string name="ai_writing_use_this">Use This</string>
|
||||||
|
<string name="ai_writing_dismiss">Dismiss</string>
|
||||||
|
<string name="ai_tone_correct">Correct</string>
|
||||||
|
<string name="ai_tone_rephrase">Rephrase</string>
|
||||||
|
<string name="ai_tone_shorter">Shorter</string>
|
||||||
|
<string name="ai_tone_elaborate">Elaborate</string>
|
||||||
|
<string name="ai_tone_friendly">Friendly</string>
|
||||||
|
<string name="ai_tone_professional">Professional</string>
|
||||||
|
<string name="ai_tone_more_direct">More Direct</string>
|
||||||
|
<string name="ai_tone_punchy">Punchy</string>
|
||||||
|
<string name="ai_tone_emojify">+ Emoji</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* 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.android.gms.tasks.Tasks
|
||||||
|
import com.google.mlkit.genai.common.FeatureStatus
|
||||||
|
import com.google.mlkit.genai.proofreading.Proofreader
|
||||||
|
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 com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class MLKitWritingAssistant(
|
||||||
|
private val context: Context,
|
||||||
|
) : WritingAssistant {
|
||||||
|
private var rewriters = mutableMapOf<Long, Rewriter>()
|
||||||
|
private var proofreaders = mutableMapOf<Int, Proofreader>()
|
||||||
|
|
||||||
|
private fun rewriterCacheKey(
|
||||||
|
@RewriterOptions.OutputType outputType: Int,
|
||||||
|
@RewriterOptions.Language language: Int,
|
||||||
|
): Long = (outputType.toLong() shl 32) or language.toLong()
|
||||||
|
|
||||||
|
private fun getRewriter(
|
||||||
|
@RewriterOptions.OutputType outputType: Int,
|
||||||
|
@RewriterOptions.Language language: Int,
|
||||||
|
): Rewriter =
|
||||||
|
rewriters.getOrPut(rewriterCacheKey(outputType, language)) {
|
||||||
|
Rewriting.getClient(
|
||||||
|
RewriterOptions
|
||||||
|
.builder(context)
|
||||||
|
.setOutputType(outputType)
|
||||||
|
.setLanguage(language)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getProofreader(
|
||||||
|
@ProofreaderOptions.Language language: Int,
|
||||||
|
): Proofreader =
|
||||||
|
proofreaders.getOrPut(language) {
|
||||||
|
Proofreading.getClient(
|
||||||
|
ProofreaderOptions
|
||||||
|
.builder(context)
|
||||||
|
.setInputType(ProofreaderOptions.InputType.KEYBOARD)
|
||||||
|
.setLanguage(language)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun checkAvailability(): WritingAssistantStatus =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val rewriter = getRewriter(RewriterOptions.OutputType.REPHRASE, RewriterOptions.Language.ENGLISH)
|
||||||
|
val status = rewriter.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 language = detectLanguage(text)
|
||||||
|
val transformedText =
|
||||||
|
when (tone) {
|
||||||
|
WritingTone.CORRECT -> proofread(text, language)
|
||||||
|
WritingTone.REPHRASE -> rewrite(text, RewriterOptions.OutputType.REPHRASE, language)
|
||||||
|
WritingTone.SHORTER -> rewrite(text, RewriterOptions.OutputType.SHORTEN, language)
|
||||||
|
WritingTone.ELABORATE -> rewrite(text, RewriterOptions.OutputType.ELABORATE, language)
|
||||||
|
WritingTone.FRIENDLY -> rewrite(text, RewriterOptions.OutputType.FRIENDLY, language)
|
||||||
|
WritingTone.PROFESSIONAL -> rewrite(text, RewriterOptions.OutputType.PROFESSIONAL, language)
|
||||||
|
WritingTone.EMOJIFY -> rewrite(text, RewriterOptions.OutputType.EMOJIFY, language)
|
||||||
|
WritingTone.MORE_DIRECT -> rewrite(text, RewriterOptions.OutputType.PROFESSIONAL, language)
|
||||||
|
WritingTone.PUNCHY -> rewrite(text, RewriterOptions.OutputType.SHORTEN, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
return WritingResult(
|
||||||
|
originalText = text,
|
||||||
|
transformedText = transformedText,
|
||||||
|
tone = tone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun detectLanguage(text: String): Int =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val langTag = Tasks.await(LanguageTranslatorService.identifyLanguage(text))
|
||||||
|
mapLanguageTag(langTag)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RewriterOptions.Language.ENGLISH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rewrite(
|
||||||
|
text: String,
|
||||||
|
@RewriterOptions.OutputType outputType: Int,
|
||||||
|
@RewriterOptions.Language language: Int,
|
||||||
|
): String =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val rewriter = getRewriter(outputType, language)
|
||||||
|
val request = RewritingRequest.builder(text).build()
|
||||||
|
val result = rewriter.runInference(request).get()
|
||||||
|
result.results.firstOrNull()?.text ?: text
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun proofread(
|
||||||
|
text: String,
|
||||||
|
@ProofreaderOptions.Language language: Int,
|
||||||
|
): String =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val proofreader = getProofreader(language)
|
||||||
|
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()
|
||||||
|
proofreaders.values.forEach { it.close() }
|
||||||
|
proofreaders.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun mapLanguageTag(tag: String?): Int =
|
||||||
|
when (tag?.lowercase()?.take(2)) {
|
||||||
|
"en" -> RewriterOptions.Language.ENGLISH
|
||||||
|
"ja" -> RewriterOptions.Language.JAPANESE
|
||||||
|
"ko" -> RewriterOptions.Language.KOREAN
|
||||||
|
"de" -> RewriterOptions.Language.GERMAN
|
||||||
|
"fr" -> RewriterOptions.Language.FRENCH
|
||||||
|
"it" -> RewriterOptions.Language.ITALIAN
|
||||||
|
"es" -> RewriterOptions.Language.SPANISH
|
||||||
|
else -> RewriterOptions.Language.ENGLISH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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)
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ kotlin = "2.3.20"
|
|||||||
kotlinxCollectionsImmutable = "0.4.0"
|
kotlinxCollectionsImmutable = "0.4.0"
|
||||||
kotlinxCoroutinesCore = "1.10.2"
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
kotlinxSerialization = "1.10.0"
|
kotlinxSerialization = "1.10.0"
|
||||||
|
genaiProofreading = "1.0.0-beta1"
|
||||||
|
genaiPrompt = "1.0.0-beta2"
|
||||||
|
genaiRewriting = "1.0.0-beta1"
|
||||||
languageId = "17.0.6"
|
languageId = "17.0.6"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
lightcompressor-enhanced = "1.8.1"
|
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 = { 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 = { 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" }
|
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-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" }
|
||||||
google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" }
|
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" }
|
jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user