feat: markdown/code message renderers, collapsible non-chat content, dark mode fixes, and data persistence

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 12:36:06 +08:00
parent 64c7018729
commit 7fcf562648
14 changed files with 651 additions and 76 deletions
@@ -30,7 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"cyrene.db", "cyrene.db",
) )
.fallbackToDestructiveMigration()
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
@@ -34,6 +34,7 @@ class PreferencesDataStore(private val context: Context) {
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") private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
} }
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" } val typingIndicatorStyle: Flow<String> = 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 } context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
} }
val enterToSend: Flow<Boolean> = 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<String?> = context.dataStore.data.map { it[KEY_TOKEN] } val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] } val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] } val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
@@ -55,11 +55,18 @@ data class WSServerMessage(
) )
data class WSReviewMessage( data class WSReviewMessage(
@SerializedName("role") val role: String?, @SerializedName("type") val type: String? = null,
@SerializedName("text") val text: String?, @SerializedName("role") val role: String? = null,
@SerializedName("content") val content: String?, @SerializedName("text") val text: String? = null,
@SerializedName("msg_type") val msgType: String?, @SerializedName("content") val content: String? = null,
@SerializedName("msg_type") val msgType: String? = null,
@SerializedName("delay_ms") val delayMs: Long? = 0, @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( data class WSHistoryMessage(
@@ -308,20 +308,7 @@ class ChatRepositoryImpl(
?.toLongOrNull() ?: 0L ?.toLongOrNull() ?: 0L
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared } val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
ensureConversation(sessionId) ensureConversation(sessionId)
filteredDtos.forEach { dto -> val messages = filteredDtos.map { 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 ->
Message( Message(
id = "db_${dto.id}", id = "db_${dto.id}",
conversationId = sessionId, conversationId = sessionId,
@@ -330,7 +317,22 @@ class ChatRepositoryImpl(
msgType = dto.msgType ?: "chat", msgType = dto.msgType ?: "chat",
timestamp = dto.createdAt, 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 { } else {
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS") RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
requestHistoryViaWs(sessionId) requestHistoryViaWs(sessionId)
@@ -479,12 +481,16 @@ class ChatRepositoryImpl(
"review" -> { "review" -> {
recentParsedContents.clear() recentParsedContents.clear()
wsMsg.reviewMessages?.forEach { review -> 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 role = review.role ?: "assistant"
val rvMsgType = review.msgType ?: "action" val rvMsgType = review.type ?: review.msgType ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}" val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
recentParsedContents.add(text) // Encode code language metadata into content for the renderer
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false) 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() if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
// Clean up wrapping response that arrived before this review // Clean up wrapping response that arrived before this review
@@ -72,7 +72,7 @@ val appModule = module {
factory { GetConversationsUseCase(get()) } factory { GetConversationsUseCase(get()) }
// ViewModels // ViewModels
viewModel { ChatViewModel(get(), get()) } viewModel { ChatViewModel(get(), get(), get()) }
viewModel { IoTViewModel(get()) } viewModel { IoTViewModel(get()) }
viewModel { OverlayViewModel(get(), get(), get()) } viewModel { OverlayViewModel(get(), get(), get()) }
viewModel { ProfileViewModel(get(), get(), get()) } viewModel { ProfileViewModel(get(), get(), get()) }
@@ -16,6 +16,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import android.content.res.Configuration
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
@@ -66,6 +67,21 @@ class CyreneVoiceInteractionSession(context: Context) :
lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.CREATED
val vm = overlayViewModel val vm = overlayViewModel
val session = this@CyreneVoiceInteractionSession val session = this@CyreneVoiceInteractionSession
val darkTheme = runBlocking {
val prefs = GlobalContext.get().get<PreferencesDataStore>()
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 { return ComposeView(context).apply {
// Configure window as soon as view is attached — before system overrides flags // Configure window as soon as view is attached — before system overrides flags
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
@@ -77,7 +93,7 @@ class CyreneVoiceInteractionSession(context: Context) :
setViewTreeLifecycleOwner(session) setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session) setViewTreeSavedStateRegistryOwner(session)
setContent { setContent {
CyreneTheme { CyreneTheme(darkTheme = darkTheme) {
if (vm != null) { if (vm != null) {
OverlayContent( OverlayContent(
onDismiss = { finish() }, onDismiss = { finish() },
@@ -9,15 +9,21 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -28,12 +34,434 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString 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.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.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale 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<MdBlock> {
val lines = text.lines()
val blocks = mutableListOf<MdBlock>()
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<String>()
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<String>()
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<String>()
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("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""").find(remaining)
// Inline code `
val code = Regex("""`([^`]+)`""").find(remaining)
// Link [text](url)
val link = Regex("""\[([^\]]+)\]\(([^)]+)\)""").find(remaining)
val matches = listOfNotNull(
boldItalic?.let { "bi" to it },
bold?.let { "b" to it },
italic?.let { "i" to it },
code?.let { "c" to it },
link?.let { "l" to it },
).sortedBy { it.second.range.first }
if (matches.isEmpty()) {
append(remaining)
remaining = ""
} else {
val (kind, match) = matches.first()
// Append text before the match
if (match.range.first > 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 @Composable
fun ChatBubble( fun ChatBubble(
content: String, content: String,
@@ -48,13 +476,25 @@ fun ChatBubble(
when (msgType) { when (msgType) {
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier) "chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
"action" -> ActionMessage(content, modifier) "action" -> ActionMessage(content, modifier)
"thinking" -> ThinkingBubble(content, modifier) "markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
"tool_progress" -> ToolProgressBubble(content, modifier) 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) "system_info" -> SystemInfoBubble(content, modifier)
else -> ChatMessageBubble(content, isUser, formattedTime, modifier) else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
} }
} }
// --- Chat message bubble ---
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun ChatMessageBubble( private fun ChatMessageBubble(
@@ -125,6 +565,8 @@ private fun ChatMessageBubble(
} }
} }
// --- Action message ---
@Composable @Composable
private fun ActionMessage(content: String, modifier: Modifier = Modifier) { private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
Row( Row(
@@ -136,7 +578,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
Text( Text(
text = content, text = content,
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, fontStyle = FontStyle.Italic,
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
@@ -144,6 +586,8 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
} }
} }
// --- Thinking bubble ---
@Composable @Composable
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) { private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
Box( Box(
@@ -164,6 +608,8 @@ private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
} }
} }
// --- Tool progress bubble ---
@Composable @Composable
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) { private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
Row( Row(
@@ -186,6 +632,8 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
} }
} }
// --- System info bubble ---
@Composable @Composable
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) { private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
Row( Row(
@@ -49,15 +49,15 @@ fun StatusIndicator(
modifier = Modifier.size(8.dp), modifier = Modifier.size(8.dp),
tint = Color(0xFF4CAF50), tint = Color(0xFF4CAF50),
) )
Text("昔涟", style = MaterialTheme.typography.labelLarge) Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
CyreneStatus.THINKING -> { CyreneStatus.THINKING -> {
PulsingDot(Color(0xFFFFA726)) PulsingDot(Color(0xFFFFA726))
Text("思考中…", style = MaterialTheme.typography.labelLarge) Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
CyreneStatus.SPEAKING -> { CyreneStatus.SPEAKING -> {
PulsingDot(Color(0xFF42A5F5)) PulsingDot(Color(0xFF42A5F5))
Text("正在说话…", style = MaterialTheme.typography.labelLarge) Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
CyreneStatus.OFFLINE -> { CyreneStatus.OFFLINE -> {
Icon( Icon(
@@ -66,7 +66,7 @@ fun StatusIndicator(
modifier = Modifier.size(8.dp), modifier = Modifier.size(8.dp),
tint = Color(0xFF9E9E9E), tint = Color(0xFF9E9E9E),
) )
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge) Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
} }
} }
@@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -112,6 +115,7 @@ fun OverlayContent(
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 typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.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
@@ -191,6 +195,7 @@ fun OverlayContent(
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots.value, typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle, typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex, animIndex = animIndex,
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
@@ -209,6 +214,7 @@ fun OverlayContent(
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots.value, typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle, typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex, animIndex = animIndex,
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
@@ -233,6 +239,7 @@ private fun PortraitContent(
isLocked: Boolean, isLocked: Boolean,
typingDots: String, typingDots: String,
typingIndicatorStyle: String, typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>, animIndex: Map<String, Int>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
@@ -286,6 +293,7 @@ private fun PortraitContent(
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots, typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle, typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
) )
} }
} }
@@ -303,6 +311,7 @@ private fun LandscapeContent(
isLocked: Boolean, isLocked: Boolean,
typingDots: String, typingDots: String,
typingIndicatorStyle: String, typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>, animIndex: Map<String, Int>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
@@ -366,6 +375,7 @@ private fun LandscapeContent(
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots, typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle, typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
) )
} }
} }
@@ -408,6 +418,7 @@ private fun InputArea(
isLocked: Boolean = false, isLocked: Boolean = false,
typingDots: String = "", typingDots: String = "",
typingIndicatorStyle: String = "bubble", typingIndicatorStyle: String = "bubble",
enterToSend: Boolean = false,
) { ) {
// Gesture tracking state — local to InputArea // Gesture tracking state — local to InputArea
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@@ -417,16 +428,10 @@ private fun InputArea(
val inLockZone = isDragging && dragOffsetX > 60f val inLockZone = isDragging && dragOffsetX > 60f
val isProcessing = state == OverlayState.PROCESSING 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( Column(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
) { ) {
// "昔涟正在输入..." indicator (text mode only) // "昔涟正在输入..." indicator (text mode only)
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") { if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
@@ -524,7 +529,23 @@ private fun InputArea(
placeholder = { Text("输入消息...") }, placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
maxLines = 3, 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)) Spacer(modifier = Modifier.width(8.dp))
@@ -611,5 +632,4 @@ private fun InputArea(
) )
} }
} }
}
} }
@@ -23,19 +23,20 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.KeyboardVoice import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -94,7 +96,6 @@ private fun AnimatedChatBubble(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(), viewModel: ChatViewModel = koinViewModel(),
@@ -109,6 +110,7 @@ fun ChatScreen(
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 typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.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()
@@ -173,7 +175,7 @@ fun ChatScreen(
.statusBarsPadding() .statusBarsPadding()
.imePadding(), .imePadding(),
) { ) {
// Top status bar // Top status bar with refresh button
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -181,14 +183,28 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
StatusIndicator(status = status) 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) // Messages area (fills remaining space, shrinks with IME)
PullToRefreshBox( Box(modifier = Modifier.weight(1f)) {
isRefreshing = isRefreshing,
onRefresh = { viewModel.refreshMessages() },
modifier = Modifier.weight(1f),
) {
if (messages.isEmpty() && !isStreaming) { if (messages.isEmpty() && !isStreaming) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -324,6 +340,18 @@ fun ChatScreen(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
maxLines = 4, maxLines = 4,
shape = MaterialTheme.shapes.medium, 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( Box(
modifier = Modifier modifier = Modifier
@@ -380,7 +408,11 @@ fun ChatScreen(
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} else { } else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Check
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.DeleteForever
@@ -80,6 +81,7 @@ fun SettingsScreen(
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 typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val enterToSend by viewModel.enterToSend.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() 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)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -66,7 +66,7 @@ private val DarkColorScheme = darkColorScheme(
@Composable @Composable
fun CyreneTheme( fun CyreneTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = false,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colorScheme = when { val colorScheme = when {
@@ -8,9 +8,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.domain.repository.ChatRepository
@@ -24,27 +26,10 @@ private fun List<Message>.deduplicate(): List<Message> {
return filter { seen.add(it.id) } return filter { seen.add(it.id) }
} }
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
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( class ChatViewModel(
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder, private val voiceRecorder: VoiceRecorder,
private val preferencesDataStore: PreferencesDataStore,
) : ViewModel() { ) : ViewModel() {
val isConnected: StateFlow<Boolean> = chatRepository.connectionState val isConnected: StateFlow<Boolean> = chatRepository.connectionState
@@ -93,7 +78,14 @@ class ChatViewModel(
chatRepository.ensureConnected() chatRepository.ensureConnected()
chatRepository.loadMessagesFromServer(sessionId) chatRepository.loadMessagesFromServer(sessionId)
} catch (_: Exception) { } } 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 // Observe incoming live messages — insert at correct descending position
@@ -144,6 +136,27 @@ class ChatViewModel(
if (streaming) _isSending.value = false 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) --- // --- Voice recording (WeChat-style gesture) ---
@@ -183,7 +196,6 @@ class ChatViewModel(
(db + live).values (db + live).values
.sortedByDescending { it.timestamp } .sortedByDescending { it.timestamp }
.deduplicate() .deduplicate()
.removeWrappingDuplicates()
} }
val idx = _messageAnimIndex.value.toMutableMap() val idx = _messageAnimIndex.value.toMutableMap()
messages.forEach { m -> messages.forEach { m ->
@@ -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 _enterToSend = MutableStateFlow(false)
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble") private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow() val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
@@ -69,6 +72,11 @@ class SettingsViewModel(
_typingIndicatorStyle.value = value _typingIndicatorStyle.value = value
} }
} }
scope.launch {
preferencesDataStore.enterToSend.collect { value ->
_enterToSend.value = value
}
}
scope.launch { scope.launch {
combine( combine(
preferencesDataStore.baseUrl, preferencesDataStore.baseUrl,
@@ -178,6 +186,11 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) } scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
} }
fun saveEnterToSend(enabled: Boolean) {
_enterToSend.value = enabled
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
}
fun clearLocalMessages() { fun clearLocalMessages() {
scope.launch { scope.launch {
chatRepository.clearLocalMessages() chatRepository.clearLocalMessages()