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:
@@ -30,7 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"cyrene.db",
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.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_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<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 }
|
||||
}
|
||||
|
||||
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 refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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<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 {
|
||||
// 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() },
|
||||
|
||||
@@ -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<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
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Int>,
|
||||
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<String, Int>,
|
||||
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,16 +428,10 @@ 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
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator (text mode only)
|
||||
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "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))
|
||||
@@ -612,4 +633,3 @@ private fun InputArea(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Message>.deduplicate(): List<Message> {
|
||||
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(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
) : ViewModel() {
|
||||
|
||||
val isConnected: StateFlow<Boolean> = 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 ->
|
||||
|
||||
@@ -48,6 +48,9 @@ class SettingsViewModel(
|
||||
private val _autoScreenContext = MutableStateFlow(false)
|
||||
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
||||
|
||||
private val _enterToSend = MutableStateFlow(false)
|
||||
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
|
||||
|
||||
private val _typingIndicatorStyle = MutableStateFlow("bubble")
|
||||
val typingIndicatorStyle: StateFlow<String> = _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()
|
||||
|
||||
Reference in New Issue
Block a user