diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 4a018e88..4bfff7e1 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -50,6 +50,7 @@ import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.DisableDrawerGestures import com.m57.hermescontrol.ui.common.DrawerGestureController import com.m57.hermescontrol.ui.common.LocalDrawerGestureController +import com.m57.hermescontrol.ui.plugins.MemoryProviderDetailScreen import com.m57.hermescontrol.ui.settings.SettingsAboutPage import com.m57.hermescontrol.ui.settings.SettingsAppearancePage import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage @@ -139,6 +140,15 @@ private fun appEntryProvider( onBack = { NavigationController.goBack() }, ) } + + // ── Memory provider detail drill-down (issue #783) ───────────────── + entry { key -> + MemoryProviderDetailScreen( + name = key.name, + label = key.label, + onBack = { NavigationController.goBack() }, + ) + } } @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 5388e93b..5dd09c0e 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -28,6 +28,11 @@ import kotlinx.serialization.Serializable val label: String? = null, ) : NavKey +@Serializable data class MemoryProviderDetailKey( + val name: String, + val label: String? = null, +) : NavKey + @Serializable data object AchievementsScreen : NavKey @Serializable data object ConfigScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MemoryProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryProvider.kt new file mode 100644 index 00000000..f366d7ed --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryProvider.kt @@ -0,0 +1,101 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** A memory provider row as reported by `GET /api/memory` (issue #783). */ +@Serializable +data class MemoryProviderStatusRow( + val name: String = "", + val description: String = "", + val available: Boolean = false, + val configured: Boolean = false, + val status: String = "", + val setup: MemoryProviderSetupInfo? = null, +) + +/** Setup manifest for a memory provider (pip deps, external deps, env). */ +@Serializable +data class MemoryProviderSetupInfo( + val pip_dependencies: List = emptyList(), + val external_dependencies: List = emptyList(), + val required_env: List = emptyList(), + val dependencies_installed: Boolean? = null, +) + +@Serializable +data class MemoryProviderExternalDependency( + val name: String = "", + val install: String = "", + val check: String = "", +) + +/** Per-provider config surface from `GET /api/memory/providers/{name}/config`. */ +@Serializable +data class MemoryProviderConfigResponse( + val name: String = "", + val label: String = "", + val docs_url: String = "", + val fields: List = emptyList(), + val setup: MemoryProviderSetupInfo? = null, +) + +@Serializable +data class MemoryProviderField( + val description: String = "", + val group: String = "", + val info: String? = null, + val inline: Boolean = false, + val is_set: Boolean = false, + val key: String = "", + val kind: String = "text", + val label: String = "", + val options: List = emptyList(), + val placeholder: String = "", + val value: String = "", +) + +@Serializable +data class MemoryProviderFieldOption( + val description: String = "", + val label: String = "", + val value: String = "", +) + +@Serializable +data class MemoryProviderConfigUpdateRequest( + val values: Map = emptyMap(), +) + +@Serializable +data class MemoryProviderConfigUpdateResponse( + val ok: Boolean = false, + val active: String? = null, +) + +@Serializable +data class MemoryProviderSetupRequest( + val values: Map = emptyMap(), +) + +/** Result of `POST /api/memory/providers/{name}/setup` — synchronous, no polling. */ +@Serializable +data class MemoryProviderSetupResponse( + val ok: Boolean = false, + val provider: String = "", + val results: List = emptyList(), + val status: MemoryProviderStatusRow? = null, +) + +@Serializable +data class MemoryProviderSetupResult( + val kind: String = "", + val name: String = "", + val status: String = "", + val command: String = "", + val returncode: Int? = null, + val stdout: String = "", + val stderr: String = "", +) { + val succeeded: Boolean + get() = status != "failed" +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt index 9b69de0c..ef1e88f8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable data class MemoryResponse( val active: String? = null, val builtin_files: BuiltinFileSizes? = null, + val providers: List = emptyList(), ) @Serializable 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 01c4cc4f..9bbe394d 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 @@ -50,6 +50,11 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse +import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse +import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest +import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateResponse +import com.m57.hermescontrol.data.model.MemoryProviderSetupRequest +import com.m57.hermescontrol.data.model.MemoryProviderSetupResponse import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.MessagingPlatformResponse import com.m57.hermescontrol.data.model.MessagingPlatformTestResult @@ -806,6 +811,23 @@ interface HermesApiService { @GET("api/memory") suspend fun getMemory(): Response + @GET("api/memory/providers/{name}/config") + suspend fun getMemoryProviderConfig( + @Path("name") name: String, + ): Response + + @PUT("api/memory/providers/{name}/config") + suspend fun updateMemoryProviderConfig( + @Path("name") name: String, + @Body body: MemoryProviderConfigUpdateRequest, + ): Response + + @POST("api/memory/providers/{name}/setup") + suspend fun setupMemoryProvider( + @Path("name") name: String, + @Body body: MemoryProviderSetupRequest, + ): Response + @POST("api/memory/reset") suspend fun resetMemory( @Body body: Map, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt new file mode 100644 index 00000000..b79c7519 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt @@ -0,0 +1,464 @@ +@file:OptIn( + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.foundation.layout.ExperimentalLayoutApi::class, +) + +package com.m57.hermescontrol.ui.plugins + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse +import com.m57.hermescontrol.data.model.MemoryProviderField +import com.m57.hermescontrol.data.model.MemoryProviderSetupResult +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.ExposedDropdownField +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.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing + +/** + * Per-memory-provider management page (issue #783) — the mobile counterpart + * of the desktop provider-config-panel: schema fields with existing values, + * write-only secrets, a synchronous setup run with inline per-step results, + * and an honest status pill refreshed after each mutation. + */ +@Composable +fun MemoryProviderDetailScreen( + name: String, + label: String?, + onBack: () -> Unit, + viewModel: MemoryProviderDetailViewModel = viewModel { MemoryProviderDetailViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val config = state.config + + ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + + // The VM is shared across all provider-detail entries (activity-level + // ViewModelStoreOwner) — drive it with the CURRENT entry's provider. + LaunchedEffect(name) { + viewModel.setProvider(name) + viewModel.load() + } + + HermesScaffold( + title = { Text(label ?: name) }, + navigationIcon = NavIcon.Back(onBack), + drawerGesturesEnabled = false, + isRefreshing = state.isLoading, + onRefresh = { viewModel.load() }, + ) { paddingValues -> + when { + state.isLoading && config == null -> { + SkeletonListState(modifier = Modifier) + } + + state.errorMessage != null && config == null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.load() }, + modifier = Modifier, + ) + } + + config == null -> { + EmptyState( + title = stringResource(R.string.memory_provider_empty_title), + subtitle = stringResource(R.string.memory_provider_empty_desc), + onAction = { viewModel.load() }, + actionLabel = stringResource(R.string.content_desc_refresh), + modifier = Modifier, + ) + } + + else -> { + MemoryProviderConfigContent( + config = config, + state = state, + onFieldValue = viewModel::setFieldValue, + onSave = viewModel::saveConfig, + onRunSetup = viewModel::runSetup, + modifier = Modifier, + ) + } + } + } +} + +@Composable +private fun MemoryProviderConfigContent( + config: MemoryProviderConfigResponse, + state: MemoryProviderDetailUiState, + onFieldValue: (String, String) -> Unit, + onSave: () -> Unit, + onRunSetup: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + // Status header + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = config.label.ifEmpty { config.name }, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + state.status?.let { status -> + StatusBadge( + text = memoryProviderStatusLabel(status.status), + status = memoryProviderStatusType(status.status), + ) + } + } + if (config.docs_url.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = config.docs_url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + config.setup?.let { setup -> + if (setup.pip_dependencies.isNotEmpty() || setup.required_env.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + stringResource( + R.string.memory_provider_setup_summary, + setup.pip_dependencies.joinToString(", "), + setup.required_env.joinToString(", "), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + // Config fields + items(config.fields, key = { it.key }) { field -> + ProviderFieldRow( + field = field, + value = state.edits[field.key] ?: "", + enabled = !state.saving, + onChange = { onFieldValue(field.key, it) }, + ) + } + + // Save + item { + Button( + onClick = onSave, + enabled = !state.saving, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.saving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.memory_provider_save)) + } + } + + // Setup + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.memory_provider_setup_heading), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.memory_provider_setup_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onRunSetup, + enabled = !state.runningSetup, + ) { + if (state.runningSetup) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.memory_provider_run_setup)) + } + state.setupResult?.let { result -> + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = + if (result.ok) { + stringResource(R.string.memory_provider_setup_ok) + } else { + stringResource(R.string.memory_provider_setup_failed) + }, + style = MaterialTheme.typography.labelMedium, + color = + if (result.ok) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + result.results.forEach { step -> + Spacer(modifier = Modifier.height(8.dp)) + SetupStepRow(step) + } + } + } + } + } + } +} + +@Composable +private fun ProviderFieldRow( + field: MemoryProviderField, + value: String, + enabled: Boolean, + onChange: (String) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.label.ifEmpty { field.key }, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (field.kind == "secret") { + StatusBadge( + text = + stringResource( + if (field.is_set) { + R.string.memory_provider_secret_set + } else { + R.string.memory_provider_secret_not_set + }, + ), + status = + if (field.is_set) { + StatusBadgeType.SUCCESS + } else { + StatusBadgeType.WARNING + }, + ) + } + } + if (field.description.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = field.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + when (field.kind) { + "select" -> + ExposedDropdownField( + label = field.label.ifEmpty { field.key }, + options = field.options.map { it.value }.ifEmpty { listOf(value) }, + selectedValue = value, + onOptionSelected = onChange, + ) + + "bool" -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = value == "true", + onCheckedChange = { onChange(it.toString()) }, + enabled = enabled, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + stringResource( + if (value == "true") { + R.string.memory_provider_bool_on + } else { + R.string.memory_provider_bool_off + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + + else -> + OutlinedTextField( + value = value, + onValueChange = onChange, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + label = { + Text( + if (field.kind == "secret") { + stringResource(R.string.memory_provider_secret_label) + } else { + field.label.ifEmpty { field.key } + }, + ) + }, + placeholder = { + if (field.placeholder.isNotBlank()) { + Text(field.placeholder) + } + }, + singleLine = field.kind != "json", + visualTransformation = + if (field.kind == "secret") { + PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + keyboardOptions = + if (field.kind == "number") { + KeyboardOptions(keyboardType = KeyboardType.Number) + } else { + KeyboardOptions.Default + }, + ) + } + } + } +} + +@Composable +private fun SetupStepRow(step: MemoryProviderSetupResult) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = step.name.ifEmpty { step.kind }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + StatusBadge( + text = step.status.replace('_', ' '), + status = + when (step.status) { + "failed" -> StatusBadgeType.ERROR + "skipped" -> StatusBadgeType.NEUTRAL + else -> StatusBadgeType.SUCCESS + }, + ) + } + if (step.command.isNotBlank()) { + Text( + text = step.command, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (step.stdout.isNotBlank()) { + Text( + text = step.stdout.trim(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + if (step.stderr.isNotBlank()) { + Text( + text = step.stderr.trim(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +private fun memoryProviderStatusType(status: String): StatusBadgeType = + when (status) { + "ready" -> StatusBadgeType.SUCCESS + "needs_config" -> StatusBadgeType.WARNING + "unavailable", "missing" -> StatusBadgeType.ERROR + else -> StatusBadgeType.NEUTRAL + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModel.kt new file mode 100644 index 00000000..f4151412 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModel.kt @@ -0,0 +1,233 @@ +package com.m57.hermescontrol.ui.plugins + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse +import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest +import com.m57.hermescontrol.data.model.MemoryProviderSetupRequest +import com.m57.hermescontrol.data.model.MemoryProviderSetupResponse +import com.m57.hermescontrol.data.model.MemoryProviderStatusRow +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.ui.common.ToastHost +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 MemoryProviderDetailUiState( + val isLoading: Boolean = false, + val providerName: String = "", + val config: MemoryProviderConfigResponse? = null, + val status: MemoryProviderStatusRow? = null, + val errorMessage: String? = null, + val toastMessage: String? = null, + /** In-progress edits, keyed by field key. Secrets only hold typed text. */ + val edits: Map = emptyMap(), + val saving: Boolean = false, + val runningSetup: Boolean = false, + val setupResult: MemoryProviderSetupResponse? = null, +) + +/** + * Per-memory-provider management — issue #783. Contracts verified against + * hermes-agent web_server.py (`/api/memory/providers/{name}/config` GET/PUT, + * `POST .../setup`); mirrors the desktop provider-config-panel (schema fields + * with existing values, secrets write-only, one-shot setup with inline + * results). Deliberately avoids an explicit IO hop: Retrofit suspend calls + * already run off the caller thread, keeping tests free of the static + * Dispatchers mock that bleeds across test classes. + * + * NOTE: this ViewModel is shared across all memory-provider detail nav + * entries — the app's NavDisplay uses the activity-level ViewModelStoreOwner, + * so class-keyed `viewModel {}` returns the same instance for every entry. + * The provider name is therefore NOT a constructor arg; the screen drives + * [setProvider] + [load] per entry instead (same bug/fix as #782's + * ToolsetDetailViewModel). + */ +class MemoryProviderDetailViewModel : ViewModel(), ToastHost { + private val _uiState = MutableStateFlow(MemoryProviderDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var providerName: String = "" + + /** + * Point the shared instance at [name] and drop all state from the + * previously viewed provider (config, edits, setup result) so one + * provider's data never bleeds into another. + */ + fun setProvider(name: String) { + if (name == providerName) return + providerName = name + _uiState.value = MemoryProviderDetailUiState(providerName = name) + } + + fun load() { + val name = providerName + if (name.isEmpty()) return + if (_uiState.value.isLoading) return + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + // GET /api/memory for the provider's live status row. + val statusResult = safeApiCall { ApiClient.hermesApi.getMemory() } + val status = + when (statusResult) { + is NetworkResult.Success -> + statusResult.data.providers.firstOrNull { it.name == name } + else -> null + } + val configResult = safeApiCall { ApiClient.hermesApi.getMemoryProviderConfig(name) } + when (configResult) { + is NetworkResult.Success -> + _uiState.update { + it.copy( + isLoading = false, + providerName = name, + config = configResult.data, + status = status ?: it.status, + edits = seedEdits(configResult.data), + ) + } + is NetworkResult.Failure -> + _uiState.update { + it.copy( + isLoading = false, + providerName = name, + status = status ?: it.status, + errorMessage = configResult.error.message, + ) + } + } + } + } + + /** Field text values; secrets start blank (write-only, never echoed). */ + private fun seedEdits(config: MemoryProviderConfigResponse): Map = + config.fields + .filter { it.kind != "secret" } + .associate { it.key to it.value } + + fun setFieldValue( + key: String, + value: String, + ) { + _uiState.update { it.copy(edits = it.edits + (key to value)) } + } + + /** + * Save the edited fields via PUT config. A blank secret means "leave the + * stored value untouched"; the backend also activates the provider when + * the write succeeds, so the status row is refreshed afterwards. + */ + fun saveConfig() { + val name = providerName + if (name.isEmpty() || _uiState.value.saving) return + val state = _uiState.value + val config = state.config ?: return + _uiState.update { it.copy(saving = true, errorMessage = null) } + viewModelScope.launch { + val values = + config.fields.associate { field -> + when { + field.kind == "secret" -> field.key to (state.edits[field.key] ?: "") + else -> field.key to (state.edits[field.key] ?: field.value) + } + } + val result = + safeApiCall { + ApiClient.hermesApi.updateMemoryProviderConfig( + name, + MemoryProviderConfigUpdateRequest(values), + ) + } + when (result) { + is NetworkResult.Success -> + _uiState.update { + it.copy( + saving = false, + edits = + it.edits.filterKeys { key -> + config.fields.none { f -> f.key == key && f.kind == "secret" } + }, + toastMessage = "Memory provider settings saved", + ) + } + is NetworkResult.Failure -> + _uiState.update { + it.copy( + saving = false, + toastMessage = "Failed to save settings: ${result.error.message}", + ) + } + } + refreshStatus(name) + } + } + + /** + * Run the provider's setup (installs pip/external dependencies). The + * backend executes it synchronously and returns per-step results inline. + */ + fun runSetup() { + val name = providerName + if (name.isEmpty() || _uiState.value.runningSetup) return + _uiState.update { it.copy(runningSetup = true, errorMessage = null) } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.setupMemoryProvider(name, MemoryProviderSetupRequest()) + } + when (result) { + is NetworkResult.Success -> + _uiState.update { + it.copy( + runningSetup = false, + setupResult = result.data, + toastMessage = + if (result.data.ok) { + "Setup completed" + } else { + "Setup finished with failures" + }, + ) + } + is NetworkResult.Failure -> + _uiState.update { + it.copy( + runningSetup = false, + toastMessage = "Setup failed: ${result.error.message}", + ) + } + } + refreshStatus(name) + } + } + + override fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } + + private fun refreshStatus(name: String) { + viewModelScope.launch { + val result = safeApiCall { ApiClient.hermesApi.getMemory() } + if (result is NetworkResult.Success) { + val row = result.data.providers.firstOrNull { it.name == name } + if (row != null) { + _uiState.update { it.copy(status = row) } + } + } + } + } +} + +/** Status pill mapping — matches the backend's provider status values. */ +fun memoryProviderStatusLabel(status: String): String = + when (status) { + "ready" -> "Ready" + "needs_config" -> "Needs config" + "unavailable" -> "Unavailable" + "missing" -> "Missing" + else -> status.replace('_', ' ') + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 4880655b..b166a137 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.MemoryProviderDetailKey +import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.theme.LocalHermesStatusColors @@ -264,6 +266,23 @@ private fun ProviderSelectionSection( }, ) + // Manage the selected provider's config/setup (issue #783). + if (!state.isMemoryBuiltin && state.memoryProvider.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + TextButton( + onClick = { + NavigationController.navigateTo( + MemoryProviderDetailKey( + name = state.memoryProvider, + label = state.memoryProvider, + ), + ) + }, + ) { + Text(stringResource(R.string.plugins_memory_manage)) + } + } + Spacer(modifier = Modifier.height(12.dp)) // Context engine dropdown diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47351a80..d6395de6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -977,6 +977,21 @@ Save to device Share Share image + Manage provider… + No provider config + This provider has no configurable surface. + Save settings + Dependencies: %1$s · Required env: %2$s + Setup + Installs missing dependencies for this provider. + Run setup + Setup completed successfully. + Setup finished with failures. + set + not set + API key (leave blank to keep) + On + Off Image saved to Downloads Could not save image Could not load image: %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModelTest.kt new file mode 100644 index 00000000..0fe71235 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModelTest.kt @@ -0,0 +1,274 @@ +package com.m57.hermescontrol.ui.plugins + +import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse +import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest +import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateResponse +import com.m57.hermescontrol.data.model.MemoryProviderField +import com.m57.hermescontrol.data.model.MemoryProviderSetupResponse +import com.m57.hermescontrol.data.model.MemoryProviderSetupResult +import com.m57.hermescontrol.data.model.MemoryProviderStatusRow +import com.m57.hermescontrol.data.model.MemoryResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +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.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +/** + * Issue #783 — per-memory-provider management. Contracts verified against + * hermes-agent web_server.py: + * - GET /api/memory → {active, providers:[{name, status, ...}], builtin_files} + * - GET /api/memory/providers/{name}/config → {name, label, docs_url, fields, setup} + * where fields are schema rows (text/secret/select/bool/number) carrying + * existing values; secrets are write-only (is_set, value never echoed). + * - PUT /api/memory/providers/{name}/config {values} → {ok, active} + * (also activates the provider). + * - POST /api/memory/providers/{name}/setup {values} → synchronous + * {ok, provider, results:[{kind,name,status,...}], status}. + * + * NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock bleeds + * JVM-wide and breaks later test classes. The ViewModel under test does no + * explicit IO hop (Retrofit suspend calls run off the caller thread), so + * setMain(StandardTestDispatcher) alone makes it fully deterministic. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MemoryProviderDetailViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockApi: HermesApiService + + private val webStatus = + MemoryProviderStatusRow( + name = "web", + description = "Web memory", + available = true, + configured = true, + status = "ready", + ) + private val webConfig = + MemoryProviderConfigResponse( + name = "web", + label = "Web Memory", + docs_url = "https://example.com/docs", + fields = + listOf( + MemoryProviderField( + key = "model", + kind = "text", + label = "Model", + value = "gpt-4o", + ), + MemoryProviderField( + key = "api_key", + kind = "secret", + label = "API Key", + is_set = true, + ), + MemoryProviderField( + key = "memory_dir", + kind = "select", + label = "Directory", + options = + listOf( + com.m57.hermescontrol.data.model.MemoryProviderFieldOption( + value = "default", + label = "Default", + ), + com.m57.hermescontrol.data.model.MemoryProviderFieldOption( + value = "custom", + label = "Custom", + ), + ), + value = "default", + ), + ), + ) + private val setupOk = + MemoryProviderSetupResponse( + ok = true, + provider = "web", + results = + listOf( + MemoryProviderSetupResult( + kind = "pip", + name = "honcho-ai", + status = "installed", + stdout = "Requirement already satisfied", + ), + ), + status = webStatus, + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + coEvery { mockApi.getMemory() } returns + Response.success( + MemoryResponse(active = "web", providers = listOf(webStatus)), + ) + coEvery { mockApi.getMemoryProviderConfig("web") } returns Response.success(webConfig) + coEvery { mockApi.updateMemoryProviderConfig("web", any()) } returns + Response.success(MemoryProviderConfigUpdateResponse(ok = true, active = "web")) + coEvery { mockApi.setupMemoryProvider("web", any()) } returns Response.success(setupOk) + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + private fun createViewModel(): MemoryProviderDetailViewModel { + val vm = MemoryProviderDetailViewModel() + vm.setProvider("web") + vm.load() + testDispatcher.scheduler.advanceUntilIdle() + return vm + } + + @Test + fun `load fetches config and seeds non-secret edits`() { + val vm = createViewModel() + + val state = vm.uiState.value + assertEquals("web", state.providerName) + assertEquals("web", state.config?.name) + assertEquals("ready", state.status?.status) + // Secrets stay blank (write-only); text/select values are seeded. + assertEquals("gpt-4o", state.edits["model"]) + assertEquals("default", state.edits["memory_dir"]) + assertNull(state.edits["api_key"]) + } + + @Test + fun `setProvider switches config to the selected provider and drops prior state`() { + val vm = createViewModel() + assertTrue(vm.uiState.value.config != null) + + vm.setFieldValue("model", "claude-4") + vm.setProvider("tts") + vm.load() + testDispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.value + assertEquals("tts", state.providerName) + // No bleed from the previously viewed provider. + assertNull(state.edits["model"]) + coVerify { mockApi.getMemoryProviderConfig("tts") } + } + + @Test + fun `saveConfig sends edits and shows saved toast`() { + val vm = createViewModel() + vm.setFieldValue("model", "claude-4") + vm.setFieldValue("api_key", "sk-test") + vm.saveConfig() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockApi.updateMemoryProviderConfig( + "web", + MemoryProviderConfigUpdateRequest( + mapOf( + "model" to "claude-4", + "memory_dir" to "default", + "api_key" to "sk-test", + ), + ), + ) + } + assertEquals("Memory provider settings saved", vm.uiState.value.toastMessage) + } + + @Test + fun `saveConfig with blank secret still sends it and backend keeps the stored value`() { + val vm = createViewModel() + vm.saveConfig() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockApi.updateMemoryProviderConfig( + "web", + MemoryProviderConfigUpdateRequest( + mapOf( + "model" to "gpt-4o", + "memory_dir" to "default", + "api_key" to "", + ), + ), + ) + } + assertFalse(vm.uiState.value.saving) + } + + @Test + fun `saveConfig failure shows error toast`() { + coEvery { mockApi.updateMemoryProviderConfig("web", any()) } returns + Response.error(500, "boom".toResponseBody()) + val vm = createViewModel() + vm.saveConfig() + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to save")) + } + + @Test + fun `runSetup stores inline results and shows success toast`() { + coEvery { mockApi.setupMemoryProvider("web", any()) } returns Response.success(setupOk) + val vm = createViewModel() + vm.runSetup() + testDispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.value + assertEquals("Setup completed", state.toastMessage) + assertTrue(state.setupResult?.ok == true) + assertEquals("installed", state.setupResult?.results?.first()?.status) + } + + @Test + fun `runSetup failure surfaces error toast`() { + coEvery { mockApi.setupMemoryProvider("web", any()) } returns + Response.error(400, "nope".toResponseBody()) + val vm = createViewModel() + vm.runSetup() + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Setup failed")) + } + + @Test + fun `load failure surfaces error message`() { + coEvery { mockApi.getMemoryProviderConfig("web") } returns + Response.error(404, "unknown".toResponseBody()) + val vm = MemoryProviderDetailViewModel() + vm.setProvider("web") + vm.load() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(vm.uiState.value.config) + assertEquals( + "Not Found (HTTP 404): The requested resource could not be found.", + vm.uiState.value.errorMessage, + ) + } +}