* 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
+416
-8
No files matched your search
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 4,
|
||||||
|
"identityHash": "7837ef04f86f9b8fca7a52a62b96f610",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "chat_messages",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sessionId",
|
||||||
|
"columnName": "session_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "role",
|
||||||
|
"columnName": "role",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "content",
|
||||||
|
"columnName": "content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "reasoningText",
|
||||||
|
"columnName": "reasoning_text",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timestamp",
|
||||||
|
"columnName": "timestamp",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "toolName",
|
||||||
|
"columnName": "tool_name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "toolStatus",
|
||||||
|
"columnName": "tool_status",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isStreaming",
|
||||||
|
"columnName": "is_streaming",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_chat_messages_session_id_timestamp",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"session_id",
|
||||||
|
"timestamp"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7837ef04f86f9b8fca7a52a62b96f610')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ data class ChatMessageEntity(
|
|||||||
val role: String,
|
val role: String,
|
||||||
@ColumnInfo(name = "content")
|
@ColumnInfo(name = "content")
|
||||||
val content: String,
|
val content: String,
|
||||||
|
@ColumnInfo(name = "reasoning_text")
|
||||||
|
val reasoningText: String = "",
|
||||||
@ColumnInfo(name = "timestamp")
|
@ColumnInfo(name = "timestamp")
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
@ColumnInfo(name = "tool_name")
|
@ColumnInfo(name = "tool_name")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
|||||||
else -> MessageRole.ASSISTANT
|
else -> MessageRole.ASSISTANT
|
||||||
},
|
},
|
||||||
content = content,
|
content = content,
|
||||||
|
reasoningText = reasoningText,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isStreaming = isStreaming,
|
isStreaming = isStreaming,
|
||||||
toolName = toolName,
|
toolName = toolName,
|
||||||
@@ -40,6 +41,7 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
|||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
role = role.name,
|
role = role.name,
|
||||||
content = content,
|
content = content,
|
||||||
|
reasoningText = reasoningText,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
toolName = toolName,
|
toolName = toolName,
|
||||||
toolStatus = toolStatus?.name,
|
toolStatus = toolStatus?.name,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.io.File
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ChatMessageEntity::class],
|
entities = [ChatMessageEntity::class],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class HermesDatabase : RoomDatabase() {
|
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
|
instance ?: Room
|
||||||
.databaseBuilder(
|
.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
HermesDatabase::class.java,
|
HermesDatabase::class.java,
|
||||||
"hermes_control.db",
|
"hermes_control.db",
|
||||||
).openHelperFactory(factory)
|
).openHelperFactory(factory)
|
||||||
.addMigrations(migration2to3)
|
.addMigrations(migration2to3, migration3to4)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
.also { instance = it }
|
.also { instance = it }
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ object EventParser {
|
|||||||
WsEvent.ThinkingDelta(token, sessionId)
|
WsEvent.ThinkingDelta(token, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"reasoning.delta" -> {
|
||||||
|
val token = payload?.get("text") as? String ?: ""
|
||||||
|
WsEvent.ReasoningDelta(token, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
"reasoning.available" -> {
|
||||||
|
WsEvent.ReasoningAvailable(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
"message.complete" -> {
|
"message.complete" -> {
|
||||||
val text = payload?.get("text") as? String ?: ""
|
val text = payload?.get("text") as? String ?: ""
|
||||||
WsEvent.MessageComplete(text, sessionId)
|
WsEvent.MessageComplete(text, sessionId)
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ sealed class WsEvent {
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
data class ReasoningDelta(
|
||||||
|
val token: String,
|
||||||
|
val sessionId: String?,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
|
data class ReasoningAvailable(
|
||||||
|
val sessionId: String?,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
data class MessageComplete(
|
data class MessageComplete(
|
||||||
val text: String,
|
val text: String,
|
||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ data class ChatMessage(
|
|||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
val role: MessageRole,
|
val role: MessageRole,
|
||||||
val content: String,
|
val content: String,
|
||||||
|
val reasoningText: String = "",
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
val toolName: String? = null,
|
val toolName: String? = null,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.BorderStroke
|
|||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.outlined.Psychology
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -520,7 +524,7 @@ private fun ThinkingIndicator(thinkingText: String) {
|
|||||||
Card(
|
Card(
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
containerColor = LocalHermesStatusColors.current.infoContainer.copy(alpha = 0.5f),
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -544,7 +548,7 @@ private fun ThinkingIndicator(thinkingText: String) {
|
|||||||
},
|
},
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = LocalHermesStatusColors.current.info,
|
||||||
),
|
),
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
modifier = Modifier.animateContentSize(),
|
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
|
@Composable
|
||||||
private fun ChatInputBar(
|
private fun ChatInputBar(
|
||||||
inputText: String,
|
inputText: String,
|
||||||
@@ -1649,6 +1709,10 @@ private fun ChatMessageList(
|
|||||||
val isLastMessage = index == messages.lastIndex
|
val isLastMessage = index == messages.lastIndex
|
||||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||||
|
|
||||||
|
if (isAssistant && message.reasoningText.isNotBlank()) {
|
||||||
|
ReasoningIndicator(message.reasoningText)
|
||||||
|
}
|
||||||
|
|
||||||
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||||
lastAnimatedMessageId != message.id
|
lastAnimatedMessageId != message.id
|
||||||
) {
|
) {
|
||||||
@@ -1674,6 +1738,9 @@ private fun ChatMessageList(
|
|||||||
// Streaming message
|
// Streaming message
|
||||||
streamingMessage?.let { streaming ->
|
streamingMessage?.let { streaming ->
|
||||||
item(key = "streaming-${streaming.id}") {
|
item(key = "streaming-${streaming.id}") {
|
||||||
|
if (streaming.reasoningText.isNotBlank()) {
|
||||||
|
ReasoningIndicator(streaming.reasoningText)
|
||||||
|
}
|
||||||
if (typingEffectEnabled && streaming.isStreaming) {
|
if (typingEffectEnabled && streaming.isStreaming) {
|
||||||
StreamingBubbleWithTypingEffect(
|
StreamingBubbleWithTypingEffect(
|
||||||
streaming = streaming,
|
streaming = streaming,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class ChatStreamingController(
|
|||||||
private val thinkingBuffer = java.lang.StringBuilder()
|
private val thinkingBuffer = java.lang.StringBuilder()
|
||||||
private var lastThinkingFlushMs = 0L
|
private var lastThinkingFlushMs = 0L
|
||||||
|
|
||||||
|
private val reasoningBuffer = java.lang.StringBuilder()
|
||||||
|
private var lastReasoningFlushMs = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets all streaming buffers. Centralizes the clear logic that was
|
* Resets all streaming buffers. Centralizes the clear logic that was
|
||||||
* previously scattered across MessageStart, MessageComplete, MessageDone,
|
* previously scattered across MessageStart, MessageComplete, MessageDone,
|
||||||
@@ -41,8 +44,10 @@ class ChatStreamingController(
|
|||||||
fun resetStreaming() {
|
fun resetStreaming() {
|
||||||
streamingBuffer.clear()
|
streamingBuffer.clear()
|
||||||
thinkingBuffer.clear()
|
thinkingBuffer.clear()
|
||||||
|
reasoningBuffer.clear()
|
||||||
lastFlushMs = 0L
|
lastFlushMs = 0L
|
||||||
lastThinkingFlushMs = 0L
|
lastThinkingFlushMs = 0L
|
||||||
|
lastReasoningFlushMs = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resets buffers and starts a fresh streaming message (called on MessageStart). */
|
/** 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)
|
streamingController.handleThinkingDelta(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.ReasoningDelta -> {
|
||||||
|
streamingController.handleReasoningDelta(event)
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.MessageStart -> {
|
is WsEvent.MessageStart -> {
|
||||||
streamingController.beginStreamingMessage()
|
streamingController.beginStreamingMessage()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.MessageStart -> event.sessionId
|
is WsEvent.MessageStart -> event.sessionId
|
||||||
is WsEvent.MessageToken -> event.sessionId
|
is WsEvent.MessageToken -> event.sessionId
|
||||||
is WsEvent.ThinkingDelta -> event.sessionId
|
is WsEvent.ThinkingDelta -> event.sessionId
|
||||||
|
is WsEvent.ReasoningDelta -> event.sessionId
|
||||||
is WsEvent.MessageComplete -> event.sessionId
|
is WsEvent.MessageComplete -> event.sessionId
|
||||||
is WsEvent.MessageDone -> event.sessionId
|
is WsEvent.MessageDone -> event.sessionId
|
||||||
is WsEvent.ToolStart -> event.sessionId
|
is WsEvent.ToolStart -> event.sessionId
|
||||||
@@ -53,6 +54,8 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
|
is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.ThinkingDelta -> onThinkingDelta(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)
|
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 ───────────────────────────────────────────────
|
// ── MessageComplete ───────────────────────────────────────────────
|
||||||
|
|
||||||
private fun onMessageComplete(
|
private fun onMessageComplete(
|
||||||
@@ -175,9 +206,11 @@ object ChatWsEventReducer {
|
|||||||
streaming?.copy(
|
streaming?.copy(
|
||||||
content = event.text ?: streaming.content,
|
content = event.text ?: streaming.content,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
reasoningText = streamingState.reasoningText,
|
||||||
) ?: ChatMessage(
|
) ?: ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = event.text ?: "",
|
content = event.text ?: "",
|
||||||
|
reasoningText = streamingState.reasoningText,
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
@@ -206,7 +239,7 @@ object ChatWsEventReducer {
|
|||||||
state = state,
|
state = state,
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
val msg = streaming.copy(isStreaming = false)
|
val msg = streaming.copy(isStreaming = false, reasoningText = streamingState.reasoningText)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ data class StreamingState(
|
|||||||
val streamingMessage: ChatMessage? = null,
|
val streamingMessage: ChatMessage? = null,
|
||||||
val isThinking: Boolean = false,
|
val isThinking: Boolean = false,
|
||||||
val thinkingText: String = "",
|
val thinkingText: String = "",
|
||||||
|
val isReasoning: Boolean = false,
|
||||||
|
val reasoningText: String = "",
|
||||||
)
|
)
|
||||||
@@ -104,6 +104,9 @@
|
|||||||
<string name="chat_status_auth_expired">Session expired \u2014 please sign in again</string>
|
<string name="chat_status_auth_expired">Session expired \u2014 please sign in again</string>
|
||||||
<string name="chat_thinking">Thinking\u2026</string>
|
<string name="chat_thinking">Thinking\u2026</string>
|
||||||
<string name="chat_thinking_param">Thinking: %1$s\u2026</string>
|
<string name="chat_thinking_param">Thinking: %1$s\u2026</string>
|
||||||
|
<string name="chat_reasoning">Reasoning</string>
|
||||||
|
<string name="chat_reasoning_expand">Expand reasoning</string>
|
||||||
|
<string name="chat_reasoning_collapse">Collapse reasoning</string>
|
||||||
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
|
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
|
||||||
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
|
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
|
||||||
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
|
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.m57.hermescontrol.data.local
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatMessageMapperTest {
|
||||||
|
@Test
|
||||||
|
fun entityToUiModelCarriesReasoningText() {
|
||||||
|
val entity =
|
||||||
|
ChatMessageEntity(
|
||||||
|
id = "msg-1",
|
||||||
|
sessionId = "session-a",
|
||||||
|
role = "assistant",
|
||||||
|
content = "Answer",
|
||||||
|
reasoningText = "Let me think step by step",
|
||||||
|
timestamp = 1000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ui = entity.toUiModel()
|
||||||
|
|
||||||
|
assertEquals("Let me think step by step", ui.reasoningText)
|
||||||
|
assertEquals("Answer", ui.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiModelToEntityCarriesReasoningText() {
|
||||||
|
val ui =
|
||||||
|
ChatMessage(
|
||||||
|
id = "msg-2",
|
||||||
|
role = MessageRole.ASSISTANT,
|
||||||
|
content = "Answer",
|
||||||
|
reasoningText = "Chain of thought",
|
||||||
|
timestamp = 2000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val entity = ui.toEntity("session-b")
|
||||||
|
|
||||||
|
assertEquals("Chain of thought", entity.reasoningText)
|
||||||
|
assertEquals("session-b", entity.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripPreservesReasoningText() {
|
||||||
|
val ui =
|
||||||
|
ChatMessage(
|
||||||
|
id = "msg-3",
|
||||||
|
role = MessageRole.ASSISTANT,
|
||||||
|
content = "Answer",
|
||||||
|
reasoningText = "r",
|
||||||
|
timestamp = 3000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val roundTripped = ui.toEntity("s").toUiModel()
|
||||||
|
|
||||||
|
assertEquals("r", roundTripped.reasoningText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,6 +166,48 @@ class EventParserTest {
|
|||||||
assertEquals("session-2", deltaEvent.sessionId)
|
assertEquals("session-2", deltaEvent.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseReasoningDelta_returnsReasoningDeltaEvent() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "reasoning.delta",
|
||||||
|
"payload" to mapOf("text" to "reasoning token", "session_id" to "session-r1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response)
|
||||||
|
assertTrue(event is WsEvent.ReasoningDelta)
|
||||||
|
val deltaEvent = event as WsEvent.ReasoningDelta
|
||||||
|
assertEquals("reasoning token", deltaEvent.token)
|
||||||
|
assertEquals("session-r1", deltaEvent.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseReasoningAvailable_returnsReasoningAvailableEvent() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "reasoning.available",
|
||||||
|
"payload" to mapOf("session_id" to "session-r1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response)
|
||||||
|
assertTrue(event is WsEvent.ReasoningAvailable)
|
||||||
|
assertEquals("session-r1", (event as WsEvent.ReasoningAvailable).sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testParseClarifyRequest_returnsClarifyRequestEvent() {
|
fun testParseClarifyRequest_returnsClarifyRequestEvent() {
|
||||||
val response =
|
val response =
|
||||||
|
|||||||
@@ -473,6 +473,55 @@ class ChatViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testReasoningStreamingFlow() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
|
// 1 — Reasoning available: block becomes visible (no token yet)
|
||||||
|
mockEventsFlow.emit(WsEvent.ReasoningAvailable(sessionId))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.streamingState.value.isReasoning)
|
||||||
|
|
||||||
|
// 2 — Reasoning delta
|
||||||
|
mockEventsFlow.emit(WsEvent.ReasoningDelta("Let me think", sessionId))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("Let me think", viewModel.streamingState.value.reasoningText)
|
||||||
|
|
||||||
|
// 3 — Deeper reasoning
|
||||||
|
mockEventsFlow.emit(WsEvent.ReasoningDelta(" step by step", sessionId))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("Let me think step by step", viewModel.streamingState.value.reasoningText)
|
||||||
|
|
||||||
|
// 4 — Thinking still independent
|
||||||
|
mockEventsFlow.emit(WsEvent.ThinkingDelta("thinking", sessionId))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.streamingState.value.isThinking)
|
||||||
|
assertEquals("thinking", viewModel.streamingState.value.thinkingText)
|
||||||
|
// reasoning untouched
|
||||||
|
assertEquals("Let me think step by step", viewModel.streamingState.value.reasoningText)
|
||||||
|
|
||||||
|
// 5 — Complete: reducer finalizes message, attaching reasoning
|
||||||
|
mockEventsFlow.emit(WsEvent.MessageComplete("The answer is 42", sessionId))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||||
|
assertEquals(2, viewModel.uiState.value.messages.size)
|
||||||
|
assertEquals(
|
||||||
|
"The answer is 42",
|
||||||
|
viewModel.uiState.value.messages[1].content,
|
||||||
|
)
|
||||||
|
// reasoning carried onto the finalized UI message
|
||||||
|
assertEquals(
|
||||||
|
"Let me think step by step",
|
||||||
|
viewModel.uiState.value.messages[1].reasoningText,
|
||||||
|
)
|
||||||
|
// reasoning persisted to the entity (survives reload)
|
||||||
|
val persisted =
|
||||||
|
fakeRepo.dao.getMessagesForSession(sessionId).first { it.role == "ASSISTANT" }
|
||||||
|
assertEquals("Let me think step by step", persisted.reasoningText)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testToolExecution_finalizesPreviousStreamingMessage() =
|
fun testToolExecution_finalizesPreviousStreamingMessage() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -22,10 +22,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
buildToolsVersion = "36.0.0";
|
buildToolsVersion = "36.0.0";
|
||||||
androidSdk = (pkgs.androidenv.composeAndroidPackages {
|
androidSdk =
|
||||||
|
(pkgs.androidenv.composeAndroidPackages {
|
||||||
# Command-line & platform tools
|
# Command-line & platform tools
|
||||||
cmdLineToolsVersion = "11.0";
|
cmdLineToolsVersion = "11.0";
|
||||||
platformToolsVersion = "35.0.2";
|
platformToolsVersion = "36.0.0";
|
||||||
|
|
||||||
# Build tools
|
# Build tools
|
||||||
buildToolsVersions = [buildToolsVersion];
|
buildToolsVersions = [buildToolsVersion];
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
GRADLE_USER_HOME = "$PWD/.gradle-home";
|
GRADLE_USER_HOME = "$PWD/.gradle-home";
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "🤖 HermesControl Android dev shell"
|
echo " HermesControl Android dev shell"
|
||||||
echo " Java: $(java -version 2>&1 | head -1)"
|
echo " Java: $(java -version 2>&1 | head -1)"
|
||||||
echo " Kotlin: $(kotlin -version 2>&1)"
|
echo " Kotlin: $(kotlin -version 2>&1)"
|
||||||
echo " Gradle: $(gradle --version 2>&1 | grep '^Gradle' || echo 'available')"
|
echo " Gradle: $(gradle --version 2>&1 | grep '^Gradle' || echo 'available')"
|
||||||
|
|||||||
Reference in new issue
Block a user