feat(ui): add CreateBotDialog to BotsScreen with avatar customizer (#981)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:51:43 +03:00
1 parent b422ead921
commit d4d6a04afc
8 files changed
+396

No files matched your search

@@ -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"
@@ -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"),
@@ -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<String, Any?> {
put("title", meta.title)
put("description", meta.description)
meta.avatar?.let { av ->
put(
"avatar",
buildMap<String, Any?> {
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),
),
)
}
}
@@ -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))
}
},
)
}
+11
View File
@@ -71,6 +71,17 @@
<string name="bots_hidden_badge">숨김</string>
<string name="bots_show_hidden">숨겨진 봇 표시</string>
<string name="bots_hide_hidden">숨겨진 봇 숨기기</string>
<string name="bots_action_create">봇 생성</string>
<string name="bots_create_title">새 봇 생성</string>
<string name="bots_create_name_label">식별자 / 슬러그</string>
<string name="bots_create_name_placeholder">예: scout, reviewer</string>
<string name="bots_create_title_label">표시 이름</string>
<string name="bots_create_title_placeholder">예: 스카우트 에이전트</string>
<string name="bots_create_desc_label">설명</string>
<string name="bots_create_desc_placeholder">이 에이전트는 어떤 작업을 수행하나요?</string>
<string name="bots_create_avatar_shape">아바타 모양</string>
<string name="bots_create_avatar_color">강조 색상</string>
<string name="bots_create_button">생성</string>
<string name="screen_webhooks">웹훅</string>
<string name="screen_gateway">게이트웨이</string>
<string name="screen_toolsets">툴셋</string>
+11
View File
@@ -75,6 +75,17 @@
<string name="bots_hidden_badge">已隐藏</string>
<string name="bots_show_hidden">显示隐藏的智能体</string>
<string name="bots_hide_hidden">隐藏不显示的智能体</string>
<string name="bots_action_create">创建 Bot</string>
<string name="bots_create_title">新建 Bot</string>
<string name="bots_create_name_label">标识 / 别名</string>
<string name="bots_create_name_placeholder">例如 scout, reviewer</string>
<string name="bots_create_title_label">显示名称</string>
<string name="bots_create_title_placeholder">例如 Scout 助手</string>
<string name="bots_create_desc_label">描述</string>
<string name="bots_create_desc_placeholder">此智能体负责什么?</string>
<string name="bots_create_avatar_shape">头像形状</string>
<string name="bots_create_avatar_color">主题色</string>
<string name="bots_create_button">创建</string>
<string name="screen_webhooks">Webhook</string>
<string name="screen_gateway">网关</string>
<string name="screen_toolsets">工具集</string>
+11
View File
@@ -88,6 +88,17 @@
<string name="bots_hidden_badge">Hidden</string>
<string name="bots_show_hidden">Show hidden bots</string>
<string name="bots_hide_hidden">Hide hidden bots</string>
<string name="bots_action_create">Create Bot</string>
<string name="bots_create_title">New Bot</string>
<string name="bots_create_name_label">Handle / Slug</string>
<string name="bots_create_name_placeholder">e.g. scout, reviewer</string>
<string name="bots_create_title_label">Display Name</string>
<string name="bots_create_title_placeholder">e.g. Scout Agent</string>
<string name="bots_create_desc_label">Description</string>
<string name="bots_create_desc_placeholder">What does this agent do?</string>
<string name="bots_create_avatar_shape">Avatar Shape</string>
<string name="bots_create_avatar_color">Accent Color</string>
<string name="bots_create_button">Create</string>
<string name="screen_webhooks">Webhooks</string>
<string name="screen_gateway">Gateway</string>
<string name="screen_toolsets">Toolsets</string>
@@ -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" }) }
}
}