diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index 78019c63..c2fcdff2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -69,8 +69,8 @@ data class CanonicalSessionInfo( val root_title: String? = null, val title: String? = null, val preview: String? = null, - val started_at: Long? = null, - val last_active: Long? = null, + val started_at: Double? = null, + val last_active: Double? = null, val message_count: Int? = null, ) @@ -79,8 +79,8 @@ data class ProfileSessionSummary( val id: String, val title: String? = null, val preview: String? = null, - val started_at: Long? = null, - val last_active: Long? = null, + val started_at: Double? = null, + val last_active: Double? = null, val message_count: Int? = null, ) @@ -89,7 +89,7 @@ data class ProfileWorkerSummary( val id: String, val source: String? = null, val title: String? = null, - val last_active: Long? = null, + val last_active: Double? = null, ) @Serializable @@ -100,7 +100,7 @@ data class BotRosterMeta( val hidden: Boolean? = null, val groups: List? = null, val group: String? = null, - val created: Long? = null, + val created: Double? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 27817804..78c68728 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -939,6 +939,16 @@ object HermesWsClient { val event = try { val rpc = OkHttpProvider.json.decodeFromString(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) if (parsed is WsEvent.MessageComplete) { parsed.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt index 8c82476a..1c15f4bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.longOrNull /** @@ -46,6 +47,19 @@ fun Any?.toJsonElement(): JsonElement = null -> JsonNull is JsonElement -> 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 String -> JsonPrimitive(this) is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap()) @@ -63,7 +77,7 @@ fun JsonElement.toAny(): Any? = if (isString) { content } else { - booleanOrNull ?: doubleOrNull ?: longOrNull ?: content + booleanOrNull ?: intOrNull ?: longOrNull ?: doubleOrNull ?: content } } 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 8816d6de..8a18a0ce 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 @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.filled.Add 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.Group import androidx.compose.material.icons.filled.GroupAdd 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.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.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,9 +40,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Surface +import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -75,8 +86,49 @@ fun BotsScreen( var showCreateDialog by remember { mutableStateOf(false) } var showCreateGroupDialog by remember { mutableStateOf(false) } var editingBot by remember { mutableStateOf(null) } + var disbandingGroup by remember { mutableStateOf(null) } 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 -> EditBotBottomSheet( bot = bot, @@ -216,77 +268,161 @@ fun BotsScreen( isRefreshing = state.isRefreshing, onRefresh = { viewModel.loadBots(isRefresh = true) }, ) { - when { - state.isLoading && state.profiles.isEmpty() -> { - SkeletonListState() - } - - state.errorMessage != null && state.profiles.isEmpty() -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadBots() }, + 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) }, ) - } - - state.displayProfiles.isEmpty() -> { - EmptyState( - title = stringResource(R.string.bots_empty_title), - subtitle = stringResource(R.string.bots_empty_description), - 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), - ) { - if (state.activeNowBots.isNotEmpty() && !isSearchActive) { - item(key = "active_now_strip") { - ActiveNowStrip( - activeBots = state.activeNowBots, - onSelectBot = { bot -> - scope.launch { - viewModel.selectBot(bot) - val canonicalId = - bot.canonical_session?.resolved_id - ?: bot.canonical_session?.id - if (!canonicalId.isNullOrBlank()) { - NavigationController.openChatSession(canonicalId) - } else { - NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen) - } - } - }, - ) - } - } - - items( - items = state.displayProfiles, - key = { it.name }, - ) { profile -> - BotCard( - profile = profile, - isActiveProfile = profile.name == state.activeProfileName, - onClick = { - scope.launch { - viewModel.selectBot(profile) - val canonicalId = - profile.canonical_session?.resolved_id - ?: profile.canonical_session?.id - if (!canonicalId.isNullOrBlank()) { - NavigationController.openChatSession(canonicalId) - } else { - NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen) - } - } - }, - onEditClick = { - editingBot = profile + 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 { + state.isLoading && state.profiles.isEmpty() -> { + SkeletonListState() + } + + state.errorMessage != null && state.profiles.isEmpty() -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadBots() }, + ) + } + + state.displayProfiles.isEmpty() -> { + EmptyState( + title = stringResource(R.string.bots_empty_title), + subtitle = stringResource(R.string.bots_empty_description), + 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), + ) { + if (state.activeNowBots.isNotEmpty() && !isSearchActive) { + item(key = "active_now_strip") { + ActiveNowStrip( + activeBots = state.activeNowBots, + onSelectBot = { bot -> + scope.launch { + viewModel.selectBot(bot) + val canonicalId = + bot.canonical_session?.resolved_id + ?: bot.canonical_session?.id + if (!canonicalId.isNullOrBlank()) { + NavigationController.openChatSession(canonicalId) + } else { + NavigationController.navigateTo( + com.m57.hermescontrol.ChatScreen, + ) + } + } + }, + ) + } + } + + items( + items = state.displayProfiles, + key = { it.name }, + ) { profile -> + BotCard( + profile = profile, + isActiveProfile = profile.name == state.activeProfileName, + onClick = { + scope.launch { + viewModel.selectBot(profile) + val canonicalId = + profile.canonical_session?.resolved_id + ?: profile.canonical_session?.id + if (!canonicalId.isNullOrBlank()) { + NavigationController.openChatSession(canonicalId) + } else { + NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen) + } + } + }, + onEditClick = { + editingBot = profile + }, + ) + } + } + } + } + } + + 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 + }, + ) + } + } + } } } } @@ -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, + ) + } + } + } +} 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 b384f4d5..ba3d7a64 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 @@ -6,12 +6,15 @@ 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.model.ProfilesResponse import com.m57.hermescontrol.data.remote.ApiClient 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.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -20,6 +23,18 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update 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, +) data class BotsUiState( val isLoading: Boolean = false, @@ -28,27 +43,49 @@ data class BotsUiState( val activeProfileName: String? = null, val searchQuery: String = "", val showHidden: Boolean = false, + val selectedTab: BotsTab = BotsTab.BOTS, val errorMessage: String? = null, val toastMessage: String? = null, ) { val hasHiddenBots: Boolean get() = profiles.any { it.isHidden } - val allGroups: List - get() = - profiles - .flatMap { it.botMeta()?.groups.orEmpty() } - .distinct() - .sorted() + val allGroups: List + get() { + val groupNames = + profiles + .flatMap { it.botMeta()?.groups.orEmpty() } + .distinct() + .sorted() + return groupNames.map { gName -> + GroupInfo( + name = gName, + members = profiles.filter { it.botMeta()?.groups.orEmpty().contains(gName) }, + ) + } + } + + val displayGroups: List + 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 get() { - val nowSeconds = System.currentTimeMillis() / 1000 + val nowSeconds = System.currentTimeMillis() / 1000.0 return profiles.filter { profile -> profile.worker_session != null || profile.name == activeProfileName || - ((profile.canonical_session?.last_active ?: 0L) > nowSeconds - 90) || - ((profile.last_session?.last_active ?: 0L) > nowSeconds - 90) + ((profile.canonical_session?.last_active ?: 0.0) > nowSeconds - 90) || + ((profile.last_session?.last_active ?: 0.0) > nowSeconds - 90) } } @@ -68,7 +105,7 @@ data class BotsUiState( .thenByDescending { it.canonical_session?.last_active ?: it.last_session?.last_active - ?: 0L + ?: 0.0 } .thenBy { it.name }, ) @@ -98,10 +135,46 @@ class BotsViewModel( } } 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? = 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(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() } - 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 _uiState.update { it.copy( @@ -131,6 +204,10 @@ class BotsViewModel( _uiState.update { it.copy(searchQuery = query) } } + fun setSelectedTab(tab: BotsTab) { + _uiState.update { it.copy(selectedTab = tab) } + } + fun toggleShowHidden() { _uiState.update { it.copy(showHidden = !it.showHidden) } } @@ -209,7 +286,10 @@ class BotsViewModel( color = color, ), ) - wsClientConfigureBot(name, updatedMeta) + try { + wsClientConfigureBot(name, updatedMeta).await() + } catch (_: Exception) { + } loadBots() onSuccess() } @@ -248,7 +328,34 @@ class BotsViewModel( (bot.botMeta() ?: BotRosterMeta()).copy( 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() @@ -259,7 +366,7 @@ class BotsViewModel( private fun wsClientConfigureBot( name: String, meta: BotRosterMeta, - ) { + ): kotlinx.coroutines.CompletableDeferred { val metaMap = buildMap { put("title", meta.title) @@ -279,7 +386,7 @@ class BotsViewModel( } } - HermesWsClient.send( + return HermesWsClient.request( WsMethods.PROFILES_CONFIGURE, mapOf( "name" to name, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 604b5a83..b7afce43 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -92,6 +92,10 @@ 그룹 그룹 채팅 없음 봇 간 협업을 위한 다중 에이전트 그룹 채팅을 만드세요. + 봇 %1$d개 + 그룹 해산 + %1$s 해산하시겠습니까? + 모든 멤버 봇에서 해당 그룹 채팅이 제거됩니다. 개별 봇은 삭제되지 않습니다. 봇 편집 변경 사항 저장 봇 삭제 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e1292e3c..cee14cbc 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -96,6 +96,10 @@ 群聊 暂无群聊 创建多智能体群聊以实现多 Bot 协同。 + %1$d 个智能体 + 解散群聊 + 解散 %1$s? + 这将从所有成员智能体中移除该群聊。单个智能体不会被删除。 编辑 Bot 保存更改 删除 Bot diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61abe3b3..b4466c30 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,6 +109,10 @@ Groups No Group Chats Create a multi-agent group chat to let bots collaborate. + %1$d bots + Disband Group + Disband %1$s? + This removes the group chat from all member bots. The individual bots will not be deleted. Edit Bot Save Changes Delete Bot 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 40984df4..f4b0498e 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 @@ -47,6 +47,9 @@ class BotsViewModelTest { every { ApiClient.hermesApi } returns mockApi mockkObject(HermesWsClient) every { HermesWsClient.send(any(), any(), any()) } returns "req-1" + val deferred = kotlinx.coroutines.CompletableDeferred() + deferred.complete(null) + every { HermesWsClient.request(any(), any(), any()) } returns deferred } @After @@ -59,7 +62,7 @@ class BotsViewModelTest { @Test fun testLoadBotsAndFilterActiveNow() = runTest(testDispatcher) { - val nowSeconds = System.currentTimeMillis() / 1000 + val nowSeconds = (System.currentTimeMillis() / 1000).toDouble() val bot1Meta = BotRosterMeta( @@ -191,6 +194,37 @@ class BotsViewModelTest { 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 fun testUpdateBotMeta_sendsRpcAndReloads() = runTest {