feat(bots): add multi-agent group chat collaboration room (#998)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -155,6 +155,14 @@ private fun appEntryProvider(
|
|||||||
onBack = { NavigationController.goBack() },
|
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
|
@Composable
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object BotsScreen : NavKey
|
@Serializable data object BotsScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data class GroupChatKey(
|
||||||
|
val groupName: String,
|
||||||
|
) : NavKey
|
||||||
|
|
||||||
@Serializable data object ToolsetsScreen : NavKey
|
@Serializable data object ToolsetsScreen : NavKey
|
||||||
|
|
||||||
@Serializable data class ToolsetDetailKey(
|
@Serializable data class ToolsetDetailKey(
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package com.m57.hermescontrol.data.model
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ProfilesResponse(
|
data class ProfilesResponse(
|
||||||
@@ -121,12 +124,42 @@ data class GroupChatSyncSnapshot(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GroupChatRoomMeta(
|
data class GroupChatRoomMeta(
|
||||||
val id: String,
|
val id: String? = null,
|
||||||
|
val roomId: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val members: List<String>? = null,
|
val members: List<JsonElement>? = null,
|
||||||
val picture: String? = null,
|
val picture: String? = null,
|
||||||
|
val image: String? = null,
|
||||||
|
val log: List<GroupChatSyncLogEntry>? = null,
|
||||||
|
val revision: Long? = null,
|
||||||
val updatedAt: Long? = null,
|
val updatedAt: Long? = null,
|
||||||
val createdAt: 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
|
@Serializable
|
||||||
|
|||||||
@@ -400,20 +400,9 @@ fun BotsScreen(
|
|||||||
group = group,
|
group = group,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (group.members.isNotEmpty()) {
|
if (group.members.isNotEmpty()) {
|
||||||
scope.launch {
|
NavigationController.navigateTo(
|
||||||
val primary = group.members.first()
|
com.m57.hermescontrol.GroupChatKey(group.name),
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDisbandClick = {
|
onDisbandClick = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
|
|||||||
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.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -142,9 +143,14 @@ fun CreateBotDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AVAILABLE_SHAPES.forEach { shape ->
|
AVAILABLE_SHAPES.forEach { shape ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
|
|||||||
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.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -173,9 +174,14 @@ fun EditBotBottomSheet(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AVAILABLE_SHAPES.forEach { shape ->
|
AVAILABLE_SHAPES.forEach { shape ->
|
||||||
FilterChip(
|
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.calculateEndPadding
|
||||||
import androidx.compose.foundation.layout.calculateStartPadding
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
@@ -160,7 +161,7 @@ fun HermesScaffold(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
snackbarHost = { snackbarHost() },
|
snackbarHost = { snackbarHost() },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -802,4 +802,12 @@
|
|||||||
<item quantity="other">에이전트 %1$d개 실행 중</item>
|
<item quantity="other">에이전트 %1$d개 실행 중</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="task_progress_open_details">작업 세부 정보 열기</string>
|
<string name="task_progress_open_details">작업 세부 정보 열기</string>
|
||||||
|
<!-- Group Chat Room -->
|
||||||
|
<string name="group_chat_composer_hint">%1$s에게 메시지 보내기… (@bot으로 멘션)</string>
|
||||||
|
<string name="group_chat_empty_title">메시지 없음</string>
|
||||||
|
<string name="group_chat_empty_subtitle">봇을 멘션하거나 @all을 사용하여 협업을 시작하세요.</string>
|
||||||
|
<string name="group_chat_thinking">%1$s 생각 중…</string>
|
||||||
|
<string name="group_chat_typing">입력 중…</string>
|
||||||
|
<string name="group_chat_members_count">멤버 %1$d명</string>
|
||||||
|
<string name="group_chat_mention_all">@all</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1101,4 +1101,12 @@
|
|||||||
<!-- system action toast templates -->
|
<!-- system action toast templates -->
|
||||||
<string name="system_action_started">%1$s 已开始</string>
|
<string name="system_action_started">%1$s 已开始</string>
|
||||||
<string name="system_action_failed">%1$s 失败:%2$s</string>
|
<string name="system_action_failed">%1$s 失败:%2$s</string>
|
||||||
|
<!-- Group Chat Room -->
|
||||||
|
<string name="group_chat_composer_hint">发消息给 %1$s…(使用 @bot 提及)</string>
|
||||||
|
<string name="group_chat_empty_title">暂无消息</string>
|
||||||
|
<string name="group_chat_empty_subtitle">提及某个 Bot 或使用 @all 开始协同。</string>
|
||||||
|
<string name="group_chat_thinking">%1$s 正在思考…</string>
|
||||||
|
<string name="group_chat_typing">正在输入…</string>
|
||||||
|
<string name="group_chat_members_count">%1$d 个成员</string>
|
||||||
|
<string name="group_chat_mention_all">@all</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1232,4 +1232,12 @@
|
|||||||
<!-- system action toast templates -->
|
<!-- system action toast templates -->
|
||||||
<string name="system_action_started">%1$s started</string>
|
<string name="system_action_started">%1$s started</string>
|
||||||
<string name="system_action_failed">%1$s failed: %2$s</string>
|
<string name="system_action_failed">%1$s failed: %2$s</string>
|
||||||
|
<!-- Group Chat Room -->
|
||||||
|
<string name="group_chat_composer_hint">Message %1$s… (use @bot to mention)</string>
|
||||||
|
<string name="group_chat_empty_title">No Messages Yet</string>
|
||||||
|
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
|
||||||
|
<string name="group_chat_thinking">%1$s is thinking…</string>
|
||||||
|
<string name="group_chat_typing">Typing…</string>
|
||||||
|
<string name="group_chat_members_count">%1$d members</string>
|
||||||
|
<string name="group_chat_mention_all">@all</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
@@ -11,120 +12,132 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ProfileSerializationTest {
|
class ProfileSerializationTest {
|
||||||
private val json = OkHttpProvider.json
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testProfileInfoDeserializationWithBotMetadata() {
|
fun testBotRosterMetaSerialization() {
|
||||||
val jsonStr =
|
val meta =
|
||||||
"""
|
BotRosterMeta(
|
||||||
{
|
title = "Literature Scout",
|
||||||
"name": "researcher",
|
description = "Scrapes arXiv and summarizes papers",
|
||||||
"path": "/home/user/.hermes/profiles/researcher",
|
avatar =
|
||||||
"is_default": false,
|
BotAvatarMeta(
|
||||||
"model": "anthropic/claude-3-7-sonnet",
|
shape = "circle",
|
||||||
"provider": "anthropic",
|
color = "#4A90E2",
|
||||||
"skill_count": 5,
|
icon = "science",
|
||||||
"description": "Base description",
|
),
|
||||||
"display_name": "Researcher Agent",
|
hidden = false,
|
||||||
"has_avatar": true,
|
groups = listOf("research", "ai-lab"),
|
||||||
"canonical_session": {
|
group = "research",
|
||||||
"id": "sess-canon-123",
|
created = 1718000000.0,
|
||||||
"resolved_id": "sess-canon-tip",
|
)
|
||||||
"root_title": "Bot Chat",
|
|
||||||
"title": "Bot Chat",
|
|
||||||
"preview": "Latest research report ready.",
|
|
||||||
"started_at": 1700000000,
|
|
||||||
"last_active": 1700005000,
|
|
||||||
"message_count": 42
|
|
||||||
},
|
|
||||||
"worker_session": {
|
|
||||||
"id": "worker-456",
|
|
||||||
"source": "kanban",
|
|
||||||
"title": "Background analysis",
|
|
||||||
"last_active": 1700006000
|
|
||||||
},
|
|
||||||
"last_session": {
|
|
||||||
"id": "last-789",
|
|
||||||
"title": "Ad-hoc task",
|
|
||||||
"preview": "Done!",
|
|
||||||
"started_at": 1700001000,
|
|
||||||
"last_active": 1700002000,
|
|
||||||
"message_count": 10
|
|
||||||
},
|
|
||||||
"ui_meta": {
|
|
||||||
"hermes-bots": {
|
|
||||||
"title": "Dr. Researcher",
|
|
||||||
"description": "Deep literature scout",
|
|
||||||
"avatar": {
|
|
||||||
"shape": "hexagon",
|
|
||||||
"color": "#4A90E2",
|
|
||||||
"icon": "science",
|
|
||||||
"image_url": "https://example.com/avatar.png"
|
|
||||||
},
|
|
||||||
"hidden": false,
|
|
||||||
"groups": ["science-team", "daily-sync"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val profile = json.decodeFromString<ProfileInfo>(jsonStr)
|
val encoded = json.encodeToString(meta)
|
||||||
assertEquals("researcher", profile.name)
|
val decoded = json.decodeFromString<BotRosterMeta>(encoded)
|
||||||
assertEquals("Researcher Agent", profile.display_name)
|
|
||||||
assertTrue(profile.has_avatar == true)
|
|
||||||
|
|
||||||
// Canonical session
|
assertEquals("Literature Scout", decoded.title)
|
||||||
val canon = profile.canonical_session
|
assertEquals("Scrapes arXiv and summarizes papers", decoded.description)
|
||||||
assertNotNull(canon)
|
assertEquals("circle", decoded.avatar?.shape)
|
||||||
assertEquals("sess-canon-123", canon?.id)
|
assertEquals("#4A90E2", decoded.avatar?.color)
|
||||||
assertEquals("sess-canon-tip", canon?.resolved_id)
|
assertEquals("science", decoded.avatar?.icon)
|
||||||
assertEquals("Latest research report ready.", canon?.preview)
|
assertEquals(false, decoded.hidden)
|
||||||
assertEquals(42, canon?.message_count)
|
assertEquals(listOf("research", "ai-lab"), decoded.groups)
|
||||||
|
assertEquals("research", decoded.group)
|
||||||
|
assertEquals(1718000000.0, decoded.created)
|
||||||
|
}
|
||||||
|
|
||||||
// Worker session
|
@Test
|
||||||
val worker = profile.worker_session
|
fun testProfileInfoWithBotMetaExtraction() {
|
||||||
assertNotNull(worker)
|
val botMeta =
|
||||||
assertEquals("worker-456", worker?.id)
|
BotRosterMeta(
|
||||||
assertEquals("kanban", worker?.source)
|
title = "Custom Bot Title",
|
||||||
|
description = "Custom Bot Description",
|
||||||
|
avatar = BotAvatarMeta(shape = "hexagon", color = "#E91E63", icon = "robot"),
|
||||||
|
hidden = false,
|
||||||
|
)
|
||||||
|
|
||||||
// Bot metadata decoding
|
val profile =
|
||||||
val botMeta = profile.botMeta(json)
|
ProfileInfo(
|
||||||
assertNotNull(botMeta)
|
name = "custom_bot",
|
||||||
assertEquals("Dr. Researcher", botMeta?.title)
|
display_name = "Original Name",
|
||||||
assertEquals("Deep literature scout", botMeta?.description)
|
description = "Original Description",
|
||||||
assertEquals("hexagon", botMeta?.avatar?.shape)
|
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(botMeta)),
|
||||||
assertEquals("#4A90E2", botMeta?.avatar?.color)
|
canonical_session =
|
||||||
assertEquals(listOf("science-team", "daily-sync"), botMeta?.groups)
|
CanonicalSessionInfo(
|
||||||
|
id = "sess-123",
|
||||||
|
last_active = 1720000000.0,
|
||||||
|
),
|
||||||
|
worker_session =
|
||||||
|
ProfileWorkerSummary(
|
||||||
|
id = "worker-456",
|
||||||
|
source = "kanban",
|
||||||
|
title = "Task #42",
|
||||||
|
last_active = 1720000100.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Helpers
|
val extractedMeta = profile.botMeta(json)
|
||||||
assertEquals("Dr. Researcher", profile.effectiveTitle)
|
assertNotNull(extractedMeta)
|
||||||
assertEquals("Deep literature scout", profile.effectiveDescription)
|
assertEquals("Custom Bot Title", extractedMeta?.title)
|
||||||
|
assertEquals("Custom Bot Title", profile.effectiveTitle)
|
||||||
|
assertEquals("Custom Bot Description", profile.effectiveDescription)
|
||||||
|
assertFalse(profile.isHidden)
|
||||||
|
assertEquals("sess-123", profile.canonical_session?.id)
|
||||||
|
assertEquals("worker-456", profile.worker_session?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testProfileInfoWithoutBotMeta_fallsBackToProfileFields() {
|
||||||
|
val profile =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "regular_profile",
|
||||||
|
display_name = "My Profile",
|
||||||
|
description = "Just a profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(profile.botMeta(json))
|
||||||
|
assertEquals("My Profile", profile.effectiveTitle)
|
||||||
|
assertEquals("Just a profile", profile.effectiveDescription)
|
||||||
assertFalse(profile.isHidden)
|
assertFalse(profile.isHidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testProfileInfoBackwardCompatibility() {
|
fun testProfileInfoHidden_derivedFromBotMeta() {
|
||||||
val legacyJson =
|
val hiddenBot =
|
||||||
"""
|
ProfileInfo(
|
||||||
{
|
name = "hidden_bot",
|
||||||
"name": "default",
|
ui_meta =
|
||||||
"is_default": true,
|
mapOf(
|
||||||
"model": "openai/gpt-4o",
|
"hermes-bots" to
|
||||||
"provider": "openai",
|
json.encodeToJsonElement(
|
||||||
"description": "Default Assistant"
|
BotRosterMeta(title = "Hidden", hidden = true),
|
||||||
}
|
),
|
||||||
""".trimIndent()
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val visibleBot =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "visible_bot",
|
||||||
|
ui_meta =
|
||||||
|
mapOf(
|
||||||
|
"hermes-bots" to
|
||||||
|
json.encodeToJsonElement(
|
||||||
|
BotRosterMeta(title = "Visible", hidden = false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(hiddenBot.isHidden)
|
||||||
|
assertFalse(visibleBot.isHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testProfileInfoDefault_fallbackNames() {
|
||||||
|
val profile = ProfileInfo(name = "default")
|
||||||
|
|
||||||
val profile = json.decodeFromString<ProfileInfo>(legacyJson)
|
|
||||||
assertEquals("default", profile.name)
|
|
||||||
assertTrue(profile.is_default == true)
|
|
||||||
assertNull(profile.canonical_session)
|
|
||||||
assertNull(profile.worker_session)
|
|
||||||
assertNull(profile.last_session)
|
|
||||||
assertNull(profile.ui_meta)
|
|
||||||
assertNull(profile.botMeta(json))
|
assertNull(profile.botMeta(json))
|
||||||
assertEquals("default", profile.effectiveTitle)
|
assertEquals("default", profile.effectiveTitle)
|
||||||
assertEquals("Default Assistant", profile.effectiveDescription)
|
assertEquals("", profile.effectiveDescription)
|
||||||
assertFalse(profile.isHidden)
|
assertFalse(profile.isHidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +153,17 @@ class ProfileSerializationTest {
|
|||||||
GroupChatRoomMeta(
|
GroupChatRoomMeta(
|
||||||
id = "room-1",
|
id = "room-1",
|
||||||
name = "War Room",
|
name = "War Room",
|
||||||
members = listOf("default", "researcher"),
|
members = listOf(JsonPrimitive("default"), JsonPrimitive("researcher")),
|
||||||
picture = null,
|
picture = null,
|
||||||
|
log =
|
||||||
|
listOf(
|
||||||
|
GroupChatSyncLogEntry(
|
||||||
|
id = "msg-1",
|
||||||
|
from = GroupChatSyncFrom(kind = "user", name = "You"),
|
||||||
|
text = "Let's review the code",
|
||||||
|
at = 1724000000L,
|
||||||
|
),
|
||||||
|
),
|
||||||
updatedAt = 1724000000L,
|
updatedAt = 1724000000L,
|
||||||
createdAt = 1723000000L,
|
createdAt = 1723000000L,
|
||||||
),
|
),
|
||||||
@@ -153,8 +175,11 @@ class ProfileSerializationTest {
|
|||||||
val decoded = json.decodeFromString<GroupChatSyncSnapshot>(encoded)
|
val decoded = json.decodeFromString<GroupChatSyncSnapshot>(encoded)
|
||||||
assertEquals(3, decoded.version)
|
assertEquals(3, decoded.version)
|
||||||
assertEquals(1, decoded.rooms?.size)
|
assertEquals(1, decoded.rooms?.size)
|
||||||
assertEquals("War Room", decoded.rooms?.get("room-1")?.name)
|
val room = decoded.rooms?.get("room-1")
|
||||||
assertEquals(listOf("default", "researcher"), decoded.rooms?.get("room-1")?.members)
|
assertEquals("War Room", room?.name)
|
||||||
|
assertEquals(listOf("default", "researcher"), room?.memberNames)
|
||||||
|
assertEquals(1, room?.log?.size)
|
||||||
|
assertEquals("Let's review the code", room?.log?.first()?.text)
|
||||||
assertEquals(1723500000L, decoded.deleted?.get("old-room"))
|
assertEquals(1723500000L, decoded.deleted?.get("old-room"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.m57.hermescontrol.ui.bots.group
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class GroupChatMentionsTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val botA =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "scoutbot",
|
||||||
|
ui_meta =
|
||||||
|
mapOf(
|
||||||
|
"hermes-bots" to
|
||||||
|
json.encodeToJsonElement(
|
||||||
|
BotRosterMeta(title = "Scout Bot", avatar = BotAvatarMeta()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val botB =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "coder",
|
||||||
|
ui_meta =
|
||||||
|
mapOf(
|
||||||
|
"hermes-bots" to
|
||||||
|
json.encodeToJsonElement(
|
||||||
|
BotRosterMeta(title = "Dev Bot", avatar = BotAvatarMeta()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val members = listOf(botA, botB)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseMentions_detectsDirectBotHandle() {
|
||||||
|
val result = GroupChatMentions.parseMentions("Hey @scoutbot can you check this?", members)
|
||||||
|
assertFalse(result.isEveryone)
|
||||||
|
assertTrue(result.mentionedBots.contains("scoutbot"))
|
||||||
|
assertFalse(result.mentionedBots.contains("coder"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseMentions_detectsEveryoneAndAll() {
|
||||||
|
val resEveryone = GroupChatMentions.parseMentions("Hello @everyone!", members)
|
||||||
|
assertTrue(resEveryone.isEveryone)
|
||||||
|
|
||||||
|
val resAll = GroupChatMentions.parseMentions("@all status update please", members)
|
||||||
|
assertTrue(resAll.isEveryone)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveResponders_defaultsToAllWhenNoMention() {
|
||||||
|
val responders = GroupChatMentions.resolveResponders("General discussion message", members)
|
||||||
|
assertEquals(2, responders.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveResponders_filtersToMentionedOnly() {
|
||||||
|
val responders = GroupChatMentions.resolveResponders("@coder build the apk", members)
|
||||||
|
assertEquals(1, responders.size)
|
||||||
|
assertEquals("coder", responders.first().name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package com.m57.hermescontrol.ui.bots.group
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
|
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.HermesApiService
|
||||||
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkObject
|
||||||
|
import io.mockk.unmockkStatic
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class GroupChatViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private lateinit var mockApi: HermesApiService
|
||||||
|
private val eventsFlow = MutableSharedFlow<WsEvent>()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val botA =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "scout",
|
||||||
|
ui_meta =
|
||||||
|
mapOf(
|
||||||
|
"hermes-bots" to
|
||||||
|
json.encodeToJsonElement(
|
||||||
|
BotRosterMeta(
|
||||||
|
title = "Scout Bot",
|
||||||
|
groups = listOf("Dev Team"),
|
||||||
|
avatar = BotAvatarMeta(icon = "science"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val botB =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "coder",
|
||||||
|
ui_meta =
|
||||||
|
mapOf(
|
||||||
|
"hermes-bots" to
|
||||||
|
json.encodeToJsonElement(
|
||||||
|
BotRosterMeta(
|
||||||
|
title = "Dev Bot",
|
||||||
|
groups = listOf("Dev Team"),
|
||||||
|
avatar = BotAvatarMeta(icon = "code"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any(), any<String>()) } returns 0
|
||||||
|
every { Log.e(any(), any()) } returns 0
|
||||||
|
|
||||||
|
mockkObject(ApiClient)
|
||||||
|
mockApi = mockk(relaxed = true)
|
||||||
|
every { ApiClient.hermesApi } returns mockApi
|
||||||
|
|
||||||
|
coEvery { mockApi.getProfiles() } returns
|
||||||
|
Response.success(
|
||||||
|
ProfilesResponse(
|
||||||
|
profiles = listOf(botA, botB),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockkObject(HermesWsClient)
|
||||||
|
every { HermesWsClient.events } returns eventsFlow
|
||||||
|
every { HermesWsClient.send(any(), any(), any()) } returns "req-1"
|
||||||
|
every { HermesWsClient.sendMessage(any(), any(), any()) } returns "req-msg"
|
||||||
|
|
||||||
|
val createDeferred = CompletableDeferred<Any?>()
|
||||||
|
createDeferred.complete(mapOf("session_id" to "session-scout-1"))
|
||||||
|
every { HermesWsClient.request(any(), any(), any()) } returns createDeferred
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkObject(ApiClient)
|
||||||
|
unmockkObject(HermesWsClient)
|
||||||
|
unmockkStatic(Log::class)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadGroup_resolvesMembers() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertFalse(state.isLoading)
|
||||||
|
assertNull(state.errorMessage)
|
||||||
|
assertEquals(2, state.members.size)
|
||||||
|
assertEquals("scout", state.members[0].name)
|
||||||
|
assertEquals("coder", state.members[1].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadGroup_hydratesFromSyncSnapshot() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val syncSnapshot =
|
||||||
|
GroupChatSyncSnapshot(
|
||||||
|
version = 3,
|
||||||
|
updatedAt = 1724000000L,
|
||||||
|
rooms =
|
||||||
|
mapOf(
|
||||||
|
"name:Dev Team" to
|
||||||
|
GroupChatRoomMeta(
|
||||||
|
name = "Dev Team",
|
||||||
|
members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")),
|
||||||
|
log =
|
||||||
|
listOf(
|
||||||
|
GroupChatSyncLogEntry(
|
||||||
|
id = "msg-prev-1",
|
||||||
|
from = GroupChatSyncFrom(kind = "user", name = "You"),
|
||||||
|
text = "Initial design spec",
|
||||||
|
at = 1724000000L,
|
||||||
|
),
|
||||||
|
GroupChatSyncLogEntry(
|
||||||
|
id = "msg-prev-2",
|
||||||
|
from = GroupChatSyncFrom(kind = "member", name = "scout"),
|
||||||
|
text = "Looks solid, ready to build.",
|
||||||
|
at = 1724000010L,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultProfileWithSnapshot =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "default",
|
||||||
|
is_default = true,
|
||||||
|
ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(syncSnapshot)),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery { mockApi.getProfiles() } returns
|
||||||
|
Response.success(
|
||||||
|
ProfilesResponse(
|
||||||
|
profiles = listOf(defaultProfileWithSnapshot, botA, botB),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals(2, state.messages.size)
|
||||||
|
assertEquals("msg-prev-1", state.messages[0].id)
|
||||||
|
assertTrue(state.messages[0].isUser)
|
||||||
|
assertEquals("Initial design spec", state.messages[0].text)
|
||||||
|
|
||||||
|
assertEquals("msg-prev-2", state.messages[1].id)
|
||||||
|
assertFalse(state.messages[1].isUser)
|
||||||
|
assertEquals("scout", state.messages[1].senderName)
|
||||||
|
assertEquals("Looks solid, ready to build.", state.messages[1].text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSendMessage_streamsTokensAndCompletes() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// Send message targeting only @scout
|
||||||
|
viewModel.sendMessage("@scout what's the status?")
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// State should contain user message
|
||||||
|
val userMsg = viewModel.uiState.value.messages.first()
|
||||||
|
assertTrue(userMsg.isUser)
|
||||||
|
assertEquals("@scout what's the status?", userMsg.text)
|
||||||
|
|
||||||
|
// Stream tokens
|
||||||
|
eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1"))
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val streamingMsg = viewModel.uiState.value.messages.last()
|
||||||
|
assertFalse(streamingMsg.isUser)
|
||||||
|
assertEquals("scout", streamingMsg.senderName)
|
||||||
|
assertEquals("Building ", streamingMsg.text)
|
||||||
|
assertTrue(streamingMsg.isStreaming)
|
||||||
|
|
||||||
|
eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1"))
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val updatedStreaming = viewModel.uiState.value.messages.last()
|
||||||
|
assertEquals("Building the APK...", updatedStreaming.text)
|
||||||
|
|
||||||
|
// Complete turn
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.MessageComplete(
|
||||||
|
text = "Building the APK now! Everything looks green.",
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val finalState = viewModel.uiState.value
|
||||||
|
assertEquals(2, finalState.messages.size)
|
||||||
|
val botMsg = finalState.messages[1]
|
||||||
|
assertEquals("Building the APK now! Everything looks green.", botMsg.text)
|
||||||
|
assertFalse(botMsg.isStreaming)
|
||||||
|
assertNull(finalState.activeSpeaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSendMessage_filtersOutPass() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
viewModel.sendMessage("@scout any updates?")
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.MessageComplete(
|
||||||
|
text = "(pass)",
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val finalState = viewModel.uiState.value
|
||||||
|
// Only the user message should remain; (pass) is filtered out
|
||||||
|
assertEquals(1, finalState.messages.size)
|
||||||
|
assertTrue(finalState.messages.first().isUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user