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:
@@ -17,6 +17,8 @@ data class SessionMessage(
|
|||||||
val content: JsonElement? = null,
|
val content: JsonElement? = null,
|
||||||
val timestamp: JsonElement? = null,
|
val timestamp: JsonElement? = null,
|
||||||
val type: String? = null,
|
val type: String? = null,
|
||||||
|
val reasoning: JsonElement? = null,
|
||||||
|
val reasoning_text: JsonElement? = null,
|
||||||
) {
|
) {
|
||||||
val timestampText: String?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
get() = (timestamp as? JsonPrimitive)?.content
|
||||||
@@ -28,4 +30,12 @@ data class SessionMessage(
|
|||||||
null -> ""
|
null -> ""
|
||||||
else -> content.toString()
|
else -> content.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val reasoningText: String
|
||||||
|
get() =
|
||||||
|
when (val r = reasoning ?: reasoning_text) {
|
||||||
|
is JsonPrimitive -> r.content
|
||||||
|
null -> ""
|
||||||
|
else -> r.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import com.m57.hermescontrol.theme.SystemMessageColor
|
|||||||
import com.m57.hermescontrol.theme.ToolChipColor
|
import com.m57.hermescontrol.theme.ToolChipColor
|
||||||
import com.m57.hermescontrol.theme.ToolChipColorLight
|
import com.m57.hermescontrol.theme.ToolChipColorLight
|
||||||
import com.m57.hermescontrol.theme.onColorFor
|
import com.m57.hermescontrol.theme.onColorFor
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
@@ -389,6 +390,13 @@ private fun AssistantBubble(
|
|||||||
tonalElevation = 1.dp,
|
tonalElevation = 1.dp,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.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 {
|
SelectionContainer {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ChatStreamingController(
|
|||||||
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
|
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
|
||||||
*/
|
*/
|
||||||
fun resetStreaming() {
|
fun resetStreaming() {
|
||||||
|
flushReasoning()
|
||||||
streamingBuffer.clear()
|
streamingBuffer.clear()
|
||||||
thinkingBuffer.clear()
|
thinkingBuffer.clear()
|
||||||
reasoningBuffer.clear()
|
reasoningBuffer.clear()
|
||||||
@@ -69,10 +70,13 @@ class ChatStreamingController(
|
|||||||
streamingState.update { state ->
|
streamingState.update { state ->
|
||||||
val current = state.streamingMessage
|
val current = state.streamingMessage
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
|
val currentReasoning =
|
||||||
|
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
|
||||||
state.copy(
|
state.copy(
|
||||||
streamingMessage =
|
streamingMessage =
|
||||||
current.copy(
|
current.copy(
|
||||||
content = currentContent,
|
content = currentContent,
|
||||||
|
reasoningText = currentReasoning,
|
||||||
),
|
),
|
||||||
isThinking = false,
|
isThinking = false,
|
||||||
)
|
)
|
||||||
@@ -82,6 +86,7 @@ class ChatStreamingController(
|
|||||||
ChatMessage(
|
ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = currentContent,
|
content = currentContent,
|
||||||
|
reasoningText = state.reasoningText,
|
||||||
isStreaming = true,
|
isStreaming = true,
|
||||||
)
|
)
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -125,15 +130,20 @@ class ChatStreamingController(
|
|||||||
val shouldFlush =
|
val shouldFlush =
|
||||||
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
|
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
|
||||||
if (shouldFlush) {
|
if (shouldFlush) {
|
||||||
val currentContent = reasoningBuffer.toString()
|
flushReasoning(now)
|
||||||
lastReasoningFlushMs = now
|
}
|
||||||
streamingState.update { state ->
|
}
|
||||||
state.copy(
|
|
||||||
isReasoning = true,
|
private fun flushReasoning(now: Long = System.currentTimeMillis()) {
|
||||||
reasoningText = currentContent,
|
val currentContent = reasoningBuffer.toString()
|
||||||
streamingMessage = state.streamingMessage?.copy(reasoningText = currentContent),
|
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,
|
sessionId: String,
|
||||||
messages: List<SessionMessage>,
|
messages: List<SessionMessage>,
|
||||||
offset: Int,
|
offset: Int,
|
||||||
): List<ChatMessage> =
|
): List<ChatMessage> {
|
||||||
messages.mapIndexed { index, msg ->
|
val existingReasoningMap =
|
||||||
|
_uiState.value.messages
|
||||||
|
.filter { it.reasoningText.isNotBlank() }
|
||||||
|
.associateBy { it.content }
|
||||||
|
|
||||||
|
return messages.mapIndexed { index, msg ->
|
||||||
val role =
|
val role =
|
||||||
when (msg.role?.lowercase()) {
|
when (msg.role?.lowercase()) {
|
||||||
"user" -> MessageRole.USER
|
"user" -> MessageRole.USER
|
||||||
@@ -1592,14 +1597,25 @@ class ChatViewModel(
|
|||||||
?.times(1000)
|
?.times(1000)
|
||||||
?.toLong()
|
?.toLong()
|
||||||
?: System.currentTimeMillis()
|
?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
val content = msg.contentText
|
||||||
|
val reasoning =
|
||||||
|
if (msg.reasoningText.isNotBlank()) {
|
||||||
|
msg.reasoningText
|
||||||
|
} else {
|
||||||
|
existingReasoningMap[content]?.reasoningText.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
id = "rest-$sessionId-$globalIndex",
|
id = "rest-$sessionId-$globalIndex",
|
||||||
role = role,
|
role = role,
|
||||||
content = msg.contentText,
|
content = content,
|
||||||
|
reasoningText = reasoning,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun serverMessageIndex(
|
private fun serverMessageIndex(
|
||||||
id: String,
|
id: String,
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ object ChatWsEventReducer {
|
|||||||
ChatMessage(
|
ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = "",
|
content = "",
|
||||||
|
reasoningText = streamingState.reasoningText,
|
||||||
isStreaming = true,
|
isStreaming = true,
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
@@ -148,7 +149,12 @@ object ChatWsEventReducer {
|
|||||||
isAgentTyping = true,
|
isAgentTyping = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val newStreamingState = StreamingState(streamingMessage = msg)
|
val newStreamingState =
|
||||||
|
StreamingState(
|
||||||
|
streamingMessage = msg,
|
||||||
|
isReasoning = streamingState.isReasoning,
|
||||||
|
reasoningText = streamingState.reasoningText,
|
||||||
|
)
|
||||||
val newState = preState
|
val newState = preState
|
||||||
val sid = newState.currentSessionId
|
val sid = newState.currentSessionId
|
||||||
if (orphan != null && sid != null) {
|
if (orphan != null && sid != null) {
|
||||||
@@ -224,15 +230,21 @@ object ChatWsEventReducer {
|
|||||||
event: WsEvent.MessageComplete,
|
event: WsEvent.MessageComplete,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val streaming = streamingState.streamingMessage
|
val streaming = streamingState.streamingMessage
|
||||||
|
val reasoning =
|
||||||
|
if (streamingState.reasoningText.isNotBlank()) {
|
||||||
|
streamingState.reasoningText
|
||||||
|
} else {
|
||||||
|
streaming?.reasoningText ?: ""
|
||||||
|
}
|
||||||
val msg =
|
val msg =
|
||||||
streaming?.copy(
|
streaming?.copy(
|
||||||
content = event.text,
|
content = event.text,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = streamingState.reasoningText,
|
reasoningText = reasoning,
|
||||||
) ?: ChatMessage(
|
) ?: ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = event.text,
|
content = event.text,
|
||||||
reasoningText = streamingState.reasoningText,
|
reasoningText = reasoning,
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
@@ -261,7 +273,13 @@ object ChatWsEventReducer {
|
|||||||
state = state,
|
state = state,
|
||||||
streamingState = streamingState,
|
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 effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
@@ -300,7 +318,17 @@ object ChatWsEventReducer {
|
|||||||
var orphanToPersist: ChatMessage? = null
|
var orphanToPersist: ChatMessage? = null
|
||||||
val newState =
|
val newState =
|
||||||
if (streamingState.streamingMessage?.content?.isNotEmpty() == true) {
|
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
|
orphanToPersist = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
messages = state.messages + finalized,
|
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.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -92,16 +90,6 @@ fun ChatMessageList(
|
|||||||
val isLastMessage = index == messages.lastIndex
|
val isLastMessage = index == messages.lastIndex
|
||||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
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 &&
|
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||||
lastAnimatedMessageId != message.id
|
lastAnimatedMessageId != message.id
|
||||||
) {
|
) {
|
||||||
@@ -127,15 +115,6 @@ fun ChatMessageList(
|
|||||||
// Streaming message
|
// Streaming message
|
||||||
streamingMessage?.let { streaming ->
|
streamingMessage?.let { streaming ->
|
||||||
item(key = "streaming-${streaming.id}") {
|
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) {
|
if (typingEffectEnabled && streaming.isStreaming) {
|
||||||
StreamingBubbleWithTypingEffect(
|
StreamingBubbleWithTypingEffect(
|
||||||
streaming = streaming,
|
streaming = streaming,
|
||||||
|
|||||||
Reference in New Issue
Block a user