feat: precompute all AI tone results in parallel
Instead of computing on-tap, all 9 tones are precomputed in parallel after a 1-second debounce when the user stops typing. Tone chips only appear once results are ready. Tapping a chip instantly shows the precomputed result. Text changes cancel and restart the computation. https://claude.ai/code/session_01RbCYGrbbapRMike8WQy41F
This commit is contained in:
+20
-26
@@ -26,24 +26,20 @@ import androidx.compose.animation.shrinkVertically
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -55,8 +51,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
|||||||
@Composable
|
@Composable
|
||||||
fun AiWritingHelpPanel(
|
fun AiWritingHelpPanel(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
isProcessing: Boolean,
|
readyResults: Map<WritingTone, WritingResult>,
|
||||||
result: WritingResult?,
|
selectedResult: WritingResult?,
|
||||||
onToneSelected: (WritingTone) -> Unit,
|
onToneSelected: (WritingTone) -> Unit,
|
||||||
onApply: () -> Unit,
|
onApply: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -72,19 +68,7 @@ fun AiWritingHelpPanel(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 8.dp),
|
||||||
) {
|
) {
|
||||||
if (isProcessing) {
|
selectedResult?.let {
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result?.let {
|
|
||||||
AiResultCard(
|
AiResultCard(
|
||||||
result = it,
|
result = it,
|
||||||
onApply = onApply,
|
onApply = onApply,
|
||||||
@@ -92,7 +76,11 @@ fun AiWritingHelpPanel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToneChipRow(onToneSelected = onToneSelected)
|
ToneChipRow(
|
||||||
|
readyTones = readyResults.keys,
|
||||||
|
selectedTone = selectedResult?.tone,
|
||||||
|
onToneSelected = onToneSelected,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +129,11 @@ private fun AiResultCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToneChipRow(onToneSelected: (WritingTone) -> Unit) {
|
private fun ToneChipRow(
|
||||||
|
readyTones: Set<WritingTone>,
|
||||||
|
selectedTone: WritingTone?,
|
||||||
|
onToneSelected: (WritingTone) -> Unit,
|
||||||
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -152,11 +144,13 @@ private fun ToneChipRow(onToneSelected: (WritingTone) -> Unit) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
WritingTone.entries.forEach { tone ->
|
WritingTone.entries.forEach { tone ->
|
||||||
FilterChip(
|
if (tone in readyTones) {
|
||||||
selected = false,
|
FilterChip(
|
||||||
onClick = { onToneSelected(tone) },
|
selected = tone == selectedTone,
|
||||||
label = { Text(toneDisplayName(tone)) },
|
onClick = { onToneSelected(tone) },
|
||||||
)
|
label = { Text(toneDisplayName(tone)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -563,9 +563,9 @@ private fun NewPostScreenBody(
|
|||||||
|
|
||||||
AiWritingHelpPanel(
|
AiWritingHelpPanel(
|
||||||
isVisible = postViewModel.showAiPanel,
|
isVisible = postViewModel.showAiPanel,
|
||||||
isProcessing = postViewModel.isAiProcessing,
|
readyResults = postViewModel.aiResults,
|
||||||
result = postViewModel.aiResult,
|
selectedResult = postViewModel.aiSelectedResult,
|
||||||
onToneSelected = postViewModel::requestAiTransform,
|
onToneSelected = postViewModel::selectAiResult,
|
||||||
onApply = postViewModel::applyAiResult,
|
onApply = postViewModel::applyAiResult,
|
||||||
onDismiss = postViewModel::dismissAiResult,
|
onDismiss = postViewModel::dismissAiResult,
|
||||||
)
|
)
|
||||||
|
|||||||
+55
-21
@@ -147,7 +147,12 @@ 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.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
|
||||||
@@ -302,17 +307,19 @@ open class ShortNotePostViewModel :
|
|||||||
// TODO: Remove useMockAi before shipping. Set to true to test UI without Gemini Nano.
|
// TODO: Remove useMockAi before shipping. Set to true to test UI without Gemini Nano.
|
||||||
private val useMockAi = true
|
private val useMockAi = true
|
||||||
|
|
||||||
var aiResult by mutableStateOf<WritingResult?>(null)
|
var aiResults by mutableStateOf<Map<WritingTone, WritingResult>>(emptyMap())
|
||||||
|
var aiSelectedResult by mutableStateOf<WritingResult?>(null)
|
||||||
var aiStatus by mutableStateOf<WritingAssistantStatus>(WritingAssistantStatus.Unavailable)
|
var aiStatus by mutableStateOf<WritingAssistantStatus>(WritingAssistantStatus.Unavailable)
|
||||||
var isAiProcessing by mutableStateOf(false)
|
|
||||||
private var writingAssistant: WritingAssistant? = null
|
private var writingAssistant: WritingAssistant? = null
|
||||||
|
private var aiComputeJob: Job? = null
|
||||||
|
private var lastComputedText: String = ""
|
||||||
|
|
||||||
val showAiPanel: Boolean
|
val showAiPanel: Boolean
|
||||||
get() {
|
get() {
|
||||||
val prefEnabled =
|
val prefEnabled =
|
||||||
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
|
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
|
||||||
BooleanType.ALWAYS
|
BooleanType.ALWAYS
|
||||||
return prefEnabled && aiStatus is WritingAssistantStatus.Available
|
return prefEnabled && aiStatus is WritingAssistantStatus.Available && aiResults.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initWritingAssistant(context: android.content.Context) {
|
fun initWritingAssistant(context: android.content.Context) {
|
||||||
@@ -329,35 +336,61 @@ open class ShortNotePostViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestAiTransform(tone: WritingTone) {
|
fun precomputeAiResults() {
|
||||||
val text = message.text.toString()
|
val assistant = writingAssistant ?: return
|
||||||
if (text.isBlank() || isAiProcessing) return
|
if (aiStatus !is WritingAssistantStatus.Available) return
|
||||||
|
val prefEnabled =
|
||||||
|
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
|
||||||
|
BooleanType.ALWAYS
|
||||||
|
if (!prefEnabled) return
|
||||||
|
|
||||||
isAiProcessing = true
|
val text = message.text.toString().trim()
|
||||||
aiResult = null
|
if (text.isBlank() || text == lastComputedText) return
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
aiComputeJob?.cancel()
|
||||||
val result = writingAssistant?.transform(text, tone)
|
aiResults = emptyMap()
|
||||||
aiResult = result
|
aiSelectedResult = null
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
aiComputeJob =
|
||||||
aiResult = null
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
} finally {
|
delay(1000)
|
||||||
isAiProcessing = false
|
lastComputedText = text
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
WritingTone.entries
|
||||||
|
.map { tone ->
|
||||||
|
async {
|
||||||
|
try {
|
||||||
|
assistant.transform(text, tone)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.forEach { deferred ->
|
||||||
|
val result = deferred.await() ?: return@forEach
|
||||||
|
aiResults = aiResults + (result.tone to result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectAiResult(tone: WritingTone) {
|
||||||
|
aiSelectedResult = aiResults[tone]
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyAiResult() {
|
fun applyAiResult() {
|
||||||
aiResult?.let {
|
aiSelectedResult?.let {
|
||||||
message.setTextAndPlaceCursorAtEnd(it.transformedText)
|
message.setTextAndPlaceCursorAtEnd(it.transformedText)
|
||||||
aiResult = null
|
aiSelectedResult = null
|
||||||
|
aiResults = emptyMap()
|
||||||
|
lastComputedText = ""
|
||||||
draftTag.newVersion()
|
draftTag.newVersion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissAiResult() {
|
fun dismissAiResult() {
|
||||||
aiResult = null
|
aiSelectedResult = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun lnAddress(): String? = account.userProfile().lnAddress()
|
fun lnAddress(): String? = account.userProfile().lnAddress()
|
||||||
@@ -1200,6 +1233,7 @@ open class ShortNotePostViewModel :
|
|||||||
emojiSuggestions?.processCurrentWord(lastWord)
|
emojiSuggestions?.processCurrentWord(lastWord)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
precomputeAiResults()
|
||||||
draftTag.newVersion()
|
draftTag.newVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user