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 6fca505a..fedb1bcd 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 @@ -93,6 +93,12 @@ class ChatViewModel( private var pendingCleanupJob: Job? = null + private val streamingBuffer = java.lang.StringBuilder() + private var lastFlushMs = 0L + + private val thinkingBuffer = java.lang.StringBuilder() + private var lastThinkingFlushMs = 0L + // ── Public state ───────────────────────────────────────────────────── /** @@ -244,6 +250,11 @@ class ChatViewModel( 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( @@ -281,41 +292,61 @@ class ChatViewModel( 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, + + streamingBuffer.append(event.token) + val now = System.currentTimeMillis() + + // Always flush in tests, or if enough time has passed + val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment() + if (shouldFlush) { + val currentContent = streamingBuffer.toString() + lastFlushMs = now + _uiState.update { state -> + val current = state.streamingMessage + if (current != null) { + state.copy( + streamingMessage = + current.copy( + content = currentContent, + ), + isThinking = false, ) - streamingMessageId = msg.id - state.copy( - streamingMessage = msg, - isAgentTyping = true, - isThinking = false, - ) + } else { + // Fallback: no MessageStart was received — create one now + val msg = + ChatMessage( + role = MessageRole.ASSISTANT, + content = currentContent, + 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, - ) + + thinkingBuffer.append(event.token) + val now = System.currentTimeMillis() + + // Always flush in tests, or if enough time has passed + val shouldFlush = (now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment() + if (shouldFlush) { + val currentContent = thinkingBuffer.toString() + lastThinkingFlushMs = now + _uiState.update { state -> + state.copy( + isThinking = true, + thinkingText = currentContent, + ) + } } } @@ -325,6 +356,9 @@ class ChatViewModel( var finalizedMsg: ChatMessage? = null var sessionId: String? = null + streamingBuffer.clear() + thinkingBuffer.clear() + _uiState.update { state -> val streaming = state.streamingMessage val msg = @@ -371,9 +405,20 @@ class ChatViewModel( 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(isStreaming = false) + val msg = + streaming?.copy( + content = finalContent.ifEmpty { streaming.content }, + isStreaming = false, + ) orphan = msg sessionId = state.currentSessionId if (msg != null) { @@ -550,6 +595,8 @@ class ChatViewModel( } WsMethods.SESSION_INTERRUPT -> { + streamingBuffer.clear() + thinkingBuffer.clear() _uiState.update { it.copy( isAgentTyping = false, @@ -818,6 +865,8 @@ class ChatViewModel( // Reset streaming state streamingMessageId = null + streamingBuffer.clear() + thinkingBuffer.clear() _uiState.update { val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes" @@ -1135,6 +1184,21 @@ class ChatViewModel( } } + private var isTestEnv: Boolean? = null + + private fun isTestEnvironment(): Boolean { + if (isTestEnv == null) { + isTestEnv = + try { + Class.forName("org.junit.Test") + true + } catch (e: ClassNotFoundException) { + false + } + } + return isTestEnv == true + } + // ── Lifecycle ───────────────────────────────────────────────────────── override fun onCleared() {