From 7fcf5626485fca15db8facde883e50647fb3a2bf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 27 May 2026 12:36:06 +0800 Subject: [PATCH] feat: markdown/code message renderers, collapsible non-chat content, dark mode fixes, and data persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MarkdownBubble (headings, bold/italic, code blocks, lists, quotes, links) - Add CodeBubble with dark background + language header - Add CollapsibleBubble wrapper for long non-chat content with fold/expand button - Update WSReviewMessage DTO: add type and metadata fields for review messages - Fix message dedup: apply removeWrappingDuplicates before DB insert instead of on return value - Fix dark mode: explicit text colors on StatusIndicator, icon tints, dynamicColor=false - Add enter-to-send toggle and typing indicator style (bubble/text) in settings - Overlay: transparent window background, pill-shaped semi-transparent input field - Remove PullToRefreshBox (conflicted with reverseLayout scroll), use refresh button - Add auto-refresh when connection transitions offline→online - Fix session ID fallback for DB message loading after APK update Co-Authored-By: Claude Opus 4.7 --- .../top/yeij/cyrene/data/local/AppDatabase.kt | 1 - .../cyrene/data/local/PreferencesDataStore.kt | 7 + .../top/yeij/cyrene/data/remote/dto/WSDtos.kt | 15 +- .../data/repository/ChatRepositoryImpl.kt | 44 +- .../main/java/top/yeij/cyrene/di/AppModule.kt | 2 +- .../service/CyreneVoiceInteractionSession.kt | 18 +- .../yeij/cyrene/ui/components/ChatBubble.kt | 454 +++++++++++++++++- .../cyrene/ui/components/StatusIndicator.kt | 8 +- .../yeij/cyrene/ui/overlay/OverlayContent.kt | 44 +- .../yeij/cyrene/ui/screens/chat/ChatScreen.kt | 52 +- .../ui/screens/settings/SettingsScreen.kt | 15 + .../java/top/yeij/cyrene/ui/theme/Theme.kt | 2 +- .../yeij/cyrene/viewmodel/ChatViewModel.kt | 52 +- .../cyrene/viewmodel/SettingsViewModel.kt | 13 + 14 files changed, 651 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt b/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt index 786a073..16d87ce 100644 --- a/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt +++ b/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt @@ -30,7 +30,6 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "cyrene.db", ) - .fallbackToDestructiveMigration() .build() .also { INSTANCE = it } } 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 55fab82..86560b2 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 @@ -34,6 +34,7 @@ class PreferencesDataStore(private val context: Context) { 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") + private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send") } val typingIndicatorStyle: Flow = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" } @@ -42,6 +43,12 @@ class PreferencesDataStore(private val context: Context) { context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style } } + val enterToSend: Flow = context.dataStore.data.map { it[KEY_ENTER_TO_SEND] ?: false } + + suspend fun saveEnterToSend(enabled: Boolean) { + context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled } + } + val token: Flow = context.dataStore.data.map { it[KEY_TOKEN] } val refreshToken: Flow = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] } val baseUrl: Flow = context.dataStore.data.map { it[KEY_BASE_URL] } diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt index 9dd58ba..579f386 100644 --- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt @@ -55,11 +55,18 @@ data class WSServerMessage( ) data class WSReviewMessage( - @SerializedName("role") val role: String?, - @SerializedName("text") val text: String?, - @SerializedName("content") val content: String?, - @SerializedName("msg_type") val msgType: String?, + @SerializedName("type") val type: String? = null, + @SerializedName("role") val role: String? = null, + @SerializedName("text") val text: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("msg_type") val msgType: String? = null, @SerializedName("delay_ms") val delayMs: Long? = 0, + @SerializedName("metadata") val metadata: WSReviewMetadata? = null, +) + +data class WSReviewMetadata( + @SerializedName("language") val language: String? = null, + @SerializedName("url") val url: String? = null, ) data class WSHistoryMessage( 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 54b690c..9696c10 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 @@ -308,20 +308,7 @@ class ChatRepositoryImpl( ?.toLongOrNull() ?: 0L val filteredDtos = messageDtos.filter { it.createdAt > lastCleared } ensureConversation(sessionId) - filteredDtos.forEach { dto -> - messageDao.upsert( - MessageEntity( - id = "db_${dto.id}", - conversationId = sessionId, - role = dto.role, - content = dto.content, - msgType = dto.msgType ?: "chat", - timestamp = dto.createdAt, - ) - ) - } - RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId") - filteredDtos.map { dto -> + val messages = filteredDtos.map { dto -> Message( id = "db_${dto.id}", conversationId = sessionId, @@ -330,7 +317,22 @@ class ChatRepositoryImpl( msgType = dto.msgType ?: "chat", timestamp = dto.createdAt, ) - }.removeWrappingDuplicates() + } + val deduped = messages.removeWrappingDuplicates() + deduped.forEach { msg -> + messageDao.upsert( + MessageEntity( + id = msg.id, + conversationId = msg.conversationId, + role = msg.role, + content = msg.content, + msgType = msg.msgType, + timestamp = msg.timestamp, + ) + ) + } + RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId") + deduped } else { RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS") requestHistoryViaWs(sessionId) @@ -479,12 +481,16 @@ class ChatRepositoryImpl( "review" -> { recentParsedContents.clear() wsMsg.reviewMessages?.forEach { review -> - val text = review.content ?: review.text ?: return@forEach + val rawText = review.content ?: review.text ?: return@forEach val role = review.role ?: "assistant" - val rvMsgType = review.msgType ?: "action" + val rvMsgType = review.type ?: review.msgType ?: "action" val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}" - recentParsedContents.add(text) - emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false) + // Encode code language metadata into content for the renderer + val content = if (rvMsgType == "code" && review.metadata?.language != null) { + "[lang:${review.metadata.language}]\n$rawText" + } else rawText + recentParsedContents.add(rawText) + emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, msgType = rvMsgType, isStreaming = false) } if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis() // Clean up wrapping response that arrived before this review diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt index 591e3eb..088ecf6 100644 --- a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt +++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt @@ -72,7 +72,7 @@ val appModule = module { factory { GetConversationsUseCase(get()) } // ViewModels - viewModel { ChatViewModel(get(), get()) } + viewModel { ChatViewModel(get(), get(), get()) } viewModel { IoTViewModel(get()) } viewModel { OverlayViewModel(get(), get(), get()) } viewModel { ProfileViewModel(get(), get(), get()) } diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt index 6a77d29..2e77dbb 100644 --- a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt @@ -16,6 +16,7 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import android.content.res.Configuration import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import org.koin.core.context.GlobalContext @@ -66,6 +67,21 @@ class CyreneVoiceInteractionSession(context: Context) : lifecycleRegistry.currentState = Lifecycle.State.CREATED val vm = overlayViewModel val session = this@CyreneVoiceInteractionSession + + val darkTheme = runBlocking { + val prefs = GlobalContext.get().get() + val mode = prefs.themeMode.firstOrNull() + when (mode) { + "light" -> false + "dark" -> true + else -> { + val nightMode = session.context.resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK + nightMode == Configuration.UI_MODE_NIGHT_YES + } + } + } + return ComposeView(context).apply { // Configure window as soon as view is attached — before system overrides flags addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { @@ -77,7 +93,7 @@ class CyreneVoiceInteractionSession(context: Context) : setViewTreeLifecycleOwner(session) setViewTreeSavedStateRegistryOwner(session) setContent { - CyreneTheme { + CyreneTheme(darkTheme = darkTheme) { if (vm != null) { OverlayContent( onDismiss = { finish() }, diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt index 06eda26..b56defb 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt @@ -9,15 +9,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn + import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,12 +34,434 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +// --- Markdown block model --- + +private sealed class MdBlock { + data class Heading(val level: Int, val text: String) : MdBlock() + data class Paragraph(val text: String) : MdBlock() + data class CodeBlock(val language: String?, val code: String) : MdBlock() + data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock() + data class Quote(val text: String) : MdBlock() + class ThematicBreak : MdBlock() +} + +private fun parseMarkdownBlocks(text: String): List { + val lines = text.lines() + val blocks = mutableListOf() + var i = 0 + + while (i < lines.size) { + val line = lines[i] + val trimmed = line.trimStart() + + when { + // Fenced code block + trimmed.startsWith("```") -> { + val lang = trimmed.removePrefix("```").trim().ifBlank { null } + val codeLines = mutableListOf() + i++ + while (i < lines.size && !lines[i].trimStart().startsWith("```")) { + codeLines.add(lines[i]) + i++ + } + if (i < lines.size) i++ // skip closing ``` + blocks.add(MdBlock.CodeBlock(lang, codeLines.joinToString("\n"))) + } + // Heading + trimmed.startsWith("#") -> { + val match = Regex("^(#{1,6})\\s+(.+)$").find(trimmed) + if (match != null) { + val level = match.groupValues[1].length + blocks.add(MdBlock.Heading(level, match.groupValues[2])) + } + i++ + } + // Thematic break + trimmed.matches(Regex("^[-*_]{3,}$")) -> { + blocks.add(MdBlock.ThematicBreak()) + i++ + } + // Blockquote + line.startsWith(">") || trimmed.startsWith(">") -> { + val quoteLines = mutableListOf() + while (i < lines.size) { + val cur = lines[i] + if (cur.trimStart().startsWith(">")) { + quoteLines.add(cur.trimStart().removePrefix(">").trimStart()) + i++ + } else if (cur.isBlank()) { + i++ + break + } else { + break + } + } + if (quoteLines.isNotEmpty()) { + blocks.add(MdBlock.Quote(quoteLines.joinToString("\n"))) + } + } + // Unordered list + trimmed.matches(Regex("^[-*+]\\s+.*$")) -> { + while (i < lines.size && lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$"))) { + val itemText = lines[i].trimStart().replaceFirst(Regex("^[-*+]\\s+"), "") + blocks.add(MdBlock.ListItem(false, blocks.size + 1, itemText)) + i++ + } + } + // Ordered list + trimmed.matches(Regex("^\\d+\\.\\s+.*$")) -> { + var idx = 1 + while (i < lines.size && lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))) { + val itemText = lines[i].trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "") + blocks.add(MdBlock.ListItem(true, idx, itemText)) + idx++ + i++ + } + } + // Blank line — skip + line.isBlank() -> { i++ } + // Paragraph + else -> { + val paraLines = mutableListOf() + while (i < lines.size && + lines[i].isNotBlank() && + !lines[i].trimStart().startsWith("```") && + !lines[i].trimStart().startsWith("#") && + !lines[i].trimStart().matches(Regex("^[-*_]{3,}$")) && + !lines[i].trimStart().startsWith(">") && + !lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$")) && + !lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$")) + ) { + paraLines.add(lines[i]) + i++ + } + if (paraLines.isNotEmpty()) { + blocks.add(MdBlock.Paragraph(paraLines.joinToString(" "))) + } + } + } + } + return blocks +} + +@Composable +private fun renderInlineMarkdown(text: String): AnnotatedString { + return buildAnnotatedString { + var remaining = text + while (remaining.isNotEmpty()) { + // Bold + Italic *** + val boldItalic = Regex("""\*\*\*(.+?)\*\*\*""").find(remaining) + // Bold ** + val bold = Regex("""\*\*(.+?)\*\*""").find(remaining) + // Italic * + val italic = Regex("""(? 0) { + append(remaining.substring(0, match.range.first)) + } + when (kind) { + "bi" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { + append(match.groupValues[1]) + } + "b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(match.groupValues[1]) + } + "i" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(match.groupValues[1]) + } + "c" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray.copy(alpha = 0.2f))) { + append(match.groupValues[1]) + } + "l" -> { + val label = match.groupValues[1] + val url = match.groupValues[2] + pushStringAnnotation("url", url) + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) { + append(label) + } + pop() + } + } + remaining = remaining.substring(match.range.last + 1) + } + } + } +} + +// --- Markdown bubble --- + +@Composable +private fun MarkdownBubble(content: String, modifier: Modifier = Modifier) { + val blocks = remember(content) { parseMarkdownBlocks(content) } + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), + shadowElevation = 1.dp, + ) { + Column(modifier = Modifier.padding(12.dp)) { + blocks.forEach { block -> + when (block) { + is MdBlock.Heading -> { + val fontSize = when (block.level) { + 1 -> 22.sp + 2 -> 19.sp + 3 -> 17.sp + 4 -> 15.sp + 5 -> 14.sp + else -> 13.sp + } + Text( + text = renderInlineMarkdown(block.text), + fontSize = fontSize, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = if (block.level <= 2) 6.dp else 2.dp), + ) + } + is MdBlock.Paragraph -> { + if (block.text.isNotBlank()) { + Text( + text = renderInlineMarkdown(block.text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + is MdBlock.CodeBlock -> { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + shape = MaterialTheme.shapes.small, + color = Color(0xFF1E1E1E), + ) { + Column { + if (block.language != null) { + Text( + text = block.language, + modifier = Modifier + .background(Color(0xFF333333)) + .padding(horizontal = 10.dp, vertical = 4.dp), + color = Color(0xFFCCCCCC), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + ) + } + Text( + text = block.code, + modifier = Modifier.padding(10.dp), + color = Color(0xFFD4D4D4), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + ) + } + } + } + is MdBlock.ListItem -> { + val prefix = if (block.ordered) "${block.index}. " else "• " + Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) { + Text( + text = prefix, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = renderInlineMarkdown(block.text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + is MdBlock.Quote -> { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.small, + ) { + Row { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) + .weight(0.012f) + ) {} + Text( + text = renderInlineMarkdown(block.text), + modifier = Modifier + .weight(1f) + .padding(8.dp), + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + is MdBlock.ThematicBreak -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .background( + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + shape = MaterialTheme.shapes.extraSmall, + ) + .weight(1f) + ) {} + } + } + } + } + } +} + +// --- Code bubble (standalone code block message) --- + +private val codeDarkBg = Color(0xFF1E1E1E) +private val codeSurface = Color(0xFF333333) + +@Composable +private fun CodeBubble(content: String, modifier: Modifier = Modifier) { + val (language, code) = remember(content) { + if (content.startsWith("[lang:")) { + val endBracket = content.indexOf("]\n") + if (endBracket > 0) { + content.substring(6, endBracket) to content.substring(endBracket + 2) + } else "Code" to content + } else "Code" to content + } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = codeDarkBg, + shadowElevation = 2.dp, + ) { + Column { + // Language header + Box( + modifier = Modifier + .background(codeSurface, MaterialTheme.shapes.medium) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = language, + color = Color(0xFFAAAAAA), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + ) + } + } + // Code content + Text( + text = code, + modifier = Modifier.padding(12.dp), + color = Color(0xFFD4D4D4), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + ) + } + } + } +} + +// --- Collapsible wrapper for non-chat content --- + +private const val COLLAPSE_THRESHOLD = 300 + +@Composable +private fun CollapsibleBubble( + content: String, + modifier: Modifier = Modifier, + bubble: @Composable (String, Modifier) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val lines = content.lines() + val isLong = content.length > COLLAPSE_THRESHOLD || lines.size > 8 + + if (!isLong) { + bubble(content, modifier) + return + } + + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth(), + ) { + Box(modifier = Modifier.weight(1f)) { + if (expanded) { + bubble(content, Modifier) + } else { + val truncated = lines.take(5).joinToString("\n").let { + if (it.length >= content.length) it else it + "\n…" + } + bubble(truncated, Modifier) + } + } + IconButton( + onClick = { expanded = !expanded }, + modifier = Modifier + .padding(top = 4.dp) + .size(32.dp), + ) { + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "折叠" else "展开", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +// --- Main ChatBubble dispatcher --- + @Composable fun ChatBubble( content: String, @@ -48,13 +476,25 @@ fun ChatBubble( when (msgType) { "chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier) "action" -> ActionMessage(content, modifier) - "thinking" -> ThinkingBubble(content, modifier) - "tool_progress" -> ToolProgressBubble(content, modifier) + "markdown" -> CollapsibleBubble(content, modifier) { text, mod -> + MarkdownBubble(text, mod) + } + "code" -> CollapsibleBubble(content, modifier) { text, mod -> + CodeBubble(text, mod) + } + "thinking" -> CollapsibleBubble(content, modifier) { text, mod -> + ThinkingBubble(text, mod) + } + "tool_progress" -> CollapsibleBubble(content, modifier) { text, mod -> + ToolProgressBubble(text, mod) + } "system_info" -> SystemInfoBubble(content, modifier) else -> ChatMessageBubble(content, isUser, formattedTime, modifier) } } +// --- Chat message bubble --- + @OptIn(ExperimentalFoundationApi::class) @Composable private fun ChatMessageBubble( @@ -125,6 +565,8 @@ private fun ChatMessageBubble( } } +// --- Action message --- + @Composable private fun ActionMessage(content: String, modifier: Modifier = Modifier) { Row( @@ -136,7 +578,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) { Text( text = content, style = MaterialTheme.typography.bodyMedium.copy( - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + fontStyle = FontStyle.Italic, ), color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Start, @@ -144,6 +586,8 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) { } } +// --- Thinking bubble --- + @Composable private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) { Box( @@ -164,6 +608,8 @@ private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) { } } +// --- Tool progress bubble --- + @Composable private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) { Row( @@ -186,6 +632,8 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) { } } +// --- System info bubble --- + @Composable private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) { Row( diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt b/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt index d23a0b4..919ce60 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt @@ -49,15 +49,15 @@ fun StatusIndicator( modifier = Modifier.size(8.dp), tint = Color(0xFF4CAF50), ) - Text("昔涟", style = MaterialTheme.typography.labelLarge) + Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) } CyreneStatus.THINKING -> { PulsingDot(Color(0xFFFFA726)) - Text("思考中…", style = MaterialTheme.typography.labelLarge) + Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) } CyreneStatus.SPEAKING -> { PulsingDot(Color(0xFF42A5F5)) - Text("正在说话…", style = MaterialTheme.typography.labelLarge) + Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) } CyreneStatus.OFFLINE -> { Icon( @@ -66,7 +66,7 @@ fun StatusIndicator( modifier = Modifier.size(8.dp), tint = Color(0xFF9E9E9E), ) - Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge) + Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) } } } 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 12953d3..062c853 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 @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -57,6 +59,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -112,6 +115,7 @@ fun OverlayContent( val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() val animIndex by viewModel.messageAnimIndex.collectAsState() val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState() + val enterToSend by settingsViewModel.enterToSend.collectAsState() val listState = rememberLazyListState() val isProcessing = state == OverlayState.PROCESSING val recordSec = recordDurationMs / 1000f @@ -191,6 +195,7 @@ fun OverlayContent( isLocked = isLocked, typingDots = typingDots.value, typingIndicatorStyle = typingIndicatorStyle, + enterToSend = enterToSend, animIndex = animIndex, onDismiss = onDismiss, onNavigateToMain = onNavigateToMain, @@ -209,6 +214,7 @@ fun OverlayContent( isLocked = isLocked, typingDots = typingDots.value, typingIndicatorStyle = typingIndicatorStyle, + enterToSend = enterToSend, animIndex = animIndex, onDismiss = onDismiss, onNavigateToMain = onNavigateToMain, @@ -233,6 +239,7 @@ private fun PortraitContent( isLocked: Boolean, typingDots: String, typingIndicatorStyle: String, + enterToSend: Boolean, animIndex: Map, onDismiss: () -> Unit, onNavigateToMain: () -> Unit, @@ -286,6 +293,7 @@ private fun PortraitContent( isLocked = isLocked, typingDots = typingDots, typingIndicatorStyle = typingIndicatorStyle, + enterToSend = enterToSend, ) } } @@ -303,6 +311,7 @@ private fun LandscapeContent( isLocked: Boolean, typingDots: String, typingIndicatorStyle: String, + enterToSend: Boolean, animIndex: Map, onDismiss: () -> Unit, onNavigateToMain: () -> Unit, @@ -366,6 +375,7 @@ private fun LandscapeContent( isLocked = isLocked, typingDots = typingDots, typingIndicatorStyle = typingIndicatorStyle, + enterToSend = enterToSend, ) } } @@ -408,6 +418,7 @@ private fun InputArea( isLocked: Boolean = false, typingDots: String = "", typingIndicatorStyle: String = "bubble", + enterToSend: Boolean = false, ) { // Gesture tracking state — local to InputArea var isDragging by remember { mutableStateOf(false) } @@ -417,17 +428,11 @@ private fun InputArea( val inLockZone = isDragging && dragOffsetX > 60f val isProcessing = state == OverlayState.PROCESSING - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - shadowElevation = 8.dp, - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - ) { // "昔涟正在输入..." indicator (text mode only) if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") { Text( @@ -524,7 +529,23 @@ private fun InputArea( placeholder = { Text("输入消息...") }, modifier = Modifier.weight(1f), maxLines = 3, - shape = MaterialTheme.shapes.medium, + shape = RoundedCornerShape(24.dp), + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f), + focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f), + ), + keyboardOptions = if (enterToSend) { + KeyboardOptions(imeAction = ImeAction.Done) + } else { + KeyboardOptions.Default + }, + keyboardActions = if (enterToSend) { + KeyboardActions( + onDone = { if (inputText.isNotBlank()) viewModel.sendText() }, + ) + } else { + KeyboardActions.Default + }, ) Spacer(modifier = Modifier.width(8.dp)) @@ -610,6 +631,5 @@ private fun InputArea( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } } } 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 5585fb1..2a02bfa 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 @@ -23,19 +23,20 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.KeyboardVoice import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -51,6 +52,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -94,7 +96,6 @@ private fun AnimatedChatBubble( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( viewModel: ChatViewModel = koinViewModel(), @@ -109,6 +110,7 @@ fun ChatScreen( val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() val animIndex by viewModel.messageAnimIndex.collectAsState() val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState() + val enterToSend by settingsViewModel.enterToSend.collectAsState() // reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top) val listState = rememberLazyListState() @@ -173,7 +175,7 @@ fun ChatScreen( .statusBarsPadding() .imePadding(), ) { - // Top status bar + // Top status bar with refresh button Row( modifier = Modifier .fillMaxWidth() @@ -181,14 +183,28 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically, ) { StatusIndicator(status = status) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = { viewModel.refreshMessages() }, + enabled = !isRefreshing, + ) { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + Icons.Filled.Refresh, + contentDescription = "刷新", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } // Messages area (fills remaining space, shrinks with IME) - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = { viewModel.refreshMessages() }, - modifier = Modifier.weight(1f), - ) { + Box(modifier = Modifier.weight(1f)) { if (messages.isEmpty() && !isStreaming) { Box( modifier = Modifier.fillMaxSize(), @@ -324,6 +340,18 @@ fun ChatScreen( modifier = Modifier.weight(1f), maxLines = 4, shape = MaterialTheme.shapes.medium, + keyboardOptions = if (enterToSend) { + KeyboardOptions(imeAction = ImeAction.Done) + } else { + KeyboardOptions.Default + }, + keyboardActions = if (enterToSend) { + KeyboardActions( + onDone = { if (inputText.isNotBlank()) viewModel.sendMessage() }, + ) + } else { + KeyboardActions.Default + }, ) Box( modifier = Modifier @@ -380,7 +408,11 @@ fun ChatScreen( strokeWidth = 2.dp, ) } else { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "发送", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } 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 a36c617..4812cc1 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DeleteForever @@ -80,6 +81,7 @@ fun SettingsScreen( val dashScopeModel by viewModel.dashScopeModel.collectAsState() val autoScreenContext by viewModel.autoScreenContext.collectAsState() val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState() + val enterToSend by viewModel.enterToSend.collectAsState() val context = LocalContext.current val scope = rememberCoroutineScope() @@ -221,6 +223,19 @@ fun SettingsScreen( }, ) + ListItem( + headlineContent = { Text("回车键发送") }, + supportingContent = { Text(if (enterToSend) "回车直接发送消息" else "回车换行") }, + leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) }, + trailingContent = { + androidx.compose.material3.Switch( + checked = enterToSend, + onCheckedChange = { viewModel.saveEnterToSend(it) }, + ) + }, + modifier = Modifier.clickable { viewModel.saveEnterToSend(!enterToSend) }, + ) + Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt index 7f28780..59eea3a 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt @@ -66,7 +66,7 @@ private val DarkColorScheme = darkColorScheme( @Composable fun CyreneTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, + dynamicColor: Boolean = false, content: @Composable () -> Unit, ) { val colorScheme = when { diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt index 18e0dc1..4fd39c8 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt @@ -8,9 +8,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.repository.ChatRepository @@ -24,27 +26,10 @@ private fun List.deduplicate(): List { return filter { seen.add(it.id) } } -private fun List.removeWrappingDuplicates(): List { - if (size < 3) return this - val toRemove = mutableSetOf() - for (msg in this) { - val containedCount = count { other -> - other.id != msg.id && - other.content.isNotBlank() && - other.content.length < msg.content.length && - msg.content.contains(other.content) && - kotlin.math.abs(other.timestamp - msg.timestamp) < 2000 - } - if (containedCount >= 2) { - toRemove.add(msg.id) - } - } - return if (toRemove.isEmpty()) this else filter { it.id !in toRemove } -} - class ChatViewModel( private val chatRepository: ChatRepository, private val voiceRecorder: VoiceRecorder, + private val preferencesDataStore: PreferencesDataStore, ) : ViewModel() { val isConnected: StateFlow = chatRepository.connectionState @@ -93,7 +78,14 @@ class ChatViewModel( chatRepository.ensureConnected() chatRepository.loadMessagesFromServer(sessionId) } catch (_: Exception) { } - loadMessagesFromDb(currentSessionId ?: return@launch) + // Fall back to persisted sessionId if initializeSession failed + val sid = currentSessionId + ?: preferencesDataStore.currentSessionId.firstOrNull() + if (sid != null) { + currentSessionId = sid + chatRepository.currentSessionId = sid + loadMessagesFromDb(sid) + } } // Observe incoming live messages — insert at correct descending position @@ -144,6 +136,27 @@ class ChatViewModel( if (streaming) _isSending.value = false } } + + // Auto-refresh when connection transitions from offline to online + viewModelScope.launch { + var wasOffline = false + chatRepository.connectionState.collect { connected -> + if (connected && wasOffline && currentSessionId != null) { + try { + val lastCleared = preferencesDataStore.lastClearedTimestamp + .firstOrNull()?.toLongOrNull() ?: 0L + val latestMessages = chatRepository.loadMessagesFromServer(currentSessionId!!) + val latestServerTs = latestMessages.maxOfOrNull { it.timestamp } ?: 0L + if (latestServerTs > lastCleared) { + // Newer messages exist on server — DB upsert already + // triggered the Room Flow; force a scroll to newest + RuntimeLog.chat("auto-refresh", "Reconnected, loaded ${latestMessages.size} messages") + } + } catch (_: Exception) { } + } + wasOffline = !connected + } + } } // --- Voice recording (WeChat-style gesture) --- @@ -183,7 +196,6 @@ class ChatViewModel( (db + live).values .sortedByDescending { it.timestamp } .deduplicate() - .removeWrappingDuplicates() } val idx = _messageAnimIndex.value.toMutableMap() messages.forEach { m -> 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 4d16efd..4444fb2 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 _enterToSend = MutableStateFlow(false) + val enterToSend: StateFlow = _enterToSend.asStateFlow() + private val _typingIndicatorStyle = MutableStateFlow("bubble") val typingIndicatorStyle: StateFlow = _typingIndicatorStyle.asStateFlow() @@ -69,6 +72,11 @@ class SettingsViewModel( _typingIndicatorStyle.value = value } } + scope.launch { + preferencesDataStore.enterToSend.collect { value -> + _enterToSend.value = value + } + } scope.launch { combine( preferencesDataStore.baseUrl, @@ -178,6 +186,11 @@ class SettingsViewModel( scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) } } + fun saveEnterToSend(enabled: Boolean) { + _enterToSend.value = enabled + scope.launch { preferencesDataStore.saveEnterToSend(enabled) } + } + fun clearLocalMessages() { scope.launch { chatRepository.clearLocalMessages()