feat(ui): add BotsScreen and BotsViewModel with active presence strip (#977)
This commit is contained in:
8 files changed
+699
No files matched your search
@@ -21,6 +21,8 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object ProfilesScreen : NavKey
|
@Serializable data object ProfilesScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object BotsScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ToolsetsScreen : NavKey
|
@Serializable data object ToolsetsScreen : NavKey
|
||||||
|
|
||||||
@Serializable data class ToolsetDetailKey(
|
@Serializable data class ToolsetDetailKey(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Psychology
|
|||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Sensors
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.SmartToy
|
||||||
import androidx.compose.material.icons.filled.ViewKanban
|
import androidx.compose.material.icons.filled.ViewKanban
|
||||||
import androidx.compose.material.icons.filled.Webhook
|
import androidx.compose.material.icons.filled.Webhook
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -34,6 +35,7 @@ import com.m57.hermescontrol.ui.common.NeurologyIcon
|
|||||||
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
||||||
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
|
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
|
||||||
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
|
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
|
||||||
|
import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent
|
||||||
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
|
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
|
||||||
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
||||||
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
||||||
@@ -96,6 +98,12 @@ object ScreenRegistry {
|
|||||||
Icons.Filled.AccountCircle,
|
Icons.Filled.AccountCircle,
|
||||||
DrawerSection.CONVERSE,
|
DrawerSection.CONVERSE,
|
||||||
) { sessionId, openDrawer -> ProfilesScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> ProfilesScreenContent(onOpenDrawer = openDrawer) },
|
||||||
|
ScreenDefinition(
|
||||||
|
BotsScreen,
|
||||||
|
R.string.screen_bots,
|
||||||
|
Icons.Filled.SmartToy,
|
||||||
|
DrawerSection.CONVERSE,
|
||||||
|
) { sessionId, openDrawer -> BotsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
CronJobsScreen,
|
CronJobsScreen,
|
||||||
R.string.screen_cron,
|
R.string.screen_cron,
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
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.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.NavigationController
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
|
import com.m57.hermescontrol.ui.common.BotAvatar
|
||||||
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BotsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
|
viewModel: BotsViewModel = viewModel { BotsViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
ToastEffect(
|
||||||
|
toastMessage = state.toastMessage,
|
||||||
|
onClearToast = { viewModel.clearToast() },
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
title = {
|
||||||
|
if (isSearchActive) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.searchQuery,
|
||||||
|
onValueChange = { viewModel.setSearchQuery(it) },
|
||||||
|
placeholder = { Text(stringResource(R.string.bots_search_placeholder)) },
|
||||||
|
singleLine = true,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (state.searchQuery.isNotEmpty()) {
|
||||||
|
viewModel.setSearchQuery("")
|
||||||
|
} else {
|
||||||
|
isSearchActive = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("bots_search_field"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.screen_bots),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
|
actions = {
|
||||||
|
if (!isSearchActive) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { isSearchActive = true },
|
||||||
|
modifier = Modifier.testTag("bots_action_search"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Search,
|
||||||
|
contentDescription = stringResource(R.string.bots_search_placeholder),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.hasHiddenBots) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.toggleShowHidden() },
|
||||||
|
modifier = Modifier.testTag("bots_action_toggle_hidden"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (state.showHidden) {
|
||||||
|
Icons.Filled.VisibilityOff
|
||||||
|
} else {
|
||||||
|
Icons.Filled.Visibility
|
||||||
|
},
|
||||||
|
contentDescription =
|
||||||
|
if (state.showHidden) {
|
||||||
|
stringResource(R.string.bots_hide_hidden)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.bots_show_hidden)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveNowStrip(
|
||||||
|
activeBots: List<ProfileInfo>,
|
||||||
|
onSelectBot: (ProfileInfo) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bots_active_now),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(bottom = 6.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
activeBots.forEach { bot ->
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.clickable { onSelectBot(bot) }
|
||||||
|
.testTag("bot_active_chip_${bot.name}"),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
BotAvatar(
|
||||||
|
name = bot.name,
|
||||||
|
avatar = bot.botMeta()?.avatar,
|
||||||
|
size = 24.dp,
|
||||||
|
isActive = true,
|
||||||
|
showPresence = true,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = bot.effectiveTitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BotCard(
|
||||||
|
profile: ProfileInfo,
|
||||||
|
isActiveProfile: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val botMeta = profile.botMeta()
|
||||||
|
val isHidden = profile.isHidden
|
||||||
|
val hasWorker = profile.worker_session != null
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (isActiveProfile) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.testTag("bot_card_${profile.name}"),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
BotAvatar(
|
||||||
|
name = profile.name,
|
||||||
|
avatar = botMeta?.avatar,
|
||||||
|
size = 44.dp,
|
||||||
|
isActive = isActiveProfile || hasWorker,
|
||||||
|
showPresence = true,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = profile.effectiveTitle,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (isHidden) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bots_hidden_badge),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.effectiveTitle != profile.name) {
|
||||||
|
Text(
|
||||||
|
text = "@${profile.name}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.effectiveDescription.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = profile.effectiveDescription,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val preview = profile.canonical_session?.preview ?: profile.last_session?.preview
|
||||||
|
if (!preview.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = preview,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.m57.hermescontrol.ui.bots
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.ui.common.ToastHost
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class BotsUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val profiles: List<ProfileInfo> = emptyList(),
|
||||||
|
val activeProfileName: String? = null,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val showHidden: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
) {
|
||||||
|
val hasHiddenBots: Boolean
|
||||||
|
get() = profiles.any { it.isHidden }
|
||||||
|
|
||||||
|
val activeNowBots: List<ProfileInfo>
|
||||||
|
get() {
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayProfiles: List<ProfileInfo>
|
||||||
|
get() {
|
||||||
|
val query = searchQuery.trim().lowercase()
|
||||||
|
return profiles
|
||||||
|
.filter { profile ->
|
||||||
|
if (!showHidden && profile.isHidden) return@filter false
|
||||||
|
if (query.isBlank()) return@filter true
|
||||||
|
profile.name.lowercase().contains(query) ||
|
||||||
|
profile.effectiveTitle.lowercase().contains(query) ||
|
||||||
|
profile.effectiveDescription.lowercase().contains(query)
|
||||||
|
}
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<ProfileInfo> { it.name == activeProfileName }
|
||||||
|
.thenByDescending {
|
||||||
|
it.canonical_session?.last_active
|
||||||
|
?: it.last_session?.last_active
|
||||||
|
?: 0L
|
||||||
|
}
|
||||||
|
.thenBy { it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotsViewModel(
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
autoLoad: Boolean = true,
|
||||||
|
) : ViewModel(),
|
||||||
|
ToastHost {
|
||||||
|
private val _uiState = MutableStateFlow(BotsUiState())
|
||||||
|
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (autoLoad) {
|
||||||
|
loadBots()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBots(isRefresh: Boolean = false) {
|
||||||
|
_uiState.update {
|
||||||
|
if (isRefresh) {
|
||||||
|
it.copy(isRefreshing = true, errorMessage = null)
|
||||||
|
} else {
|
||||||
|
it.copy(isLoading = true, errorMessage = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||||
|
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
|
||||||
|
|
||||||
|
if (profilesResult is NetworkResult.Success) {
|
||||||
|
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
profiles = profilesResult.data.profiles.orEmpty(),
|
||||||
|
activeProfileName = activeName ?: it.activeProfileName,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val err =
|
||||||
|
(profilesResult as? NetworkResult.Failure)?.error?.message
|
||||||
|
?: "Failed to load bots"
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
errorMessage = err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchQuery(query: String) {
|
||||||
|
_uiState.update { it.copy(searchQuery = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleShowHidden() {
|
||||||
|
_uiState.update { it.copy(showHidden = !it.showHidden) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun selectBot(bot: ProfileInfo): Boolean {
|
||||||
|
val result = ProfileSwitchCoordinator.switchProfile(bot.name)
|
||||||
|
return result is NetworkResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showToast(message: String) {
|
||||||
|
_uiState.update { it.copy(toastMessage = message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,14 @@
|
|||||||
<string name="screen_system">시스템</string>
|
<string name="screen_system">시스템</string>
|
||||||
<string name="screen_settings">설정</string>
|
<string name="screen_settings">설정</string>
|
||||||
<string name="screen_profiles">프로필</string>
|
<string name="screen_profiles">프로필</string>
|
||||||
|
<string name="screen_bots">Bots</string>
|
||||||
|
<string name="bots_active_now">현재 활동 중</string>
|
||||||
|
<string name="bots_empty_title">봇을 찾을 수 없음</string>
|
||||||
|
<string name="bots_empty_description">봇 모드를 사용하려면 에이전트 프로필을 생성하세요.</string>
|
||||||
|
<string name="bots_search_placeholder">봇 검색…</string>
|
||||||
|
<string name="bots_hidden_badge">숨김</string>
|
||||||
|
<string name="bots_show_hidden">숨겨진 봇 표시</string>
|
||||||
|
<string name="bots_hide_hidden">숨겨진 봇 숨기기</string>
|
||||||
<string name="screen_webhooks">웹훅</string>
|
<string name="screen_webhooks">웹훅</string>
|
||||||
<string name="screen_gateway">게이트웨이</string>
|
<string name="screen_gateway">게이트웨이</string>
|
||||||
<string name="screen_toolsets">툴셋</string>
|
<string name="screen_toolsets">툴셋</string>
|
||||||
|
|||||||
@@ -67,6 +67,14 @@
|
|||||||
<string name="screen_system">系统</string>
|
<string name="screen_system">系统</string>
|
||||||
<string name="screen_settings">设置</string>
|
<string name="screen_settings">设置</string>
|
||||||
<string name="screen_profiles">配置</string>
|
<string name="screen_profiles">配置</string>
|
||||||
|
<string name="screen_bots">Bots</string>
|
||||||
|
<string name="bots_active_now">当前活跃</string>
|
||||||
|
<string name="bots_empty_title">未找到智能体</string>
|
||||||
|
<string name="bots_empty_description">创建智能体配置文件以开始使用 Bot 模式。</string>
|
||||||
|
<string name="bots_search_placeholder">搜索智能体…</string>
|
||||||
|
<string name="bots_hidden_badge">已隐藏</string>
|
||||||
|
<string name="bots_show_hidden">显示隐藏的智能体</string>
|
||||||
|
<string name="bots_hide_hidden">隐藏不显示的智能体</string>
|
||||||
<string name="screen_webhooks">Webhook</string>
|
<string name="screen_webhooks">Webhook</string>
|
||||||
<string name="screen_gateway">网关</string>
|
<string name="screen_gateway">网关</string>
|
||||||
<string name="screen_toolsets">工具集</string>
|
<string name="screen_toolsets">工具集</string>
|
||||||
|
|||||||
@@ -80,6 +80,14 @@
|
|||||||
<string name="screen_system">System</string>
|
<string name="screen_system">System</string>
|
||||||
<string name="screen_settings">Settings</string>
|
<string name="screen_settings">Settings</string>
|
||||||
<string name="screen_profiles">Profiles</string>
|
<string name="screen_profiles">Profiles</string>
|
||||||
|
<string name="screen_bots">Bots</string>
|
||||||
|
<string name="bots_active_now">Active Now</string>
|
||||||
|
<string name="bots_empty_title">No Bots Found</string>
|
||||||
|
<string name="bots_empty_description">Create an agent profile to start using Bot Mode.</string>
|
||||||
|
<string name="bots_search_placeholder">Search bots…</string>
|
||||||
|
<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="screen_webhooks">Webhooks</string>
|
<string name="screen_webhooks">Webhooks</string>
|
||||||
<string name="screen_gateway">Gateway</string>
|
<string name="screen_gateway">Gateway</string>
|
||||||
<string name="screen_toolsets">Toolsets</string>
|
<string name="screen_toolsets">Toolsets</string>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.m57.hermescontrol.ui.bots
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||||
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
|
import com.m57.hermescontrol.data.model.CanonicalSessionInfo
|
||||||
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
|
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 io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkObject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class BotsViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private lateinit var mockApi: HermesApiService
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
mockkObject(ApiClient)
|
||||||
|
mockApi = mockk(relaxed = true)
|
||||||
|
every { ApiClient.hermesApi } returns mockApi
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkObject(ApiClient)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadBotsAndFilterActiveNow() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
|
||||||
|
val bot1Meta =
|
||||||
|
BotRosterMeta(
|
||||||
|
title = "Literature Scout",
|
||||||
|
description = "Arxiv searcher",
|
||||||
|
avatar = BotAvatarMeta(shape = "hexagon", color = "#4A90E2", icon = "science"),
|
||||||
|
hidden = false,
|
||||||
|
)
|
||||||
|
val bot2Meta =
|
||||||
|
BotRosterMeta(
|
||||||
|
title = "Code Reviewer",
|
||||||
|
description = "Kotlin linter",
|
||||||
|
avatar = BotAvatarMeta(shape = "square", color = "#FF5C5C", icon = "code"),
|
||||||
|
hidden = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val profiles =
|
||||||
|
listOf(
|
||||||
|
ProfileInfo(
|
||||||
|
name = "default",
|
||||||
|
is_default = true,
|
||||||
|
canonical_session =
|
||||||
|
CanonicalSessionInfo(
|
||||||
|
id = "canon-def",
|
||||||
|
last_active = nowSeconds - 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ProfileInfo(
|
||||||
|
name = "scout",
|
||||||
|
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(bot1Meta)),
|
||||||
|
worker_session =
|
||||||
|
ProfileWorkerSummary(
|
||||||
|
id = "worker-1",
|
||||||
|
source = "kanban",
|
||||||
|
last_active = nowSeconds - 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ProfileInfo(
|
||||||
|
name = "reviewer",
|
||||||
|
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(bot2Meta)),
|
||||||
|
canonical_session =
|
||||||
|
CanonicalSessionInfo(
|
||||||
|
id = "canon-rev",
|
||||||
|
last_active = nowSeconds - 500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(profiles))
|
||||||
|
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default"))
|
||||||
|
|
||||||
|
val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false)
|
||||||
|
viewModel.loadBots()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertFalse(state.isLoading)
|
||||||
|
assertEquals(3, state.profiles.size)
|
||||||
|
assertEquals("default", state.activeProfileName)
|
||||||
|
assertTrue(state.hasHiddenBots)
|
||||||
|
|
||||||
|
// Active now includes default (active profile) and scout (active worker)
|
||||||
|
val activeNowNames = state.activeNowBots.map { it.name }
|
||||||
|
assertTrue(activeNowNames.contains("default"))
|
||||||
|
assertTrue(activeNowNames.contains("scout"))
|
||||||
|
assertFalse(activeNowNames.contains("reviewer"))
|
||||||
|
|
||||||
|
// Hidden filtering: reviewer is hidden by default
|
||||||
|
val displayed = state.displayProfiles.map { it.name }
|
||||||
|
assertEquals(listOf("default", "scout"), displayed)
|
||||||
|
|
||||||
|
// Toggle show hidden (sorted: default (active), scout (worker active now), reviewer (canonical last_active))
|
||||||
|
viewModel.toggleShowHidden()
|
||||||
|
val displayedWithHidden = viewModel.uiState.value.displayProfiles.map { it.name }
|
||||||
|
assertEquals(listOf("default", "reviewer", "scout"), displayedWithHidden)
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
viewModel.setSearchQuery("arxiv")
|
||||||
|
val searchResults = viewModel.uiState.value.displayProfiles.map { it.name }
|
||||||
|
assertEquals(listOf("scout"), searchResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user