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