From e7b2973c1caa33b7bb9519c6ae07d395a54171cd Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:19:25 +0300 Subject: [PATCH] refactor(#288): split ChatViewModel god object (CODE-34) (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(#288): extract ChatPersistenceRepository from ChatViewModel * refactor(#288): extract ChatWsEventReducer pure state reducer * refactor(#288): extract SlashCommandDispatcher * refactor(#288): wire SlashCommandDispatcher into ViewModel * refactor(#288): extract ChatSearchController * refactor(#288): final cleanup — remove unused import, ktlint pass * fix: compile & test fixes — GatewayReady handler, Gson import, MessageStart id, reducer args * fix: align reducer handlers with old behavior — onMessageComplete onMessageDone onMessageStart * chore: remove plan file from PR * chore: remove unrelated Room schema from PR * fix: add session filtering to reducer, fix onToolStart streaming state * chore: remove schema file again * fix: pass currentSessionId to reducer, align onMessageStart and onThinkingDelta state * chore: remove schema --------- Co-authored-by: M57 --- .../ui/chat/ChatPersistenceRepository.kt | 37 ++ .../ui/chat/ChatSearchController.kt | 37 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 547 ++++++------------ .../ui/chat/ChatWsEventReducer.kt | 389 +++++++++++++ .../ui/chat/SlashCommandDispatcher.kt | 63 ++ 5 files changed, 701 insertions(+), 372 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt new file mode 100644 index 00000000..b1439212 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.local.ChatMessageDao +import com.m57.hermescontrol.data.local.toEntity +import com.m57.hermescontrol.data.local.toUiModel + +/** + * Wraps Room DAO operations for chat message persistence. + * + * Extracted from ChatViewModel to separate persistence concerns from + * UI state management and WebSocket event handling. + */ +class ChatPersistenceRepository( + private val dao: ChatMessageDao, +) { + /** Persist a single message for the given session. */ + suspend fun persistMessage( + message: ChatMessage, + sessionId: String, + ) { + dao.upsert(message.toEntity(sessionId)) + } + + /** Persist multiple messages in one transaction. */ + suspend fun persistMessages( + messages: List, + sessionId: String, + ) { + val entities = messages.map { it.toEntity(sessionId) } + dao.upsertAll(entities) + } + + /** Load cached messages for a session from Room. */ + suspend fun loadMessages(sessionId: String): List { + return dao.getMessagesForSession(sessionId).map { it.toUiModel() } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt new file mode 100644 index 00000000..5255dd26 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Pure search logic for in-chat text search. + * + * Computes match indices and navigation without depending on ViewModel or Android. + */ +class ChatSearchController { + /** + * Find all message indices where [query] appears in the content. + */ + fun findMatches( + messages: List, + query: String, + ): List { + if (query.isBlank()) return emptyList() + return messages.indices.filter { idx -> + messages[idx].content.contains(query, ignoreCase = true) + } + } + + /** + * Compute the next/previous match index given the current position. + */ + fun navigate( + currentIndex: Int, + matchCount: Int, + direction: Int, + ): Int { + if (matchCount == 0) return -1 + return when (direction) { + 1 -> if (currentIndex >= matchCount - 1) 0 else currentIndex + 1 + -1 -> if (currentIndex <= 0) matchCount - 1 else currentIndex - 1 + else -> currentIndex + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a4f69d36..ddac5c42 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -7,8 +7,6 @@ import androidx.lifecycle.viewModelScope import com.google.gson.Gson 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.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall @@ -88,7 +86,12 @@ class ChatViewModel( private val wsClient = HermesWsClient private val pendingRequests = ConcurrentHashMap() - private val dao = HermesDatabase.get(application).chatMessageDao() + private val repo = + ChatPersistenceRepository( + HermesDatabase.get(application).chatMessageDao(), + ) + private val slashDispatcher = SlashCommandDispatcher() + private val searchController = ChatSearchController() /** Tracks the ID of the currently streaming assistant message. */ @Volatile @@ -158,6 +161,36 @@ class ChatViewModel( var initialSessionId: String? = null private fun handleWsEvent(event: WsEvent) { + // First, let the reducer compute the new state and any effects + val result = ChatWsEventReducer.reduce(_uiState.value, event, _uiState.value.currentSessionId) + + // Apply the new state + _uiState.update { result.state } + + // Process side-effects from the reducer + for (effect in result.effects) { + when (effect) { + is ReducerEffect.PersistMessage -> { + viewModelScope.launch(Dispatchers.IO) { + repo.persistMessage(effect.message, effect.sessionId) + } + } + + is ReducerEffect.CreateNewSession -> { + createNewSession() + } + + is ReducerEffect.LoadSessions -> { + loadSessions() + } + + is ReducerEffect.RefreshSessions -> { + loadSessions() + } + } + } + + // Handle complex events that need ViewModel-specific context when (event) { is WsEvent.GatewayReady -> { _uiState.update { it.copy(isLoading = false) } @@ -174,12 +207,6 @@ class ChatViewModel( } } - is WsEvent.SessionInfo -> {} - - is WsEvent.MessageStart -> { - handleMessageStart(event) - } - is WsEvent.MessageToken -> { handleMessageToken(event) } @@ -188,40 +215,34 @@ class ChatViewModel( handleThinkingDelta(event) } + is WsEvent.MessageStart -> { + streamingMessageId = + java.util.UUID + .randomUUID() + .toString() + streamingBuffer.clear() + thinkingBuffer.clear() + lastFlushMs = 0L + lastThinkingFlushMs = 0L + } + is WsEvent.MessageComplete -> { - handleMessageComplete(event) + // Buffers cleared before reduce; ViewModel resets them after + streamingMessageId = null + streamingBuffer.clear() + thinkingBuffer.clear() } is WsEvent.MessageDone -> { - handleMessageDone(event) + streamingMessageId = null + streamingBuffer.clear() + thinkingBuffer.clear() } is WsEvent.ToolStart -> { - handleToolStart(event) - } - - is WsEvent.ToolComplete -> { - handleToolComplete(event) - } - - is WsEvent.ClarifyRequest -> { - if (!isCurrentSession(event.sessionId)) return - _uiState.update { - it.copy( - clarifyRequest = - ClarifyUi( - text = event.text.orEmpty(), - options = event.options.orEmpty(), - clarifyId = event.clarifyId, - ), - ) - } - } - - is WsEvent.StatusUpdate -> {} - - is WsEvent.SessionUpdated -> { - loadSessions() + // Reset streaming state when a tool starts + streamingBuffer.clear() + thinkingBuffer.clear() } is WsEvent.RpcResult -> { @@ -232,7 +253,19 @@ class ChatViewModel( handleRpcError(event.id, event.error) } - is WsEvent.Unknown -> {} + is WsEvent.SessionUpdated -> { + loadSessions() + } + + is WsEvent.ClarifyRequest -> { + _uiState.update { + it.copy( + isAgentTyping = false, + ) + } + } + + else -> { /* reducer handles these */ } } } @@ -248,52 +281,6 @@ class ChatViewModel( return eventSessionId == _uiState.value.currentSessionId } - private fun handleMessageStart(event: WsEvent.MessageStart) { - if (!isCurrentSession(event.sessionId)) return - - var orphanToPersist: ChatMessage? = null - val sessionId = _uiState.value.currentSessionId - - streamingBuffer.clear() - thinkingBuffer.clear() - lastFlushMs = 0L - lastThinkingFlushMs = 0L - - // Create the streaming message as a standalone state field. - val msg = - ChatMessage( - role = MessageRole.ASSISTANT, - content = "", - isStreaming = true, - ) - streamingMessageId = msg.id - - _uiState.update { state -> - val messages = state.messages.toMutableList() - val existing = state.streamingMessage - if (existing != null && existing.content.isNotEmpty()) { - val finalized = existing.copy(isStreaming = false) - messages.add(finalized) - orphanToPersist = finalized - } - state.copy( - messages = messages, - isAgentTyping = true, - streamingMessage = msg, - isThinking = false, - thinkingText = "", - ) - } - - // Persist — OUTSIDE update{} - val orphan = orphanToPersist - if (orphan != null && sessionId != null) { - viewModelScope.launch(Dispatchers.IO) { - dao.upsert(orphan.toEntity(sessionId)) - } - } - } - private fun handleMessageToken(event: WsEvent.MessageToken) { if (!isCurrentSession(event.sessionId)) return @@ -354,183 +341,6 @@ class ChatViewModel( } } - private fun handleMessageComplete(event: WsEvent.MessageComplete) { - if (!isCurrentSession(event.sessionId)) return - - var finalizedMsg: ChatMessage? = null - var sessionId: String? = null - - streamingBuffer.clear() - thinkingBuffer.clear() - - _uiState.update { state -> - val streaming = state.streamingMessage - val msg = - if (streaming != null) { - streaming.copy( - content = event.text, - isStreaming = false, - ) - } else { - ChatMessage( - role = MessageRole.ASSISTANT, - content = event.text, - ) - } - finalizedMsg = msg - sessionId = state.currentSessionId - state.copy( - messages = state.messages + msg, - streamingMessage = null, - isAgentTyping = false, - isThinking = false, - thinkingText = "", - ) - } - streamingMessageId = null - - // Persist finalized message — OUTSIDE update{} to avoid CAS retry - val msgToPersist = finalizedMsg - val sid = sessionId ?: _uiState.value.currentSessionId - if (msgToPersist != null && sid != null) { - viewModelScope.launch(Dispatchers.IO) { - dao.upsert(msgToPersist.toEntity(sid)) - } - } - // Refresh session list to catch newly generated titles - loadSessions() - } - - 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. - var orphan: ChatMessage? = null - var sessionId: String? = null - - // Flush any remaining buffered content to the message before marking it done - val finalContent = streamingBuffer.toString() - val finalThinkingText = thinkingBuffer.toString() - - streamingBuffer.clear() - thinkingBuffer.clear() - - _uiState.update { state -> - val streaming = state.streamingMessage - val msg = - streaming?.copy( - content = finalContent.ifEmpty { streaming.content }, - isStreaming = false, - ) - orphan = msg - sessionId = state.currentSessionId - if (msg != null) { - state.copy( - messages = state.messages + msg, - streamingMessage = null, - isAgentTyping = false, - isThinking = false, - thinkingText = "", - ) - } else { - state.copy( - isAgentTyping = false, - isThinking = false, - thinkingText = "", - ) - } - } - streamingMessageId = null - - // Persist orphaned streaming message - val msgToPersist = orphan - val sid = sessionId ?: _uiState.value.currentSessionId - if (msgToPersist != null && sid != null) { - viewModelScope.launch(Dispatchers.IO) { - dao.upsert(msgToPersist.toEntity(sid)) - } - } - } - - // ── Tool events ────────────────────────────────────────────────────── - - private fun handleToolStart(event: WsEvent.ToolStart) { - if (!isCurrentSession(event.sessionId)) return - var orphanToPersist: ChatMessage? = null - val sessionId = _uiState.value.currentSessionId - - val contentJson = event.data?.let { gson.toJson(it) } ?: "" - val toolMessage = - ChatMessage( - role = MessageRole.TOOL, - content = contentJson, - toolName = event.name, - toolStatus = ToolStatus.RUNNING, - ) - - _uiState.update { state -> - val messages = state.messages.toMutableList() - val existing = state.streamingMessage - if (existing != null && existing.content.isNotEmpty()) { - val finalized = existing.copy(isStreaming = false) - messages.add(finalized) - orphanToPersist = finalized - } - messages.add(toolMessage) - state.copy( - messages = messages, - streamingMessage = null, - ) - } - - // Persist — OUTSIDE update{} - val orphan = orphanToPersist - if (sessionId != null) { - viewModelScope.launch(Dispatchers.IO) { - if (orphan != null) { - dao.upsert(orphan.toEntity(sessionId)) - } - dao.upsert(toolMessage.toEntity(sessionId)) - } - } - } - - private fun handleToolComplete(event: WsEvent.ToolComplete) { - if (!isCurrentSession(event.sessionId)) return - 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 contentJson = event.data?.let { gson.toJson(it) } ?: "" - val updated = - messages[toolIdx].copy( - toolStatus = ToolStatus.COMPLETED, - content = contentJson, - ) - 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") @@ -661,7 +471,7 @@ class ChatViewModel( // Persist to Room — OUTSIDE update{} viewModelScope.launch(Dispatchers.IO) { - dao.upsert(userMessage.toEntity(sessionId)) + repo.persistMessage(userMessage, sessionId) } viewModelScope.launch(Dispatchers.IO) { @@ -682,125 +492,124 @@ class ChatViewModel( // Persist — OUTSIDE update{} if (sessionId != null) { viewModelScope.launch(Dispatchers.IO) { - dao.upsert(userMsg.toEntity(sessionId)) + repo.persistMessage(userMsg, sessionId) } } - val parts = command.split(" ") - val cmd = parts[0].lowercase() + when (val result = slashDispatcher.dispatch(command)) { + is SlashResult.Message -> { + addAssistantMessage(result.text) + } - when (cmd) { - "/stop", "/interrupt" -> { + is SlashResult.Interrupt -> { interruptSession() } - "/new" -> { + is SlashResult.NewSession -> { createNewSession() } - "/help" -> { - val helpText = - """ - **Available Commands:** - • `/help` - Show this help menu - • `/status` - Check gateway and platform status - • `/sessions` - List all chat sessions - • `/stats` or `/system` - Check system resource usage - • `/new` - Create a new chat session - • `/stop` or `/interrupt` - Interrupt the active run - """.trimIndent() - addAssistantMessage(helpText) + is SlashResult.FetchStatus -> { + fetchAndDisplayStatus() } - "/status" -> { - viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getStatus() } - } - when (result) { - is NetworkResult.Success -> { - val body = result.data - val platformsStr = - body.gateway_platforms?.entries?.joinToString("\n") { (k, v) -> - " • **$k**: ${v.state ?: "Unknown"}${if (v.error_code != null) " (Error: ${v.error_code})" else ""}" - } ?: "No active platforms" + is SlashResult.FetchSessions -> { + fetchAndDisplaySessions() + } - val statusText = - """ - **Hermes Gateway Status:** - • **Version:** ${body.version ?: "Unknown"} - • **Gateway Running:** ${body.gateway_running ?: false} - • **Active Sessions:** ${body.active_sessions ?: 0} - • **Auth Required:** ${body.auth_required ?: false} + is SlashResult.FetchStats -> { + fetchAndDisplayStats() + } - **Platforms:** - $platformsStr - """.trimIndent() - addAssistantMessage(statusText) - } + is SlashResult.Unknown -> { + addAssistantMessage( + "Unknown command: `${result.cmd}`. Type `/help` to view a list of available commands.", + ) + } + } + } - is NetworkResult.Failure -> { - addAssistantMessage("Failed to retrieve status: ${result.error.message}") - } - } + private fun fetchAndDisplayStatus() { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getStatus() } + } + when (result) { + is NetworkResult.Success -> { + val body = result.data + val platformsStr = + body.gateway_platforms?.entries?.joinToString("\n") { (k, v) -> + " • **$k**: ${v.state ?: "Unknown"}${if (v.error_code != null) " (Error: ${v.error_code})" else ""}" + } ?: "No active platforms" + addAssistantMessage( + """ + **Hermes Gateway Status:** + • **Version:** ${body.version ?: "Unknown"} + • **Gateway Running:** ${body.gateway_running ?: false} + • **Active Sessions:** ${body.active_sessions ?: 0} + • **Auth Required:** ${body.auth_required ?: false} + + **Platforms:** + $platformsStr + """.trimIndent(), + ) + } + + is NetworkResult.Failure -> { + addAssistantMessage("Failed to retrieve status: ${result.error.message}") } } + } + } - "/sessions" -> { - viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getSessions() } - } - when (result) { - is NetworkResult.Success -> { - val body = result.data - val sessionsStr = - body.sessions.joinToString("\n") { s -> - "• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})" - } - addAssistantMessage("**Sessions List:**\n$sessionsStr") + private fun fetchAndDisplaySessions() { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getSessions() } + } + when (result) { + is NetworkResult.Success -> { + val body = result.data + val sessionsStr = + body.sessions.joinToString("\n") { s -> + "• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})" } + addAssistantMessage("**Sessions List:**\n$sessionsStr") + } - is NetworkResult.Failure -> { - addAssistantMessage("Failed to list sessions: ${result.error.message}") - } - } + is NetworkResult.Failure -> { + addAssistantMessage("Failed to list sessions: ${result.error.message}") } } + } + } - "/stats", "/system" -> { - viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getSystemStats() } - } - when (result) { - is NetworkResult.Success -> { - val body = result.data - val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A" - val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A" - val uptimeVal = body.uptime ?: "N/A" - val statsText = - """ - **System Resource Stats:** - • **CPU Usage:** $cpuPct - • **Memory Usage:** $memPct - • **Uptime:** $uptimeVal - """.trimIndent() - addAssistantMessage(statsText) - } - - is NetworkResult.Failure -> { - addAssistantMessage("Failed to retrieve system stats: ${result.error.message}") - } - } + private fun fetchAndDisplayStats() { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getSystemStats() } + } + when (result) { + is NetworkResult.Success -> { + val body = result.data + val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A" + val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A" + addAssistantMessage( + """ + **System Resource Stats:** + • **CPU Usage:** $cpuPct + • **Memory Usage:** $memPct + • **Uptime:** ${body.uptime ?: "N/A"} + """.trimIndent(), + ) } - } - else -> { - addAssistantMessage("Unknown command: `$cmd`. Type `/help` to view a list of available commands.") + is NetworkResult.Failure -> { + addAssistantMessage("Failed to retrieve system stats: ${result.error.message}") + } } } } @@ -813,7 +622,7 @@ class ChatViewModel( val sessionId = _uiState.value.currentSessionId if (sessionId != null) { viewModelScope.launch(Dispatchers.IO) { - dao.upsert(msg.toEntity(sessionId)) + repo.persistMessage(msg, sessionId) } } } @@ -913,8 +722,7 @@ class ChatViewModel( private fun loadCachedMessages(sessionId: String) { viewModelScope.launch(Dispatchers.IO) { - val cached = dao.getMessagesForSession(sessionId) - val cachedMessages = cached.map { it.toUiModel() } + val cachedMessages = repo.loadMessages(sessionId) _uiState.update { state -> // Only replace if still showing this session if (state.currentSessionId == sessionId) { @@ -958,9 +766,8 @@ class ChatViewModel( ) } // Persist loaded messages to Room for offline access - val entities = chatMessages.map { it.toEntity(sessionId) } withContext(Dispatchers.IO) { - dao.upsertAll(entities) + repo.persistMessages(chatMessages, sessionId) } _uiState.update { state -> // Only update if still on the same session @@ -1022,7 +829,7 @@ class ChatViewModel( } viewModelScope.launch(Dispatchers.IO) { - dao.upsert(userMessage.toEntity(sessionId)) + repo.persistMessage(userMessage, sessionId) } viewModelScope.launch(Dispatchers.IO) { @@ -1076,7 +883,7 @@ class ChatViewModel( // Persist — OUTSIDE update{} if (persist && sessionId != null) { viewModelScope.launch(Dispatchers.IO) { - dao.upsert(msg.toEntity(sessionId)) + repo.persistMessage(msg, sessionId) } } } @@ -1152,10 +959,7 @@ class ChatViewModel( searchJob = viewModelScope.launch(Dispatchers.Default) { val messages = _uiState.value.messages - val indices = - messages.indices.filter { idx -> - messages[idx].content.contains(query, ignoreCase = true) - } + val indices = searchController.findMatches(messages, query) _uiState.update { // Only update indices if the search query hasn't changed in the meantime @@ -1175,13 +979,12 @@ class ChatViewModel( _uiState.update { state -> val indices = state.searchMatchIndices if (indices.isEmpty()) return@update state - val current = state.currentSearchMatchIndex val newIdx = - when (direction) { - 1 -> if (current >= indices.lastIndex) 0 else current + 1 - -1 -> if (current <= 0) indices.lastIndex else current - 1 - else -> current - } + searchController.navigate( + currentIndex = state.currentSearchMatchIndex, + matchCount = indices.size, + direction = direction, + ) state.copy(currentSearchMatchIndex = newIdx) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt new file mode 100644 index 00000000..c969379a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -0,0 +1,389 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent + +/** + * Pure state reducer for WebSocket events. + * + * Transforms [ChatUiState] in response to each [WsEvent] and returns a + * [ReducerResult] containing the new state plus any side-effects the + * ViewModel should execute (persistence, navigation, etc.). + * + * This is a pure function — no I/O, no mutable state, no dependencies on + * Android or ViewModel classes. Easy to unit test. + */ +object ChatWsEventReducer { + fun reduce( + state: ChatUiState, + event: WsEvent, + currentSessionId: String? = null, + ): ReducerResult { + val eventSessionId = + when (event) { + is WsEvent.MessageStart -> event.sessionId + is WsEvent.MessageToken -> event.sessionId + is WsEvent.ThinkingDelta -> event.sessionId + is WsEvent.MessageComplete -> event.sessionId + is WsEvent.MessageDone -> event.sessionId + is WsEvent.ToolStart -> event.sessionId + is WsEvent.ToolComplete -> event.sessionId + is WsEvent.ClarifyRequest -> event.sessionId + else -> null + } + if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { + return ReducerResult(state) + } + return reduceInternal(state, event) + } + + private fun reduceInternal( + state: ChatUiState, + event: WsEvent, + ): ReducerResult = + when (event) { + is WsEvent.GatewayReady -> onGatewayReady(state) + + is WsEvent.MessageStart -> onMessageStart(state, event) + + is WsEvent.MessageToken -> onMessageToken(state, event) + + is WsEvent.ThinkingDelta -> onThinkingDelta(state, event) + + is WsEvent.MessageComplete -> onMessageComplete(state, event) + + is WsEvent.MessageDone -> onMessageDone(state) + + is WsEvent.ToolStart -> onToolStart(state, event) + + is WsEvent.ToolComplete -> onToolComplete(state, event) + + is WsEvent.ClarifyRequest -> onClarifyRequest(state, event) + + is WsEvent.RpcError -> onRpcError(state, event) + + is WsEvent.SessionUpdated -> onSessionUpdated(state) + + is WsEvent.StatusUpdate -> onStatusUpdate(state) + + is WsEvent.Unknown -> onUnknown(state) + + // SessionInfo is a no-op in the original code + is WsEvent.SessionInfo -> ReducerResult(state) + + // RpcResult is handled by the ViewModel (needs pending request context) + is WsEvent.RpcResult -> ReducerResult(state) + } + + // ── GatewayReady ────────────────────────────────────────────────── + + private fun onGatewayReady(state: ChatUiState): ReducerResult = ReducerResult(state) + + // ── MessageStart ────────────────────────────────────────────────── + + private fun onMessageStart( + state: ChatUiState, + event: WsEvent.MessageStart, + ): ReducerResult { + val msg = + ChatMessage( + role = MessageRole.ASSISTANT, + content = "", + isStreaming = true, + ) + val effects = mutableListOf() + + // Build new state: finalize any orphan streaming message, then set the new one + var orphan: ChatMessage? = null + val preState = + if (state.streamingMessage?.content?.isNotEmpty() == true) { + val finalized = state.streamingMessage!!.copy(isStreaming = false) + orphan = finalized + state.copy( + messages = state.messages + finalized, + streamingMessage = null, + isAgentTyping = true, + isThinking = false, + thinkingText = "", + ) + } else { + state.copy( + isAgentTyping = true, + isThinking = false, + thinkingText = "", + ) + } + val newState = preState.copy(streamingMessage = msg) + val sid = newState.currentSessionId + if (orphan != null && sid != null) { + effects.add(ReducerEffect.PersistMessage(orphan, sid)) + } + + return ReducerResult(newState, effects) + } + + // ── MessageToken ────────────────────────────────────────────────── + + private fun onMessageToken( + state: ChatUiState, + event: WsEvent.MessageToken, + ): ReducerResult { + // Token accumulation is done by the ViewModel (streaming buffer + flush timer). + // The reducer only signals that new content arrived — ViewModel owns the timer. + return ReducerResult(state) + } + + // ── ThinkingDelta ───────────────────────────────────────────────── + + private fun onThinkingDelta( + state: ChatUiState, + event: WsEvent.ThinkingDelta, + ): ReducerResult { + val currentContent = state.thinkingText + event.token + return ReducerResult( + state = + state.copy( + isThinking = true, + thinkingText = currentContent, + ), + ) + } + + // ── MessageComplete ─────────────────────────────────────────────── + + private fun onMessageComplete( + state: ChatUiState, + event: WsEvent.MessageComplete, + ): ReducerResult { + val streaming = state.streamingMessage + val msg = + streaming?.copy( + content = event.text ?: streaming.content, + isStreaming = false, + ) ?: ChatMessage( + role = MessageRole.ASSISTANT, + content = event.text ?: "", + ) + val effects = mutableListOf() + val sid = state.currentSessionId + if (sid != null) { + effects.add(ReducerEffect.PersistMessage(msg, sid)) + } + effects.add(ReducerEffect.RefreshSessions) + return ReducerResult( + state = + state.copy( + messages = state.messages + msg, + streamingMessage = null, + isAgentTyping = false, + isThinking = false, + thinkingText = "", + ), + effects = effects, + ) + } + + // ── MessageDone ─────────────────────────────────────────────────── + + private fun onMessageDone(state: ChatUiState): ReducerResult { + val streaming = state.streamingMessage ?: return ReducerResult(state) + val msg = streaming.copy(isStreaming = false) + val effects = mutableListOf() + val sid = state.currentSessionId + if (sid != null) { + effects.add(ReducerEffect.PersistMessage(msg, sid)) + } + return ReducerResult( + state = + state.copy( + messages = state.messages + msg, + streamingMessage = null, + isAgentTyping = false, + isThinking = false, + thinkingText = "", + ), + effects = effects, + ) + } + + // ── ToolStart ───────────────────────────────────────────────────── + + private fun onToolStart( + state: ChatUiState, + event: WsEvent.ToolStart, + ): ReducerResult { + val contentJson = + event.data?.let { + com.google.gson + .Gson() + .toJson(it) + } ?: "" + val toolMessage = + ChatMessage( + role = MessageRole.TOOL, + content = contentJson, + toolName = event.name, + toolStatus = ToolStatus.RUNNING, + ) + + // Finalize any orphan streaming message + var orphanToPersist: ChatMessage? = null + val newState = + if (state.streamingMessage?.content?.isNotEmpty() == true) { + val finalized = state.streamingMessage!!.copy(isStreaming = false) + orphanToPersist = finalized + state.copy( + messages = state.messages + finalized, + streamingMessage = null, + ) + } else { + state.copy(streamingMessage = null) + } + val sid = newState.currentSessionId + val effects = mutableListOf() + if (sid != null) { + if (orphanToPersist != null) { + effects.add(ReducerEffect.PersistMessage(orphanToPersist, sid)) + } + effects.add(ReducerEffect.PersistMessage(toolMessage, sid)) + } + + return ReducerResult( + state = newState.copy(messages = newState.messages + toolMessage), + effects = effects, + ) + } + + // ── ToolComplete ────────────────────────────────────────────────── + + private fun onToolComplete( + state: ChatUiState, + event: WsEvent.ToolComplete, + ): ReducerResult { + val contentJson = + event.data?.let { + com.google.gson + .Gson() + .toJson(it) + } ?: "" + val messages = state.messages.toMutableList() + val toolIdx = + messages.indexOfLast { + it.role == MessageRole.TOOL && + it.toolName == event.name && + it.toolStatus == ToolStatus.RUNNING + } + if (toolIdx < 0) return ReducerResult(state) + + val updated = + messages[toolIdx].copy( + toolStatus = ToolStatus.COMPLETED, + content = contentJson, + ) + messages[toolIdx] = updated + + val effects = mutableListOf() + val sid = state.currentSessionId + if (sid != null) { + effects.add(ReducerEffect.PersistMessage(updated, sid)) + } + return ReducerResult( + state = state.copy(messages = messages), + effects = effects, + ) + } + + // ── ClarifyRequest ──────────────────────────────────────────────── + + private fun onClarifyRequest( + state: ChatUiState, + event: WsEvent.ClarifyRequest, + ): ReducerResult = + ReducerResult( + state = + state.copy( + clarifyRequest = + ClarifyUi( + text = event.text.orEmpty(), + options = event.options.orEmpty(), + clarifyId = event.clarifyId, + ), + isAgentTyping = false, + ), + ) + + // ── RpcError ────────────────────────────────────────────────────── + + private fun onRpcError( + state: ChatUiState, + event: WsEvent.RpcError, + ): ReducerResult = + ReducerResult( + state = + state.copy( + isLoading = false, + errorMessage = "Error: ${event.error}", + ), + ) + + // ── SessionUpdated / StatusUpdate / Unknown ─────────────────────── + + private fun onSessionUpdated(state: ChatUiState): ReducerResult = ReducerResult(state) + + private fun onStatusUpdate(state: ChatUiState): ReducerResult = ReducerResult(state) + + private fun onUnknown(state: ChatUiState): ReducerResult = ReducerResult(state) + + // ── Helpers ─────────────────────────────────────────────────────── + + /** + * If there's a non-empty streaming message, finalize it into the message + * list and return it as an orphan that should be persisted. + */ + private fun finalizeStreamingMessage( + state: ChatUiState, + newMessage: ChatMessage, + ): Pair { + val existing = state.streamingMessage + if (existing != null && existing.content.isNotEmpty()) { + val finalized = existing.copy(isStreaming = false) + return Pair( + state.copy( + messages = state.messages + finalized, + streamingMessage = newMessage, + ), + finalized, + ) + } + return Pair( + state.copy(streamingMessage = newMessage), + null, + ) + } +} + +/** + * Result of reducing one event. + * + * @param state The new UI state after applying the event. + * @param effects Side-effects the ViewModel should execute. + */ +data class ReducerResult( + val state: ChatUiState, + val effects: List = emptyList(), +) + +/** + * Side-effects that the reducer cannot perform itself (needs I/O or ViewModel context). + */ +sealed class ReducerEffect { + data class PersistMessage( + val message: ChatMessage, + val sessionId: String, + ) : ReducerEffect() + + data object CreateNewSession : ReducerEffect() + + data object LoadSessions : ReducerEffect() + + data object RefreshSessions : ReducerEffect() +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt new file mode 100644 index 00000000..fe775fed --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -0,0 +1,63 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Parses slash commands and returns a [SlashResult] describing what action + * the ViewModel should take. + * + * Pure logic — no I/O, no Android dependencies. + */ +class SlashCommandDispatcher { + fun dispatch(command: String): SlashResult { + val parts = command.split(" ") + val cmd = parts[0].lowercase() + + return when (cmd) { + "/stop", "/interrupt" -> SlashResult.Interrupt + "/new" -> SlashResult.NewSession + "/help" -> SlashResult.Message(HELP_TEXT) + "/status" -> SlashResult.FetchStatus + "/sessions" -> SlashResult.FetchSessions + "/stats", "/system" -> SlashResult.FetchStats + else -> SlashResult.Unknown(cmd) + } + } + + companion object { + val HELP_TEXT = + """ + **Available Commands:** + • `/help` - Show this help menu + • `/status` - Check gateway and platform status + • `/sessions` - List all chat sessions + • `/stats` or `/system` - Check system resource usage + • `/new` - Create a new chat session + • `/stop` or `/interrupt` - Interrupt the active run + """.trimIndent() + } +} + +/** + * The result of dispatching a slash command. + */ +sealed class SlashResult { + /** Display a static message in the chat. */ + data class Message(val text: String) : SlashResult() + + /** Interrupt the active session. */ + data object Interrupt : SlashResult() + + /** Create a new session. */ + data object NewSession : SlashResult() + + /** Fetch gateway status via REST and display. */ + data object FetchStatus : SlashResult() + + /** Fetch session list via REST and display. */ + data object FetchSessions : SlashResult() + + /** Fetch system stats via REST and display. */ + data object FetchStats : SlashResult() + + /** Command not recognised. */ + data class Unknown(val cmd: String) : SlashResult() +}