diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt index a7360660..ec1ac08b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt @@ -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"), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index e885ab89..bfd57e36 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -34,6 +34,13 @@ data class BotsUiState( val hasHiddenBots: Boolean get() = profiles.any { it.isHidden } + val allGroups: List + get() = + profiles + .flatMap { it.botMeta()?.groups.orEmpty() } + .distinct() + .sorted() + val activeNowBots: List get() { val nowSeconds = System.currentTimeMillis() / 1000 @@ -181,6 +188,29 @@ class BotsViewModel( } } + fun createGroupChat( + groupName: String, + botNames: List, + 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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateGroupChatDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateGroupChatDialog.kt new file mode 100644 index 00000000..e13470b4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateGroupChatDialog.kt @@ -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, + onDismiss: () -> Unit, + onCreateGroup: (groupName: String, selectedBotNames: List) -> Unit, +) { + var groupName by remember { mutableStateOf("") } + var selectedBots by remember { mutableStateOf(setOf()) } + + 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)) + } + }, + ) +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d0bc9ce2..71375b72 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -82,6 +82,16 @@ 아바타 모양 강조 색상 생성 + 그룹 채팅 생성 + 새 그룹 채팅 + 그룹 이름 + 예: 연구 팀 + 봇 선택 (최소 2개) + 그룹 생성 + 봇 + 그룹 + 그룹 채팅 없음 + 봇 간 협업을 위한 다중 에이전트 그룹 채팅을 만드세요. 웹훅 게이트웨이 툴셋 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index bf617614..129298f7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -86,6 +86,16 @@ 头像形状 主题色 创建 + 创建群聊 + 新建群聊 + 群聊名称 + 例如 研发小组 + 选择智能体 (至少 2 个) + 创建群聊 + 智能体 + 群聊 + 暂无群聊 + 创建多智能体群聊以实现多 Bot 协同。 Webhook 网关 工具集 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce8be5e7..54f5e44f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,16 @@ Avatar Shape Accent Color Create + Create Group Chat + New Group Chat + Group Name + e.g. Research Team + Select Bots (at least 2) + Create Group + Bots + Groups + No Group Chats + Create a multi-agent group chat to let bots collaborate. Webhooks Gateway Toolsets diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt index eeb31996..12579fc2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt @@ -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) + } }