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.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.
|
||||
@@ -17,6 +28,7 @@ data class GroupChatMessage(
|
||||
val isStreaming: Boolean = false,
|
||||
val thread: String? = null,
|
||||
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.material.icons.Icons
|
||||
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.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
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.input.ImeAction
|
||||
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.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -542,8 +547,19 @@ private fun GroupMessageCard(
|
||||
fontWeight = FontWeight.Bold,
|
||||
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))
|
||||
if (message.isStreaming && message.text.isBlank()) {
|
||||
if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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()
|
||||
}
|
||||
|
||||
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() {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
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 -> {
|
||||
val sid = event.sessionId?.trim('\"', ' ')
|
||||
if (sid != null) {
|
||||
@@ -617,18 +717,21 @@ class GroupChatViewModel(
|
||||
}
|
||||
|
||||
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 ->
|
||||
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 =
|
||||
if (existing != null) {
|
||||
state.messages.map { if (it.id == streamMsgId) finalMsg else it }
|
||||
|
||||
Reference in new issue
Block a user