fix: stream_end dedup, typing indicator position, and indicator style toggle

- Fix stream_end not suppressed: populate recentParsedContents in response handler
  instead of clearing it, so the dedup check can correctly suppress wrapping text
- Fix typing indicator appearing at top (oldest) in reverseLayout: place item
  before itemsIndexed so it gets index 0 (visual bottom)
- Add typing indicator style toggle in settings: bubble (default) vs text mode,
  persisted via PreferencesDataStore, applied in ChatScreen and OverlayContent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:02:47 +08:00
parent 86d196b857
commit 64c7018729
6 changed files with 61 additions and 23 deletions
@@ -33,6 +33,13 @@ class PreferencesDataStore(private val context: Context) {
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
}
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
suspend fun saveTypingIndicatorStyle(style: String) {
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
}
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
@@ -447,18 +447,6 @@ class ChatRepositoryImpl(
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
// Suppress response if it wraps recently emitted review/multi_message items
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
val allContained = recentParsedContents.all { text.contains(it) }
if (allContained) {
RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
recentParsedContents.clear()
return
}
}
recentParsedContents.clear()
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid)
}
@@ -480,6 +468,10 @@ class ChatRepositoryImpl(
lastResponseContent = text
lastResponseTime = System.currentTimeMillis()
// Track parsed content so stream_end can suppress the wrapping full text
recentParsedContents.add(text)
lastParsedTime = System.currentTimeMillis()
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
}
@@ -68,6 +68,7 @@ import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.OverlayState
import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min
@Composable
@@ -102,6 +103,7 @@ fun OverlayContent(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
val state by viewModel.state.collectAsState()
val messages by viewModel.messages.collectAsState()
@@ -109,6 +111,7 @@ fun OverlayContent(
val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState()
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val listState = rememberLazyListState()
val isProcessing = state == OverlayState.PROCESSING
val recordSec = recordDurationMs / 1000f
@@ -187,6 +190,7 @@ fun OverlayContent(
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
@@ -204,6 +208,7 @@ fun OverlayContent(
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
@@ -227,6 +232,7 @@ private fun PortraitContent(
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
typingIndicatorStyle: String,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
@@ -259,7 +265,7 @@ private fun PortraitContent(
)
}
}
if (isProcessing) {
if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
@@ -279,6 +285,7 @@ private fun PortraitContent(
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
)
}
}
@@ -295,6 +302,7 @@ private fun LandscapeContent(
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
typingIndicatorStyle: String,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
@@ -326,7 +334,7 @@ private fun LandscapeContent(
)
}
}
if (isProcessing) {
if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
@@ -357,6 +365,7 @@ private fun LandscapeContent(
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
)
}
}
@@ -398,6 +407,7 @@ private fun InputArea(
isRecording: Boolean = false,
isLocked: Boolean = false,
typingDots: String = "",
typingIndicatorStyle: String = "bubble",
) {
// Gesture tracking state — local to InputArea
var isDragging by remember { mutableStateOf(false) }
@@ -418,8 +428,8 @@ private fun InputArea(
.fillMaxWidth()
.padding(12.dp),
) {
// "昔涟正在输入..." indicator
if (isProcessing && typingDots.isNotEmpty()) {
// "昔涟正在输入..." indicator (text mode only)
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
Text(
text = "昔涟正在输入$typingDots",
style = MaterialTheme.typography.labelSmall,
@@ -56,6 +56,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble
import top.yeij.cyrene.ui.components.CyreneStatus
@@ -63,6 +64,7 @@ import top.yeij.cyrene.ui.components.StatusIndicator
import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min
@Composable
@@ -96,6 +98,7 @@ private fun AnimatedChatBubble(
@Composable
fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
@@ -105,6 +108,7 @@ fun ChatScreen(
val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState()
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
val listState = rememberLazyListState()
@@ -202,17 +206,17 @@ fun ChatScreen(
state = listState,
reverseLayout = true,
) {
if (isStreaming && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
}
itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
AnimatedChatBubble(
message = message,
animIndex = index.coerceAtMost(20),
)
}
if (isStreaming) {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
}
@@ -224,8 +228,8 @@ fun ChatScreen(
.background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding(),
) {
// "昔涟正在输入..." indicator
if (isStreaming) {
// "昔涟正在输入..." indicator (text mode only)
if (isStreaming && typingIndicatorStyle == "text") {
Text(
text = "昔涟正在输入${typingDots.value}",
style = MaterialTheme.typography.labelSmall,
@@ -79,6 +79,7 @@ fun SettingsScreen(
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -209,6 +210,17 @@ fun SettingsScreen(
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
)
val indicatorStyleLabel = if (typingIndicatorStyle == "text") "文字" else "气泡"
ListItem(
headlineContent = { Text("正在输入指示器") },
supportingContent = { Text(indicatorStyleLabel) },
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
modifier = Modifier.clickable {
val next = if (typingIndicatorStyle == "bubble") "text" else "bubble"
viewModel.saveTypingIndicatorStyle(next)
},
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
@@ -48,6 +48,9 @@ class SettingsViewModel(
private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@@ -61,6 +64,11 @@ class SettingsViewModel(
_autoScreenContext.value = value
}
}
scope.launch {
preferencesDataStore.typingIndicatorStyle.collect { value ->
_typingIndicatorStyle.value = value
}
}
scope.launch {
combine(
preferencesDataStore.baseUrl,
@@ -165,6 +173,11 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
}
fun saveTypingIndicatorStyle(style: String) {
_typingIndicatorStyle.value = style
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
}
fun clearLocalMessages() {
scope.launch {
chatRepository.clearLocalMessages()