feat(ui): add CreateGroupChatDialog for multi-bot room creation (#982)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:52:34 +03:00
1 parent d4d6a04afc
commit 274120cc86
7 files changed
+273

No files matched your search

@@ -21,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -71,6 +72,7 @@ fun BotsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var isSearchActive by remember { mutableStateOf(false) }
var showCreateDialog by remember { mutableStateOf(false) }
var showCreateGroupDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (showCreateDialog) {
@@ -84,6 +86,18 @@ fun BotsScreen(
)
}
if (showCreateGroupDialog) {
CreateGroupChatDialog(
availableBots = state.profiles,
onDismiss = { showCreateGroupDialog = false },
onCreateGroup = { groupName, botNames ->
viewModel.createGroupChat(groupName, botNames) {
showCreateGroupDialog = false
}
},
)
}
ToastEffect(
toastMessage = state.toastMessage,
onClearToast = { viewModel.clearToast() },
@@ -130,6 +144,15 @@ fun BotsScreen(
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
actions = {
if (!isSearchActive) {
IconButton(
onClick = { showCreateGroupDialog = true },
modifier = Modifier.testTag("bots_action_create_group"),
) {
Icon(
imageVector = Icons.Filled.GroupAdd,
contentDescription = stringResource(R.string.bots_action_create_group),
)
}
IconButton(
onClick = { showCreateDialog = true },
modifier = Modifier.testTag("bots_action_create"),
@@ -34,6 +34,13 @@ data class BotsUiState(
val hasHiddenBots: Boolean
get() = profiles.any { it.isHidden }
val allGroups: List<String>
get() =
profiles
.flatMap { it.botMeta()?.groups.orEmpty() }
.distinct()
.sorted()
val activeNowBots: List<ProfileInfo>
get() {
val nowSeconds = System.currentTimeMillis() / 1000
@@ -181,6 +188,29 @@ class BotsViewModel(
}
}
fun createGroupChat(
groupName: String,
botNames: List<String>,
onSuccess: () -> Unit,
) {
viewModelScope.launch(ioDispatcher) {
val currentProfiles = _uiState.value.profiles
for (name in botNames) {
val bot = currentProfiles.find { it.name == name } ?: continue
val existingGroups = bot.botMeta()?.groups.orEmpty()
if (!existingGroups.contains(groupName)) {
val updatedMeta =
(bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups + groupName,
)
wsClientConfigureBot(name, updatedMeta)
}
}
loadBots()
onSuccess()
}
}
private fun wsClientConfigureBot(
name: String,
meta: BotRosterMeta,
@@ -199,6 +229,9 @@ class BotsViewModel(
},
)
}
if (!meta.groups.isNullOrEmpty()) {
put("groups", meta.groups)
}
}
HermesWsClient.send(
@@ -0,0 +1,161 @@
package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.ui.common.BotAvatar
@Composable
fun CreateGroupChatDialog(
availableBots: List<ProfileInfo>,
onDismiss: () -> Unit,
onCreateGroup: (groupName: String, selectedBotNames: List<String>) -> Unit,
) {
var groupName by remember { mutableStateOf("") }
var selectedBots by remember { mutableStateOf(setOf<String>()) }
val defaultPlaceholder =
if (selectedBots.isNotEmpty()) {
selectedBots.joinToString(", ")
} else {
stringResource(R.string.bots_create_group_name_placeholder)
}
val effectiveName = groupName.ifBlank { selectedBots.joinToString(", ") }
val isValid = selectedBots.size >= 2 && effectiveName.isNotBlank()
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.bots_create_group_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = groupName,
onValueChange = { groupName = it },
label = { Text(stringResource(R.string.bots_create_group_name_label)) },
placeholder = { Text(defaultPlaceholder) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.bots_create_group_select_bots),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp),
) {
items(availableBots, key = { it.name }) { bot ->
val isChecked = selectedBots.contains(bot.name)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
selectedBots =
if (isChecked) {
selectedBots - bot.name
} else {
selectedBots + bot.name
}
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = isChecked,
onCheckedChange = { checked ->
selectedBots =
if (checked) {
selectedBots + bot.name
} else {
selectedBots - bot.name
}
},
)
Spacer(modifier = Modifier.width(4.dp))
BotAvatar(
name = bot.name,
avatar = bot.botMeta()?.avatar,
size = 28.dp,
showPresence = false,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = bot.effectiveTitle,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "@${bot.name}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
},
confirmButton = {
Button(
onClick = {
if (isValid) {
onCreateGroup(effectiveName.trim(), selectedBots.toList())
}
},
enabled = isValid,
) {
Text(stringResource(R.string.bots_create_group_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
+10
View File
@@ -82,6 +82,16 @@
<string name="bots_create_avatar_shape">아바타 모양</string>
<string name="bots_create_avatar_color">강조 색상</string>
<string name="bots_create_button">생성</string>
<string name="bots_action_create_group">그룹 채팅 생성</string>
<string name="bots_create_group_title">새 그룹 채팅</string>
<string name="bots_create_group_name_label">그룹 이름</string>
<string name="bots_create_group_name_placeholder">예: 연구 팀</string>
<string name="bots_create_group_select_bots">봇 선택 (최소 2개)</string>
<string name="bots_create_group_button">그룹 생성</string>
<string name="bots_tab_all">봇</string>
<string name="bots_tab_groups">그룹</string>
<string name="bots_groups_empty_title">그룹 채팅 없음</string>
<string name="bots_groups_empty_desc">봇 간 협업을 위한 다중 에이전트 그룹 채팅을 만드세요.</string>
<string name="screen_webhooks">웹훅</string>
<string name="screen_gateway">게이트웨이</string>
<string name="screen_toolsets">툴셋</string>
+10
View File
@@ -86,6 +86,16 @@
<string name="bots_create_avatar_shape">头像形状</string>
<string name="bots_create_avatar_color">主题色</string>
<string name="bots_create_button">创建</string>
<string name="bots_action_create_group">创建群聊</string>
<string name="bots_create_group_title">新建群聊</string>
<string name="bots_create_group_name_label">群聊名称</string>
<string name="bots_create_group_name_placeholder">例如 研发小组</string>
<string name="bots_create_group_select_bots">选择智能体 (至少 2 个)</string>
<string name="bots_create_group_button">创建群聊</string>
<string name="bots_tab_all">智能体</string>
<string name="bots_tab_groups">群聊</string>
<string name="bots_groups_empty_title">暂无群聊</string>
<string name="bots_groups_empty_desc">创建多智能体群聊以实现多 Bot 协同。</string>
<string name="screen_webhooks">Webhook</string>
<string name="screen_gateway">网关</string>
<string name="screen_toolsets">工具集</string>
+10
View File
@@ -99,6 +99,16 @@
<string name="bots_create_avatar_shape">Avatar Shape</string>
<string name="bots_create_avatar_color">Accent Color</string>
<string name="bots_create_button">Create</string>
<string name="bots_action_create_group">Create Group Chat</string>
<string name="bots_create_group_title">New Group Chat</string>
<string name="bots_create_group_name_label">Group Name</string>
<string name="bots_create_group_name_placeholder">e.g. Research Team</string>
<string name="bots_create_group_select_bots">Select Bots (at least 2)</string>
<string name="bots_create_group_button">Create Group</string>
<string name="bots_tab_all">Bots</string>
<string name="bots_tab_groups">Groups</string>
<string name="bots_groups_empty_title">No Group Chats</string>
<string name="bots_groups_empty_desc">Create a multi-agent group chat to let bots collaborate.</string>
<string name="screen_webhooks">Webhooks</string>
<string name="screen_gateway">Gateway</string>
<string name="screen_toolsets">Toolsets</string>
@@ -164,4 +164,30 @@ class BotsViewModelTest {
assertTrue(successCalled)
coVerify(exactly = 1) { mockApi.createProfile(match { it.name == "researcher" }) }
}
@Test
fun testCreateGroupChat_updatesMemberMeta() =
runTest {
val profiles =
listOf(
ProfileInfo(name = "botA"),
ProfileInfo(name = "botB"),
)
coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(profiles))
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "botA"))
val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false)
viewModel.loadBots()
advanceUntilIdle()
var success = false
viewModel.createGroupChat(
groupName = "Dream Team",
botNames = listOf("botA", "botB"),
onSuccess = { success = true },
)
advanceUntilIdle()
assertTrue(success)
}
}