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_IS_ADMIN = stringPreferencesKey("profile_is_admin")
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at") private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context") 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] } 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 msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
val sid = wsMsg.sessionId ?: currentSessionId ?: "default" 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)) { if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid) changeSessionId(sid)
} }
@@ -480,6 +468,10 @@ class ChatRepositoryImpl(
lastResponseContent = text lastResponseContent = text
lastResponseTime = System.currentTimeMillis() 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) 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)}") 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.util.RecordState
import top.yeij.cyrene.viewmodel.OverlayState import top.yeij.cyrene.viewmodel.OverlayState
import top.yeij.cyrene.viewmodel.OverlayViewModel import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min import kotlin.math.min
@Composable @Composable
@@ -102,6 +103,7 @@ fun OverlayContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(), viewModel: OverlayViewModel = koinInject(),
settingsViewModel: SettingsViewModel = koinInject(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
@@ -109,6 +111,7 @@ fun OverlayContent(
val recordState by viewModel.voiceRecordState.collectAsState() val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState() val animIndex by viewModel.messageAnimIndex.collectAsState()
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isProcessing = state == OverlayState.PROCESSING val isProcessing = state == OverlayState.PROCESSING
val recordSec = recordDurationMs / 1000f val recordSec = recordDurationMs / 1000f
@@ -187,6 +190,7 @@ fun OverlayContent(
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots.value, typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
animIndex = animIndex, animIndex = animIndex,
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
@@ -204,6 +208,7 @@ fun OverlayContent(
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots.value, typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
animIndex = animIndex, animIndex = animIndex,
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
@@ -227,6 +232,7 @@ private fun PortraitContent(
isRecording: Boolean, isRecording: Boolean,
isLocked: Boolean, isLocked: Boolean,
typingDots: String, typingDots: String,
typingIndicatorStyle: String,
animIndex: Map<String, Int>, animIndex: Map<String, Int>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
@@ -259,7 +265,7 @@ private fun PortraitContent(
) )
} }
} }
if (isProcessing) { if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") { item(key = "typing_indicator") {
TypingIndicator() TypingIndicator()
} }
@@ -279,6 +285,7 @@ private fun PortraitContent(
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots, typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
) )
} }
} }
@@ -295,6 +302,7 @@ private fun LandscapeContent(
isRecording: Boolean, isRecording: Boolean,
isLocked: Boolean, isLocked: Boolean,
typingDots: String, typingDots: String,
typingIndicatorStyle: String,
animIndex: Map<String, Int>, animIndex: Map<String, Int>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
@@ -326,7 +334,7 @@ private fun LandscapeContent(
) )
} }
} }
if (isProcessing) { if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") { item(key = "typing_indicator") {
TypingIndicator() TypingIndicator()
} }
@@ -357,6 +365,7 @@ private fun LandscapeContent(
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots, typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
) )
} }
} }
@@ -398,6 +407,7 @@ private fun InputArea(
isRecording: Boolean = false, isRecording: Boolean = false,
isLocked: Boolean = false, isLocked: Boolean = false,
typingDots: String = "", typingDots: String = "",
typingIndicatorStyle: String = "bubble",
) { ) {
// Gesture tracking state — local to InputArea // Gesture tracking state — local to InputArea
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@@ -418,8 +428,8 @@ private fun InputArea(
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(12.dp),
) { ) {
// "昔涟正在输入..." indicator // "昔涟正在输入..." indicator (text mode only)
if (isProcessing && typingDots.isNotEmpty()) { if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
Text( Text(
text = "昔涟正在输入$typingDots", text = "昔涟正在输入$typingDots",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -56,6 +56,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble import top.yeij.cyrene.ui.components.ChatBubble
import top.yeij.cyrene.ui.components.CyreneStatus 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.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.ChatViewModel import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min import kotlin.math.min
@Composable @Composable
@@ -96,6 +98,7 @@ private fun AnimatedChatBubble(
@Composable @Composable
fun ChatScreen( fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(), viewModel: ChatViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinInject(),
) { ) {
val messages by viewModel.currentMessages.collectAsState() val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
@@ -105,6 +108,7 @@ fun ChatScreen(
val recordState by viewModel.voiceRecordState.collectAsState() val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.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) // reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -202,17 +206,17 @@ fun ChatScreen(
state = listState, state = listState,
reverseLayout = true, reverseLayout = true,
) { ) {
if (isStreaming && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
}
itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message -> itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
AnimatedChatBubble( AnimatedChatBubble(
message = message, message = message,
animIndex = index.coerceAtMost(20), animIndex = index.coerceAtMost(20),
) )
} }
if (isStreaming) {
item(key = "typing_indicator") {
TypingIndicator()
}
}
} }
} }
} }
@@ -224,8 +228,8 @@ fun ChatScreen(
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding(), .navigationBarsPadding(),
) { ) {
// "昔涟正在输入..." indicator // "昔涟正在输入..." indicator (text mode only)
if (isStreaming) { if (isStreaming && typingIndicatorStyle == "text") {
Text( Text(
text = "昔涟正在输入${typingDots.value}", text = "昔涟正在输入${typingDots.value}",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -79,6 +79,7 @@ fun SettingsScreen(
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState() val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
val dashScopeModel by viewModel.dashScopeModel.collectAsState() val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState() val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -209,6 +210,17 @@ fun SettingsScreen(
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) }, 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)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -48,6 +48,9 @@ class SettingsViewModel(
private val _autoScreenContext = MutableStateFlow(false) private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow() val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false) private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@@ -61,6 +64,11 @@ class SettingsViewModel(
_autoScreenContext.value = value _autoScreenContext.value = value
} }
} }
scope.launch {
preferencesDataStore.typingIndicatorStyle.collect { value ->
_typingIndicatorStyle.value = value
}
}
scope.launch { scope.launch {
combine( combine(
preferencesDataStore.baseUrl, preferencesDataStore.baseUrl,
@@ -165,6 +173,11 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) } scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
} }
fun saveTypingIndicatorStyle(style: String) {
_typingIndicatorStyle.value = style
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
}
fun clearLocalMessages() { fun clearLocalMessages() {
scope.launch { scope.launch {
chatRepository.clearLocalMessages() chatRepository.clearLocalMessages()