* 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:
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
|
||||
|
||||
Reference in new issue
Block a user