* 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
f190b67ee6
commit
e7b2973c1c
5 files changed
+701
-372
No files matched your search
@@ -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<ChatMessage>,
|
||||||
|
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<ChatMessage> {
|
||||||
|
return dao.getMessagesForSession(sessionId).map { it.toUiModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatMessage>,
|
||||||
|
query: String,
|
||||||
|
): List<Int> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -88,7 +86,12 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private val wsClient = HermesWsClient
|
private val wsClient = HermesWsClient
|
||||||
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
|
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
|
||||||
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. */
|
/** Tracks the ID of the currently streaming assistant message. */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -158,6 +161,36 @@ class ChatViewModel(
|
|||||||
var initialSessionId: String? = null
|
var initialSessionId: String? = null
|
||||||
|
|
||||||
private fun handleWsEvent(event: WsEvent) {
|
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) {
|
when (event) {
|
||||||
is WsEvent.GatewayReady -> {
|
is WsEvent.GatewayReady -> {
|
||||||
_uiState.update { it.copy(isLoading = false) }
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
@@ -174,12 +207,6 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.SessionInfo -> {}
|
|
||||||
|
|
||||||
is WsEvent.MessageStart -> {
|
|
||||||
handleMessageStart(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
is WsEvent.MessageToken -> {
|
is WsEvent.MessageToken -> {
|
||||||
handleMessageToken(event)
|
handleMessageToken(event)
|
||||||
}
|
}
|
||||||
@@ -188,40 +215,34 @@ class ChatViewModel(
|
|||||||
handleThinkingDelta(event)
|
handleThinkingDelta(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.MessageStart -> {
|
||||||
|
streamingMessageId =
|
||||||
|
java.util.UUID
|
||||||
|
.randomUUID()
|
||||||
|
.toString()
|
||||||
|
streamingBuffer.clear()
|
||||||
|
thinkingBuffer.clear()
|
||||||
|
lastFlushMs = 0L
|
||||||
|
lastThinkingFlushMs = 0L
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.MessageComplete -> {
|
is WsEvent.MessageComplete -> {
|
||||||
handleMessageComplete(event)
|
// Buffers cleared before reduce; ViewModel resets them after
|
||||||
|
streamingMessageId = null
|
||||||
|
streamingBuffer.clear()
|
||||||
|
thinkingBuffer.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.MessageDone -> {
|
is WsEvent.MessageDone -> {
|
||||||
handleMessageDone(event)
|
streamingMessageId = null
|
||||||
|
streamingBuffer.clear()
|
||||||
|
thinkingBuffer.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.ToolStart -> {
|
is WsEvent.ToolStart -> {
|
||||||
handleToolStart(event)
|
// Reset streaming state when a tool starts
|
||||||
}
|
streamingBuffer.clear()
|
||||||
|
thinkingBuffer.clear()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.RpcResult -> {
|
is WsEvent.RpcResult -> {
|
||||||
@@ -232,7 +253,19 @@ class ChatViewModel(
|
|||||||
handleRpcError(event.id, event.error)
|
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
|
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) {
|
private fun handleMessageToken(event: WsEvent.MessageToken) {
|
||||||
if (!isCurrentSession(event.sessionId)) return
|
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 ────────────────────────────────────────────
|
// ── RPC response handling ────────────────────────────────────────────
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -661,7 +471,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// Persist to Room — OUTSIDE update{}
|
// Persist to Room — OUTSIDE update{}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
dao.upsert(userMessage.toEntity(sessionId))
|
repo.persistMessage(userMessage, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@@ -682,125 +492,124 @@ class ChatViewModel(
|
|||||||
// Persist — OUTSIDE update{}
|
// Persist — OUTSIDE update{}
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
dao.upsert(userMsg.toEntity(sessionId))
|
repo.persistMessage(userMsg, sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val parts = command.split(" ")
|
when (val result = slashDispatcher.dispatch(command)) {
|
||||||
val cmd = parts[0].lowercase()
|
is SlashResult.Message -> {
|
||||||
|
addAssistantMessage(result.text)
|
||||||
|
}
|
||||||
|
|
||||||
when (cmd) {
|
is SlashResult.Interrupt -> {
|
||||||
"/stop", "/interrupt" -> {
|
|
||||||
interruptSession()
|
interruptSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
"/new" -> {
|
is SlashResult.NewSession -> {
|
||||||
createNewSession()
|
createNewSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
"/help" -> {
|
is SlashResult.FetchStatus -> {
|
||||||
val helpText =
|
fetchAndDisplayStatus()
|
||||||
"""
|
|
||||||
**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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"/status" -> {
|
is SlashResult.FetchSessions -> {
|
||||||
viewModelScope.launch {
|
fetchAndDisplaySessions()
|
||||||
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"
|
|
||||||
|
|
||||||
val statusText =
|
is SlashResult.FetchStats -> {
|
||||||
"""
|
fetchAndDisplayStats()
|
||||||
**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:**
|
is SlashResult.Unknown -> {
|
||||||
$platformsStr
|
addAssistantMessage(
|
||||||
""".trimIndent()
|
"Unknown command: `${result.cmd}`. Type `/help` to view a list of available commands.",
|
||||||
addAssistantMessage(statusText)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
private fun fetchAndDisplayStatus() {
|
||||||
addAssistantMessage("Failed to retrieve status: ${result.error.message}")
|
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" -> {
|
private fun fetchAndDisplaySessions() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.getSessions() }
|
safeApiCall { ApiClient.hermesApi.getSessions() }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
val sessionsStr =
|
val sessionsStr =
|
||||||
body.sessions.joinToString("\n") { s ->
|
body.sessions.joinToString("\n") { s ->
|
||||||
"• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})"
|
"• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})"
|
||||||
}
|
|
||||||
addAssistantMessage("**Sessions List:**\n$sessionsStr")
|
|
||||||
}
|
}
|
||||||
|
addAssistantMessage("**Sessions List:**\n$sessionsStr")
|
||||||
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
addAssistantMessage("Failed to list sessions: ${result.error.message}")
|
addAssistantMessage("Failed to list sessions: ${result.error.message}")
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"/stats", "/system" -> {
|
private fun fetchAndDisplayStats() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.getSystemStats() }
|
safeApiCall { ApiClient.hermesApi.getSystemStats() }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
|
val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
|
||||||
val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
|
val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
|
||||||
val uptimeVal = body.uptime ?: "N/A"
|
addAssistantMessage(
|
||||||
val statsText =
|
"""
|
||||||
"""
|
**System Resource Stats:**
|
||||||
**System Resource Stats:**
|
• **CPU Usage:** $cpuPct
|
||||||
• **CPU Usage:** $cpuPct
|
• **Memory Usage:** $memPct
|
||||||
• **Memory Usage:** $memPct
|
• **Uptime:** ${body.uptime ?: "N/A"}
|
||||||
• **Uptime:** $uptimeVal
|
""".trimIndent(),
|
||||||
""".trimIndent()
|
)
|
||||||
addAssistantMessage(statsText)
|
|
||||||
}
|
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
|
||||||
addAssistantMessage("Failed to retrieve system stats: ${result.error.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
is NetworkResult.Failure -> {
|
||||||
addAssistantMessage("Unknown command: `$cmd`. Type `/help` to view a list of available commands.")
|
addAssistantMessage("Failed to retrieve system stats: ${result.error.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -813,7 +622,7 @@ class ChatViewModel(
|
|||||||
val sessionId = _uiState.value.currentSessionId
|
val sessionId = _uiState.value.currentSessionId
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
dao.upsert(msg.toEntity(sessionId))
|
repo.persistMessage(msg, sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -913,8 +722,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private fun loadCachedMessages(sessionId: String) {
|
private fun loadCachedMessages(sessionId: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val cached = dao.getMessagesForSession(sessionId)
|
val cachedMessages = repo.loadMessages(sessionId)
|
||||||
val cachedMessages = cached.map { it.toUiModel() }
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
// Only replace if still showing this session
|
// Only replace if still showing this session
|
||||||
if (state.currentSessionId == sessionId) {
|
if (state.currentSessionId == sessionId) {
|
||||||
@@ -958,9 +766,8 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Persist loaded messages to Room for offline access
|
// Persist loaded messages to Room for offline access
|
||||||
val entities = chatMessages.map { it.toEntity(sessionId) }
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
dao.upsertAll(entities)
|
repo.persistMessages(chatMessages, sessionId)
|
||||||
}
|
}
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
// Only update if still on the same session
|
// Only update if still on the same session
|
||||||
@@ -1022,7 +829,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
dao.upsert(userMessage.toEntity(sessionId))
|
repo.persistMessage(userMessage, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@@ -1076,7 +883,7 @@ class ChatViewModel(
|
|||||||
// Persist — OUTSIDE update{}
|
// Persist — OUTSIDE update{}
|
||||||
if (persist && sessionId != null) {
|
if (persist && sessionId != null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
dao.upsert(msg.toEntity(sessionId))
|
repo.persistMessage(msg, sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1152,10 +959,7 @@ class ChatViewModel(
|
|||||||
searchJob =
|
searchJob =
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
val messages = _uiState.value.messages
|
val messages = _uiState.value.messages
|
||||||
val indices =
|
val indices = searchController.findMatches(messages, query)
|
||||||
messages.indices.filter { idx ->
|
|
||||||
messages[idx].content.contains(query, ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
// Only update indices if the search query hasn't changed in the meantime
|
// Only update indices if the search query hasn't changed in the meantime
|
||||||
@@ -1175,13 +979,12 @@ class ChatViewModel(
|
|||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val indices = state.searchMatchIndices
|
val indices = state.searchMatchIndices
|
||||||
if (indices.isEmpty()) return@update state
|
if (indices.isEmpty()) return@update state
|
||||||
val current = state.currentSearchMatchIndex
|
|
||||||
val newIdx =
|
val newIdx =
|
||||||
when (direction) {
|
searchController.navigate(
|
||||||
1 -> if (current >= indices.lastIndex) 0 else current + 1
|
currentIndex = state.currentSearchMatchIndex,
|
||||||
-1 -> if (current <= 0) indices.lastIndex else current - 1
|
matchCount = indices.size,
|
||||||
else -> current
|
direction = direction,
|
||||||
}
|
)
|
||||||
state.copy(currentSearchMatchIndex = newIdx)
|
state.copy(currentSearchMatchIndex = newIdx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ReducerEffect>()
|
||||||
|
|
||||||
|
// 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<ReducerEffect>()
|
||||||
|
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<ReducerEffect>()
|
||||||
|
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<ReducerEffect>()
|
||||||
|
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<ReducerEffect>()
|
||||||
|
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<ChatUiState, ChatMessage?> {
|
||||||
|
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<ReducerEffect> = 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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user