* 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.DisableDrawerGestures
|
||||||
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
||||||
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
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.SettingsAboutPage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsAppearancePage
|
import com.m57.hermescontrol.ui.settings.SettingsAppearancePage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
|
import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
|
||||||
@@ -139,6 +140,15 @@ private fun appEntryProvider(
|
|||||||
onBack = { NavigationController.goBack() },
|
onBack = { NavigationController.goBack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Memory provider detail drill-down (issue #783) ─────────────────
|
||||||
|
entry<MemoryProviderDetailKey> { key ->
|
||||||
|
MemoryProviderDetailScreen(
|
||||||
|
name = key.name,
|
||||||
|
label = key.label,
|
||||||
|
onBack = { NavigationController.goBack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import kotlinx.serialization.Serializable
|
|||||||
val label: String? = null,
|
val label: String? = null,
|
||||||
) : NavKey
|
) : NavKey
|
||||||
|
|
||||||
|
@Serializable data class MemoryProviderDetailKey(
|
||||||
|
val name: String,
|
||||||
|
val label: String? = null,
|
||||||
|
) : NavKey
|
||||||
|
|
||||||
@Serializable data object AchievementsScreen : NavKey
|
@Serializable data object AchievementsScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ConfigScreen : 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(
|
data class MemoryResponse(
|
||||||
val active: String? = null,
|
val active: String? = null,
|
||||||
val builtin_files: BuiltinFileSizes? = null,
|
val builtin_files: BuiltinFileSizes? = null,
|
||||||
|
val providers: List<MemoryProviderStatusRow> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@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.McpServer
|
||||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
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.MemoryResponse
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
||||||
@@ -806,6 +811,23 @@ interface HermesApiService {
|
|||||||
@GET("api/memory")
|
@GET("api/memory")
|
||||||
suspend fun getMemory(): Response<MemoryResponse>
|
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")
|
@POST("api/memory/reset")
|
||||||
suspend fun resetMemory(
|
suspend fun resetMemory(
|
||||||
@Body body: Map<String, String>,
|
@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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.R
|
||||||
import com.m57.hermescontrol.data.model.PluginInfo
|
import com.m57.hermescontrol.data.model.PluginInfo
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Context engine dropdown
|
// Context engine dropdown
|
||||||
|
|||||||
@@ -977,6 +977,21 @@
|
|||||||
<string name="image_viewer_save">Save to device</string>
|
<string name="image_viewer_save">Save to device</string>
|
||||||
<string name="image_viewer_share">Share</string>
|
<string name="image_viewer_share">Share</string>
|
||||||
<string name="image_viewer_share_title">Share image</string>
|
<string name="image_viewer_share_title">Share image</string>
|
||||||
|
<string name="plugins_memory_manage">Manage provider…</string>
|
||||||
|
<string name="memory_provider_empty_title">No provider config</string>
|
||||||
|
<string name="memory_provider_empty_desc">This provider has no configurable surface.</string>
|
||||||
|
<string name="memory_provider_save">Save settings</string>
|
||||||
|
<string name="memory_provider_setup_summary">Dependencies: %1$s · Required env: %2$s</string>
|
||||||
|
<string name="memory_provider_setup_heading">Setup</string>
|
||||||
|
<string name="memory_provider_setup_hint">Installs missing dependencies for this provider.</string>
|
||||||
|
<string name="memory_provider_run_setup">Run setup</string>
|
||||||
|
<string name="memory_provider_setup_ok">Setup completed successfully.</string>
|
||||||
|
<string name="memory_provider_setup_failed">Setup finished with failures.</string>
|
||||||
|
<string name="memory_provider_secret_set">set</string>
|
||||||
|
<string name="memory_provider_secret_not_set">not set</string>
|
||||||
|
<string name="memory_provider_secret_label">API key (leave blank to keep)</string>
|
||||||
|
<string name="memory_provider_bool_on">On</string>
|
||||||
|
<string name="memory_provider_bool_off">Off</string>
|
||||||
<string name="image_viewer_saved">Image saved to Downloads</string>
|
<string name="image_viewer_saved">Image saved to Downloads</string>
|
||||||
<string name="image_viewer_save_failed">Could not save image</string>
|
<string name="image_viewer_save_failed">Could not save image</string>
|
||||||
<string name="image_viewer_load_failed">Could not load image: %1$s</string>
|
<string name="image_viewer_load_failed">Could not load image: %1$s</string>
|
||||||
|
|||||||
+274
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user