fix(#205): fix typing effect by checking actual streamed word counts and default it to enabled (#246)
* fix(#205): fix typing effect by checking actual streamed word counts and default it to enabled * fix: correct import path of rememberSaveable to resolve build error * fix: make typingDelayMs reactive using rememberUpdatedState in StreamingBubbleWithTypingEffect * fix(#205): support message.delta event type to enable streaming text * feat(#205): increase typing effect delay slider maximum to 1000ms * fix(#205): continue animating completed streaming messages until all words are revealed * fix(#205): revert slider delay maximum value back to 100ms
This commit is contained in:
3 files changed
+42
-11
No files matched your search
@@ -35,7 +35,7 @@ object AuthManager {
|
|||||||
private const val DEFAULT_HOST = "127.0.0.1"
|
private const val DEFAULT_HOST = "127.0.0.1"
|
||||||
private const val DEFAULT_PORT = 9119
|
private const val DEFAULT_PORT = 9119
|
||||||
private const val DEFAULT_AUTO_RECONNECT = true
|
private const val DEFAULT_AUTO_RECONNECT = true
|
||||||
private const val DEFAULT_TYPING_EFFECT_ENABLED = false
|
private const val DEFAULT_TYPING_EFFECT_ENABLED = true
|
||||||
private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30
|
private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ object EventParser {
|
|||||||
WsEvent.MessageStart(sessionId)
|
WsEvent.MessageStart(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
"message.token" -> {
|
"message.token", "message.delta" -> {
|
||||||
val token = payload?.get("text") as? String ?: ""
|
val token = payload?.get("text") as? String ?: ""
|
||||||
WsEvent.MessageToken(token, sessionId)
|
WsEvent.MessageToken(token, sessionId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -127,6 +128,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
var inputText by rememberSaveable { mutableStateOf("") }
|
var inputText by rememberSaveable { mutableStateOf("") }
|
||||||
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -434,12 +436,28 @@ fun ChatScreen(
|
|||||||
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
|
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
|
||||||
state.searchMatchIndices[state.currentSearchMatchIndex] == index
|
state.searchMatchIndices[state.currentSearchMatchIndex] == index
|
||||||
|
|
||||||
ChatBubble(
|
val isLastMessage = index == state.messages.lastIndex
|
||||||
message = message,
|
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||||
isDarkTheme = isDark,
|
val typingEnabled = AuthManager.isTypingEffectEnabled()
|
||||||
searchQuery = if (state.isSearchActive) state.searchQuery else "",
|
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
isCurrentMatch = isCurrentMatch,
|
|
||||||
)
|
if (typingEnabled && isLastMessage && isAssistant && lastAnimatedMessageId != message.id) {
|
||||||
|
StreamingBubbleWithTypingEffect(
|
||||||
|
streaming = message,
|
||||||
|
typingDelayMs = typingDelayMs,
|
||||||
|
isDark = isDark,
|
||||||
|
onAnimationComplete = {
|
||||||
|
lastAnimatedMessageId = message.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChatBubble(
|
||||||
|
message = message,
|
||||||
|
isDarkTheme = isDark,
|
||||||
|
searchQuery = if (state.isSearchActive) state.searchQuery else "",
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming message — rendered separately for O(1) updates
|
// Streaming message — rendered separately for O(1) updates
|
||||||
@@ -1041,15 +1059,28 @@ private fun StreamingBubbleWithTypingEffect(
|
|||||||
streaming: ChatMessage,
|
streaming: ChatMessage,
|
||||||
typingDelayMs: Int,
|
typingDelayMs: Int,
|
||||||
isDark: Boolean,
|
isDark: Boolean,
|
||||||
|
onAnimationComplete: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var visibleWordCount by remember { mutableIntStateOf(0) }
|
var visibleWordCount by remember { mutableIntStateOf(0) }
|
||||||
|
val currentContent = rememberUpdatedState(streaming.content)
|
||||||
|
val currentIsStreaming = rememberUpdatedState(streaming.isStreaming)
|
||||||
|
val currentDelayMs = rememberUpdatedState(typingDelayMs)
|
||||||
|
|
||||||
// Timer that ticks at the configured delay, incrementing the visible word
|
// Timer that ticks at the configured delay, incrementing the visible word
|
||||||
// count each tick. Stops ticking when streaming ends, then shows all words.
|
// count each tick. Stops ticking when streaming ends, then shows all words.
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (streaming.isStreaming) {
|
while (true) {
|
||||||
delay(typingDelayMs.toLong())
|
val words = currentContent.value.split(" ")
|
||||||
visibleWordCount++
|
if (visibleWordCount < words.size) {
|
||||||
|
delay(currentDelayMs.value.toLong())
|
||||||
|
visibleWordCount++
|
||||||
|
} else {
|
||||||
|
if (!currentIsStreaming.value) {
|
||||||
|
onAnimationComplete()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
visibleWordCount = Int.MAX_VALUE
|
visibleWordCount = Int.MAX_VALUE
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user