feat(ui): add EditBotBottomSheet with avatar editor and bot deletion (#983)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:53:15 +03:00
1 parent 274120cc86
commit 6ca248d28b
7 files changed
+411

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.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<ProfileInfo?>(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,
)
}
}
}
}
@@ -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<String>,
@@ -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))
}
}
}
+5
View File
@@ -92,6 +92,11 @@
<string name="bots_tab_groups">그룹</string>
<string name="bots_groups_empty_title">그룹 채팅 없음</string>
<string name="bots_groups_empty_desc">봇 간 협업을 위한 다중 에이전트 그룹 채팅을 만드세요.</string>
<string name="bots_edit_title">봇 편집</string>
<string name="bots_edit_save">변경 사항 저장</string>
<string name="bots_edit_delete">봇 삭제</string>
<string name="bots_edit_delete_confirm_title">%1$s 삭제하시겠습니까?</string>
<string name="bots_edit_delete_confirm_msg">에이전트 프로필 및 해당 구성이 영구적으로 삭제됩니다.</string>
<string name="screen_webhooks">웹훅</string>
<string name="screen_gateway">게이트웨이</string>
<string name="screen_toolsets">툴셋</string>
+5
View File
@@ -96,6 +96,11 @@
<string name="bots_tab_groups">群聊</string>
<string name="bots_groups_empty_title">暂无群聊</string>
<string name="bots_groups_empty_desc">创建多智能体群聊以实现多 Bot 协同。</string>
<string name="bots_edit_title">编辑 Bot</string>
<string name="bots_edit_save">保存更改</string>
<string name="bots_edit_delete">删除 Bot</string>
<string name="bots_edit_delete_confirm_title">删除 %1$s?</string>
<string name="bots_edit_delete_confirm_msg">这将永久删除该智能体配置文件及其配置。</string>
<string name="screen_webhooks">Webhook</string>
<string name="screen_gateway">网关</string>
<string name="screen_toolsets">工具集</string>
+5
View File
@@ -109,6 +109,11 @@
<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="bots_edit_title">Edit Bot</string>
<string name="bots_edit_save">Save Changes</string>
<string name="bots_edit_delete">Delete Bot</string>
<string name="bots_edit_delete_confirm_title">Delete %1$s?</string>
<string name="bots_edit_delete_confirm_msg">This will permanently delete the agent profile and its configuration.</string>
<string name="screen_webhooks">Webhooks</string>
<string name="screen_gateway">Gateway</string>
<string name="screen_toolsets">Toolsets</string>
@@ -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") }
}
}