feat(plugins): memory provider config & setup management (issue #783) (#834)

* feat(plugins): memory provider config & setup management (issue #783)

* fix(memory): remove top padding on provider detail screen

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 04:18:50 +03:00
1 parent 4645a2b6e7
commit fd5d851405
10 files changed
+1144

No files matched your search

@@ -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<MemoryProviderDetailKey> { key ->
MemoryProviderDetailScreen(
name = key.name,
label = key.label,
onBack = { NavigationController.goBack() },
)
}
}
@Composable
@@ -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
@@ -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<String> = emptyList(),
val external_dependencies: List<MemoryProviderExternalDependency> = emptyList(),
val required_env: List<String> = 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<MemoryProviderField> = 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<MemoryProviderFieldOption> = 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<String, String> = emptyMap(),
)
@Serializable
data class MemoryProviderConfigUpdateResponse(
val ok: Boolean = false,
val active: String? = null,
)
@Serializable
data class MemoryProviderSetupRequest(
val values: Map<String, String> = 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<MemoryProviderSetupResult> = 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"
}
@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
data class MemoryResponse(
val active: String? = null,
val builtin_files: BuiltinFileSizes? = null,
val providers: List<MemoryProviderStatusRow> = emptyList(),
)
@Serializable
@@ -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<MemoryResponse>
@GET("api/memory/providers/{name}/config")
suspend fun getMemoryProviderConfig(
@Path("name") name: String,
): Response<MemoryProviderConfigResponse>
@PUT("api/memory/providers/{name}/config")
suspend fun updateMemoryProviderConfig(
@Path("name") name: String,
@Body body: MemoryProviderConfigUpdateRequest,
): Response<MemoryProviderConfigUpdateResponse>
@POST("api/memory/providers/{name}/setup")
suspend fun setupMemoryProvider(
@Path("name") name: String,
@Body body: MemoryProviderSetupRequest,
): Response<MemoryProviderSetupResponse>
@POST("api/memory/reset")
suspend fun resetMemory(
@Body body: Map<String, String>,
@@ -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
}
@@ -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<String, String> = 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<MemoryProviderDetailUiState> = _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<String, String> =
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('_', ' ')
}
@@ -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