feat(ui): add Bots and Groups tab strip with GroupCard and disband action (#987)
This commit is contained in:
9 files changed
+497
-92
No files matched your search
@@ -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<String>? = null,
|
||||
val group: String? = null,
|
||||
val created: Long? = null,
|
||||
val created: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -939,6 +939,16 @@ object HermesWsClient {
|
||||
val event =
|
||||
try {
|
||||
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)
|
||||
if (parsed is WsEvent.MessageComplete) {
|
||||
parsed.copy(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProfileInfo?>(null) }
|
||||
var disbandingGroup by remember { mutableStateOf<GroupInfo?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ProfileInfo>,
|
||||
)
|
||||
|
||||
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<String>
|
||||
get() =
|
||||
profiles
|
||||
.flatMap { it.botMeta()?.groups.orEmpty() }
|
||||
.distinct()
|
||||
.sorted()
|
||||
val allGroups: List<GroupInfo>
|
||||
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<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>
|
||||
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<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() }
|
||||
|
||||
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<Any?> {
|
||||
val metaMap =
|
||||
buildMap<String, Any?> {
|
||||
put("title", meta.title)
|
||||
@@ -279,7 +386,7 @@ class BotsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
HermesWsClient.send(
|
||||
return HermesWsClient.request(
|
||||
WsMethods.PROFILES_CONFIGURE,
|
||||
mapOf(
|
||||
"name" to name,
|
||||
|
||||
Reference in new issue
Block a user