* 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:
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 = "",
|
||||
)
|
||||
Reference in new issue
Block a user