feat(#525): render reasoning.delta / reasoning.available stream (#543)

* feat(#525): add ReasoningDelta + ReasoningAvailable WsEvent types

* feat(#525): parse reasoning.delta + reasoning.available; route through reducer + StreamingState

* feat(#525): add reasoning streaming buffer to ChatStreamingController

* feat(#525): dispatch ReasoningDelta/ReasoningAvailable in ChatViewModel

* feat(#525): render collapsible ReasoningIndicator in chat list

* fix(#525): address review — icons+semantics, LocalHermesStatusColors, drop redundant ReasoningAvailable branch

* feat(#525): persist reasoningText on model, entity, mapper + Room migration 3->4

* feat(#525): reducer + streaming carry reasoningText onto message

* feat(#525): render persisted reasoning above each assistant bubble, drop global indicator

* nix: flake

* nix: flake

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-10 23:41:53 +03:00
1 parent d04b74ee0a
commit 53b3966931
17 files changed
+441 -33

No files matched your search

@@ -28,6 +28,8 @@ data class ChatMessageEntity(
val role: String,
@ColumnInfo(name = "content")
val content: String,
@ColumnInfo(name = "reasoning_text")
val reasoningText: String = "",
@ColumnInfo(name = "timestamp")
val timestamp: Long,
@ColumnInfo(name = "tool_name")
@@ -22,6 +22,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
else -> MessageRole.ASSISTANT
},
content = content,
reasoningText = reasoningText,
timestamp = timestamp,
isStreaming = isStreaming,
toolName = toolName,
@@ -40,6 +41,7 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
sessionId = sessionId,
role = role.name,
content = content,
reasoningText = reasoningText,
timestamp = timestamp,
toolName = toolName,
toolStatus = toolStatus?.name,
@@ -11,7 +11,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 3,
version = 4,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -45,13 +45,22 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val migration3to4 =
object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `reasoning_text` TEXT NOT NULL DEFAULT ''",
)
}
}
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).openHelperFactory(factory)
.addMigrations(migration2to3)
.addMigrations(migration2to3, migration3to4)
.fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -62,6 +62,15 @@ object EventParser {
WsEvent.ThinkingDelta(token, sessionId)
}
"reasoning.delta" -> {
val token = payload?.get("text") as? String ?: ""
WsEvent.ReasoningDelta(token, sessionId)
}
"reasoning.available" -> {
WsEvent.ReasoningAvailable(sessionId)
}
"message.complete" -> {
val text = payload?.get("text") as? String ?: ""
WsEvent.MessageComplete(text, sessionId)
@@ -30,6 +30,15 @@ sealed class WsEvent {
val sessionId: String?,
) : WsEvent()
data class ReasoningDelta(
val token: String,
val sessionId: String?,
) : WsEvent()
data class ReasoningAvailable(
val sessionId: String?,
) : WsEvent()
data class MessageComplete(
val text: String,
val sessionId: String?,
@@ -18,6 +18,7 @@ data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
val content: String,
val reasoningText: String = "",
val timestamp: Long = System.currentTimeMillis(),
val isStreaming: Boolean = false,
val toolName: String? = null,
@@ -18,6 +18,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -51,11 +52,14 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.Psychology
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -520,7 +524,7 @@ private fun ThinkingIndicator(thinkingText: String) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
containerColor = LocalHermesStatusColors.current.infoContainer.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
@@ -544,7 +548,7 @@ private fun ThinkingIndicator(thinkingText: String) {
},
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = LocalHermesStatusColors.current.info,
),
maxLines = 2,
modifier = Modifier.animateContentSize(),
@@ -554,6 +558,62 @@ private fun ThinkingIndicator(thinkingText: String) {
}
}
@Composable
private fun ReasoningIndicator(reasoningText: String) {
var expanded by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clickable(
role = Role.Button,
onClick = { expanded = !expanded },
),
colors =
CardDefaults.cardColors(
containerColor = statusColors.infoContainer.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.Psychology,
contentDescription = stringResource(R.string.chat_reasoning),
tint = statusColors.info,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.chat_reasoning),
style = MaterialTheme.typography.labelMedium,
color = statusColors.info,
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription =
stringResource(
if (expanded) R.string.chat_reasoning_collapse else R.string.chat_reasoning_expand,
),
tint = statusColors.info,
modifier = Modifier.size(18.dp),
)
}
AnimatedVisibility(visible = expanded) {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall,
color = statusColors.info,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun ChatInputBar(
inputText: String,
@@ -1649,6 +1709,10 @@ private fun ChatMessageList(
val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
if (isAssistant && message.reasoningText.isNotBlank()) {
ReasoningIndicator(message.reasoningText)
}
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
lastAnimatedMessageId != message.id
) {
@@ -1674,6 +1738,9 @@ private fun ChatMessageList(
// Streaming message
streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (streaming.reasoningText.isNotBlank()) {
ReasoningIndicator(streaming.reasoningText)
}
if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
@@ -33,6 +33,9 @@ class ChatStreamingController(
private val thinkingBuffer = java.lang.StringBuilder()
private var lastThinkingFlushMs = 0L
private val reasoningBuffer = java.lang.StringBuilder()
private var lastReasoningFlushMs = 0L
/**
* Resets all streaming buffers. Centralizes the clear logic that was
* previously scattered across MessageStart, MessageComplete, MessageDone,
@@ -41,8 +44,10 @@ class ChatStreamingController(
fun resetStreaming() {
streamingBuffer.clear()
thinkingBuffer.clear()
reasoningBuffer.clear()
lastFlushMs = 0L
lastThinkingFlushMs = 0L
lastReasoningFlushMs = 0L
}
/** Resets buffers and starts a fresh streaming message (called on MessageStart). */
@@ -109,4 +114,26 @@ class ChatStreamingController(
}
}
}
fun handleReasoningDelta(event: WsEvent.ReasoningDelta) {
if (!isCurrentSession(event.sessionId)) return
reasoningBuffer.append(event.token)
val now = System.currentTimeMillis()
// Always flush in tests, or if enough time has passed
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),
)
}
}
}
}
@@ -311,6 +311,10 @@ class ChatViewModel(
streamingController.handleThinkingDelta(event)
}
is WsEvent.ReasoningDelta -> {
streamingController.handleReasoningDelta(event)
}
is WsEvent.MessageStart -> {
streamingController.beginStreamingMessage()
}
@@ -27,6 +27,7 @@ object ChatWsEventReducer {
is WsEvent.MessageStart -> event.sessionId
is WsEvent.MessageToken -> event.sessionId
is WsEvent.ThinkingDelta -> event.sessionId
is WsEvent.ReasoningDelta -> event.sessionId
is WsEvent.MessageComplete -> event.sessionId
is WsEvent.MessageDone -> event.sessionId
is WsEvent.ToolStart -> event.sessionId
@@ -53,6 +54,8 @@ object ChatWsEventReducer {
is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event)
is WsEvent.ReasoningDelta -> onReasoningDelta(state, streamingState, event)
is WsEvent.ReasoningAvailable -> onReasoningAvailable(state, streamingState, event)
is WsEvent.MessageComplete -> onMessageComplete(state, streamingState, event)
@@ -163,6 +166,34 @@ object ChatWsEventReducer {
)
}
// ── ReasoningDelta ────────────────────────────────────────────────
private fun onReasoningDelta(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ReasoningDelta,
): ReducerResult {
val currentContent = streamingState.reasoningText + event.token
return ReducerResult(
state = state,
streamingState =
streamingState.copy(
isReasoning = true,
reasoningText = currentContent,
),
)
}
// ── ReasoningAvailable ────────────────────────────────────────────
private fun onReasoningAvailable(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ReasoningAvailable,
): ReducerResult =
ReducerResult(
state = state,
streamingState = streamingState.copy(isReasoning = true),
)
// ── MessageComplete ───────────────────────────────────────────────
private fun onMessageComplete(
@@ -175,9 +206,11 @@ object ChatWsEventReducer {
streaming?.copy(
content = event.text ?: streaming.content,
isStreaming = false,
reasoningText = streamingState.reasoningText,
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text ?: "",
reasoningText = streamingState.reasoningText,
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
@@ -206,7 +239,7 @@ object ChatWsEventReducer {
state = state,
streamingState = streamingState,
)
val msg = streaming.copy(isStreaming = false)
val msg = streaming.copy(isStreaming = false, reasoningText = streamingState.reasoningText)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
if (sid != null) {
@@ -4,4 +4,6 @@ data class StreamingState(
val streamingMessage: ChatMessage? = null,
val isThinking: Boolean = false,
val thinkingText: String = "",
val isReasoning: Boolean = false,
val reasoningText: String = "",
)