refactor(chat): stabilize streaming, session loading, and connection status
Optimize streaming message updates, correct session load merging, and derive connection state reactively. Add reconnection UI banner.
This commit is contained in:
1 parent
a2d299e136
commit
d0eedf053f
4 files changed
+462
-215
No files matched your search
@@ -72,10 +72,10 @@ import com.m57.hermescontrol.theme.SystemMessageColor
|
||||
import com.m57.hermescontrol.theme.ToolChipColor
|
||||
import com.m57.hermescontrol.theme.ToolChipColorLight
|
||||
import com.m57.hermescontrol.theme.UserBubble
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ChatBubble(
|
||||
@@ -811,9 +811,11 @@ private fun RichText(
|
||||
)
|
||||
}
|
||||
|
||||
private val TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
return TIMESTAMP_FORMATTER.format(Instant.ofEpochMilli(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,6 +92,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.theme.StatusRed
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -169,9 +170,12 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
LaunchedEffect(state.messages.size, state.currentStreamingText) {
|
||||
if (state.messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(state.messages.lastIndex)
|
||||
LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) {
|
||||
val totalItems = state.messages.size +
|
||||
(if (state.streamingMessage != null) 1 else 0) +
|
||||
(if (state.isThinking) 1 else 0)
|
||||
if (totalItems > 0) {
|
||||
listState.animateScrollToItem(totalItems - 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +353,46 @@ fun ChatScreen(
|
||||
.background(backgroundGradient)
|
||||
.imePadding(),
|
||||
) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = state.connectionStatus == ConnectionStatus.RECONNECTING ||
|
||||
state.connectionStatus == ConnectionStatus.DISCONNECTED,
|
||||
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
||||
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = if (state.connectionStatus == ConnectionStatus.RECONNECTING) {
|
||||
"Reconnecting to Hermes…"
|
||||
} else {
|
||||
"Disconnected from Hermes"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (state.connectionStatus == ConnectionStatus.DISCONNECTED) {
|
||||
TextButton(
|
||||
onClick = { viewModel.reconnect() },
|
||||
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
) {
|
||||
Text("Reconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = state.isSearchActive,
|
||||
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
||||
@@ -414,6 +458,18 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Streaming message — rendered separately for O(1) updates
|
||||
state.streamingMessage?.let { streaming ->
|
||||
item(key = "streaming-${streaming.id}") {
|
||||
ChatBubble(
|
||||
message = streaming,
|
||||
isDarkTheme = isDark,
|
||||
searchQuery = "",
|
||||
isCurrentMatch = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking indicator
|
||||
if (state.isThinking) {
|
||||
item(key = "thinking") {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
@@ -10,30 +11,43 @@ import com.m57.hermescontrol.data.local.toUiModel
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private const val TAG = "ChatViewModel"
|
||||
|
||||
/**
|
||||
* Pending request timeout — if the server doesn't respond within this
|
||||
* window, the request is pruned and an error is surfaced.
|
||||
*/
|
||||
private const val PENDING_REQUEST_TIMEOUT_MS = 30_000L
|
||||
|
||||
data class ChatUiState(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val currentSessionId: String? = null,
|
||||
val sessions: List<SessionUi> = emptyList(),
|
||||
val chatTitle: String = "Hermes",
|
||||
val isConnected: Boolean = false,
|
||||
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
|
||||
val isAgentTyping: Boolean = false,
|
||||
val isThinking: Boolean = false,
|
||||
val thinkingText: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val currentStreamingText: String = "",
|
||||
/** Standalone streaming message — rendered after the main list. */
|
||||
val streamingMessage: ChatMessage? = null,
|
||||
val errorMessage: String? = null,
|
||||
val clarifyRequest: ClarifyUi? = null,
|
||||
val showSessionPicker: Boolean = false,
|
||||
@@ -42,7 +56,10 @@ data class ChatUiState(
|
||||
val searchQuery: String = "",
|
||||
val searchMatchIndices: List<Int> = emptyList(),
|
||||
val currentSearchMatchIndex: Int = -1,
|
||||
)
|
||||
) {
|
||||
/** Convenience — derived from [connectionStatus]. */
|
||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||
}
|
||||
|
||||
data class SessionUi(
|
||||
val id: String,
|
||||
@@ -58,17 +75,44 @@ data class ClarifyUi(
|
||||
class ChatViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application) {
|
||||
// ── Internal state ───────────────────────────────────────────────────
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val wsClient = HermesWsClient
|
||||
private val pendingRequests = ConcurrentHashMap<String, String>() // requestId -> method
|
||||
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
|
||||
private val dao = HermesDatabase.get(application).chatMessageDao()
|
||||
|
||||
/** Tracks the ID of the currently streaming assistant message. */
|
||||
@Volatile
|
||||
private var streamingMessageId: String? = null
|
||||
|
||||
private var pendingCleanupJob: Job? = null
|
||||
|
||||
// ── Public state ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Combined UI state: merges internal state with the WS connection status
|
||||
* flow so there is a single source of truth for connection state.
|
||||
*/
|
||||
val uiState: StateFlow<ChatUiState> =
|
||||
combine(
|
||||
_uiState,
|
||||
wsClient.connectionStatus,
|
||||
) { state, connStatus ->
|
||||
state.copy(connectionStatus = connStatus)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
_uiState.value,
|
||||
)
|
||||
|
||||
init {
|
||||
connectWebSocket()
|
||||
startPendingRequestCleanup()
|
||||
}
|
||||
|
||||
// ── Connection ───────────────────────────────────────────────────────
|
||||
|
||||
private fun connectWebSocket() {
|
||||
val token = AuthManager.getToken() ?: return
|
||||
|
||||
@@ -83,21 +127,14 @@ class ChatViewModel(
|
||||
handleWsEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
if (wsClient.isConnected) {
|
||||
_uiState.update { it.copy(isConnected = true, isLoading = false) }
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
if (_uiState.value.currentSessionId == null) {
|
||||
createNewSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WS Event Handling ────────────────────────────────────────────────
|
||||
|
||||
private fun handleWsEvent(event: WsEvent) {
|
||||
when (event) {
|
||||
is WsEvent.GatewayReady -> {
|
||||
_uiState.update { it.copy(isConnected = true, isLoading = false) }
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
if (_uiState.value.currentSessionId == null) {
|
||||
@@ -107,152 +144,13 @@ class ChatViewModel(
|
||||
|
||||
is WsEvent.SessionInfo -> {}
|
||||
|
||||
is WsEvent.MessageStart -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAgentTyping = true,
|
||||
currentStreamingText = "",
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.MessageToken -> {
|
||||
_uiState.update { state ->
|
||||
val newText = state.currentStreamingText + event.token
|
||||
val messages = state.messages.toMutableList()
|
||||
val streamingIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.ASSISTANT && it.isStreaming
|
||||
}
|
||||
if (streamingIdx >= 0) {
|
||||
messages[streamingIdx] = messages[streamingIdx].copy(content = newText)
|
||||
} else {
|
||||
messages.add(
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = newText,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
// 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,
|
||||
isThinking = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ThinkingDelta -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isThinking = true,
|
||||
thinkingText = state.thinkingText + event.token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.MessageComplete -> {
|
||||
_uiState.update { state ->
|
||||
val messages = state.messages.toMutableList()
|
||||
val streamingIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.ASSISTANT && it.isStreaming
|
||||
}
|
||||
val finalMessage: ChatMessage
|
||||
if (streamingIdx >= 0) {
|
||||
finalMessage =
|
||||
messages[streamingIdx].copy(
|
||||
content = event.text,
|
||||
isStreaming = false,
|
||||
)
|
||||
messages[streamingIdx] = finalMessage
|
||||
} else {
|
||||
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,
|
||||
isAgentTyping = false,
|
||||
currentStreamingText = "",
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.MessageDone -> {
|
||||
_uiState.update {
|
||||
it.copy(isAgentTyping = false, isThinking = false, thinkingText = "")
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ToolStart -> {
|
||||
val toolMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = event.data.toString(),
|
||||
toolName = event.name,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ToolComplete -> {
|
||||
_uiState.update { state ->
|
||||
val messages = state.messages.toMutableList()
|
||||
val toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
if (toolIdx >= 0) {
|
||||
messages[toolIdx] =
|
||||
messages[toolIdx].copy(
|
||||
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)
|
||||
}
|
||||
}
|
||||
is WsEvent.MessageStart -> handleMessageStart(event)
|
||||
is WsEvent.MessageToken -> handleMessageToken(event)
|
||||
is WsEvent.ThinkingDelta -> handleThinkingDelta(event)
|
||||
is WsEvent.MessageComplete -> handleMessageComplete(event)
|
||||
is WsEvent.MessageDone -> handleMessageDone(event)
|
||||
is WsEvent.ToolStart -> handleToolStart(event)
|
||||
is WsEvent.ToolComplete -> handleToolComplete(event)
|
||||
|
||||
is WsEvent.ClarifyRequest -> {
|
||||
_uiState.update {
|
||||
@@ -284,12 +182,217 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message streaming ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks if an incoming WS event belongs to the currently active
|
||||
* session. Returns true if the event should be processed.
|
||||
*/
|
||||
private fun isCurrentSession(eventSessionId: String?): Boolean {
|
||||
// If the event has no session ID, process it (legacy compatibility)
|
||||
if (eventSessionId == null) return true
|
||||
return eventSessionId == _uiState.value.currentSessionId
|
||||
}
|
||||
|
||||
private fun handleMessageStart(event: WsEvent.MessageStart) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
// Create the streaming message as a standalone state field.
|
||||
val msg = ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
isStreaming = true,
|
||||
)
|
||||
streamingMessageId = msg.id
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAgentTyping = true,
|
||||
streamingMessage = msg,
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessageToken(event: WsEvent.MessageToken) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
_uiState.update { state ->
|
||||
val current = state.streamingMessage
|
||||
if (current != null) {
|
||||
state.copy(
|
||||
streamingMessage = current.copy(
|
||||
content = current.content + event.token,
|
||||
),
|
||||
isThinking = false,
|
||||
)
|
||||
} else {
|
||||
// Fallback: no MessageStart was received — create one now
|
||||
val msg = ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = event.token,
|
||||
isStreaming = true,
|
||||
)
|
||||
streamingMessageId = msg.id
|
||||
state.copy(
|
||||
streamingMessage = msg,
|
||||
isAgentTyping = true,
|
||||
isThinking = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isThinking = true,
|
||||
thinkingText = state.thinkingText + event.token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessageComplete(event: WsEvent.MessageComplete) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
|
||||
// Finalize: move the streaming message into the main list
|
||||
val finalizedMsg: ChatMessage
|
||||
val sessionId: String?
|
||||
|
||||
_uiState.update { state ->
|
||||
val streaming = state.streamingMessage
|
||||
finalizedMsg = if (streaming != null) {
|
||||
streaming.copy(
|
||||
content = event.text,
|
||||
isStreaming = false,
|
||||
)
|
||||
} else {
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = event.text,
|
||||
)
|
||||
}
|
||||
sessionId = state.currentSessionId
|
||||
state.copy(
|
||||
messages = state.messages + finalizedMsg,
|
||||
streamingMessage = null,
|
||||
isAgentTyping = false,
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
streamingMessageId = null
|
||||
|
||||
// Persist finalized message — OUTSIDE update{} to avoid CAS retry
|
||||
val sid = sessionId ?: _uiState.value.currentSessionId
|
||||
if (sid != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(finalizedMsg.toEntity(sid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessageDone(event: WsEvent.MessageDone) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
|
||||
// If we still have an un-finalized streaming message (no MessageComplete
|
||||
// was sent, which happens on interrupts), fold it into the list.
|
||||
val orphan: ChatMessage?
|
||||
val sessionId: String?
|
||||
|
||||
_uiState.update { state ->
|
||||
val streaming = state.streamingMessage
|
||||
orphan = streaming?.copy(isStreaming = false)
|
||||
sessionId = state.currentSessionId
|
||||
if (orphan != null) {
|
||||
state.copy(
|
||||
messages = state.messages + orphan,
|
||||
streamingMessage = null,
|
||||
isAgentTyping = false,
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
} else {
|
||||
state.copy(
|
||||
isAgentTyping = false,
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
streamingMessageId = null
|
||||
|
||||
// Persist orphaned streaming message
|
||||
val sid = sessionId ?: _uiState.value.currentSessionId
|
||||
if (orphan != null && sid != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(orphan.toEntity(sid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool events ──────────────────────────────────────────────────────
|
||||
|
||||
private fun handleToolStart(event: WsEvent.ToolStart) {
|
||||
val toolMessage = ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = event.data.toString(),
|
||||
toolName = event.name,
|
||||
toolStatus = ToolStatus.RUNNING,
|
||||
)
|
||||
|
||||
_uiState.update { state ->
|
||||
state.copy(messages = state.messages + toolMessage)
|
||||
}
|
||||
|
||||
// Persist — OUTSIDE update{}
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(toolMessage.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleToolComplete(event: WsEvent.ToolComplete) {
|
||||
var completedTool: ChatMessage? = null
|
||||
|
||||
_uiState.update { state ->
|
||||
val messages = state.messages.toMutableList()
|
||||
val toolIdx = messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
if (toolIdx >= 0) {
|
||||
val updated = messages[toolIdx].copy(
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
content = event.data.toString(),
|
||||
)
|
||||
messages[toolIdx] = updated
|
||||
completedTool = updated
|
||||
}
|
||||
state.copy(messages = messages)
|
||||
}
|
||||
|
||||
// Persist — OUTSIDE update{}
|
||||
val tool = completedTool
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (tool != null && sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(tool.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC response handling ────────────────────────────────────────────
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun handleRpcResult(
|
||||
id: String,
|
||||
result: Any?,
|
||||
) {
|
||||
val method = pendingRequests.remove(id) ?: return
|
||||
val pending = pendingRequests.remove(id) ?: return
|
||||
val method = pending.method
|
||||
|
||||
when (method) {
|
||||
WsMethods.SESSION_CREATE -> {
|
||||
@@ -300,6 +403,7 @@ class ChatViewModel(
|
||||
currentSessionId = sessionId,
|
||||
isLoading = false,
|
||||
messages = emptyList(),
|
||||
streamingMessage = null,
|
||||
)
|
||||
}
|
||||
addSystemMessage("Session created", persist = true)
|
||||
@@ -347,8 +451,13 @@ class ChatViewModel(
|
||||
|
||||
WsMethods.SESSION_INTERRUPT -> {
|
||||
_uiState.update {
|
||||
it.copy(isAgentTyping = false, isThinking = false)
|
||||
it.copy(
|
||||
isAgentTyping = false,
|
||||
isThinking = false,
|
||||
streamingMessage = null,
|
||||
)
|
||||
}
|
||||
streamingMessageId = null
|
||||
addSystemMessage("Session interrupted")
|
||||
}
|
||||
}
|
||||
@@ -358,7 +467,8 @@ class ChatViewModel(
|
||||
id: String,
|
||||
error: Any?,
|
||||
) {
|
||||
val method = pendingRequests.remove(id)
|
||||
val pending = pendingRequests.remove(id)
|
||||
val method = pending?.method
|
||||
val errorMsg =
|
||||
when (error) {
|
||||
is Map<*, *> -> error["message"] as? String ?: error.toString()
|
||||
@@ -372,6 +482,8 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send message ─────────────────────────────────────────────────────
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
@@ -382,27 +494,29 @@ class ChatViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
val userMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.USER,
|
||||
content = text,
|
||||
)
|
||||
val userMessage = ChatMessage(
|
||||
role = MessageRole.USER,
|
||||
content = text,
|
||||
)
|
||||
|
||||
// Update UI — no DB writes inside update{}
|
||||
_uiState.update { state ->
|
||||
val messages = state.messages + userMessage
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(userMessage.toEntity(sessionId))
|
||||
}
|
||||
state.copy(
|
||||
messages = messages,
|
||||
messages = state.messages + userMessage,
|
||||
isAgentTyping = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Persist to Room — OUTSIDE update{}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(userMessage.toEntity(sessionId))
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.sendMessage(
|
||||
sessionId,
|
||||
text,
|
||||
onSent = { id -> pendingRequests[id] = WsMethods.PROMPT_SUBMIT },
|
||||
onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -410,7 +524,10 @@ class ChatViewModel(
|
||||
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) }
|
||||
|
||||
// Persist — OUTSIDE update{}
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(userMsg.toEntity(sessionId))
|
||||
@@ -538,8 +655,10 @@ class ChatViewModel(
|
||||
|
||||
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) }
|
||||
|
||||
// Persist — OUTSIDE update{}
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(msg.toEntity(sessionId))
|
||||
@@ -547,23 +666,32 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session management ───────────────────────────────────────────────
|
||||
|
||||
fun interruptSession() {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_INTERRUPT,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_INTERRUPT },
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_INTERRUPT) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewSession() {
|
||||
_uiState.update { it.copy(isLoading = true, messages = emptyList()) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
messages = emptyList(),
|
||||
streamingMessage = null,
|
||||
)
|
||||
}
|
||||
streamingMessageId = null
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_CREATE,
|
||||
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_CREATE },
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -572,18 +700,23 @@ class ChatViewModel(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_LIST,
|
||||
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_LIST },
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
|
||||
// Reset streaming state
|
||||
streamingMessageId = null
|
||||
|
||||
_uiState.update {
|
||||
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
messages = emptyList(),
|
||||
streamingMessage = null,
|
||||
currentSessionId = sessionId,
|
||||
chatTitle = title,
|
||||
showSessionPicker = false,
|
||||
@@ -592,16 +725,17 @@ class ChatViewModel(
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
// Load cached messages first — instant display
|
||||
// Step 1: Load cached messages first — instant display
|
||||
loadCachedMessages(sessionId)
|
||||
// Then resume session on server
|
||||
// Step 2: Resume session on server
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_RESUME },
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||
)
|
||||
}
|
||||
// Step 3: Load fresh messages from REST and merge
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
|
||||
@@ -610,7 +744,7 @@ class ChatViewModel(
|
||||
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
|
||||
// Only replace if still showing this session
|
||||
if (state.currentSessionId == sessionId) {
|
||||
state.copy(messages = cachedMessages, isLoading = false)
|
||||
} else {
|
||||
@@ -630,7 +764,7 @@ class ChatViewModel(
|
||||
is NetworkResult.Success -> {
|
||||
val messagesList = result.data.messages.orEmpty()
|
||||
val chatMessages =
|
||||
messagesList.map { msg ->
|
||||
messagesList.mapIndexed { index, msg ->
|
||||
val role =
|
||||
when (msg.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
@@ -638,28 +772,45 @@ class ChatViewModel(
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
// Use stable IDs based on session + index to prevent
|
||||
// Room duplicates when re-loading the same session.
|
||||
val stableId = "rest-$sessionId-$index"
|
||||
// Preserve original timestamp from the API when available
|
||||
val ts = msg.timestamp?.toLongOrNull() ?: System.currentTimeMillis()
|
||||
ChatMessage(
|
||||
id = stableId,
|
||||
role = role,
|
||||
content = msg.content.orEmpty(),
|
||||
timestamp = ts,
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
// Persist loaded messages to Room for offline access
|
||||
val entities = chatMessages.map { it.toEntity(sessionId) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.upsertAll(entities)
|
||||
}
|
||||
_uiState.update { state ->
|
||||
val streamingTail = state.messages.filter { it.isStreaming }
|
||||
// Only update if still on the same session
|
||||
if (state.currentSessionId != sessionId) return@update state
|
||||
|
||||
// Merge: keep any user messages sent between cache load
|
||||
// and REST response (they won't be in the REST response
|
||||
// yet), plus preserve any active streaming message.
|
||||
val existingIds = chatMessages.map { it.id }.toSet()
|
||||
val localOnly = state.messages.filter { it.id !in existingIds }
|
||||
state.copy(
|
||||
messages = chatMessages + streamingTail,
|
||||
messages = chatMessages + localOnly,
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
// Don't overwrite messages on REST failure — cached messages
|
||||
// are still valid.
|
||||
_uiState.update {
|
||||
if (it.currentSessionId != sessionId) return@update it
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load messages: ${result.error.message}",
|
||||
@@ -670,6 +821,8 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI actions ───────────────────────────────────────────────────────
|
||||
|
||||
fun toggleSessionPicker() {
|
||||
_uiState.update { it.copy(showSessionPicker = !it.showSessionPicker) }
|
||||
}
|
||||
@@ -690,7 +843,6 @@ class ChatViewModel(
|
||||
fun reconnect() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnected = false,
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
)
|
||||
@@ -709,18 +861,53 @@ class ChatViewModel(
|
||||
persist: Boolean = false,
|
||||
) {
|
||||
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
|
||||
_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))
|
||||
|
||||
// Persist — OUTSIDE update{}
|
||||
if (persist && sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(msg.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending request tracking ─────────────────────────────────────────
|
||||
|
||||
private data class PendingRequest(
|
||||
val method: String,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
private fun trackRequest(
|
||||
id: String,
|
||||
method: String,
|
||||
) {
|
||||
pendingRequests[id] = PendingRequest(method)
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically prunes stale pending requests that the server never
|
||||
* responded to. Surfaces a timeout error for the user.
|
||||
*/
|
||||
private fun startPendingRequestCleanup() {
|
||||
pendingCleanupJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(PENDING_REQUEST_TIMEOUT_MS)
|
||||
val now = System.currentTimeMillis()
|
||||
val stale = pendingRequests.entries.filter {
|
||||
now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS
|
||||
}
|
||||
for (entry in stale) {
|
||||
pendingRequests.remove(entry.key)
|
||||
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────
|
||||
// ── Search ────────────────────────────────────────────────────────────
|
||||
|
||||
fun toggleSearch() {
|
||||
val current = _uiState.value
|
||||
@@ -776,8 +963,11 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pendingCleanupJob?.cancel()
|
||||
wsClient.disconnect()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user