From d4d6a04afcd277f3a194152bc5f35017df2431da Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:51:43 +0300 Subject: [PATCH] feat(ui): add CreateBotDialog to BotsScreen with avatar customizer (#981) --- .../m57/hermescontrol/data/ws/WsMethods.kt | 2 + .../m57/hermescontrol/ui/bots/BotsScreen.kt | 22 ++ .../hermescontrol/ui/bots/BotsViewModel.kt | 74 ++++++ .../hermescontrol/ui/bots/CreateBotDialog.kt | 237 ++++++++++++++++++ app/src/main/res/values-ko/strings.xml | 11 + app/src/main/res/values-zh/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + .../ui/bots/BotsViewModelTest.kt | 28 +++ 8 files changed, 396 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 4803ce3d..1401ac15 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -20,6 +20,8 @@ object WsMethods { /** Lifetime usage snapshot (calls, token totals, compression count). */ const val SESSION_USAGE = "session.usage" + const val PROFILES_CONFIGURE = "profiles.configure" + const val PROFILES_LIST = "profiles.list" // ── Interaction ─────────────────────────────────────────────────────── const val PROMPT_SUBMIT = "prompt.submit" 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 0ad2f690..a7360660 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.Search import androidx.compose.material.icons.filled.Visibility @@ -69,8 +70,20 @@ fun BotsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() var isSearchActive by remember { mutableStateOf(false) } + var showCreateDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + if (showCreateDialog) { + CreateBotDialog( + onDismiss = { showCreateDialog = false }, + onCreate = { name, title, description, shape, color -> + viewModel.createBot(name, title, description, shape, color) { + showCreateDialog = false + } + }, + ) + } + ToastEffect( toastMessage = state.toastMessage, onClearToast = { viewModel.clearToast() }, @@ -117,6 +130,15 @@ fun BotsScreen( navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, actions = { if (!isSearchActive) { + IconButton( + onClick = { showCreateDialog = true }, + modifier = Modifier.testTag("bots_action_create"), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.bots_action_create), + ) + } IconButton( onClick = { isSearchActive = true }, modifier = Modifier.testTag("bots_action_search"), 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 14fa4968..e885ab89 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 @@ -2,11 +2,16 @@ package com.m57.hermescontrol.ui.bots import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.BotAvatarMeta +import com.m57.hermescontrol.data.model.BotRosterMeta +import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -135,4 +140,73 @@ class BotsViewModel( override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } + + fun createBot( + name: String, + title: String, + description: String, + shape: String, + color: String, + onSuccess: () -> Unit, + ) { + viewModelScope.launch(ioDispatcher) { + val req = + CreateProfileRequest( + name = name, + description = description.ifBlank { null }, + clone_from_default = true, + ) + val result = safeApiCall { ApiClient.hermesApi.createProfile(req) } + if (result is NetworkResult.Success) { + // Configure UI metadata (avatar, custom title) via RPC + val botMeta = + BotRosterMeta( + title = title.ifBlank { null }, + description = description.ifBlank { null }, + avatar = + BotAvatarMeta( + shape = shape, + color = color, + ), + ) + wsClientConfigureBot(name, botMeta) + loadBots() + onSuccess() + } else { + val err = + (result as? NetworkResult.Failure)?.error?.message + ?: "Failed to create bot" + _uiState.update { it.copy(errorMessage = err) } + } + } + } + + private fun wsClientConfigureBot( + name: String, + meta: BotRosterMeta, + ) { + val metaMap = + buildMap { + put("title", meta.title) + put("description", meta.description) + meta.avatar?.let { av -> + put( + "avatar", + buildMap { + av.shape?.let { put("shape", it) } + av.color?.let { put("color", it) } + av.icon?.let { put("icon", it) } + }, + ) + } + } + + HermesWsClient.send( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to name, + "ui_meta" to mapOf("hermes-bots" to metaMap), + ), + ) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt new file mode 100644 index 00000000..5c5c5c6e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt @@ -0,0 +1,237 @@ +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.FilterChip +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.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.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 + ) + +@Composable +fun CreateBotDialog( + onDismiss: () -> Unit, + onCreate: (name: String, title: String, description: String, shape: String, color: String) -> Unit, +) { + var name by remember { mutableStateOf("") } + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var selectedShape by remember { mutableStateOf("circle") } + var selectedColor by remember { mutableStateOf(PALETTE_COLORS[0]) } + + val isValid = name.isNotBlank() && name.trim().matches(Regex("^[a-zA-Z0-9_-]+$")) + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.bots_create_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + // Live Avatar Preview Header + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + BotAvatar( + name = name.ifBlank { "bot" }, + avatar = + BotAvatarMeta( + shape = selectedShape, + color = selectedColor, + ), + size = 64.dp, + showPresence = false, + ) + } + + OutlinedTextField( + value = name, + onValueChange = { name = it.filter { c -> c.isLetterOrDigit() || c == '_' || c == '-' } }, + label = { Text(stringResource(R.string.bots_create_name_label)) }, + placeholder = { Text(stringResource(R.string.bots_create_name_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.bots_create_title_label)) }, + placeholder = { Text(stringResource(R.string.bots_create_title_placeholder)) }, + 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)) }, + placeholder = { Text(stringResource(R.string.bots_create_desc_placeholder)) }, + 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 + }, + ), + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + if (isValid) { + onCreate(name.trim(), title.trim(), description.trim(), selectedShape, selectedColor) + } + }, + enabled = isValid, + ) { + Text(stringResource(R.string.bots_create_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 d0794d84..d0bc9ce2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -71,6 +71,17 @@ 숨김 숨겨진 봇 표시 숨겨진 봇 숨기기 + 봇 생성 + 새 봇 생성 + 식별자 / 슬러그 + 예: scout, reviewer + 표시 이름 + 예: 스카우트 에이전트 + 설명 + 이 에이전트는 어떤 작업을 수행하나요? + 아바타 모양 + 강조 색상 + 생성 웹훅 게이트웨이 툴셋 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 02bab50d..bf617614 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -75,6 +75,17 @@ 已隐藏 显示隐藏的智能体 隐藏不显示的智能体 + 创建 Bot + 新建 Bot + 标识 / 别名 + 例如 scout, reviewer + 显示名称 + 例如 Scout 助手 + 描述 + 此智能体负责什么? + 头像形状 + 主题色 + 创建 Webhook 网关 工具集 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 607183b7..ce8be5e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,6 +88,17 @@ Hidden Show hidden bots Hide hidden bots + Create Bot + New Bot + Handle / Slug + e.g. scout, reviewer + Display Name + e.g. Scout Agent + Description + What does this agent do? + Avatar Shape + Accent Color + Create 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 6afce94d..eeb31996 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 @@ -9,7 +9,9 @@ import com.m57.hermescontrol.data.model.ProfileWorkerSummary import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.HermesWsClient import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -43,11 +45,14 @@ class BotsViewModelTest { mockkObject(ApiClient) mockApi = mockk(relaxed = true) every { ApiClient.hermesApi } returns mockApi + mockkObject(HermesWsClient) + every { HermesWsClient.send(any(), any(), any()) } returns "req-1" } @After fun tearDown() { unmockkObject(ApiClient) + unmockkObject(HermesWsClient) Dispatchers.resetMain() } @@ -136,4 +141,27 @@ class BotsViewModelTest { val searchResults = viewModel.uiState.value.displayProfiles.map { it.name } assertEquals(listOf("scout"), searchResults) } + + @Test + fun testCreateBot_callsApiAndConfiguresMeta() = + runTest { + coEvery { mockApi.createProfile(any()) } 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 successCalled = false + viewModel.createBot( + name = "researcher", + title = "Research Bot", + description = "Deep research agent", + shape = "hexagon", + color = "#4F46E5", + onSuccess = { successCalled = true }, + ) + advanceUntilIdle() + + assertTrue(successCalled) + coVerify(exactly = 1) { mockApi.createProfile(match { it.name == "researcher" }) } + } }