feat(ui): add Bots and Groups tab strip with GroupCard and disband action (#987)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:53:54 +03:00
1 parent 6ca248d28b
commit 2fed891d78
9 files changed
+426 -21

No files matched your search

@@ -69,8 +69,8 @@ data class CanonicalSessionInfo(
val root_title: String? = null, val root_title: String? = null,
val title: String? = null, val title: String? = null,
val preview: String? = null, val preview: String? = null,
val started_at: Long? = null, val started_at: Double? = null,
val last_active: Long? = null, val last_active: Double? = null,
val message_count: Int? = null, val message_count: Int? = null,
) )
@@ -79,8 +79,8 @@ data class ProfileSessionSummary(
val id: String, val id: String,
val title: String? = null, val title: String? = null,
val preview: String? = null, val preview: String? = null,
val started_at: Long? = null, val started_at: Double? = null,
val last_active: Long? = null, val last_active: Double? = null,
val message_count: Int? = null, val message_count: Int? = null,
) )
@@ -89,7 +89,7 @@ data class ProfileWorkerSummary(
val id: String, val id: String,
val source: String? = null, val source: String? = null,
val title: String? = null, val title: String? = null,
val last_active: Long? = null, val last_active: Double? = null,
) )
@Serializable @Serializable
@@ -100,7 +100,7 @@ data class BotRosterMeta(
val hidden: Boolean? = null, val hidden: Boolean? = null,
val groups: List<String>? = null, val groups: List<String>? = null,
val group: String? = null, val group: String? = null,
val created: Long? = null, val created: Double? = null,
) )
@Serializable @Serializable
@@ -939,6 +939,16 @@ object HermesWsClient {
val event = val event =
try { try {
val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text) val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text)
// Resolve pending request() calls with the RAW JsonElement from
// rpc.result, BEFORE EventParser.parse() converts it via toAny().
// This preserves integer/float type fidelity needed by callers
// that re-deserialize the result into typed data classes.
val rpcId = rpc.id
if (rpcId != null && rpc.error == null && rpc.result != null) {
synchronized(outboundLock) { pendingPromptSubmits.remove(rpcId) }
removeQueuedMessage(rpcId)
resolvePending(rpcId, rpc.result, null)
}
val parsed = EventParser.parse(rpc, text) val parsed = EventParser.parse(rpc, text)
if (parsed is WsEvent.MessageComplete) { if (parsed is WsEvent.MessageComplete) {
parsed.copy( parsed.copy(
@@ -8,6 +8,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
/** /**
@@ -46,6 +47,19 @@ fun Any?.toJsonElement(): JsonElement =
null -> JsonNull null -> JsonNull
is JsonElement -> this is JsonElement -> this
is Boolean -> JsonPrimitive(this) is Boolean -> JsonPrimitive(this)
is Int -> JsonPrimitive(this)
is Long -> JsonPrimitive(this)
is Float -> JsonPrimitive(this)
is Double -> {
// Preserve integer semantics: 177.0 → JsonPrimitive(177) not "177.0"
if (this % 1.0 == 0.0 && this >= Int.MIN_VALUE.toDouble() && this <= Int.MAX_VALUE.toDouble()) {
JsonPrimitive(this.toInt())
} else if (this % 1.0 == 0.0 && this >= Long.MIN_VALUE.toDouble() && this <= Long.MAX_VALUE.toDouble()) {
JsonPrimitive(this.toLong())
} else {
JsonPrimitive(this)
}
}
is Number -> JsonPrimitive(this) is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this) is String -> JsonPrimitive(this)
is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap()) is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
@@ -63,7 +77,7 @@ fun JsonElement.toAny(): Any? =
if (isString) { if (isString) {
content content
} else { } else {
booleanOrNull ?: doubleOrNull ?: longOrNull ?: content booleanOrNull ?: intOrNull ?: longOrNull ?: doubleOrNull ?: content
} }
} }
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -21,11 +22,17 @@ 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.DeleteOutline
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.GroupAdd 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.SmartToy
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
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -33,9 +40,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -75,8 +86,49 @@ fun BotsScreen(
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var showCreateGroupDialog by remember { mutableStateOf(false) } var showCreateGroupDialog by remember { mutableStateOf(false) }
var editingBot by remember { mutableStateOf<ProfileInfo?>(null) } var editingBot by remember { mutableStateOf<ProfileInfo?>(null) }
var disbandingGroup by remember { mutableStateOf<GroupInfo?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
viewModel.loadBots()
}
disbandingGroup?.let { group ->
AlertDialog(
onDismissRequest = { disbandingGroup = null },
title = {
Text(
text = stringResource(R.string.bots_group_disband_confirm_title, group.name),
fontWeight = FontWeight.Bold,
)
},
text = {
Text(stringResource(R.string.bots_group_disband_confirm_msg))
},
confirmButton = {
Button(
onClick = {
viewModel.disbandGroupChat(group.name) {
disbandingGroup = null
}
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(R.string.bots_group_disband))
}
},
dismissButton = {
TextButton(onClick = { disbandingGroup = null }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
editingBot?.let { bot -> editingBot?.let { bot ->
EditBotBottomSheet( EditBotBottomSheet(
bot = bot, bot = bot,
@@ -216,6 +268,35 @@ fun BotsScreen(
isRefreshing = state.isRefreshing, isRefreshing = state.isRefreshing,
onRefresh = { viewModel.loadBots(isRefresh = true) }, onRefresh = { viewModel.loadBots(isRefresh = true) },
) { ) {
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(
selectedTabIndex = state.selectedTab.ordinal,
modifier = Modifier.fillMaxWidth(),
) {
Tab(
selected = state.selectedTab == BotsTab.BOTS,
onClick = { viewModel.setSelectedTab(BotsTab.BOTS) },
text = { Text(stringResource(R.string.bots_tab_all)) },
icon = { Icon(Icons.Filled.SmartToy, contentDescription = null) },
)
Tab(
selected = state.selectedTab == BotsTab.GROUPS,
onClick = { viewModel.setSelectedTab(BotsTab.GROUPS) },
text = {
Text(
if (state.allGroups.isNotEmpty()) {
"${stringResource(R.string.bots_tab_groups)} (${state.allGroups.size})"
} else {
stringResource(R.string.bots_tab_groups)
},
)
},
icon = { Icon(Icons.Filled.Group, contentDescription = null) },
)
}
when (state.selectedTab) {
BotsTab.BOTS -> {
when { when {
state.isLoading && state.profiles.isEmpty() -> { state.isLoading && state.profiles.isEmpty() -> {
SkeletonListState() SkeletonListState()
@@ -255,7 +336,9 @@ fun BotsScreen(
if (!canonicalId.isNullOrBlank()) { if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId) NavigationController.openChatSession(canonicalId)
} else { } else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen) NavigationController.navigateTo(
com.m57.hermescontrol.ChatScreen,
)
} }
} }
}, },
@@ -292,6 +375,59 @@ fun BotsScreen(
} }
} }
} }
BotsTab.GROUPS -> {
when {
state.displayGroups.isEmpty() -> {
EmptyState(
title = stringResource(R.string.bots_groups_empty_title),
subtitle = stringResource(R.string.bots_groups_empty_desc),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(
items = state.displayGroups,
key = { it.name },
) { group ->
GroupCard(
group = group,
onClick = {
if (group.members.isNotEmpty()) {
scope.launch {
val primary = group.members.first()
viewModel.selectBot(primary)
val canonicalId =
primary.canonical_session?.resolved_id
?: primary.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(
com.m57.hermescontrol.ChatScreen,
)
}
}
}
},
onDisbandClick = {
disbandingGroup = group
},
)
}
}
}
}
}
}
}
}
} }
@Composable @Composable
@@ -464,3 +600,95 @@ private fun BotCard(
} }
} }
} }
@Composable
private fun GroupCard(
group: GroupInfo,
onClick: () -> Unit,
onDisbandClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.testTag("group_card_${group.name}"),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(44.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = group.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.bots_group_members_count, group.members.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
if (group.members.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
group.members.take(4).forEach { member ->
BotAvatar(
name = member.name,
avatar = member.botMeta()?.avatar,
size = 20.dp,
showPresence = false,
)
}
if (group.members.size > 4) {
Text(
text = "+${group.members.size - 4}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
IconButton(
onClick = onDisbandClick,
modifier = Modifier.testTag("group_disband_button_${group.name}"),
) {
Icon(
imageVector = Icons.Filled.DeleteOutline,
contentDescription = stringResource(R.string.bots_group_disband),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -6,12 +6,15 @@ import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -20,6 +23,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
enum class BotsTab {
BOTS,
GROUPS,
}
data class GroupInfo(
val name: String,
val members: List<ProfileInfo>,
)
data class BotsUiState( data class BotsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -28,27 +43,49 @@ data class BotsUiState(
val activeProfileName: String? = null, val activeProfileName: String? = null,
val searchQuery: String = "", val searchQuery: String = "",
val showHidden: Boolean = false, val showHidden: Boolean = false,
val selectedTab: BotsTab = BotsTab.BOTS,
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
) { ) {
val hasHiddenBots: Boolean val hasHiddenBots: Boolean
get() = profiles.any { it.isHidden } get() = profiles.any { it.isHidden }
val allGroups: List<String> val allGroups: List<GroupInfo>
get() = get() {
val groupNames =
profiles profiles
.flatMap { it.botMeta()?.groups.orEmpty() } .flatMap { it.botMeta()?.groups.orEmpty() }
.distinct() .distinct()
.sorted() .sorted()
return groupNames.map { gName ->
GroupInfo(
name = gName,
members = profiles.filter { it.botMeta()?.groups.orEmpty().contains(gName) },
)
}
}
val displayGroups: List<GroupInfo>
get() {
val query = searchQuery.trim().lowercase()
return allGroups.filter { group ->
if (query.isBlank()) return@filter true
group.name.lowercase().contains(query) ||
group.members.any {
it.name.lowercase().contains(query) ||
it.effectiveTitle.lowercase().contains(query)
}
}
}
val activeNowBots: List<ProfileInfo> val activeNowBots: List<ProfileInfo>
get() { get() {
val nowSeconds = System.currentTimeMillis() / 1000 val nowSeconds = System.currentTimeMillis() / 1000.0
return profiles.filter { profile -> return profiles.filter { profile ->
profile.worker_session != null || profile.worker_session != null ||
profile.name == activeProfileName || profile.name == activeProfileName ||
((profile.canonical_session?.last_active ?: 0L) > nowSeconds - 90) || ((profile.canonical_session?.last_active ?: 0.0) > nowSeconds - 90) ||
((profile.last_session?.last_active ?: 0L) > nowSeconds - 90) ((profile.last_session?.last_active ?: 0.0) > nowSeconds - 90)
} }
} }
@@ -68,7 +105,7 @@ data class BotsUiState(
.thenByDescending { .thenByDescending {
it.canonical_session?.last_active it.canonical_session?.last_active
?: it.last_session?.last_active ?: it.last_session?.last_active
?: 0L ?: 0.0
} }
.thenBy { it.name }, .thenBy { it.name },
) )
@@ -98,10 +135,46 @@ class BotsViewModel(
} }
} }
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() } // First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
}
}
} catch (_: Exception) {
// Fallback to REST API below
}
val profilesResult =
if (profilesWithMeta != null) {
null
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() } val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
if (profilesResult is NetworkResult.Success) { if (profilesWithMeta != null) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -131,6 +204,10 @@ class BotsViewModel(
_uiState.update { it.copy(searchQuery = query) } _uiState.update { it.copy(searchQuery = query) }
} }
fun setSelectedTab(tab: BotsTab) {
_uiState.update { it.copy(selectedTab = tab) }
}
fun toggleShowHidden() { fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) } _uiState.update { it.copy(showHidden = !it.showHidden) }
} }
@@ -209,7 +286,10 @@ class BotsViewModel(
color = color, color = color,
), ),
) )
wsClientConfigureBot(name, updatedMeta) try {
wsClientConfigureBot(name, updatedMeta).await()
} catch (_: Exception) {
}
loadBots() loadBots()
onSuccess() onSuccess()
} }
@@ -248,7 +328,34 @@ class BotsViewModel(
(bot.botMeta() ?: BotRosterMeta()).copy( (bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups + groupName, groups = existingGroups + groupName,
) )
wsClientConfigureBot(name, updatedMeta) try {
wsClientConfigureBot(name, updatedMeta).await()
} catch (_: Exception) {
}
}
}
loadBots()
onSuccess()
}
}
fun disbandGroupChat(
groupName: String,
onSuccess: () -> Unit,
) {
viewModelScope.launch(ioDispatcher) {
val currentProfiles = _uiState.value.profiles
for (bot in currentProfiles) {
val existingGroups = bot.botMeta()?.groups.orEmpty()
if (existingGroups.contains(groupName)) {
val updatedMeta =
(bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups - groupName,
)
try {
wsClientConfigureBot(bot.name, updatedMeta).await()
} catch (_: Exception) {
}
} }
} }
loadBots() loadBots()
@@ -259,7 +366,7 @@ class BotsViewModel(
private fun wsClientConfigureBot( private fun wsClientConfigureBot(
name: String, name: String,
meta: BotRosterMeta, meta: BotRosterMeta,
) { ): kotlinx.coroutines.CompletableDeferred<Any?> {
val metaMap = val metaMap =
buildMap<String, Any?> { buildMap<String, Any?> {
put("title", meta.title) put("title", meta.title)
@@ -279,7 +386,7 @@ class BotsViewModel(
} }
} }
HermesWsClient.send( return HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE, WsMethods.PROFILES_CONFIGURE,
mapOf( mapOf(
"name" to name, "name" to name,
+4
View File
@@ -92,6 +92,10 @@
<string name="bots_tab_groups">그룹</string> <string name="bots_tab_groups">그룹</string>
<string name="bots_groups_empty_title">그룹 채팅 없음</string> <string name="bots_groups_empty_title">그룹 채팅 없음</string>
<string name="bots_groups_empty_desc">봇 간 협업을 위한 다중 에이전트 그룹 채팅을 만드세요.</string> <string name="bots_groups_empty_desc">봇 간 협업을 위한 다중 에이전트 그룹 채팅을 만드세요.</string>
<string name="bots_group_members_count">봇 %1$d개</string>
<string name="bots_group_disband">그룹 해산</string>
<string name="bots_group_disband_confirm_title">%1$s 해산하시겠습니까?</string>
<string name="bots_group_disband_confirm_msg">모든 멤버 봇에서 해당 그룹 채팅이 제거됩니다. 개별 봇은 삭제되지 않습니다.</string>
<string name="bots_edit_title">봇 편집</string> <string name="bots_edit_title">봇 편집</string>
<string name="bots_edit_save">변경 사항 저장</string> <string name="bots_edit_save">변경 사항 저장</string>
<string name="bots_edit_delete">봇 삭제</string> <string name="bots_edit_delete">봇 삭제</string>
+4
View File
@@ -96,6 +96,10 @@
<string name="bots_tab_groups">群聊</string> <string name="bots_tab_groups">群聊</string>
<string name="bots_groups_empty_title">暂无群聊</string> <string name="bots_groups_empty_title">暂无群聊</string>
<string name="bots_groups_empty_desc">创建多智能体群聊以实现多 Bot 协同。</string> <string name="bots_groups_empty_desc">创建多智能体群聊以实现多 Bot 协同。</string>
<string name="bots_group_members_count">%1$d 个智能体</string>
<string name="bots_group_disband">解散群聊</string>
<string name="bots_group_disband_confirm_title">解散 %1$s?</string>
<string name="bots_group_disband_confirm_msg">这将从所有成员智能体中移除该群聊。单个智能体不会被删除。</string>
<string name="bots_edit_title">编辑 Bot</string> <string name="bots_edit_title">编辑 Bot</string>
<string name="bots_edit_save">保存更改</string> <string name="bots_edit_save">保存更改</string>
<string name="bots_edit_delete">删除 Bot</string> <string name="bots_edit_delete">删除 Bot</string>
+4
View File
@@ -109,6 +109,10 @@
<string name="bots_tab_groups">Groups</string> <string name="bots_tab_groups">Groups</string>
<string name="bots_groups_empty_title">No Group Chats</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_groups_empty_desc">Create a multi-agent group chat to let bots collaborate.</string>
<string name="bots_group_members_count">%1$d bots</string>
<string name="bots_group_disband">Disband Group</string>
<string name="bots_group_disband_confirm_title">Disband %1$s?</string>
<string name="bots_group_disband_confirm_msg">This removes the group chat from all member bots. The individual bots will not be deleted.</string>
<string name="bots_edit_title">Edit Bot</string> <string name="bots_edit_title">Edit Bot</string>
<string name="bots_edit_save">Save Changes</string> <string name="bots_edit_save">Save Changes</string>
<string name="bots_edit_delete">Delete Bot</string> <string name="bots_edit_delete">Delete Bot</string>
@@ -47,6 +47,9 @@ class BotsViewModelTest {
every { ApiClient.hermesApi } returns mockApi every { ApiClient.hermesApi } returns mockApi
mockkObject(HermesWsClient) mockkObject(HermesWsClient)
every { HermesWsClient.send(any(), any(), any()) } returns "req-1" every { HermesWsClient.send(any(), any(), any()) } returns "req-1"
val deferred = kotlinx.coroutines.CompletableDeferred<Any?>()
deferred.complete(null)
every { HermesWsClient.request(any(), any(), any()) } returns deferred
} }
@After @After
@@ -59,7 +62,7 @@ class BotsViewModelTest {
@Test @Test
fun testLoadBotsAndFilterActiveNow() = fun testLoadBotsAndFilterActiveNow() =
runTest(testDispatcher) { runTest(testDispatcher) {
val nowSeconds = System.currentTimeMillis() / 1000 val nowSeconds = (System.currentTimeMillis() / 1000).toDouble()
val bot1Meta = val bot1Meta =
BotRosterMeta( BotRosterMeta(
@@ -191,6 +194,37 @@ class BotsViewModelTest {
assertTrue(success) assertTrue(success)
} }
@Test
fun testDisbandGroupChat_removesGroupFromMembers() =
runTest {
val botMeta =
BotRosterMeta(
groups = listOf("Dream Team", "Other Group"),
)
val uiMeta = mapOf("hermes-bots" to json.encodeToJsonElement(botMeta))
val profiles =
listOf(
ProfileInfo(name = "botA", ui_meta = uiMeta),
ProfileInfo(name = "botB", ui_meta = uiMeta),
)
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()
assertEquals(2, viewModel.uiState.value.allGroups.size)
assertEquals("Dream Team", viewModel.uiState.value.allGroups[0].name)
assertEquals(2, viewModel.uiState.value.allGroups[0].members.size)
var success = false
viewModel.disbandGroupChat("Dream Team") { success = true }
advanceUntilIdle()
assertTrue(success)
}
@Test @Test
fun testUpdateBotMeta_sendsRpcAndReloads() = fun testUpdateBotMeta_sendsRpcAndReloads() =
runTest { runTest {