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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user