diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 91c309a6..3561283d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -26,11 +26,14 @@ import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent +import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent +import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent +import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent @Composable fun MainNavigation() { @@ -48,7 +51,9 @@ fun MainNavigation() { currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen || currentScreen == GatewayScreen || currentScreen == ProfilesScreen || currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen || - currentScreen == PairingScreen || currentScreen == ConfigScreen + currentScreen == PairingScreen || currentScreen == ConfigScreen || + currentScreen == McpServersScreen || currentScreen == WebhooksScreen || + currentScreen == ModelScreen val openDrawer = { scope.launch { drawerState.open() } } ModalNavigationDrawer( @@ -135,6 +140,33 @@ fun MainNavigation() { }, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) + NavigationDrawerItem( + label = { Text("MCP Servers") }, + selected = currentScreen == McpServersScreen, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(McpServersScreen) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + NavigationDrawerItem( + label = { Text("Webhooks") }, + selected = currentScreen == WebhooksScreen, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(WebhooksScreen) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + NavigationDrawerItem( + label = { Text("Models") }, + selected = currentScreen == ModelScreen, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(ModelScreen) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) NavigationDrawerItem( label = { Text("Gateway") }, selected = currentScreen == GatewayScreen, @@ -222,6 +254,24 @@ fun MainNavigation() { ) } + entry { + McpServersScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } + + entry { + WebhooksScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } + + entry { + ModelScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } + entry { GatewayScreenContent( onOpenDrawer = { openDrawer() }, diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index b6bd2795..42bb92c8 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -24,3 +24,9 @@ import kotlinx.serialization.Serializable @Serializable data object PairingScreen : NavKey @Serializable data object ConfigScreen : NavKey + +@Serializable data object McpServersScreen : NavKey + +@Serializable data object WebhooksScreen : NavKey + +@Serializable data object ModelScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt new file mode 100644 index 00000000..933fea7a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -0,0 +1,19 @@ +package com.m57.hermescontrol.data.model + +data class McpServersResponse( + val servers: List, +) + +data class McpServer( + val name: String, + val transport: String?, + val url: String?, + val command: String?, + val args: List?, + val env: Map?, + val enabled: Boolean, +) + +data class McpServerToggleRequest( + val enabled: Boolean, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt new file mode 100644 index 00000000..fe766d66 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt @@ -0,0 +1,18 @@ +package com.m57.hermescontrol.data.model + +data class ModelOptionsResponse( + val providers: List, +) + +data class ModelProvider( + val slug: String, + val name: String, + val is_current: Boolean?, + val is_user_defined: Boolean?, + val models: List?, + val total_models: Int?, + val source: String?, + val authenticated: Boolean?, + val auth_type: String?, + val warning: String?, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Webhook.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Webhook.kt new file mode 100644 index 00000000..fb282ec4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Webhook.kt @@ -0,0 +1,17 @@ +package com.m57.hermescontrol.data.model + +data class WebhooksResponse( + val enabled: Boolean, + val base_url: String?, + val subscriptions: List?, +) + +data class WebhookSubscription( + val name: String, + val url: String, + val events: List?, +) + +data class WebhooksToggleRequest( + val enabled: Boolean, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index dc15cd85..3ca586c3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.remote import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.McpServerToggleRequest +import com.m57.hermescontrol.data.model.McpServersResponse +import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingRevokeRequest @@ -22,6 +25,8 @@ import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest +import com.m57.hermescontrol.data.model.WebhooksResponse +import com.m57.hermescontrol.data.model.WebhooksToggleRequest import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -157,4 +162,34 @@ interface HermesApiService { suspend fun updateRawConfig( @Body body: UpdateRawConfigRequest, ): Response + + @GET("api/mcp/servers") + suspend fun getMcpServers(): Response + + @PUT("api/mcp/servers/{name}/enabled") + suspend fun toggleMcpServer( + @Path("name") name: String, + @Body body: McpServerToggleRequest, + ): Response + + @POST("api/mcp/servers/{name}/test") + suspend fun testMcpServer( + @Path("name") name: String, + ): Response + + @DELETE("api/mcp/servers/{name}") + suspend fun deleteMcpServer( + @Path("name") name: String, + ): Response + + @GET("api/webhooks") + suspend fun getWebhooks(): Response + + @POST("api/webhooks/enable") + suspend fun toggleWebhooks( + @Body body: WebhooksToggleRequest, + ): Response + + @GET("api/model/options") + suspend fun getModelOptions(): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt new file mode 100644 index 00000000..e893fc41 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -0,0 +1,203 @@ +package com.m57.hermescontrol.ui.mcp + +import android.widget.Toast +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 +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun McpServersScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: McpServersViewModel = viewModel { McpServersViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.loadServers() + } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + viewModel.clearToast() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + if (onOpenDrawer != null) { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Open Drawer", + ) + } + } + }, + title = { Text("MCP Servers") }, + actions = { + IconButton(onClick = { viewModel.loadServers() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + modifier = modifier, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (state.isLoading && state.servers.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (state.errorMessage != null && state.servers.isEmpty()) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadServers() }) { + Text("Retry") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.servers) { server -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (server.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Transport: ${server.transport ?: "stdio"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Switch( + checked = server.enabled, + onCheckedChange = { viewModel.toggleServer(server) }, + ) + } + + if (server.command != null) { + Spacer(modifier = Modifier.height(8.dp)) + val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}" + Text( + text = "Command:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = fullCmd, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { viewModel.testServer(server.name) }, + modifier = Modifier.padding(end = 8.dp), + ) { + Text("Test") + } + + OutlinedButton( + onClick = { viewModel.deleteServer(server.name) }, + ) { + Text("Delete") + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt new file mode 100644 index 00000000..3d9d149a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -0,0 +1,184 @@ +package com.m57.hermescontrol.ui.mcp + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.data.model.McpServerToggleRequest +import com.m57.hermescontrol.data.remote.ApiClient +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 +import kotlinx.coroutines.withContext + +data class McpServersUiState( + val isLoading: Boolean = false, + val isActionRunning: Boolean = false, + val servers: List = emptyList(), + val errorMessage: String? = null, + val toastMessage: String? = null, +) + +class McpServersViewModel : ViewModel() { + private val _uiState = MutableStateFlow(McpServersUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadServers() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getMcpServers() + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isLoading = false, + servers = response.body()?.servers.orEmpty(), + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load MCP servers: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load MCP servers: ${e.message}", + ) + } + } + } + } + + fun toggleServer(server: McpServer) { + val originalEnabled = server.enabled + val targetEnabled = !originalEnabled + + _uiState.update { state -> + state.copy( + servers = + state.servers.map { + if (it.name == server.name) it.copy(enabled = targetEnabled) else it + }, + ) + } + + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.toggleMcpServer(server.name, McpServerToggleRequest(targetEnabled)) + } + if (!response.isSuccessful) { + revertToggle(server.name, originalEnabled, "Failed to toggle server: HTTP ${response.code()}") + } else { + _uiState.update { + it.copy( + toastMessage = "Server '${server.name}' ${if (targetEnabled) "enabled" else "disabled"}", + ) + } + } + } catch (e: Exception) { + revertToggle(server.name, originalEnabled, "Failed to toggle server: ${e.message}") + } + } + } + + fun testServer(name: String) { + _uiState.update { it.copy(isActionRunning = true) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.testMcpServer(name) + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Server '$name' tested successfully", + ) + } + } else { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Server '$name' test failed: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Server '$name' test failed: ${e.message}", + ) + } + } + } + } + + fun deleteServer(name: String) { + _uiState.update { it.copy(isActionRunning = true) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.deleteMcpServer(name) + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Server '$name' deleted successfully", + ) + } + loadServers() + } else { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Failed to delete server: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Failed to delete server: ${e.message}", + ) + } + } + } + } + + private fun revertToggle( + name: String, + originalEnabled: Boolean, + errorMsg: String, + ) { + _uiState.update { state -> + state.copy( + servers = + state.servers.map { + if (it.name == name) it.copy(enabled = originalEnabled) else it + }, + toastMessage = errorMsg, + ) + } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt new file mode 100644 index 00000000..308d1d5a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -0,0 +1,215 @@ +package com.m57.hermescontrol.ui.model + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +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 +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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 +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModelScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: ModelViewModel = viewModel { ModelViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + var expandedProviderSlug by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.loadModelOptions() + } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + viewModel.clearToast() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + if (onOpenDrawer != null) { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Open Drawer", + ) + } + } + }, + title = { Text("Model Providers") }, + actions = { + IconButton(onClick = { viewModel.loadModelOptions() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + modifier = modifier, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (state.isLoading && state.providers.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (state.errorMessage != null && state.providers.isEmpty()) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + IconButton(onClick = { viewModel.loadModelOptions() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.providers) { provider -> + val isExpanded = expandedProviderSlug == provider.slug + val isCurrent = provider.is_current == true + + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + expandedProviderSlug = if (isExpanded) null else provider.slug + }, + colors = + CardDefaults.cardColors( + containerColor = + if (isCurrent) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = provider.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Slug: ${provider.slug}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (isCurrent) { + Text( + text = "CURRENT", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + + provider.warning?.let { + if (it.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + AnimatedVisibility(visible = isExpanded) { + Column(modifier = Modifier.padding(top = 16.dp)) { + Text( + text = "Available Models:", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + val models = provider.models.orEmpty() + if (models.isEmpty()) { + Text( + text = "No models reported or provider needs authentication.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + models.forEach { model -> + Text( + text = "• $model", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt new file mode 100644 index 00000000..6509358a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -0,0 +1,63 @@ +package com.m57.hermescontrol.ui.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.remote.ApiClient +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 +import kotlinx.coroutines.withContext + +data class ModelUiState( + val isLoading: Boolean = false, + val providers: List = emptyList(), + val errorMessage: String? = null, + val toastMessage: String? = null, +) + +class ModelViewModel : ViewModel() { + private val _uiState = MutableStateFlow(ModelUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadModelOptions() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getModelOptions() + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isLoading = false, + providers = response.body()?.providers.orEmpty(), + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load model options: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load model options: ${e.message}", + ) + } + } + } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt new file mode 100644 index 00000000..3d618490 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.ui.webhooks + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WebhooksScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.loadWebhooks() + } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + viewModel.clearToast() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + if (onOpenDrawer != null) { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Open Drawer", + ) + } + } + }, + title = { Text("Webhooks Dispatcher") }, + actions = { + IconButton(onClick = { viewModel.loadWebhooks() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + modifier = modifier, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (state.isLoading && state.baseUrl == null) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (state.errorMessage != null && state.baseUrl == null) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadWebhooks() }) { + Text("Retry") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Global Status Switch + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (state.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Global Webhooks Status", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + state.baseUrl?.let { + Text( + text = "Local Server URL: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Switch( + checked = state.enabled, + onCheckedChange = { viewModel.toggleWebhooks(it) }, + ) + } + } + } + + // Subscriptions List Header + item { + Text( + text = "Subscriptions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (state.subscriptions.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No webhook subscriptions configured.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + items(state.subscriptions) { sub -> + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = sub.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Target URL:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = sub.url, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + sub.events?.let { events -> + if (events.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Events Subscribed:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + events.forEach { event -> + SuggestionChip( + onClick = {}, + label = { + Text( + text = event, + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt new file mode 100644 index 00000000..acf42f85 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt @@ -0,0 +1,107 @@ +package com.m57.hermescontrol.ui.webhooks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.WebhookSubscription +import com.m57.hermescontrol.data.model.WebhooksResponse +import com.m57.hermescontrol.data.model.WebhooksToggleRequest +import com.m57.hermescontrol.data.remote.ApiClient +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 +import kotlinx.coroutines.withContext + +data class WebhooksUiState( + val isLoading: Boolean = false, + val isActionRunning: Boolean = false, + val enabled: Boolean = false, + val baseUrl: String? = null, + val subscriptions: List = emptyList(), + val errorMessage: String? = null, + val toastMessage: String? = null, +) + +class WebhooksViewModel : ViewModel() { + private val _uiState = MutableStateFlow(WebhooksUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadWebhooks() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getWebhooks() + } + if (response.isSuccessful) { + val body = response.body() + _uiState.update { + it.copy( + isLoading = false, + enabled = body?.enabled ?: false, + baseUrl = body?.base_url, + subscriptions = body?.subscriptions.orEmpty(), + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load webhooks: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load webhooks: ${e.message}", + ) + } + } + } + } + + fun toggleWebhooks(targetEnabled: Boolean) { + val originalEnabled = _uiState.value.enabled + _uiState.update { it.copy(enabled = targetEnabled) } + + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.toggleWebhooks(WebhooksToggleRequest(targetEnabled)) + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}", + ) + } + loadWebhooks() + } else { + _uiState.update { + it.copy( + enabled = originalEnabled, + toastMessage = "Failed to toggle webhooks: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + enabled = originalEnabled, + toastMessage = "Failed to toggle webhooks: ${e.message}", + ) + } + } + } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +}