feat(#1292): plugin settings/diagnostics and persisted edit previews (#1304)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 17:35:25 +03:00
1 parent ef54804b72
commit 2e0fd46a4c
13 files changed
+1786 -10

No files matched your search

@@ -0,0 +1,102 @@
package com.m57.hermescontrol.data.ws
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
enum class PluginSettingFieldType {
@SerialName("string")
STRING,
@SerialName("number")
NUMBER,
@SerialName("boolean")
BOOLEAN,
@SerialName("enum")
ENUM,
@SerialName("secret")
SECRET,
@SerialName("json")
JSON,
}
@Serializable
data class PluginSettingField(
val key: String,
val type: PluginSettingFieldType,
val label: String,
val description: String = "",
val required: Boolean = false,
val value: JsonElement? = null,
val default: JsonElement? = null,
val choices: List<String>? = null,
val env: String? = null,
@SerialName("has_value") val hasValue: Boolean? = null,
)
@Serializable
enum class PluginServerState {
@SerialName("connected")
CONNECTED,
@SerialName("app_not_running")
APP_NOT_RUNNING,
@SerialName("endpoint_unavailable")
ENDPOINT_UNAVAILABLE,
@SerialName("no_interactive_session")
NO_INTERACTIVE_SESSION,
@SerialName("version_too_old")
VERSION_TOO_OLD,
@SerialName("missing_app")
MISSING_APP,
@SerialName("unknown")
UNKNOWN,
}
@Serializable
data class PluginServerRow(
val name: String,
val state: PluginServerState,
val sentence: String = "",
)
@Serializable
data class AgentPluginRow(
val name: String,
val key: String? = null,
val version: String = "",
val description: String = "",
val source: String = "",
val status: String = "",
val portable: Boolean = false,
@SerialName("install_dir") val installDir: String = "",
@SerialName("has_desktop_half") val hasDesktopHalf: Boolean = false,
val servers: List<PluginServerRow> = emptyList(),
@SerialName("settings_schema") val settingsSchema: List<PluginSettingField>? = null,
@SerialName("catalog_name") val catalogName: String? = null,
@SerialName("catalog_tier") val catalogTier: String? = null,
@SerialName("installed_sha") val installedSha: String? = null,
@SerialName("catalog_sha") val catalogSha: String? = null,
@SerialName("catalog_version") val catalogVersion: String? = null,
@SerialName("update_available") val updateAvailable: Boolean? = null,
@SerialName("pinned_sha") val pinnedSha: String? = null,
)
@Serializable
data class PluginsManageSettingsResult(
val ok: Boolean = false,
val name: String? = null,
val written: List<String>? = null,
val plugin: AgentPluginRow? = null,
val error: String? = null,
)
@@ -0,0 +1,52 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
/**
* Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC.
*/
class PluginManageRepository(
private val rpcRequest: suspend (String, Map<String, Any?>) -> Any? = { method, params ->
val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" })
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
}
},
) {
suspend fun listPlugins(profile: String? = null): List<AgentPluginRow> {
val params = mutableMapOf<String, Any?>("action" to "list")
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
val element = result.toJsonElement()
val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList()
return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement)
}
suspend fun saveSettings(
key: String,
values: Map<String, JsonElement>,
profile: String? = null,
): PluginsManageSettingsResult {
val params =
mutableMapOf<String, Any?>(
"action" to "settings",
"key" to key,
"values" to JsonObject(values),
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result.toJsonElement())
}
}
@@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import kotlinx.serialization.json.JsonObject
/**
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
@@ -179,7 +181,19 @@ internal fun mapServerMessages(
?: existingById[restId]?.timestamp
?: System.currentTimeMillis()
val rawContent = msg.visibleText
val rawContent =
if (role == MessageRole.TOOL && msg.display_metadata != null) {
// Enrich tool content with display_metadata so persisted edit previews
// (inline_diff) survive reload into the diff renderer
val parsedContent = ToolJson.parseMaybeObject(msg.content)
if (parsedContent != null && !parsedContent.containsKey("display_metadata")) {
JsonObject(parsedContent + ("display_metadata" to msg.display_metadata)).toString()
} else {
msg.visibleText
}
} else {
msg.visibleText
}
// #1284: projected reasoning already excludes public commentary; it is authoritative.
val rowReasoning =
if (msg.hasDisplayReasoning) {
@@ -52,7 +52,22 @@ fun parseToolOutput(
} else {
rawArgs
}
val result = element["result"] as? JsonObject ?: element
val rawResultObj = element["result"] as? JsonObject
val displayMetadata = element["display_metadata"]
val result =
when {
rawResultObj != null && displayMetadata != null && !rawResultObj.containsKey("display_metadata") -> {
JsonObject(rawResultObj + ("display_metadata" to displayMetadata))
}
rawResultObj != null -> {
rawResultObj
}
else -> {
element
}
}
ToolViewBuilder.build(
toolName = resolvedToolName ?: "tool",
@@ -58,6 +58,18 @@ internal object FileEditSupport {
}
}
// Support persisted edit previews from display_metadata.tool_result_metadata.inline_diff or display_metadata.inline_diff
val displayMetadata =
record["display_metadata"] as? JsonObject
?: ToolJson.parseMaybeObject(record["display_metadata"])
val toolResultMeta =
displayMetadata?.get("tool_result_metadata") as? JsonObject
?: ToolJson.parseMaybeObject(displayMetadata?.get("tool_result_metadata"))
val metaDiff = toolResultMeta?.get("inline_diff") ?: displayMetadata?.get("inline_diff")
if (metaDiff is JsonPrimitive && metaDiff.isString && metaDiff.content.trim().isNotEmpty()) {
return stripInlineDiffChrome(metaDiff.content)
}
return ""
}
@@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List<DetailRow> =
* Map a free-form status string to a semantic tone. Recognises the app's
* common status vocabulary (enabled/active/running/ok → success, etc.).
*/
private fun toneForStatus(status: String?): DetailRowTone {
fun toneForStatus(status: String?): DetailRowTone {
if (status.isNullOrBlank()) return DetailRowTone.NONE
return when (status.lowercase()) {
"enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS
@@ -69,6 +69,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -273,7 +274,7 @@ fun PluginsScreen(
plugin = plugin,
state = state,
viewModel = viewModel,
onClick = { showDetail = plugin },
onClick = { viewModel.openPluginDetail(plugin) },
)
}
@@ -289,7 +290,10 @@ fun PluginsScreen(
)
}
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
OrphanPluginCard(
plugin = plugin,
onClick = { viewModel.openPluginDetail(plugin) },
)
}
}
}
@@ -313,11 +317,19 @@ fun PluginsScreen(
}
}
showDetail?.let { plugin ->
DetailDialog(
title = plugin.name,
rows = plugin.toDetailRows(),
onDismiss = { showDetail = null },
state.activeDetailPlugin?.let { plugin ->
PluginDetailDialog(
plugin = plugin,
agentPluginRow = state.pluginDetailRow,
isLoading = state.isPluginDetailLoading,
isSaving = state.isPluginDetailSaving,
edits = state.pluginDetailEdits,
onFieldChange = { key, value -> viewModel.setPluginDetailEdit(key, value) },
onSaveSettings = { viewModel.savePluginSettings() },
onDismiss = {
showDetail = null
viewModel.closePluginDetail()
},
)
}
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
@@ -16,7 +17,11 @@ import com.m57.hermescontrol.data.model.ProviderOption
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.data.ws.AgentPluginRow
import com.m57.hermescontrol.data.ws.PluginManageRepository
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
import com.m57.hermescontrol.data.ws.PluginSettingField
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad
@@ -29,6 +34,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.longOrNull
enum class PluginsTab {
INSTALLED,
@@ -64,6 +75,12 @@ data class PluginsUiState(
// Confirm remove dialog
val removeConfirmPlugin: String? = null,
val updateConsent: PluginUpdateConsent? = null,
// Active plugin detail with settings & diagnostics
val activeDetailPlugin: PluginInfo? = null,
val isPluginDetailLoading: Boolean = false,
val pluginDetailRow: AgentPluginRow? = null,
val pluginDetailEdits: Map<String, String> = emptyMap(),
val isPluginDetailSaving: Boolean = false,
// Rescan
val rescanBusy: Boolean = false,
// Catalog state
@@ -85,6 +102,7 @@ data class PluginsUiState(
class PluginsViewModel(
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
private val pluginManage: PluginManageRepository = PluginManageRepository(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(PluginsUiState())
@@ -109,6 +127,9 @@ class PluginsViewModel(
rowBusy = null,
removeConfirmPlugin = null,
updateConsent = null,
activeDetailPlugin = null,
pluginDetailRow = null,
pluginDetailEdits = emptyMap(),
rescanBusy = false,
)
}
@@ -684,6 +705,198 @@ class PluginsViewModel(
)
}
fun openPluginDetail(plugin: PluginInfo) {
_uiState.update {
it.copy(
activeDetailPlugin = plugin,
isPluginDetailLoading = true,
pluginDetailRow = null,
pluginDetailEdits = emptyMap(),
)
}
viewModelScope.launch {
try {
val profile = AuthManager.activeProfileId.value
val rows = pluginManage.listPlugins(profile)
val matchingRow = rows.firstOrNull { it.key == plugin.name || it.name == plugin.name }
val initialEdits = mutableMapOf<String, String>()
matchingRow?.settingsSchema?.forEach { field ->
if (field.type != PluginSettingFieldType.SECRET) {
val strVal =
when (val v = field.value ?: field.default) {
null -> ""
is JsonPrimitive -> v.content
else -> v.toString()
}
initialEdits[field.key] = strVal
}
}
_uiState.update {
it.copy(
isPluginDetailLoading = false,
pluginDetailRow = matchingRow,
pluginDetailEdits = initialEdits,
)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
_uiState.update {
it.copy(
isPluginDetailLoading = false,
pluginDetailRow = null,
)
}
}
}
}
fun closePluginDetail() {
_uiState.update {
it.copy(
activeDetailPlugin = null,
pluginDetailRow = null,
pluginDetailEdits = emptyMap(),
isPluginDetailSaving = false,
)
}
}
fun setPluginDetailEdit(
key: String,
value: String,
) {
_uiState.update {
it.copy(pluginDetailEdits = it.pluginDetailEdits + (key to value))
}
}
fun savePluginSettings() {
val plugin = _uiState.value.pluginDetailRow ?: return
val schema = plugin.settingsSchema ?: return
val canonicalKey = plugin.key ?: plugin.name
val currentEdits = _uiState.value.pluginDetailEdits
_uiState.update { it.copy(isPluginDetailSaving = true) }
viewModelScope.launch {
try {
val profile = AuthManager.activeProfileId.value
val valuesToSave = mutableMapOf<String, JsonElement>()
for (field in schema) {
val raw = currentEdits[field.key] ?: ""
when (field.type) {
PluginSettingFieldType.SECRET -> {
val envVar = field.env
if (raw.isNotBlank() && !envVar.isNullOrBlank()) {
val envResult =
safeApiCall {
ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key = envVar, value = raw))
}
if (envResult is NetworkResult.Failure) {
throw IllegalStateException(
"Failed to update secret $envVar: ${envResult.error.message}",
)
}
}
}
PluginSettingFieldType.STRING -> {
valuesToSave[field.key] = JsonPrimitive(raw)
}
PluginSettingFieldType.NUMBER -> {
val num =
raw.trim().toLongOrNull()
?: raw.trim().toDoubleOrNull()
if (num != null) {
valuesToSave[field.key] =
if (num is Long) JsonPrimitive(num) else JsonPrimitive(num as Double)
} else if (raw.trim().isNotEmpty()) {
throw IllegalArgumentException("${field.label}: expected a number")
}
}
PluginSettingFieldType.BOOLEAN -> {
valuesToSave[field.key] = JsonPrimitive(raw.trim().equals("true", ignoreCase = true))
}
PluginSettingFieldType.ENUM -> {
if (!field.choices.isNullOrEmpty() && raw.trim() !in field.choices) {
throw IllegalArgumentException(
"${field.label}: must be one of ${field.choices.joinToString()}",
)
}
valuesToSave[field.key] = JsonPrimitive(raw.trim())
}
PluginSettingFieldType.JSON -> {
val parsed =
if (raw.trim().isEmpty()) {
JsonPrimitive("")
} else {
Json.parseToJsonElement(raw.trim())
}
valuesToSave[field.key] = parsed
}
}
}
if (valuesToSave.isNotEmpty()) {
val result = pluginManage.saveSettings(canonicalKey, valuesToSave, profile)
if (!result.ok) {
throw IllegalStateException(result.error ?: "Failed to save settings")
}
if (result.plugin != null) {
val refreshed = result.plugin
val newEdits = mutableMapOf<String, String>()
refreshed.settingsSchema?.forEach { f ->
if (f.type != PluginSettingFieldType.SECRET) {
val strVal =
when (val v = f.value ?: f.default) {
null -> ""
is JsonPrimitive -> v.content
else -> v.toString()
}
newEdits[f.key] = strVal
}
}
_uiState.update {
it.copy(
isPluginDetailSaving = false,
pluginDetailRow = refreshed,
pluginDetailEdits = newEdits,
toastMessage = "Plugin settings saved",
)
}
return@launch
}
}
// If only secrets were saved or result plugin is null
_uiState.update {
it.copy(
isPluginDetailSaving = false,
pluginDetailEdits =
it.pluginDetailEdits.filterKeys { k ->
schema.none { f -> f.key == k && f.type == PluginSettingFieldType.SECRET }
},
toastMessage = "Plugin settings saved",
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.update {
it.copy(
isPluginDetailSaving = false,
toastMessage = "Failed to save settings: ${e.message}",
)
}
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -0,0 +1,397 @@
package com.m57.hermescontrol.ui.plugins.components
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.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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.compose.ui.window.Dialog
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.ws.AgentPluginRow
import com.m57.hermescontrol.data.ws.PluginServerRow
import com.m57.hermescontrol.data.ws.PluginServerState
import com.m57.hermescontrol.data.ws.PluginSettingField
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailRow
import com.m57.hermescontrol.ui.common.DetailRowTone
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.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.common.toneForStatus
@Composable
fun PluginDetailDialog(
plugin: PluginInfo,
agentPluginRow: AgentPluginRow?,
isLoading: Boolean,
isSaving: Boolean,
edits: Map<String, String>,
onFieldChange: (String, String) -> Unit,
onSaveSettings: () -> Unit,
onDismiss: () -> Unit,
) {
val statusColors = LocalHermesStatusColors.current
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
HermesScaffold(
title = { Text(plugin.name, style = MaterialTheme.typography.titleLarge) },
navigationIcon = NavIcon.Back(onDismiss),
isRefreshing = false,
) { _ ->
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Standard metadata rows
val rows = plugin.toDetailRows().filter { !it.value.isNullOrBlank() }
rows.forEachIndexed { index, (label, value, tone) ->
if (index > 0) {
HorizontalDivider()
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value ?: "",
style = MaterialTheme.typography.bodyMedium,
color =
when (tone) {
DetailRowTone.SUCCESS -> statusColors.success
DetailRowTone.WARNING -> statusColors.warning
DetailRowTone.ERROR -> statusColors.error
DetailRowTone.INFO -> statusColors.info
DetailRowTone.NEUTRAL -> statusColors.neutral
DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
if (isLoading) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.plugins_settings_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Diagnostics section (only if servers present)
val servers = agentPluginRow?.servers.orEmpty()
if (servers.isNotEmpty()) {
HorizontalDivider()
Text(
text = stringResource(R.string.plugins_diagnostics_heading),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
servers.forEach { server ->
PluginServerDiagnosticRow(server = server)
}
}
// Settings section (only if schema present)
val schema = agentPluginRow?.settingsSchema.orEmpty()
if (schema.isNotEmpty()) {
HorizontalDivider()
Text(
text = stringResource(R.string.plugins_settings_heading),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
schema.forEach { field ->
PluginSettingFieldRow(
field = field,
value = edits[field.key] ?: "",
enabled = !isSaving,
onChange = { onFieldChange(field.key, it) },
)
}
Button(
onClick = onSaveSettings,
enabled = !isSaving,
modifier = Modifier.align(Alignment.End),
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.plugins_settings_save))
}
}
// Close action
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
}
}
}
}
}
}
@Composable
private fun PluginServerDiagnosticRow(server: PluginServerRow) {
val statusColors = LocalHermesStatusColors.current
val (statusLabel, statusColor, badgeType) =
when (server.state) {
PluginServerState.CONNECTED -> {
Triple(
stringResource(R.string.plugins_server_state_connected),
statusColors.success,
StatusBadgeType.SUCCESS,
)
}
PluginServerState.APP_NOT_RUNNING -> {
Triple(
stringResource(R.string.plugins_server_state_app_not_running),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
PluginServerState.ENDPOINT_UNAVAILABLE -> {
Triple(
stringResource(R.string.plugins_server_state_endpoint_unavailable),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
PluginServerState.NO_INTERACTIVE_SESSION -> {
Triple(
stringResource(R.string.plugins_server_state_no_interactive_session),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
PluginServerState.VERSION_TOO_OLD -> {
Triple(
stringResource(R.string.plugins_server_state_version_too_old),
statusColors.error,
StatusBadgeType.ERROR,
)
}
PluginServerState.MISSING_APP -> {
Triple(
stringResource(R.string.plugins_server_state_missing_app),
statusColors.error,
StatusBadgeType.ERROR,
)
}
PluginServerState.UNKNOWN -> {
Triple(
stringResource(R.string.plugins_server_state_unknown),
statusColors.warning,
StatusBadgeType.WARNING,
)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = server.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
StatusBadge(text = statusLabel, status = badgeType)
}
if (server.sentence.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = server.sentence,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun PluginSettingFieldRow(
field: PluginSettingField,
value: String,
enabled: Boolean,
onChange: (String) -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = field.label.ifEmpty { field.key },
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
if (field.type == PluginSettingFieldType.SECRET) {
val isSet = field.hasValue == true
val badgeRes =
if (isSet) {
R.string.plugins_settings_secret_set
} else {
R.string.plugins_settings_secret_not_set
}
StatusBadge(
text = stringResource(badgeRes),
status = if (isSet) 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,
)
}
if (field.type == PluginSettingFieldType.SECRET && !field.env.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.plugins_settings_secret_hint, field.env),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp))
when (field.type) {
PluginSettingFieldType.ENUM -> {
ExposedDropdownField(
label = field.label.ifEmpty { field.key },
options = field.choices.orEmpty().ifEmpty { listOf(value) },
selectedValue = value,
onOptionSelected = onChange,
)
}
PluginSettingFieldType.BOOLEAN -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = value.equals("true", ignoreCase = true),
onCheckedChange = { onChange(it.toString()) },
enabled = enabled,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (value.equals("true", ignoreCase = true)) {
stringResource(R.string.memory_provider_bool_on)
} else {
stringResource(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.type == PluginSettingFieldType.SECRET) {
stringResource(R.string.memory_provider_secret_label)
} else {
field.label.ifEmpty { field.key }
},
)
},
singleLine = field.type != PluginSettingFieldType.JSON,
visualTransformation =
if (field.type == PluginSettingFieldType.SECRET) {
PasswordVisualTransformation()
} else {
androidx.compose.ui.text.input.VisualTransformation.None
},
keyboardOptions =
if (field.type == PluginSettingFieldType.NUMBER) {
KeyboardOptions(keyboardType = KeyboardType.Decimal)
} else {
KeyboardOptions.Default
},
)
}
}
}
}
}