diff --git a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt index e44c443..55fab82 100644 --- a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt +++ b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt @@ -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 = 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 = context.dataStore.data.map { it[KEY_TOKEN] } diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt index eabaf2b..54b690c 100644 --- a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt +++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt @@ -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)}") } diff --git a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt index 443c961..12953d3 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt @@ -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, 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, 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, diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt index e36242d..5585fb1 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt @@ -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, diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt index faf4096..a36c617 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt @@ -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)) diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt index b76dacd..4d16efd 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt @@ -48,6 +48,9 @@ class SettingsViewModel( private val _autoScreenContext = MutableStateFlow(false) val autoScreenContext: StateFlow = _autoScreenContext.asStateFlow() + private val _typingIndicatorStyle = MutableStateFlow("bubble") + val typingIndicatorStyle: StateFlow = _typingIndicatorStyle.asStateFlow() + private val _isLoggedIn = MutableStateFlow(false) val isLoggedIn: StateFlow = _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()