feat(#446): plugins tab full parity — install from URL, provider selection, remove confirm, badges (#456)
* docs: document HermesScaffold padding foot-gun root cause in AGENTS.md * feat(#446): plugins tab full parity — install from URL, provider selection, remove confirm, badges - Add Install from URL section with force reinstall + enable after install toggles - Add Plugin Provider selection section (memory provider + context engine dropdowns) - Add rescan button in top bar actions - Add confirmation dialog before removing plugins - Show source badges, runtime status, auth badges on plugin cards - Show dashboard slots from plugin manifest - Add visibility toggle (show/hide in sidebar) for manifest plugins - Show orphan dashboard plugins section - Add PluginManifestData, PluginsHubProviders, ProviderOption models - Add rescanPlugins, savePluginProviders, setPluginVisibility API endpoints * fix(#446): fix CI compilation errors — DashboardPluginMeta mapping and @Composable context * fix(#446): pass missing PluginInfo ctor params for orphan plugin mapping * fix(#446): remove duplicate refresh button in plugins top bar Scaffold's onRefresh param added a second refresh icon alongside the rescan action button. Removed onRefresh/isRefreshing from HermesScaffold and moved pull-to-refresh into a manual PullToRefreshBox wrapping the content, keeping the rescan icon as the sole top-bar action. * fix(#446): remove rescan icon from plugins top bar Only one action icon left in top bar; pull-to-refresh (loadPlugins) remains the sole refresh mechanism. rescanPlugins() viewmodel logic is unchanged but now unreachable from the UI. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
0ee24144c0
commit
57a815c67e
6 files changed
+881
-109
No files matched your search
@@ -9,6 +9,7 @@ data class PluginInfo(
|
||||
val source: String?,
|
||||
@SerializedName("runtime_status") val runtimeStatus: String?,
|
||||
@SerializedName("has_dashboard_manifest") val hasDashboardManifest: Boolean = false,
|
||||
@SerializedName("dashboard_manifest") val dashboardManifest: PluginManifestData? = null,
|
||||
@SerializedName("can_remove") val canRemove: Boolean = false,
|
||||
@SerializedName("can_update_git") val canUpdateGit: Boolean = false,
|
||||
@SerializedName("auth_required") val authRequired: Boolean = false,
|
||||
@@ -24,8 +25,50 @@ data class PluginInfo(
|
||||
runtimeStatus.equals("disabled", ignoreCase = true)
|
||||
}
|
||||
|
||||
data class PluginManifestData(
|
||||
val name: String? = null,
|
||||
val label: String? = null,
|
||||
val description: String? = null,
|
||||
val icon: String? = null,
|
||||
val version: String? = null,
|
||||
val tab: PluginTabInfo? = null,
|
||||
val slots: List<String>? = null,
|
||||
val entry: String? = null,
|
||||
val css: String? = null,
|
||||
@SerializedName("has_api") val hasApi: Boolean = false,
|
||||
val source: String? = null,
|
||||
)
|
||||
|
||||
data class PluginTabInfo(
|
||||
val path: String? = null,
|
||||
val position: String? = null,
|
||||
val override: String? = null,
|
||||
val hidden: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardPluginMeta(
|
||||
val name: String? = null,
|
||||
val label: String? = null,
|
||||
val description: String? = null,
|
||||
val tab: PluginTabInfo? = null,
|
||||
)
|
||||
|
||||
data class PluginsHubProviders(
|
||||
@SerializedName("memory_provider") val memoryProvider: String? = null,
|
||||
@SerializedName("memory_options") val memoryOptions: List<ProviderOption> = emptyList(),
|
||||
@SerializedName("context_engine") val contextEngine: String? = null,
|
||||
@SerializedName("context_options") val contextOptions: List<ProviderOption> = emptyList(),
|
||||
)
|
||||
|
||||
data class ProviderOption(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
||||
data class PluginsHubResponse(
|
||||
val plugins: List<PluginInfo>,
|
||||
@SerializedName("orphan_dashboard_plugins") val orphanDashboardPlugins: List<DashboardPluginMeta> = emptyList(),
|
||||
val providers: PluginsHubProviders? = null,
|
||||
)
|
||||
|
||||
data class AgentPluginInstallBody(
|
||||
@@ -33,3 +76,8 @@ data class AgentPluginInstallBody(
|
||||
val force: Boolean = false,
|
||||
val enable: Boolean = true,
|
||||
)
|
||||
|
||||
data class PluginProvidersPutRequest(
|
||||
@SerializedName("memory_provider") val memoryProvider: String? = null,
|
||||
@SerializedName("context_engine") val contextEngine: String? = null,
|
||||
)
|
||||
@@ -29,6 +29,7 @@ import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||
import com.m57.hermescontrol.data.model.PairingResponse
|
||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||
@@ -319,6 +320,20 @@ interface HermesApiService {
|
||||
@Path("name", encoded = true) name: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/dashboard/plugins/rescan")
|
||||
suspend fun rescanPlugins(): Response<Unit>
|
||||
|
||||
@PUT("api/dashboard/plugin-providers")
|
||||
suspend fun savePluginProviders(
|
||||
@Body body: PluginProvidersPutRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/dashboard/plugins/{name}/visibility")
|
||||
suspend fun setPluginVisibility(
|
||||
@Path("name", encoded = true) name: String,
|
||||
@Body body: Map<String, Boolean>,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/messaging/platforms")
|
||||
suspend fun getMessagingPlatforms(
|
||||
@Query("profile") profile: String? = null,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.plugins
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -9,18 +8,34 @@ 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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.InstallDesktop
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -34,6 +49,8 @@ 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.PluginInfo
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -69,117 +86,112 @@ fun PluginsScreen(
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
// Remove confirmation dialog
|
||||
if (state.removeConfirmPlugin != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.cancelRemovePlugin() },
|
||||
title = { Text(stringResource(R.string.plugins_remove_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.plugins_remove_confirm_text,
|
||||
state.removeConfirmPlugin ?: "",
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.confirmRemovePlugin() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_action_remove))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.cancelRemovePlugin() }) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_plugins)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadPlugins() },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.plugins.isEmpty() -> {
|
||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadPlugins() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when {
|
||||
state.isLoading && state.plugins.isEmpty() -> {
|
||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadPlugins() },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
state.errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadPlugins() },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
state.plugins.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.plugins_empty_title),
|
||||
subtitle = stringResource(R.string.plugins_empty_desc),
|
||||
onAction = { viewModel.loadPlugins() },
|
||||
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.plugins_empty_title),
|
||||
subtitle = stringResource(R.string.plugins_empty_desc),
|
||||
onAction = { viewModel.loadPlugins() },
|
||||
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else if (state.errorMessage != null) {
|
||||
Text(
|
||||
text = state.errorMessage ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
item {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = "Search plugins...",
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
// Provider selection section
|
||||
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
|
||||
item(key = "providers") {
|
||||
ProviderSelectionSection(state, viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// Install section
|
||||
item(key = "install") {
|
||||
InstallSection(state, viewModel)
|
||||
}
|
||||
|
||||
// Search bar
|
||||
item(key = "search") {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = "Search plugins...",
|
||||
)
|
||||
}
|
||||
|
||||
// Plugin list
|
||||
items(filteredPlugins, key = { it.name }) { plugin ->
|
||||
PluginCard(plugin = plugin, state = state, viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Orphan dashboard plugins section
|
||||
if (state.orphanPlugins.isNotEmpty()) {
|
||||
item(key = "orphan-header") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_orphan_heading),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(filteredPlugins, key = { it.name }) { plugin ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = plugin.name, style = MaterialTheme.typography.titleMedium)
|
||||
plugin.version?.let {
|
||||
Text(
|
||||
text = "v$it",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (plugin.installed) {
|
||||
Switch(
|
||||
checked = plugin.enabled,
|
||||
onCheckedChange = { viewModel.togglePlugin(plugin) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = plugin.description ?: stringResource(R.string.plugins_no_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (plugin.installed) {
|
||||
OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) {
|
||||
Text(stringResource(R.string.plugins_action_update))
|
||||
}
|
||||
if (plugin.canRemove) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.uninstallPlugin(plugin.name) },
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_action_uninstall))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(onClick = { viewModel.activatePlugin(plugin) }) {
|
||||
Text(stringResource(R.string.plugins_action_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
||||
OrphanPluginCard(plugin = plugin)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,3 +200,413 @@ fun PluginsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProviderSelectionSection(
|
||||
state: PluginsUiState,
|
||||
viewModel: PluginsViewModel,
|
||||
) {
|
||||
val providerDefaultsLabel = stringResource(R.string.plugins_provider_defaults)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_providers_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_providers_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Memory provider dropdown
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_memory_provider_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PluginProviderDropdown(
|
||||
label = stringResource(R.string.plugins_memory_provider_label),
|
||||
options =
|
||||
listOf(
|
||||
providerDefaultsLabel,
|
||||
) + state.memoryOptions.map { it.name },
|
||||
selectedValue =
|
||||
if (state.isMemoryBuiltin) {
|
||||
providerDefaultsLabel
|
||||
} else {
|
||||
state.memoryProvider
|
||||
},
|
||||
onOptionSelected = { selected ->
|
||||
if (selected == providerDefaultsLabel) {
|
||||
viewModel.updateMemoryProvider(PluginsUiState.MEMORY_PROVIDER_BUILTIN)
|
||||
} else {
|
||||
viewModel.updateMemoryProvider(selected)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Context engine dropdown
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_context_engine_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PluginProviderDropdown(
|
||||
label = stringResource(R.string.plugins_context_engine_label),
|
||||
options = listOf("compressor") + state.contextOptions.map { it.name },
|
||||
selectedValue = state.contextEngine,
|
||||
onOptionSelected = { viewModel.updateContextEngine(it) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.savePluginProviders() },
|
||||
enabled = !state.providerBusy,
|
||||
) {
|
||||
if (state.providerBusy) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.common_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PluginProviderDropdown(
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selectedValue: String,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedValue.ifEmpty { options.firstOrNull() ?: "" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
onOptionSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun InstallSection(
|
||||
state: PluginsUiState,
|
||||
viewModel: PluginsViewModel,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_install_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_install_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.installUrl,
|
||||
onValueChange = { viewModel.updateInstallUrl(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("owner/repo, owner/repo/subdir, or https://...") },
|
||||
singleLine = true,
|
||||
enabled = !state.installBusy,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = state.installForce,
|
||||
onCheckedChange = { viewModel.updateInstallForce(it) },
|
||||
enabled = !state.installBusy,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_force_reinstall),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = state.installEnable,
|
||||
onCheckedChange = { viewModel.updateInstallEnable(it) },
|
||||
enabled = !state.installBusy,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_enable_after_install),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.installPluginFromUrl() },
|
||||
enabled = !state.installBusy && state.installUrl.isNotBlank(),
|
||||
) {
|
||||
if (state.installBusy) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.InstallDesktop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.plugins_action_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginCard(
|
||||
plugin: PluginInfo,
|
||||
state: PluginsUiState,
|
||||
viewModel: PluginsViewModel,
|
||||
) {
|
||||
val busy = state.rowBusy == plugin.name
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header row: name + toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = plugin.name, style = MaterialTheme.typography.titleMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Version badge
|
||||
plugin.version?.let {
|
||||
Text(
|
||||
text = "v$it",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Source badge
|
||||
plugin.source?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (plugin.installed) {
|
||||
Switch(
|
||||
checked = plugin.enabled,
|
||||
onCheckedChange = { viewModel.togglePlugin(plugin) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Description
|
||||
Text(
|
||||
text = plugin.description ?: stringResource(R.string.plugins_no_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
// Status badges
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val statusColor =
|
||||
when {
|
||||
plugin.enabled -> statusColors.success
|
||||
plugin.installed -> statusColors.warning
|
||||
else -> statusColors.error
|
||||
}
|
||||
Text(
|
||||
text = plugin.runtimeStatus ?: "inactive",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColor,
|
||||
)
|
||||
if (plugin.authRequired) {
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_auth_required),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard slots
|
||||
plugin.dashboardManifest?.slots?.let { slots ->
|
||||
if (slots.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_dashboard_slots, slots.joinToString(", ")),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth command hint
|
||||
if (plugin.authRequired && plugin.authCommand != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_auth_command_hint, plugin.authCommand),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Action buttons row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (plugin.installed) {
|
||||
// Visibility toggle (only for plugins with dashboard manifest)
|
||||
if (plugin.hasDashboardManifest) {
|
||||
IconButton(
|
||||
onClick = { viewModel.togglePluginVisibility(plugin) },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription =
|
||||
if (plugin.userHidden) {
|
||||
stringResource(R.string.plugins_show_in_sidebar)
|
||||
} else {
|
||||
stringResource(R.string.plugins_hide_from_sidebar)
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
// Update button
|
||||
if (plugin.canUpdateGit) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.updatePlugin(plugin.name) },
|
||||
enabled = !busy,
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_action_update))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
// Remove button with confirmation
|
||||
if (plugin.canRemove) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.requestRemovePlugin(plugin.name) },
|
||||
enabled = !busy,
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.plugins_action_uninstall))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.activatePlugin(plugin) },
|
||||
enabled = !busy,
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_action_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrphanPluginCard(plugin: PluginInfo) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = plugin.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
plugin.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.plugins
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
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
|
||||
@@ -19,9 +21,35 @@ import kotlinx.coroutines.withContext
|
||||
data class PluginsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val plugins: List<PluginInfo> = emptyList(),
|
||||
val orphanPlugins: List<PluginInfo> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
// Install state
|
||||
val installUrl: String = "",
|
||||
val installForce: Boolean = false,
|
||||
val installEnable: Boolean = true,
|
||||
val installBusy: Boolean = false,
|
||||
// Provider selection state
|
||||
val memoryProvider: String = "",
|
||||
val memoryOptions: List<ProviderOption> = emptyList(),
|
||||
val contextEngine: String = "compressor",
|
||||
val contextOptions: List<ProviderOption> = emptyList(),
|
||||
val providerBusy: Boolean = false,
|
||||
// Plugin row operation state
|
||||
val rowBusy: String? = null,
|
||||
// Confirm remove dialog
|
||||
val removeConfirmPlugin: String? = null,
|
||||
// Rescan
|
||||
val rescanBusy: Boolean = false,
|
||||
) {
|
||||
/** Built-in memory provider sentinel — empty string means "use config defaults" */
|
||||
val isMemoryBuiltin: Boolean
|
||||
get() = memoryProvider.isEmpty()
|
||||
|
||||
companion object {
|
||||
const val MEMORY_PROVIDER_BUILTIN = ""
|
||||
}
|
||||
}
|
||||
|
||||
class PluginsViewModel : ViewModel(), ToastHost {
|
||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||
@@ -33,7 +61,27 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
val plugins = data.plugins.orEmpty()
|
||||
_uiState.update { it.copy(isLoading = false, plugins = plugins) }
|
||||
val providers = data.providers
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
plugins = plugins,
|
||||
orphanPlugins =
|
||||
data.orphanDashboardPlugins.orEmpty().map {
|
||||
PluginInfo(
|
||||
name = it.name ?: "unknown",
|
||||
description = it.description ?: it.label,
|
||||
version = null,
|
||||
source = null,
|
||||
runtimeStatus = null,
|
||||
)
|
||||
},
|
||||
memoryProvider = providers?.memoryProvider ?: "",
|
||||
memoryOptions = providers?.memoryOptions.orEmpty(),
|
||||
contextEngine = providers?.contextEngine ?: "compressor",
|
||||
contextOptions = providers?.contextOptions.orEmpty(),
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
@@ -46,6 +94,125 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
)
|
||||
}
|
||||
|
||||
fun updateInstallUrl(url: String) {
|
||||
_uiState.update { it.copy(installUrl = url) }
|
||||
}
|
||||
|
||||
fun updateInstallForce(force: Boolean) {
|
||||
_uiState.update { it.copy(installForce = force) }
|
||||
}
|
||||
|
||||
fun updateInstallEnable(enable: Boolean) {
|
||||
_uiState.update { it.copy(installEnable = enable) }
|
||||
}
|
||||
|
||||
fun installPluginFromUrl() {
|
||||
val url = _uiState.value.installUrl.trim()
|
||||
if (url.isEmpty()) {
|
||||
_uiState.update { it.copy(toastMessage = "Please enter a plugin identifier") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(installBusy = true) }
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.installPlugin(
|
||||
com.m57.hermescontrol.data.model.AgentPluginInstallBody(
|
||||
identifier = url,
|
||||
force = _uiState.value.installForce,
|
||||
enable = _uiState.value.installEnable,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
installUrl = "",
|
||||
installBusy = false,
|
||||
toastMessage = "Plugin installed successfully",
|
||||
)
|
||||
}
|
||||
loadPlugins()
|
||||
}
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
installBusy = false,
|
||||
toastMessage = "Failed to install plugin: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMemoryProvider(provider: String) {
|
||||
_uiState.update { it.copy(memoryProvider = provider) }
|
||||
}
|
||||
|
||||
fun updateContextEngine(engine: String) {
|
||||
_uiState.update { it.copy(contextEngine = engine) }
|
||||
}
|
||||
|
||||
fun savePluginProviders() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(providerBusy = true) }
|
||||
val state = _uiState.value
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.savePluginProviders(
|
||||
PluginProvidersPutRequest(
|
||||
memoryProvider = if (state.isMemoryBuiltin) "" else state.memoryProvider,
|
||||
contextEngine = state.contextEngine,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(providerBusy = false, toastMessage = "Plugin providers saved") }
|
||||
loadPlugins()
|
||||
}
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
providerBusy = false,
|
||||
toastMessage = "Failed to save providers: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rescanPlugins() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(rescanBusy = true) }
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.rescanPlugins() }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(rescanBusy = false, toastMessage = "Plugins rescanned") }
|
||||
loadPlugins()
|
||||
}
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
rescanBusy = false,
|
||||
toastMessage = "Failed to rescan: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlugin(plugin: PluginInfo) {
|
||||
val originalEnabled = plugin.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
@@ -90,7 +257,6 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
||||
loadPlugins()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
|
||||
}
|
||||
@@ -98,8 +264,21 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPlugin(name: String) {
|
||||
/** Show confirmation dialog for removing a plugin */
|
||||
fun requestRemovePlugin(name: String) {
|
||||
_uiState.update { it.copy(removeConfirmPlugin = name) }
|
||||
}
|
||||
|
||||
/** Cancel the remove confirmation dialog */
|
||||
fun cancelRemovePlugin() {
|
||||
_uiState.update { it.copy(removeConfirmPlugin = null) }
|
||||
}
|
||||
|
||||
fun confirmRemovePlugin() {
|
||||
val name = _uiState.value.removeConfirmPlugin ?: return
|
||||
_uiState.update { it.copy(removeConfirmPlugin = null) }
|
||||
viewModelScope.launch {
|
||||
setRowBusy(name)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
|
||||
@@ -107,11 +286,12 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") }
|
||||
clearRowBusy(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +299,7 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
|
||||
fun updatePlugin(name: String) {
|
||||
viewModelScope.launch {
|
||||
setRowBusy(name)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
|
||||
@@ -126,11 +307,39 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
|
||||
clearRowBusy(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePluginVisibility(plugin: PluginInfo) {
|
||||
viewModelScope.launch {
|
||||
setRowBusy(plugin.name)
|
||||
val targetHidden = !plugin.userHidden
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.setPluginVisibility(
|
||||
plugin.name,
|
||||
mapOf("hidden" to targetHidden),
|
||||
)
|
||||
}
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin visibility updated") }
|
||||
clearRowBusy(plugin.name)
|
||||
loadPlugins()
|
||||
}
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to update visibility: ${result.error.message}") }
|
||||
clearRowBusy(plugin.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +365,14 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setRowBusy(name: String) {
|
||||
_uiState.update { it.copy(rowBusy = name) }
|
||||
}
|
||||
|
||||
private fun clearRowBusy(name: String) {
|
||||
_uiState.update { if (it.rowBusy == name) it.copy(rowBusy = null) else it }
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user