feat(bots): add multi-agent group chat collaboration room (#998)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user