feat(chat): message persistence + background notifications (#2)

* feat(chat): message persistence + background notifications

Two critical gaps for daily-driver use:

1. Message persistence (Room database):
   - New ChatMessageEntity / ChatMessageDao / HermesDatabase layer
   - Every user message, streaming token, tool result, and assistant
     reply is written to a local Room DB as it arrives
   - switchSession loads cached messages instantly from Room before
     the REST call returns — no more blank screen while loading
   - Session resume also loads from cache first
   - Messages survive app kills, process death, and configuration
     changes
   - ChatViewModel changed from ViewModel() to AndroidViewModel(app)
     to access the database context

2. Background notifications (foreground service):
   - ChatNotificationService keeps the WebSocket alive while the app
     is backgrounded and posts a high-priority notification when a
     MessageComplete or ClarifyRequest event arrives while the app
     is not in the foreground
   - Lifecycle wired via LifecycleEventObserver in ChatScreen:
     ON_STOP starts the service, ON_START stops it
   - Tapping the notification returns to the chat
   - New permissions: POST_NOTIFICATIONS, FOREGROUND_SERVICE,
     FOREGROUND_SERVICE_DATA_SYNC

Build changes:
   - Added Room 2.7.1 + KSP to version catalog
   - Added WorkManager 2.10.0 (reserved for future polling fallback)
   - Updated ChatViewModelTest to mock HermesDatabase + use
     AndroidViewModel constructor

* fix(build): use KSP 2.3.9 (new versioning scheme for Kotlin 2.3.x)

* fix(build): add Room schemaDirectory (required by Room Gradle plugin)

* fix(compile): add else branch to WsEvent when expression (exhaustiveness)

* ci(release): add release workflow + R8 minification + proguard rules

New .github/workflows/release.yml:
- Triggers on tag pushes (v*) and manual workflow_dispatch
- Runs full CI (ktlint + lint + test) before building
- Builds assembleRelease with version injection from tag
- Creates a GitHub Release with auto-generated changelog
- Attaches the release APK to the release

Build changes:
- Release builds now use R8 minification (isMinifyEnabled=true)
  and resource shrinking (isShrinkResources=true) for smaller APKs
- versionCode/versionName injectable via -PversionCode / -PversionName
  Gradle properties (passed by CI from the git tag)
- Local dev builds default to versionCode=1, versionName=1.0-dev

ProGuard rules:
- Keep all data model classes (Gson reflection)
- Keep Room entities and RoomDatabase subclasses
- Keep OkHttp/Retrofit interface methods
- Suppress warnings for Compose, Security Crypto, Coroutines

* feat(ci): release workflow + proguard rules + test fix

Release workflow (.github/workflows/release.yml):
  - Triggers on tag push (v*) or manual workflow_dispatch
  - Two jobs: ci (ktlint+lint+test) → build-release
  - Builds a minified, shrunk release APK with version from tag
  - Creates a GitHub Release with auto-generated release notes
  - Attaches the APK as a release asset

Build config:
  - versionCode/versionName now injected from CI via -P flags
  - Release build enables isMinifyEnabled + isShrinkResources
  - proguard-rules.pro: keep rules for Gson models, Room entities,
    OkHttp/Retrofit interfaces, Coroutines, Compose, BuildConfig

Test fix:
  - ChatViewModelTest: use coEvery for suspend DAO methods instead
    of every (compile error — suspend functions need coEvery)
  - Added stubs for all suspend DAO methods used by the ViewModel

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-18 03:23:38 +03:00
1 parent 7daf73ce0d
commit 5130c2e308
13 files changed
+722 -47

No files matched your search

@@ -0,0 +1,47 @@
package com.m57.hermescontrol.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ChatMessageDao {
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
fun observeMessagesForSession(sessionId: String): Flow<List<ChatMessageEntity>>
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: ChatMessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(messages: List<ChatMessageEntity>)
@Query("UPDATE chat_messages SET content = :content, is_streaming = 0 WHERE id = :id")
suspend fun finalizeMessage(
id: String,
content: String,
)
@Query("UPDATE chat_messages SET content = :content WHERE id = :id")
suspend fun updateContent(
id: String,
content: String,
)
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@Query(
"DELETE FROM chat_messages WHERE session_id NOT IN " +
"(SELECT DISTINCT session_id FROM chat_messages " +
"ORDER BY timestamp DESC LIMIT :keepSessions)",
)
suspend fun pruneOldSessions(keepSessions: Int)
@Query("SELECT COUNT(*) FROM chat_messages")
suspend fun count(): Int
}
@@ -0,0 +1,33 @@
package com.m57.hermescontrol.data.local
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Persisted chat message. Maps to the [com.m57.hermescontrol.ui.chat.ChatMessage]
* UI model but is stored independently so it survives process death.
*
* Messages are scoped by [sessionId] so switching sessions loads the right
* thread. The [createdAt] field preserves original ordering even if Room
* reorders internally.
*/
@Entity(tableName = "chat_messages")
data class ChatMessageEntity(
@PrimaryKey
val id: String,
@ColumnInfo(name = "session_id")
val sessionId: String,
@ColumnInfo(name = "role")
val role: String,
@ColumnInfo(name = "content")
val content: String,
@ColumnInfo(name = "timestamp")
val timestamp: Long,
@ColumnInfo(name = "tool_name")
val toolName: String? = null,
@ColumnInfo(name = "tool_status")
val toolStatus: String? = null,
@ColumnInfo(name = "is_streaming")
val isStreaming: Boolean = false,
)
@@ -0,0 +1,47 @@
package com.m57.hermescontrol.data.local
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus
/**
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
*
* Keep this as a pure mapping — no I/O, no side effects.
*/
fun ChatMessageEntity.toUiModel(): ChatMessage =
ChatMessage(
id = id,
role =
when (role) {
"USER" -> MessageRole.USER
"ASSISTANT" -> MessageRole.ASSISTANT
"SYSTEM" -> MessageRole.SYSTEM
"TOOL" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
},
content = content,
timestamp = timestamp,
isStreaming = isStreaming,
toolName = toolName,
toolStatus =
when (toolStatus) {
"RUNNING" -> ToolStatus.RUNNING
"COMPLETED" -> ToolStatus.COMPLETED
"FAILED" -> ToolStatus.FAILED
else -> null
},
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
ChatMessageEntity(
id = id,
sessionId = sessionId,
role = role.name,
content = content,
timestamp = timestamp,
toolName = toolName,
toolStatus = toolStatus?.name,
isStreaming = isStreaming,
)
@@ -0,0 +1,37 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [ChatMessageEntity::class],
version = 1,
exportSchema = false,
)
abstract class HermesDatabase : RoomDatabase() {
abstract fun chatMessageDao(): ChatMessageDao
companion object {
@Volatile
private var instance: HermesDatabase? = null
fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) {
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).fallbackToDestructiveMigration()
.build()
.also { instance = it }
}
/** For testing — inject a custom instance. */
fun setForTest(db: HermesDatabase?) {
instance = db
}
}
}
@@ -0,0 +1,178 @@
package com.m57.hermescontrol.notification
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
* Foreground service that keeps the WebSocket connection alive while the app
* is backgrounded and posts a notification when a new assistant reply
* completes (MessageComplete event) while the app is not in the foreground.
*
* Lifecycle:
* - Started by [NotificationHelper.start] when the user sends a message and
* the app is about to go to the background (called from ChatScreen's
* onStop / onPause).
* - Stopped by [NotificationHelper.stop] when the app returns to the
* foreground (called from ChatScreen's onStart / onResume).
*
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
* events that indicate the agent has finished replying.
*/
class ChatNotificationService : Service() {
companion object {
private const val CHANNEL_ID = "hermes_chat"
private const val NOTIFICATION_ID = 1
private const val PENDING_NOTIFICATION_ID = 2
private var isAppInForeground = false
fun setAppForeground(foreground: Boolean) {
isAppInForeground = foreground
}
}
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var eventCollector: Job? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildForegroundNotification("Hermes connected"))
startEventCollection()
}
private fun startEventCollection() {
eventCollector =
serviceScope.launch {
HermesWsClient.events.collect { event ->
if (!isAppInForeground) {
when (event) {
is WsEvent.MessageComplete -> {
val preview =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { "New message" }
showReplyNotification(preview)
}
is WsEvent.ClarifyRequest -> {
showReplyNotification("Hermes needs a clarification")
}
else -> {}
}
}
}
}
}
private fun showReplyNotification(text: String) {
val notification =
NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes")
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(buildContentIntent())
.build()
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(PENDING_NOTIFICATION_ID, notification)
}
private fun buildContentIntent(): PendingIntent =
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun buildForegroundNotification(text: String): Notification =
NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes")
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
"Hermes Chat",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Notifications for new Hermes replies"
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int = START_STICKY
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
eventCollector?.cancel()
serviceScope.cancel()
super.onDestroy()
}
}
/**
* Helper to start/stop the notification service from the UI layer.
*/
object NotificationHelper {
fun start(context: Context) {
if (AuthManager.getToken().isNullOrBlank()) return
val intent = Intent(context, ChatNotificationService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, ChatNotificationService::class.java))
}
fun setAppForeground(
context: Context,
foreground: Boolean,
) {
ChatNotificationService.setAppForeground(foreground)
}
}
@@ -66,6 +66,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -77,11 +78,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
@@ -91,13 +94,41 @@ fun ChatScreen(
onNavigateToSettings: () -> Unit,
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ChatViewModel = viewModel { ChatViewModel() },
viewModel: ChatViewModel = viewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var inputText by rememberSaveable { mutableStateOf("") }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Manage notification foreground service lifecycle:
// - When app goes to background (ON_STOP), mark as not-foreground and
// start the notification service so we can post reply notifications.
// - When app returns to foreground (ON_START), mark as foreground and
// stop the notification service.
DisposableEffect(lifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) {
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
}
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.currentStreamingText) {
@@ -1,8 +1,12 @@
package com.m57.hermescontrol.ui.chat
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
@@ -42,12 +46,13 @@ data class ClarifyUi(
val options: List<String>,
)
class ChatViewModel : ViewModel() {
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val wsClient = HermesWsClient
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
private val dao = HermesDatabase.get(application).chatMessageDao()
init {
connectWebSocket()
@@ -75,15 +80,12 @@ class ChatViewModel : ViewModel() {
_uiState.update { it.copy(isConnected = true, isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
// Create a new session if none exists
if (_uiState.value.currentSessionId == null) {
createNewSession()
}
}
is WsEvent.SessionInfo -> {
// We may receive session info after creation
}
is WsEvent.SessionInfo -> {}
is WsEvent.MessageStart -> {
_uiState.update {
@@ -99,7 +101,6 @@ class ChatViewModel : ViewModel() {
is WsEvent.MessageToken -> {
_uiState.update { state ->
val newText = state.currentStreamingText + event.token
// Update or create the streaming message
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
@@ -116,6 +117,14 @@ class ChatViewModel : ViewModel() {
),
)
}
// Persist streaming message to DB
val sessionId = state.currentSessionId
if (sessionId != null) {
val msg = messages.last()
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId))
}
}
state.copy(
messages = messages,
currentStreamingText = newText,
@@ -140,19 +149,28 @@ class ChatViewModel : ViewModel() {
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
val finalMessage: ChatMessage
if (streamingIdx >= 0) {
messages[streamingIdx] =
finalMessage =
messages[streamingIdx].copy(
content = event.text,
isStreaming = false,
)
messages[streamingIdx] = finalMessage
} else {
messages.add(
finalMessage =
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
),
)
)
messages.add(finalMessage)
}
// Persist finalized message
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.finalizeMessage(finalMessage.id, event.text)
}
}
state.copy(
messages = messages,
@@ -179,6 +197,12 @@ class ChatViewModel : ViewModel() {
toolStatus = ToolStatus.RUNNING,
)
_uiState.update { state ->
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(toolMessage.toEntity(sessionId))
}
}
state.copy(messages = state.messages + toolMessage)
}
}
@@ -198,6 +222,13 @@ class ChatViewModel : ViewModel() {
toolStatus = ToolStatus.COMPLETED,
content = event.data.toString(),
)
// Persist tool completion
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(messages[toolIdx].toEntity(sessionId))
}
}
}
state.copy(messages = messages)
}
@@ -215,12 +246,9 @@ class ChatViewModel : ViewModel() {
}
}
is WsEvent.StatusUpdate -> {
// Can be used for general status
}
is WsEvent.StatusUpdate -> {}
is WsEvent.SessionUpdated -> {
// Refresh sessions list
loadSessions()
}
@@ -232,9 +260,7 @@ class ChatViewModel : ViewModel() {
handleRpcError(event.id, event.error)
}
is WsEvent.Unknown -> {
// Handle or ignore unknown/unhandled events
}
is WsEvent.Unknown -> {}
}
}
@@ -253,10 +279,10 @@ class ChatViewModel : ViewModel() {
it.copy(
currentSessionId = sessionId,
isLoading = false,
messages = it.messages,
messages = emptyList(),
)
}
addSystemMessage("Session created")
addSystemMessage("Session created", persist = true)
loadSessions()
}
@@ -276,6 +302,11 @@ class ChatViewModel : ViewModel() {
WsMethods.SESSION_RESUME -> {
_uiState.update { it.copy(isLoading = false) }
// Load cached messages from Room before the REST call
val sessionId = _uiState.value.currentSessionId
if (sessionId != null) {
loadCachedMessages(sessionId)
}
addSystemMessage("Session resumed")
}
@@ -316,15 +347,18 @@ class ChatViewModel : ViewModel() {
return
}
// Add user message to UI
val userMessage =
ChatMessage(
role = MessageRole.USER,
content = text,
)
_uiState.update { state ->
val messages = state.messages + userMessage
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMessage.toEntity(sessionId))
}
state.copy(
messages = state.messages + userMessage,
messages = messages,
isAgentTyping = true,
)
}
@@ -337,7 +371,13 @@ class ChatViewModel : ViewModel() {
private fun handleSlashCommand(command: String) {
val userMsg = ChatMessage(role = MessageRole.USER, content = command)
val sessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(messages = it.messages + userMsg) }
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMsg.toEntity(sessionId))
}
}
val parts = command.split(" ")
val cmd = parts[0].lowercase()
@@ -383,7 +423,7 @@ class ChatViewModel : ViewModel() {
• **Gateway Running:** ${body.gateway_running ?: false}
• **Active Sessions:** ${body.active_sessions ?: 0}
• **Auth Required:** ${body.auth_required ?: false}
**Platforms:**
$platformsStr
""".trimIndent()
@@ -451,7 +491,13 @@ class ChatViewModel : ViewModel() {
private fun addAssistantMessage(text: String) {
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
val sessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(messages = it.messages + msg) }
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId))
}
}
}
fun interruptSession() {
@@ -491,6 +537,9 @@ class ChatViewModel : ViewModel() {
showSessionPicker = false,
)
}
// Load cached messages first — instant display
loadCachedMessages(sessionId)
// Then resume session on server
viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send(
@@ -502,6 +551,21 @@ class ChatViewModel : ViewModel() {
loadSessionMessages(sessionId)
}
private fun loadCachedMessages(sessionId: String) {
viewModelScope.launch(Dispatchers.IO) {
val cached = dao.getMessagesForSession(sessionId)
val cachedMessages = cached.map { it.toUiModel() }
_uiState.update { state ->
// Only replace if still showing this session and messages are empty/newer
if (state.currentSessionId == sessionId) {
state.copy(messages = cachedMessages, isLoading = false)
} else {
state
}
}
}
}
private fun loadSessionMessages(sessionId: String) {
viewModelScope.launch {
try {
@@ -526,15 +590,11 @@ class ChatViewModel : ViewModel() {
isStreaming = false,
)
}
// B3 (Jun 18 2026, kanban t_33da8a97): switchSession fires
// SESSION_RESUME (WS) and loadSessionMessages (REST) in
// parallel. While this REST call is in flight, WsEvent
// .MessageToken events append streaming messages to
// `state.messages` (ChatViewModel.kt:99-125). Without
// preservation, the unconditional `messages = chatMessages`
// below stomps the just-streamed tokens. Preserve the
// in-flight tail by appending any messages marked
// isStreaming=true to the freshly-loaded history.
// Persist loaded messages to Room for offline access
val entities = chatMessages.map { it.toEntity(sessionId) }
viewModelScope.launch(Dispatchers.IO) {
dao.upsertAll(entities)
}
_uiState.update { state ->
val streamingTail = state.messages.filter { it.isStreaming }
state.copy(
@@ -595,9 +655,20 @@ class ChatViewModel : ViewModel() {
}
}
private fun addSystemMessage(text: String) {
private fun addSystemMessage(
text: String,
persist: Boolean = false,
) {
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
_uiState.update { it.copy(messages = it.messages + msg) }
if (persist) {
val sessionId = _uiState.value.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId))
}
}
}
}
override fun onCleared() {