feat(bots): add multi-agent group chat collaboration room (#998)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-28 17:50:37 +03:00
1 parent 6bc4e872ca
commit f04d878358
16 files changed
+1589 -126

No files matched your search

@@ -155,6 +155,14 @@ private fun appEntryProvider(
onBack = { NavigationController.goBack() },
)
}
// ── Multi-Agent Group Chat Room ─────────────────────────────────────
entry<GroupChatKey> { key ->
com.m57.hermescontrol.ui.bots.group.GroupChatScreen(
groupName = key.groupName,
onBack = { NavigationController.goBack() },
)
}
}
@Composable
@@ -23,6 +23,10 @@ import kotlinx.serialization.Serializable
@Serializable data object BotsScreen : NavKey
@Serializable data class GroupChatKey(
val groupName: String,
) : NavKey
@Serializable data object ToolsetsScreen : NavKey
@Serializable data class ToolsetDetailKey(
@@ -3,7 +3,10 @@ package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonPrimitive
@Serializable
data class ProfilesResponse(
@@ -121,12 +124,42 @@ data class GroupChatSyncSnapshot(
@Serializable
data class GroupChatRoomMeta(
val id: String,
val id: String? = null,
val roomId: String? = null,
val name: String? = null,
val members: List<String>? = null,
val members: List<JsonElement>? = null,
val picture: String? = null,
val image: String? = null,
val log: List<GroupChatSyncLogEntry>? = null,
val revision: Long? = null,
val updatedAt: Long? = null,
val createdAt: Long? = null,
) {
val memberNames: List<String>
get() =
members?.mapNotNull { el ->
when (el) {
is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content
else -> null
}
}.orEmpty()
}
@Serializable
data class GroupChatSyncLogEntry(
val id: String? = null,
val from: GroupChatSyncFrom? = null,
val text: String? = null,
val at: Long? = null,
val thread: String? = null,
)
@Serializable
data class GroupChatSyncFrom(
val kind: String? = null, // "user" | "member"
val name: String? = null,
val source: String? = null,
)
@Serializable
@@ -400,20 +400,9 @@ fun BotsScreen(
group = group,
onClick = {
if (group.members.isNotEmpty()) {
scope.launch {
val primary = group.members.first()
viewModel.selectBot(primary)
val canonicalId =
primary.canonical_session?.resolved_id
?: primary.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(
com.m57.hermescontrol.ChatScreen,
)
}
}
NavigationController.navigateTo(
com.m57.hermescontrol.GroupChatKey(group.name),
)
}
},
onDisbandClick = {
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -142,9 +143,14 @@ fun CreateBotDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AVAILABLE_SHAPES.forEach { shape ->
FilterChip(
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -173,9 +174,14 @@ fun EditBotBottomSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AVAILABLE_SHAPES.forEach { shape ->
FilterChip(
@@ -0,0 +1,111 @@
package com.m57.hermescontrol.ui.bots.group
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.ProfileInfo
/**
* Single message entry in a multi-agent group room.
*/
data class GroupChatMessage(
val id: String,
val senderName: String,
val senderDisplayName: String,
val isUser: Boolean,
val avatarMeta: BotAvatarMeta? = null,
val text: String,
val timestamp: Long = System.currentTimeMillis(),
val isStreaming: Boolean = false,
val thread: String? = null,
)
/**
* Pure helper for mention extraction and responder resolution matching Desktop parity.
*/
object GroupChatMentions {
data class MentionResult(
val isEveryone: Boolean,
val mentionedBots: Set<String>,
)
/**
* Parse `@botname`, `@all`, `@everyone` from text against the member roster.
*/
fun parseMentions(
text: String,
members: List<ProfileInfo>,
): MentionResult {
val lowerText = text.lowercase()
val isEveryone = lowerText.contains("@all") || lowerText.contains("@everyone")
val mentioned = mutableSetOf<String>()
for (member in members) {
val handle = member.name.lowercase()
val titleHandle = member.effectiveTitle.lowercase().replace(" ", "")
if (lowerText.contains("@$handle") || lowerText.contains("@$titleHandle")) {
mentioned.add(member.name)
}
}
return MentionResult(
isEveryone = isEveryone,
mentionedBots = mentioned,
)
}
/**
* Determine which bots should take a turn. If @all or no specific mention, all members participate.
*/
fun resolveResponders(
text: String,
members: List<ProfileInfo>,
): List<ProfileInfo> {
val parsed = parseMentions(text, members)
return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) {
members
} else {
members.filter { parsed.mentionedBots.contains(it.name) }
}
}
/**
* Build the room delta prompt injected into each bot's session.
*/
fun buildTurnPrompt(
groupName: String,
viewer: ProfileInfo,
peers: List<ProfileInfo>,
recentLog: List<GroupChatMessage>,
): String {
val viewerHandle = viewer.name
val peerNames = peers.joinToString(", ") { "@${it.name}" }
val lines =
recentLog.takeLast(10).joinToString("\n") { msg ->
val speaker = if (msg.isUser) "User" else "@${msg.senderName}"
" $speaker: ${msg.text}"
}
val lastUserMsg = recentLog.lastOrNull { it.isUser }?.text.orEmpty()
val parsed = parseMentions(lastUserMsg, peers + viewer)
val isDirectOrAll = parsed.isEveryone || parsed.mentionedBots.contains(viewer.name)
val actionGuidance =
if (isDirectOrAll) {
"- You were explicitly addressed (via @$viewerHandle or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status."
} else {
"- If you have nothing new to add, reply with exactly \"(pass)\". Passing is good and lets the conversation settle."
}
return """
[Group chat: "$groupName"] You are @$viewerHandle, one participant in a group chat with $peerNames and the user.
Recent messages in the room:
$lines
Rules for this room:
- Reply with ONE conversational message (1-3 sentences).
$actionGuidance
- Mention a teammate as @name if you want to pull them in.
- Your reply goes to the room verbatim — no preamble.
""".trimIndent()
}
}
@@ -0,0 +1,341 @@
package com.m57.hermescontrol.ui.bots.group
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
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.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
@Composable
fun GroupChatScreen(
groupName: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: GroupChatViewModel = viewModel { GroupChatViewModel() },
) {
val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
var isFocused by remember { mutableStateOf(false) }
val handleSend = {
val text = inputFieldValue.text
if (text.isNotBlank()) {
viewModel.sendMessage(text)
inputFieldValue = TextFieldValue("")
}
}
LaunchedEffect(groupName) {
viewModel.setGroup(groupName)
}
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.size - 1)
}
}
HermesScaffold(
title = {
Column {
Text(
text = state.groupName.ifBlank { groupName },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (state.members.isNotEmpty()) {
Text(
text = stringResource(R.string.group_chat_members_count, state.members.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = NavIcon.Back(onBack),
modifier = modifier,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(bottom = 8.dp)
.imePadding(),
) {
when {
state.isLoading -> {
Box(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
}
}
state.messages.isEmpty() -> {
EmptyState(
title = stringResource(R.string.group_chat_empty_title),
subtitle = stringResource(R.string.group_chat_empty_subtitle),
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
)
}
else -> {
LazyColumn(
state = listState,
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = state.messages,
key = { it.id },
) { message ->
GroupMessageCard(message = message)
}
state.activeSpeaker?.let { speaker ->
item(key = "active_speaker") {
Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.group_chat_thinking, speaker),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(20.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) {
if (inputFieldValue.text.isEmpty()) {
Text(
text =
stringResource(
R.string.group_chat_composer_hint,
state.groupName.ifBlank { groupName },
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
BasicTextField(
value = inputFieldValue,
onValueChange = { inputFieldValue = it },
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 24.dp, max = 120.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("group_chat_input"),
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { handleSend() }),
)
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = handleSend,
enabled = inputFieldValue.text.isNotBlank(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
@Composable
private fun GroupMessageCard(
message: GroupChatMessage,
modifier: Modifier = Modifier,
) {
if (message.isUser) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth(0.85f),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
} else {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
) {
BotAvatar(
name = message.senderName,
avatar = message.avatarMeta,
size = 32.dp,
showPresence = false,
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.weight(1f, fill = false),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = message.senderDisplayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(4.dp))
if (message.isStreaming && message.text.isBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.group_chat_typing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
} else if (message.text.isNotBlank()) {
MarkdownText(
text = message.text,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@@ -0,0 +1,578 @@
package com.m57.hermescontrol.ui.bots.group
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID
data class GroupChatUiState(
val groupName: String = "",
val members: List<ProfileInfo> = emptyList(),
val messages: List<GroupChatMessage> = emptyList(),
val activeSpeaker: String? = null,
val isLoading: Boolean = true,
val errorMessage: String? = null,
)
data class MemberSession(
val runtimeSessionId: String,
val storedSessionId: String? = null,
)
class GroupChatViewModel(
private var groupName: String = "",
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val json: Json = Json { ignoreUnknownKeys = true },
) : ViewModel() {
private val _uiState = MutableStateFlow(GroupChatUiState(groupName = groupName))
val uiState: StateFlow<GroupChatUiState> = _uiState.asStateFlow()
// Map of bot.name -> active session info in this group
private val memberSessions = mutableMapOf<String, MemberSession>()
// In-flight turn completion deferreds keyed by session_id
private val inFlightTurns = mutableMapOf<String, CompletableDeferred<String>>()
// Map of session_id -> active streaming message ID
private val activeStreamMsgId = mutableMapOf<String, String>()
// Map of session_id -> bot profile
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
init {
if (groupName.isNotBlank()) {
loadGroup()
}
observeWsEvents()
}
/**
* Point the shared instance at [name] and drop all state from the
* previously viewed group chat so one group's data never bleeds into another.
*/
fun setGroup(name: String) {
if (name == groupName && _uiState.value.groupName.isNotBlank()) return
groupName = name
memberSessions.clear()
inFlightTurns.clear()
activeStreamMsgId.clear()
sessionToBot.clear()
_uiState.value = GroupChatUiState(groupName = name)
loadGroup()
}
private fun observeWsEvents() {
viewModelScope.launch(ioDispatcher) {
HermesWsClient.events.collect { event ->
when (event) {
is WsEvent.MessageToken -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
val streamId = activeStreamMsgId[sid]
val bot = sessionToBot[sid]
if (streamId != null && bot != null && event.token.isNotEmpty()) {
_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(text = msg.text + event.token)
} else {
msg
}
}
} else {
state.messages +
GroupChatMessage(
id = streamId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
avatarMeta = bot.botMeta()?.avatar,
text = event.token,
isStreaming = true,
)
}
state.copy(messages = updatedMessages)
}
}
}
}
is WsEvent.MessageComplete -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
Log.d("GroupChatViewModel", "MessageComplete for session $sid: '${event.text.take(50)}'")
inFlightTurns[sid]?.complete(event.text)
}
}
else -> {}
}
}
}
}
private suspend fun fetchProfiles(): List<ProfileInfo> {
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
return resp.profiles
}
}
} catch (_: Exception) {
// Fallback to REST API below
}
val result = safeApiCall { ApiClient.hermesApi.getProfiles() }
return (result as? NetworkResult.Success)?.data?.profiles.orEmpty()
}
fun loadGroup() {
viewModelScope.launch(ioDispatcher) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val allProfiles = fetchProfiles()
if (allProfiles.isNotEmpty()) {
var groupMembers =
allProfiles.filter {
val botGroups = it.botMeta()?.groups.orEmpty()
botGroups.contains(groupName) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) }
}
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && groupName.contains(",")) {
val targetNames = groupName.split(",").map { it.trim().lowercase() }
groupMembers = allProfiles.filter { targetNames.contains(it.name.lowercase()) }
}
// Final safety fallback: If 0 members found, use all non-hidden bots
if (groupMembers.isEmpty()) {
groupMembers = allProfiles.filter { !it.isHidden }
}
Log.d(
"GroupChatViewModel",
"Resolved ${groupMembers.size} members for group '$groupName': ${groupMembers.map { it.name }}",
)
// Hydrate previous group chat messages from cross-device sync snapshot
val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" }
val syncSnapshotElement = defaultProfile?.ui_meta?.get("hermes-bots-groups")
val syncSnapshot =
syncSnapshotElement?.let { el ->
try {
json.decodeFromJsonElement<GroupChatSyncSnapshot>(el)
} catch (e: Exception) {
Log.w("GroupChatViewModel", "Failed to decode sync snapshot: ${e.message}")
null
}
}
var initialMessages = _uiState.value.messages
if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) {
val matchingRoom =
syncSnapshot.rooms[groupName]
?: syncSnapshot.rooms["name:$groupName"]
?: syncSnapshot.rooms["id:$groupName"]
?: syncSnapshot.rooms.values.find { it.name.equals(groupName, ignoreCase = true) }
val syncedLog = matchingRoom?.log.orEmpty()
if (syncedLog.isNotEmpty()) {
initialMessages =
syncedLog.map { entry ->
val isUser = entry.from?.kind != "member"
val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" }
val memberProfile = allProfiles.find { it.name.equals(senderName, ignoreCase = true) }
GroupChatMessage(
id = entry.id ?: UUID.randomUUID().toString(),
senderName = memberProfile?.name ?: senderName,
senderDisplayName =
if (isUser) "You" else (memberProfile?.effectiveTitle ?: senderName),
isUser = isUser,
avatarMeta = memberProfile?.botMeta()?.avatar,
text = entry.text.orEmpty(),
timestamp = entry.at ?: System.currentTimeMillis(),
thread = entry.thread,
)
}
Log.d("GroupChatViewModel", "Hydrated ${initialMessages.size} messages from sync snapshot")
}
}
_uiState.update {
it.copy(
members = groupMembers,
messages = initialMessages,
isLoading = false,
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load group members",
)
}
}
}
}
private suspend fun ensureMemberSession(bot: ProfileInfo): MemberSession? {
val cached = memberSessions[bot.name]
if (cached != null) {
return cached
}
val title = "Group: $groupName"
try {
val createParams =
buildMap<String, Any> {
put("profile", bot.name)
put("title", title)
put("source", "desktop")
put("hidden", true)
}
val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams)
val res = deferred.await()
val sessionInfo = extractSessionInfo(res)
if (sessionInfo != null) {
memberSessions[bot.name] = sessionInfo
return sessionInfo
}
} catch (e: Exception) {
Log.w("GroupChatViewModel", "session.create failed for ${bot.name}: ${e.message}")
}
return null
}
fun sendMessage(rawText: String) {
val text = rawText.trim()
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
if (text.isBlank()) return
val members = _uiState.value.members
if (members.isEmpty()) {
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")
return
}
val userMessage =
GroupChatMessage(
id = UUID.randomUUID().toString(),
senderName = "user",
senderDisplayName = "You",
isUser = true,
text = text,
)
_uiState.update {
it.copy(messages = it.messages + userMessage)
}
persistSyncSnapshot(_uiState.value.messages)
viewModelScope.launch(ioDispatcher) {
val responders = GroupChatMentions.resolveResponders(text, members)
Log.d("GroupChatViewModel", "Responders: ${responders.map { it.name }}")
for (bot in responders) {
_uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) }
val prompt =
GroupChatMentions.buildTurnPrompt(
groupName = groupName,
viewer = bot,
peers = members.filter { it.name != bot.name },
recentLog = _uiState.value.messages,
)
val streamMsgId = UUID.randomUUID().toString()
var activeRuntimeId: String? = null
var activeStoredId: String? = null
try {
val sessionInfo = ensureMemberSession(bot)
if (sessionInfo == null) {
Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}")
continue
}
val runtimeId = sessionInfo.runtimeSessionId
val storedId = sessionInfo.storedSessionId
activeRuntimeId = runtimeId
activeStoredId = storedId
activeStreamMsgId[runtimeId] = streamMsgId
storedId?.let { activeStreamMsgId[it] = streamMsgId }
sessionToBot[runtimeId] = bot
storedId?.let { sessionToBot[it] = bot }
// Set ActiveSessionHolder so the active WebSocket context matches this turn
ActiveSessionHolder.set(runtimeId, runtimeId)
// Register in-flight listener
val turnDeferred = CompletableDeferred<String>()
inFlightTurns[runtimeId] = turnDeferred
storedId?.let { inFlightTurns[it] = turnDeferred }
// Submit prompt via sendMessage
HermesWsClient.sendMessage(
sessionId = runtimeId,
text = prompt,
)
// Await event or polling fallback
var replyText =
withTimeoutOrNull(45_000L) {
turnDeferred.await()
}
inFlightTurns.remove(runtimeId)
storedId?.let { inFlightTurns.remove(it) }
// Polling fallback if event dropped or session was reaped
if (replyText == null) {
val targetIds = listOfNotNull(storedId, runtimeId).distinct()
for (tid in targetIds) {
try {
val res =
safeApiCall {
ApiClient.hermesApi.getSessionMessages(
sessionId = tid,
limit = 10,
order = "latest",
)
}
if (res is NetworkResult.Success) {
val lastAssistant =
res.data.messages.reversed().firstOrNull { it.role == "assistant" }
val candidate = lastAssistant?.contentText?.trim()
if (!candidate.isNullOrBlank()) {
replyText = candidate
break
}
}
} catch (e: Exception) {
Log.w("GroupChatViewModel", "REST fallback failed for $tid: ${e.message}")
}
}
}
if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamMsgId }
val finalMsg =
GroupChatMessage(
id = streamMsgId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
avatarMeta = bot.botMeta()?.avatar,
text = replyText.trim(),
isStreaming = false,
)
val updatedList =
if (existing != null) {
state.messages.map { if (it.id == streamMsgId) finalMsg else it }
} else {
state.messages + finalMsg
}
state.copy(messages = updatedList)
}
persistSyncSnapshot(_uiState.value.messages)
} else {
_uiState.update { state ->
state.copy(messages = state.messages.filter { it.id != streamMsgId })
}
}
} catch (e: Exception) {
Log.w("GroupChatViewModel", "Turn failed for ${bot.name}: ${e.message}")
_uiState.update { state ->
state.copy(messages = state.messages.filter { it.id != streamMsgId })
}
} finally {
activeRuntimeId?.let {
activeStreamMsgId.remove(it)
sessionToBot.remove(it)
inFlightTurns.remove(it)
}
activeStoredId?.let {
activeStreamMsgId.remove(it)
sessionToBot.remove(it)
inFlightTurns.remove(it)
}
}
}
_uiState.update { it.copy(activeSpeaker = null) }
}
}
private fun persistSyncSnapshot(currentMessages: List<GroupChatMessage>) {
viewModelScope.launch(ioDispatcher) {
try {
val allProfiles = fetchProfiles()
val defaultProfile =
allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch
val existingElement = defaultProfile.ui_meta?.get("hermes-bots-groups")
val existingSnapshot =
existingElement?.let {
try {
json.decodeFromJsonElement<GroupChatSyncSnapshot>(it)
} catch (_: Exception) {
null
}
} ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
val logEntries =
currentMessages.takeLast(32).map { msg ->
GroupChatSyncLogEntry(
id = msg.id,
from =
GroupChatSyncFrom(
kind = if (msg.isUser) "user" else "member",
name = if (msg.isUser) "You" else msg.senderName,
),
text = msg.text,
at = msg.timestamp,
thread = msg.thread,
)
}
val roomKey = "name:$groupName"
val updatedRoom =
GroupChatRoomMeta(
name = groupName,
members = _uiState.value.members.map { JsonPrimitive(it.name) },
log = logEntries,
updatedAt = System.currentTimeMillis(),
)
val updatedRooms = (existingSnapshot.rooms.orEmpty() + (roomKey to updatedRoom))
val newSnapshot =
existingSnapshot.copy(
version = 3,
updatedAt = System.currentTimeMillis(),
rooms = updatedRooms,
)
val uiMetaPayload = mapOf("hermes-bots-groups" to snapshotToMap(newSnapshot))
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
} catch (e: Exception) {
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
}
}
}
private fun snapshotToMap(snapshot: GroupChatSyncSnapshot): Map<String, Any?> {
val roomsMap = mutableMapOf<String, Any?>()
snapshot.rooms?.forEach { (key, room) ->
val roomMap = mutableMapOf<String, Any?>()
room.name?.let { roomMap["name"] = it }
room.roomId?.let { roomMap["roomId"] = it }
room.picture?.let { roomMap["picture"] = it }
room.image?.let { roomMap["image"] = it }
room.updatedAt?.let { roomMap["updatedAt"] = it }
room.createdAt?.let { roomMap["createdAt"] = it }
room.revision?.let { roomMap["revision"] = it }
room.members?.let { members ->
roomMap["members"] =
members.mapNotNull {
when (it) {
is JsonPrimitive -> it.content
is JsonObject -> it["name"]?.jsonPrimitive?.content
else -> null
}
}
}
room.log?.let { log ->
roomMap["log"] =
log.map { entry ->
buildMap<String, Any?> {
entry.id?.let { put("id", it) }
entry.text?.let { put("text", it) }
entry.at?.let { put("at", it) }
entry.thread?.let { put("thread", it) }
entry.from?.let { f ->
put(
"from",
buildMap<String, Any?> {
f.kind?.let { put("kind", it) }
f.name?.let { put("name", it) }
f.source?.let { put("source", it) }
},
)
}
}
}
}
roomsMap[key] = roomMap
}
return buildMap {
put("version", snapshot.version ?: 3)
put("updatedAt", snapshot.updatedAt ?: System.currentTimeMillis())
put("rooms", roomsMap)
snapshot.deleted?.let { put("deleted", it) }
}
}
private fun isPass(text: String): Boolean {
val clean = text.trim().lowercase()
return clean == "(pass)" || clean == "pass"
}
private fun extractSessionInfo(response: Any?): MemberSession? {
if (response is Map<*, *>) {
val runtimeId =
response["session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ')
val storedId =
response["stored_session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ')
if (!runtimeId.isNullOrBlank()) {
return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId)
}
}
return null
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -160,7 +161,7 @@ fun HermesScaffold(
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
contentWindowInsets = WindowInsets(0, 0, 0, 0),
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { snackbarHost() },
topBar = {
TopAppBar(