feat(bots): lightweight tool execution indicators in group chat (#1008)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
0d09f43469
commit
fbe602cb4f
4 files changed
+249
-11
No files matched your search
@@ -2,6 +2,17 @@ package com.m57.hermescontrol.ui.bots.group
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight tool execution indicator for group chat turns.
|
||||||
|
*/
|
||||||
|
data class GroupChatToolCall(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val name: String,
|
||||||
|
val summary: String? = null,
|
||||||
|
val isRunning: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single message entry in a multi-agent group room.
|
* Single message entry in a multi-agent group room.
|
||||||
@@ -17,6 +28,7 @@ data class GroupChatMessage(
|
|||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
val thread: String? = null,
|
val thread: String? = null,
|
||||||
val isSystem: Boolean = false,
|
val isSystem: Boolean = false,
|
||||||
|
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Build
|
||||||
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -56,6 +60,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -542,8 +547,19 @@ private fun GroupMessageCard(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
if (message.toolCalls.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp),
|
||||||
|
) {
|
||||||
|
message.toolCalls.forEach { tool ->
|
||||||
|
GroupChatToolChip(tool = tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
if (message.isStreaming && message.text.isBlank()) {
|
if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
@@ -571,3 +587,56 @@ private fun GroupMessageCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupChatToolChip(
|
||||||
|
tool: GroupChatToolCall,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
val icon =
|
||||||
|
when (tool.name.lowercase()) {
|
||||||
|
"terminal" -> Icons.Filled.Terminal
|
||||||
|
"web_search", "search_files" -> Icons.Filled.Search
|
||||||
|
"read_file", "write_file", "patch" -> Icons.Filled.Description
|
||||||
|
else -> Icons.Filled.Build
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = tool.name,
|
||||||
|
modifier = Modifier.size(13.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
val label =
|
||||||
|
if (!tool.summary.isNullOrBlank()) {
|
||||||
|
"${tool.name}: ${tool.summary}"
|
||||||
|
} else {
|
||||||
|
tool.name
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (tool.isRunning) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(10.dp),
|
||||||
|
strokeWidth = 1.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,19 @@ class GroupChatViewModel(
|
|||||||
loadGroup()
|
loadGroup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractToolSummary(data: Map<String, Any?>?): String? {
|
||||||
|
if (data == null) return null
|
||||||
|
val cmd = (data["command"] as? String)?.trim()
|
||||||
|
if (!cmd.isNullOrBlank()) return cmd.take(35)
|
||||||
|
val query = (data["query"] as? String)?.trim()
|
||||||
|
if (!query.isNullOrBlank()) return query.take(35)
|
||||||
|
val path = (data["path"] as? String)?.trim()
|
||||||
|
if (!path.isNullOrBlank()) return path.take(35)
|
||||||
|
val pattern = (data["pattern"] as? String)?.trim()
|
||||||
|
if (!pattern.isNullOrBlank()) return pattern.take(35)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeWsEvents() {
|
private fun observeWsEvents() {
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
HermesWsClient.events.collect { event ->
|
HermesWsClient.events.collect { event ->
|
||||||
@@ -146,6 +159,93 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.ToolStart -> {
|
||||||
|
val sid = event.sessionId?.trim('\"', ' ')
|
||||||
|
if (sid != null) {
|
||||||
|
val streamId = activeStreamMsgId[sid]
|
||||||
|
val bot = sessionToBot[sid]
|
||||||
|
val toolName = event.name ?: "tool"
|
||||||
|
val toolId =
|
||||||
|
(event.data?.get("tool_id") as? String)?.ifBlank { null }
|
||||||
|
?: UUID.randomUUID().toString()
|
||||||
|
val summary = extractToolSummary(event.data)
|
||||||
|
val toolCall =
|
||||||
|
GroupChatToolCall(
|
||||||
|
id = toolId,
|
||||||
|
name = toolName,
|
||||||
|
summary = summary,
|
||||||
|
isRunning = true,
|
||||||
|
)
|
||||||
|
if (streamId != null && bot != null) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
val existing = state.messages.find { it.id == streamId }
|
||||||
|
val updatedMessages =
|
||||||
|
if (existing != null) {
|
||||||
|
state.messages.map { msg ->
|
||||||
|
if (msg.id == streamId) {
|
||||||
|
msg.copy(toolCalls = msg.toolCalls + toolCall)
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.messages +
|
||||||
|
GroupChatMessage(
|
||||||
|
id = streamId,
|
||||||
|
senderName = bot.name,
|
||||||
|
senderDisplayName = bot.effectiveTitle,
|
||||||
|
isUser = false,
|
||||||
|
avatarMeta = bot.botMeta()?.avatar,
|
||||||
|
text = "",
|
||||||
|
isStreaming = true,
|
||||||
|
toolCalls = listOf(toolCall),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.copy(messages = updatedMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is WsEvent.ToolComplete -> {
|
||||||
|
val sid = event.sessionId?.trim('\"', ' ')
|
||||||
|
if (sid != null) {
|
||||||
|
val streamId = activeStreamMsgId[sid]
|
||||||
|
val toolId = event.data?.get("tool_id") as? String
|
||||||
|
val toolName = event.name
|
||||||
|
if (streamId != null) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
val existing = state.messages.find { it.id == streamId }
|
||||||
|
if (existing != null) {
|
||||||
|
val updatedTools =
|
||||||
|
existing.toolCalls.map { tool ->
|
||||||
|
if ((toolId != null && tool.id == toolId) ||
|
||||||
|
(toolId == null && tool.name == toolName && tool.isRunning) ||
|
||||||
|
(toolId == null && toolName == null && tool.isRunning)
|
||||||
|
) {
|
||||||
|
tool.copy(isRunning = false)
|
||||||
|
} else {
|
||||||
|
tool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
messages =
|
||||||
|
state.messages.map { msg ->
|
||||||
|
if (msg.id == streamId) {
|
||||||
|
msg.copy(toolCalls = updatedTools)
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.MessageComplete -> {
|
is WsEvent.MessageComplete -> {
|
||||||
val sid = event.sessionId?.trim('\"', ' ')
|
val sid = event.sessionId?.trim('\"', ' ')
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
@@ -617,18 +717,21 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) {
|
if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) {
|
||||||
val finalMsg =
|
|
||||||
GroupChatMessage(
|
|
||||||
id = streamMsgId,
|
|
||||||
senderName = bot.name,
|
|
||||||
senderDisplayName = bot.effectiveTitle,
|
|
||||||
isUser = false,
|
|
||||||
avatarMeta = bot.botMeta()?.avatar,
|
|
||||||
text = replyText.trim(),
|
|
||||||
isStreaming = false,
|
|
||||||
)
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val existing = state.messages.find { it.id == streamMsgId }
|
val existing = state.messages.find { it.id == streamMsgId }
|
||||||
|
val finalTools =
|
||||||
|
existing?.toolCalls?.map { it.copy(isRunning = false) } ?: emptyList()
|
||||||
|
val finalMsg =
|
||||||
|
GroupChatMessage(
|
||||||
|
id = streamMsgId,
|
||||||
|
senderName = bot.name,
|
||||||
|
senderDisplayName = bot.effectiveTitle,
|
||||||
|
isUser = false,
|
||||||
|
avatarMeta = bot.botMeta()?.avatar,
|
||||||
|
text = replyText.trim(),
|
||||||
|
isStreaming = false,
|
||||||
|
toolCalls = finalTools,
|
||||||
|
)
|
||||||
val updatedList =
|
val updatedList =
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
state.messages.map { if (it.id == streamMsgId) finalMsg else it }
|
state.messages.map { if (it.id == streamMsgId) finalMsg else it }
|
||||||
|
|||||||
@@ -432,4 +432,58 @@ class GroupChatViewModelTest {
|
|||||||
assertTrue(prompt.contains("@scout (Scout Bot)"))
|
assertTrue(prompt.contains("@scout (Scout Bot)"))
|
||||||
assertTrue(prompt.contains("@coder (Dev Bot)"))
|
assertTrue(prompt.contains("@coder (Dev Bot)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testToolEvents_rendersAndCompletesToolCall() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
viewModel.sendMessage("@scout check time")
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// Emit ToolStart
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.ToolStart(
|
||||||
|
name = "terminal",
|
||||||
|
data = mapOf("command" to "date", "tool_id" to "tool-123"),
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val msgWithTool = viewModel.uiState.value.messages.last()
|
||||||
|
assertEquals(1, msgWithTool.toolCalls.size)
|
||||||
|
val toolCall = msgWithTool.toolCalls.first()
|
||||||
|
assertEquals("terminal", toolCall.name)
|
||||||
|
assertEquals("date", toolCall.summary)
|
||||||
|
assertTrue(toolCall.isRunning)
|
||||||
|
|
||||||
|
// Emit ToolComplete
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.ToolComplete(
|
||||||
|
name = "terminal",
|
||||||
|
data = mapOf("tool_id" to "tool-123"),
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val msgAfterToolDone = viewModel.uiState.value.messages.last()
|
||||||
|
assertFalse(msgAfterToolDone.toolCalls.first().isRunning)
|
||||||
|
|
||||||
|
// Complete message
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.MessageComplete(
|
||||||
|
text = "Current time is Sat Aug 29",
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val finalMsg = viewModel.uiState.value.messages.last()
|
||||||
|
assertEquals("Current time is Sat Aug 29", finalMsg.text)
|
||||||
|
assertEquals(1, finalMsg.toolCalls.size)
|
||||||
|
assertFalse(finalMsg.toolCalls.first().isRunning)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user