feat(ui): add CreateGroupChatDialog for multi-bot room creation (#982)
This commit is contained in:
@@ -21,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Search
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
@@ -71,6 +72,7 @@ fun BotsScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var isSearchActive by remember { mutableStateOf(false) }
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
var showCreateGroupDialog by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
if (showCreateDialog) {
|
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(
|
ToastEffect(
|
||||||
toastMessage = state.toastMessage,
|
toastMessage = state.toastMessage,
|
||||||
onClearToast = { viewModel.clearToast() },
|
onClearToast = { viewModel.clearToast() },
|
||||||
@@ -130,6 +144,15 @@ fun BotsScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
actions = {
|
actions = {
|
||||||
if (!isSearchActive) {
|
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(
|
IconButton(
|
||||||
onClick = { showCreateDialog = true },
|
onClick = { showCreateDialog = true },
|
||||||
modifier = Modifier.testTag("bots_action_create"),
|
modifier = Modifier.testTag("bots_action_create"),
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ data class BotsUiState(
|
|||||||
val hasHiddenBots: Boolean
|
val hasHiddenBots: Boolean
|
||||||
get() = profiles.any { it.isHidden }
|
get() = profiles.any { it.isHidden }
|
||||||
|
|
||||||
|
val allGroups: List<String>
|
||||||
|
get() =
|
||||||
|
profiles
|
||||||
|
.flatMap { it.botMeta()?.groups.orEmpty() }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
|
||||||
val activeNowBots: List<ProfileInfo>
|
val activeNowBots: List<ProfileInfo>
|
||||||
get() {
|
get() {
|
||||||
val nowSeconds = System.currentTimeMillis() / 1000
|
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(
|
private fun wsClientConfigureBot(
|
||||||
name: String,
|
name: String,
|
||||||
meta: BotRosterMeta,
|
meta: BotRosterMeta,
|
||||||
@@ -199,6 +229,9 @@ class BotsViewModel(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!meta.groups.isNullOrEmpty()) {
|
||||||
|
put("groups", meta.groups)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HermesWsClient.send(
|
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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -82,6 +82,16 @@
|
|||||||
<string name="bots_create_avatar_shape">아바타 모양</string>
|
<string name="bots_create_avatar_shape">아바타 모양</string>
|
||||||
<string name="bots_create_avatar_color">강조 색상</string>
|
<string name="bots_create_avatar_color">강조 색상</string>
|
||||||
<string name="bots_create_button">생성</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_webhooks">웹훅</string>
|
||||||
<string name="screen_gateway">게이트웨이</string>
|
<string name="screen_gateway">게이트웨이</string>
|
||||||
<string name="screen_toolsets">툴셋</string>
|
<string name="screen_toolsets">툴셋</string>
|
||||||
|
|||||||
@@ -86,6 +86,16 @@
|
|||||||
<string name="bots_create_avatar_shape">头像形状</string>
|
<string name="bots_create_avatar_shape">头像形状</string>
|
||||||
<string name="bots_create_avatar_color">主题色</string>
|
<string name="bots_create_avatar_color">主题色</string>
|
||||||
<string name="bots_create_button">创建</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_webhooks">Webhook</string>
|
||||||
<string name="screen_gateway">网关</string>
|
<string name="screen_gateway">网关</string>
|
||||||
<string name="screen_toolsets">工具集</string>
|
<string name="screen_toolsets">工具集</string>
|
||||||
|
|||||||
@@ -99,6 +99,16 @@
|
|||||||
<string name="bots_create_avatar_shape">Avatar Shape</string>
|
<string name="bots_create_avatar_shape">Avatar Shape</string>
|
||||||
<string name="bots_create_avatar_color">Accent Color</string>
|
<string name="bots_create_avatar_color">Accent Color</string>
|
||||||
<string name="bots_create_button">Create</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_webhooks">Webhooks</string>
|
||||||
<string name="screen_gateway">Gateway</string>
|
<string name="screen_gateway">Gateway</string>
|
||||||
<string name="screen_toolsets">Toolsets</string>
|
<string name="screen_toolsets">Toolsets</string>
|
||||||
|
|||||||
@@ -164,4 +164,30 @@ class BotsViewModelTest {
|
|||||||
assertTrue(successCalled)
|
assertTrue(successCalled)
|
||||||
coVerify(exactly = 1) { mockApi.createProfile(match { it.name == "researcher" }) }
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user