This commit is contained in:
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user