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 ec1ac08b..8816d6de 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.Edit import androidx.compose.material.icons.filled.GroupAdd import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Visibility @@ -73,8 +74,26 @@ fun BotsScreen( var isSearchActive by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) } var showCreateGroupDialog by remember { mutableStateOf(false) } + var editingBot by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + editingBot?.let { bot -> + EditBotBottomSheet( + bot = bot, + onDismiss = { editingBot = null }, + onSave = { title, description, shape, color -> + viewModel.updateBotMeta(bot.name, title, description, shape, color) { + editingBot = null + } + }, + onDelete = { + viewModel.deleteBot(bot.name) { + editingBot = null + } + }, + ) + } + if (showCreateDialog) { CreateBotDialog( onDismiss = { showCreateDialog = false }, @@ -264,6 +283,9 @@ fun BotsScreen( } } }, + onEditClick = { + editingBot = profile + }, ) } } @@ -333,6 +355,7 @@ private fun BotCard( profile: ProfileInfo, isActiveProfile: Boolean, onClick: () -> Unit, + onEditClick: () -> Unit, modifier: Modifier = Modifier, ) { val botMeta = profile.botMeta() @@ -428,6 +451,16 @@ private fun BotCard( ) } } + IconButton( + onClick = onEditClick, + modifier = Modifier.testTag("bot_edit_button_${profile.name}"), + ) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.bots_edit_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } 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 bfd57e36..b384f4d5 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 @@ -188,6 +188,51 @@ class BotsViewModel( } } + fun updateBotMeta( + name: String, + title: String, + description: String, + shape: String, + color: String, + onSuccess: () -> Unit, + ) { + viewModelScope.launch(ioDispatcher) { + val bot = _uiState.value.profiles.find { it.name == name } + val existingMeta = bot?.botMeta() ?: BotRosterMeta() + val updatedMeta = + existingMeta.copy( + title = title.ifBlank { null }, + description = description.ifBlank { null }, + avatar = + BotAvatarMeta( + shape = shape, + color = color, + ), + ) + wsClientConfigureBot(name, updatedMeta) + loadBots() + onSuccess() + } + } + + fun deleteBot( + name: String, + onSuccess: () -> Unit, + ) { + viewModelScope.launch(ioDispatcher) { + val result = safeApiCall { ApiClient.hermesApi.deleteProfile(name) } + if (result is NetworkResult.Success) { + loadBots() + onSuccess() + } else { + val err = + (result as? NetworkResult.Failure)?.error?.message + ?: "Failed to delete bot" + _uiState.update { it.copy(errorMessage = err) } + } + } + } + fun createGroupChat( groupName: String, botNames: List, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt new file mode 100644 index 00000000..2a277490 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt @@ -0,0 +1,277 @@ +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.clip +import androidx.compose.ui.graphics.Color +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.BotAvatarMeta +import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.theme.parseHexColor +import com.m57.hermescontrol.ui.common.BotAvatar + +private val AVAILABLE_SHAPES = listOf("circle", "square", "rounded", "hexagon") +private val PALETTE_COLORS = + listOf( + "#4F46E5", // Indigo + "#2563EB", // Blue + "#0D9488", // Teal + "#16A34A", // Green + "#D97706", // Amber + "#EA580C", // Orange + "#DC2626", // Red + "#DB2777", // Pink + "#9333EA", // Purple + "#4B5563", // Gray + ) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditBotBottomSheet( + bot: ProfileInfo, + onDismiss: () -> Unit, + onSave: (title: String, description: String, shape: String, color: String) -> Unit, + onDelete: () -> Unit, +) { + val meta = bot.botMeta() + var title by remember { mutableStateOf(meta?.title ?: bot.effectiveTitle) } + var description by remember { mutableStateOf(meta?.description ?: bot.effectiveDescription) } + var selectedShape by remember { mutableStateOf(meta?.avatar?.shape ?: "circle") } + var selectedColor by remember { mutableStateOf(meta?.avatar?.color ?: PALETTE_COLORS[0]) } + var showDeleteConfirm by remember { mutableStateOf(false) } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { + Text( + text = stringResource(R.string.bots_edit_delete_confirm_title, bot.effectiveTitle), + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text(stringResource(R.string.bots_edit_delete_confirm_msg)) + }, + confirmButton = { + Button( + onClick = { + showDeleteConfirm = false + onDelete() + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.bots_edit_delete)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.bots_edit_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + // Live Avatar Preview Header + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + BotAvatar( + name = bot.name, + avatar = + BotAvatarMeta( + shape = selectedShape, + color = selectedColor, + ), + size = 64.dp, + showPresence = false, + ) + } + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.bots_create_title_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(R.string.bots_create_desc_label)) }, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.bots_create_avatar_shape), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + AVAILABLE_SHAPES.forEach { shape -> + FilterChip( + selected = selectedShape == shape, + onClick = { selectedShape = shape }, + label = { Text(shape.replaceFirstChar { it.uppercase() }) }, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.bots_create_avatar_color), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PALETTE_COLORS.take(5).forEach { hex -> + val color = parseHexColor(hex, Color.Gray) + Box( + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .background(color) + .clickable { selectedColor = hex } + .then( + if (selectedColor == hex) { + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ), + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PALETTE_COLORS.drop(5).forEach { hex -> + val color = parseHexColor(hex, Color.Gray) + Box( + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .background(color) + .clickable { selectedColor = hex } + .then( + if (selectedColor == hex) { + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ), + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { + onSave(title.trim(), description.trim(), selectedShape, selectedColor) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.bots_edit_save)) + } + + if (bot.name != "default") { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { showDeleteConfirm = true }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.bots_edit_delete)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 71375b72..604b5a83 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -92,6 +92,11 @@ 그룹 그룹 채팅 없음 봇 간 협업을 위한 다중 에이전트 그룹 채팅을 만드세요. + 봇 편집 + 변경 사항 저장 + 봇 삭제 + %1$s 삭제하시겠습니까? + 에이전트 프로필 및 해당 구성이 영구적으로 삭제됩니다. 웹훅 게이트웨이 툴셋 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 129298f7..e1292e3c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -96,6 +96,11 @@ 群聊 暂无群聊 创建多智能体群聊以实现多 Bot 协同。 + 编辑 Bot + 保存更改 + 删除 Bot + 删除 %1$s? + 这将永久删除该智能体配置文件及其配置。 Webhook 网关 工具集 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54f5e44f..61abe3b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,6 +109,11 @@ Groups No Group Chats Create a multi-agent group chat to let bots collaborate. + Edit Bot + Save Changes + Delete Bot + Delete %1$s? + This will permanently delete the agent profile and its configuration. 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 12579fc2..40984df4 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 @@ -190,4 +190,45 @@ class BotsViewModelTest { assertTrue(success) } + + @Test + fun testUpdateBotMeta_sendsRpcAndReloads() = + runTest { + val profiles = listOf(ProfileInfo(name = "scout")) + coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(profiles)) + coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "scout")) + + val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false) + viewModel.loadBots() + advanceUntilIdle() + + var success = false + viewModel.updateBotMeta( + name = "scout", + title = "Scout Lead", + description = "Updated desc", + shape = "square", + color = "#2563EB", + onSuccess = { success = true }, + ) + advanceUntilIdle() + + assertTrue(success) + } + + @Test + fun testDeleteBot_callsDeleteApiAndReloads() = + runTest { + coEvery { mockApi.deleteProfile("scout") } returns Response.success(Unit) + coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(emptyList())) + coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default")) + + val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false) + var success = false + viewModel.deleteBot("scout") { success = true } + advanceUntilIdle() + + assertTrue(success) + coVerify(exactly = 1) { mockApi.deleteProfile("scout") } + } }