fix(chat): render reasoning card inside bubble and preserve persistence (#691)
* fix(chat): render reasoning card inside bubble and preserve persistence Move ReasoningCard inside AssistantBubble and preserve reasoningText across REST re-fetches, MessageStart, and buffer flushes. * style(chat): resolve ktlint unused imports and blank line formatting
This commit is contained in:
6 files changed
+89
-38
No files matched your search
@@ -92,6 +92,7 @@ import com.m57.hermescontrol.theme.SystemMessageColor
|
||||
import com.m57.hermescontrol.theme.ToolChipColor
|
||||
import com.m57.hermescontrol.theme.ToolChipColorLight
|
||||
import com.m57.hermescontrol.theme.onColorFor
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
@@ -389,6 +390,13 @@ private fun AssistantBubble(
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
if (message.reasoningText.isNotBlank()) {
|
||||
ReasoningCard(
|
||||
reasoningText = message.reasoningText,
|
||||
isStreaming = message.isStreaming,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
SelectionContainer {
|
||||
MarkdownText(
|
||||
text = message.content,
|
||||
|
||||
@@ -42,6 +42,7 @@ class ChatStreamingController(
|
||||
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
|
||||
*/
|
||||
fun resetStreaming() {
|
||||
flushReasoning()
|
||||
streamingBuffer.clear()
|
||||
thinkingBuffer.clear()
|
||||
reasoningBuffer.clear()
|
||||
@@ -69,10 +70,13 @@ class ChatStreamingController(
|
||||
streamingState.update { state ->
|
||||
val current = state.streamingMessage
|
||||
if (current != null) {
|
||||
val currentReasoning =
|
||||
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
|
||||
state.copy(
|
||||
streamingMessage =
|
||||
current.copy(
|
||||
content = currentContent,
|
||||
reasoningText = currentReasoning,
|
||||
),
|
||||
isThinking = false,
|
||||
)
|
||||
@@ -82,6 +86,7 @@ class ChatStreamingController(
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = currentContent,
|
||||
reasoningText = state.reasoningText,
|
||||
isStreaming = true,
|
||||
)
|
||||
state.copy(
|
||||
@@ -125,15 +130,20 @@ class ChatStreamingController(
|
||||
val shouldFlush =
|
||||
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
|
||||
if (shouldFlush) {
|
||||
val currentContent = reasoningBuffer.toString()
|
||||
lastReasoningFlushMs = now
|
||||
streamingState.update { state ->
|
||||
state.copy(
|
||||
isReasoning = true,
|
||||
reasoningText = currentContent,
|
||||
streamingMessage = state.streamingMessage?.copy(reasoningText = currentContent),
|
||||
)
|
||||
}
|
||||
flushReasoning(now)
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushReasoning(now: Long = System.currentTimeMillis()) {
|
||||
val currentContent = reasoningBuffer.toString()
|
||||
if (currentContent.isEmpty()) return
|
||||
lastReasoningFlushMs = now
|
||||
streamingState.update { state ->
|
||||
state.copy(
|
||||
isReasoning = true,
|
||||
reasoningText = currentContent,
|
||||
streamingMessage = state.streamingMessage?.copy(reasoningText = currentContent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1576,8 +1576,13 @@ class ChatViewModel(
|
||||
sessionId: String,
|
||||
messages: List<SessionMessage>,
|
||||
offset: Int,
|
||||
): List<ChatMessage> =
|
||||
messages.mapIndexed { index, msg ->
|
||||
): List<ChatMessage> {
|
||||
val existingReasoningMap =
|
||||
_uiState.value.messages
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
return messages.mapIndexed { index, msg ->
|
||||
val role =
|
||||
when (msg.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
@@ -1592,14 +1597,25 @@ class ChatViewModel(
|
||||
?.times(1000)
|
||||
?.toLong()
|
||||
?: System.currentTimeMillis()
|
||||
|
||||
val content = msg.contentText
|
||||
val reasoning =
|
||||
if (msg.reasoningText.isNotBlank()) {
|
||||
msg.reasoningText
|
||||
} else {
|
||||
existingReasoningMap[content]?.reasoningText.orEmpty()
|
||||
}
|
||||
|
||||
ChatMessage(
|
||||
id = "rest-$sessionId-$globalIndex",
|
||||
role = role,
|
||||
content = msg.contentText,
|
||||
content = content,
|
||||
reasoningText = reasoning,
|
||||
timestamp = timestamp,
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun serverMessageIndex(
|
||||
id: String,
|
||||
|
||||
@@ -129,6 +129,7 @@ object ChatWsEventReducer {
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
reasoningText = streamingState.reasoningText,
|
||||
isStreaming = true,
|
||||
)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
@@ -148,7 +149,12 @@ object ChatWsEventReducer {
|
||||
isAgentTyping = true,
|
||||
)
|
||||
}
|
||||
val newStreamingState = StreamingState(streamingMessage = msg)
|
||||
val newStreamingState =
|
||||
StreamingState(
|
||||
streamingMessage = msg,
|
||||
isReasoning = streamingState.isReasoning,
|
||||
reasoningText = streamingState.reasoningText,
|
||||
)
|
||||
val newState = preState
|
||||
val sid = newState.currentSessionId
|
||||
if (orphan != null && sid != null) {
|
||||
@@ -224,15 +230,21 @@ object ChatWsEventReducer {
|
||||
event: WsEvent.MessageComplete,
|
||||
): ReducerResult {
|
||||
val streaming = streamingState.streamingMessage
|
||||
val reasoning =
|
||||
if (streamingState.reasoningText.isNotBlank()) {
|
||||
streamingState.reasoningText
|
||||
} else {
|
||||
streaming?.reasoningText ?: ""
|
||||
}
|
||||
val msg =
|
||||
streaming?.copy(
|
||||
content = event.text,
|
||||
isStreaming = false,
|
||||
reasoningText = streamingState.reasoningText,
|
||||
reasoningText = reasoning,
|
||||
) ?: ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = event.text,
|
||||
reasoningText = streamingState.reasoningText,
|
||||
reasoningText = reasoning,
|
||||
)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
@@ -261,7 +273,13 @@ object ChatWsEventReducer {
|
||||
state = state,
|
||||
streamingState = streamingState,
|
||||
)
|
||||
val msg = streaming.copy(isStreaming = false, reasoningText = streamingState.reasoningText)
|
||||
val reasoning =
|
||||
if (streamingState.reasoningText.isNotBlank()) {
|
||||
streamingState.reasoningText
|
||||
} else {
|
||||
streaming.reasoningText
|
||||
}
|
||||
val msg = streaming.copy(isStreaming = false, reasoningText = reasoning)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
if (sid != null) {
|
||||
@@ -300,7 +318,17 @@ object ChatWsEventReducer {
|
||||
var orphanToPersist: ChatMessage? = null
|
||||
val newState =
|
||||
if (streamingState.streamingMessage?.content?.isNotEmpty() == true) {
|
||||
val finalized = streamingState.streamingMessage.copy(isStreaming = false)
|
||||
val reasoning =
|
||||
if (streamingState.reasoningText.isNotBlank()) {
|
||||
streamingState.reasoningText
|
||||
} else {
|
||||
streamingState.streamingMessage.reasoningText
|
||||
}
|
||||
val finalized =
|
||||
streamingState.streamingMessage.copy(
|
||||
isStreaming = false,
|
||||
reasoningText = reasoning,
|
||||
)
|
||||
orphanToPersist = finalized
|
||||
state.copy(
|
||||
messages = state.messages + finalized,
|
||||
|
||||
@@ -2,10 +2,8 @@ package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -92,16 +90,6 @@ fun ChatMessageList(
|
||||
val isLastMessage = index == messages.lastIndex
|
||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||
|
||||
// Reasoning card — rendered inline in the same list item so it
|
||||
// stays visible during AND after streaming
|
||||
if (isAssistant && message.reasoningText.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
ReasoningCard(
|
||||
reasoningText = message.reasoningText,
|
||||
isStreaming = message.isStreaming,
|
||||
)
|
||||
}
|
||||
|
||||
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||
lastAnimatedMessageId != message.id
|
||||
) {
|
||||
@@ -127,15 +115,6 @@ fun ChatMessageList(
|
||||
// Streaming message
|
||||
streamingMessage?.let { streaming ->
|
||||
item(key = "streaming-${streaming.id}") {
|
||||
// Reasoning card — rendered inline in the same streaming item
|
||||
if (streaming.reasoningText.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
ReasoningCard(
|
||||
reasoningText = streaming.reasoningText,
|
||||
isStreaming = streaming.isStreaming,
|
||||
)
|
||||
}
|
||||
|
||||
if (typingEffectEnabled && streaming.isStreaming) {
|
||||
StreamingBubbleWithTypingEffect(
|
||||
streaming = streaming,
|
||||
|
||||
Reference in new issue
Block a user