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
@@ -122,6 +122,55 @@ call unconditionally.
|
|||||||
- **`StateViews`** — `LoadingState`, `ErrorState`, `EmptyState`. Every data screen
|
- **`StateViews`** — `LoadingState`, `ErrorState`, `EmptyState`. Every data screen
|
||||||
must implement all three branches in its `when { }` block.
|
must implement all three branches in its `when { }` block.
|
||||||
|
|
||||||
|
### ⚠ HermesScaffold Padding Foot-Gun
|
||||||
|
|
||||||
|
**This is the #1 recurring bug in this codebase.** It has been re-introduced on 4+ screens
|
||||||
|
across 3+ PRs (Settings, Achievements, Webhooks, Config — PRs #445, #454, #455).
|
||||||
|
|
||||||
|
**Root cause:** `HermesScaffold` wraps content in an internal `Box(Modifier.padding(paddingValues))`
|
||||||
|
that already offsets for the top bar. But it also passes `paddingValues` into the content lambda,
|
||||||
|
which looks like it should be applied — and every new screen does exactly that:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
HermesScaffold(...) { paddingValues -> // ← scaffold already handles top bar offset via Box
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues), // ← ❌ double-stacked top padding
|
||||||
|
contentPadding = listContentPadding, // ← extra 8dp, now triple-stacked
|
||||||
|
) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The deeper issue:** Passing `paddingValues` into the lambda implies "you need to use this,"
|
||||||
|
when the scaffold has ALREADY pre-applied it in its own outer `Box`. This API design creates
|
||||||
|
a natural foot-gun: every developer instinctively adds `.padding(paddingValues)` on inner
|
||||||
|
content because it seems correct.
|
||||||
|
|
||||||
|
**Correct pattern — do NOT apply `paddingValues` on inner content:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ✅ List screens:
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
|
||||||
|
contentPadding = listContentPadding, // this is the ONLY padding needed
|
||||||
|
verticalArrangement = listItemSpacing,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Non-list screens (Column/Box wrapping):
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
|
||||||
|
) { ... }
|
||||||
|
|
||||||
|
// ✅ Loading/Error/Empty states — these DO need it:
|
||||||
|
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quick test:** If your screen's top gap is wider than CronJobsScreen's, you've double-stacked.
|
||||||
|
|
||||||
|
**See also:** `references/hermes-scaffold-padding.md` in the skill doc for the full
|
||||||
|
breakdown, edge cases, and timeline of previous occurrences.
|
||||||
|
|
||||||
### Room Persistence
|
### Room Persistence
|
||||||
|
|
||||||
- `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive
|
- `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ data class PluginInfo(
|
|||||||
val source: String?,
|
val source: String?,
|
||||||
@SerializedName("runtime_status") val runtimeStatus: String?,
|
@SerializedName("runtime_status") val runtimeStatus: String?,
|
||||||
@SerializedName("has_dashboard_manifest") val hasDashboardManifest: Boolean = false,
|
@SerializedName("has_dashboard_manifest") val hasDashboardManifest: Boolean = false,
|
||||||
|
@SerializedName("dashboard_manifest") val dashboardManifest: PluginManifestData? = null,
|
||||||
@SerializedName("can_remove") val canRemove: Boolean = false,
|
@SerializedName("can_remove") val canRemove: Boolean = false,
|
||||||
@SerializedName("can_update_git") val canUpdateGit: Boolean = false,
|
@SerializedName("can_update_git") val canUpdateGit: Boolean = false,
|
||||||
@SerializedName("auth_required") val authRequired: Boolean = false,
|
@SerializedName("auth_required") val authRequired: Boolean = false,
|
||||||
@@ -24,8 +25,50 @@ data class PluginInfo(
|
|||||||
runtimeStatus.equals("disabled", ignoreCase = true)
|
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(
|
data class PluginsHubResponse(
|
||||||
val plugins: List<PluginInfo>,
|
val plugins: List<PluginInfo>,
|
||||||
|
@SerializedName("orphan_dashboard_plugins") val orphanDashboardPlugins: List<DashboardPluginMeta> = emptyList(),
|
||||||
|
val providers: PluginsHubProviders? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AgentPluginInstallBody(
|
data class AgentPluginInstallBody(
|
||||||
@@ -33,3 +76,8 @@ data class AgentPluginInstallBody(
|
|||||||
val force: Boolean = false,
|
val force: Boolean = false,
|
||||||
val enable: Boolean = true,
|
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.PairingApproveRequest
|
||||||
import com.m57.hermescontrol.data.model.PairingResponse
|
import com.m57.hermescontrol.data.model.PairingResponse
|
||||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
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.PluginsHubResponse
|
||||||
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
||||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||||
@@ -319,6 +320,20 @@ interface HermesApiService {
|
|||||||
@Path("name", encoded = true) name: String,
|
@Path("name", encoded = true) name: String,
|
||||||
): Response<Unit>
|
): 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")
|
@GET("api/messaging/platforms")
|
||||||
suspend fun getMessagingPlatforms(
|
suspend fun getMessagingPlatforms(
|
||||||
@Query("profile") profile: String? = null,
|
@Query("profile") profile: String? = null,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.plugins
|
package com.m57.hermescontrol.ui.plugins
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -34,6 +49,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
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.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
@@ -69,117 +86,112 @@ fun PluginsScreen(
|
|||||||
|
|
||||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
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(
|
HermesScaffold(
|
||||||
title = { Text(stringResource(R.string.screen_plugins)) },
|
title = { Text(stringResource(R.string.screen_plugins)) },
|
||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
isRefreshing = state.isLoading,
|
|
||||||
onRefresh = { viewModel.loadPlugins() },
|
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
when {
|
PullToRefreshBox(
|
||||||
state.isLoading && state.plugins.isEmpty() -> {
|
isRefreshing = state.isLoading,
|
||||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
onRefresh = { viewModel.loadPlugins() },
|
||||||
}
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.isLoading && state.plugins.isEmpty() -> {
|
||||||
|
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||||
|
}
|
||||||
|
|
||||||
state.errorMessage != null -> {
|
state.errorMessage != null -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: "",
|
message = state.errorMessage ?: "",
|
||||||
onRetry = { viewModel.loadPlugins() },
|
onRetry = { viewModel.loadPlugins() },
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.plugins.isEmpty() -> {
|
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.plugins_empty_title),
|
title = stringResource(R.string.plugins_empty_title),
|
||||||
subtitle = stringResource(R.string.plugins_empty_desc),
|
subtitle = stringResource(R.string.plugins_empty_desc),
|
||||||
onAction = { viewModel.loadPlugins() },
|
onAction = { viewModel.loadPlugins() },
|
||||||
actionLabel = stringResource(R.string.content_desc_refresh),
|
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Box(Modifier.fillMaxSize()) {
|
LazyColumn(
|
||||||
if (state.isLoading) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
CircularProgressIndicator()
|
contentPadding = listContentPadding,
|
||||||
} else if (state.errorMessage != null) {
|
verticalArrangement = listItemSpacing,
|
||||||
Text(
|
) {
|
||||||
text = state.errorMessage ?: "",
|
// Provider selection section
|
||||||
color = MaterialTheme.colorScheme.error,
|
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
|
||||||
modifier = Modifier.padding(16.dp),
|
item(key = "providers") {
|
||||||
)
|
ProviderSelectionSection(state, viewModel)
|
||||||
} else {
|
}
|
||||||
LazyColumn(
|
}
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = listContentPadding,
|
// Install section
|
||||||
verticalArrangement = listItemSpacing,
|
item(key = "install") {
|
||||||
) {
|
InstallSection(state, viewModel)
|
||||||
item {
|
}
|
||||||
SearchBar(
|
|
||||||
query = query,
|
// Search bar
|
||||||
onQueryChange = { query = it },
|
item(key = "search") {
|
||||||
placeholder = "Search plugins...",
|
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 ->
|
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
OrphanPluginCard(plugin = plugin)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.model.PluginInfo
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -19,9 +21,35 @@ import kotlinx.coroutines.withContext
|
|||||||
data class PluginsUiState(
|
data class PluginsUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val plugins: List<PluginInfo> = emptyList(),
|
val plugins: List<PluginInfo> = emptyList(),
|
||||||
|
val orphanPlugins: List<PluginInfo> = emptyList(),
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: 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 {
|
class PluginsViewModel : ViewModel(), ToastHost {
|
||||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||||
@@ -33,7 +61,27 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
|||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||||
onSuccess = { data ->
|
onSuccess = { data ->
|
||||||
val plugins = data.plugins.orEmpty()
|
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 ->
|
onError = { errorMsg ->
|
||||||
_uiState.update {
|
_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) {
|
fun togglePlugin(plugin: PluginInfo) {
|
||||||
val originalEnabled = plugin.enabled
|
val originalEnabled = plugin.enabled
|
||||||
val targetEnabled = !originalEnabled
|
val targetEnabled = !originalEnabled
|
||||||
@@ -90,7 +257,6 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
|||||||
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
||||||
loadPlugins()
|
loadPlugins()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
|
_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 {
|
viewModelScope.launch {
|
||||||
|
setRowBusy(name)
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
|
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
|
||||||
@@ -107,11 +286,12 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
|||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
|
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
|
||||||
|
clearRowBusy(name)
|
||||||
loadPlugins()
|
loadPlugins()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") }
|
_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) {
|
fun updatePlugin(name: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
setRowBusy(name)
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
|
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
|
||||||
@@ -126,11 +307,39 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
|||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
||||||
|
clearRowBusy(name)
|
||||||
loadPlugins()
|
loadPlugins()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
|
_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() {
|
override fun clearToast() {
|
||||||
_uiState.update { it.copy(toastMessage = null) }
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<string name="content_desc_back">Back</string>
|
<string name="content_desc_back">Back</string>
|
||||||
<string name="content_desc_open_drawer">Open navigation drawer</string>
|
<string name="content_desc_open_drawer">Open navigation drawer</string>
|
||||||
<string name="content_desc_refresh">Refresh</string>
|
<string name="content_desc_refresh">Refresh</string>
|
||||||
|
<string name="common_save">Save</string>
|
||||||
|
<string name="common_cancel">Cancel</string>
|
||||||
<string name="content_desc_clear_search">Clear search</string>
|
<string name="content_desc_clear_search">Clear search</string>
|
||||||
|
|
||||||
<!-- General Errors -->
|
<!-- General Errors -->
|
||||||
@@ -360,6 +362,25 @@
|
|||||||
<string name="plugins_action_update">Update</string>
|
<string name="plugins_action_update">Update</string>
|
||||||
<string name="plugins_action_uninstall">Uninstall</string>
|
<string name="plugins_action_uninstall">Uninstall</string>
|
||||||
<string name="plugins_action_install">Install</string>
|
<string name="plugins_action_install">Install</string>
|
||||||
|
<string name="plugins_action_remove">Remove</string>
|
||||||
|
<string name="plugins_action_rescan">Rescan</string>
|
||||||
|
<string name="plugins_install_heading">Install Plugin</string>
|
||||||
|
<string name="plugins_install_hint">Install a plugin from a git/URL source</string>
|
||||||
|
<string name="plugins_force_reinstall">Force reinstall</string>
|
||||||
|
<string name="plugins_enable_after_install">Enable after install</string>
|
||||||
|
<string name="plugins_providers_heading">Plugin Providers</string>
|
||||||
|
<string name="plugins_providers_hint">Select active memory and context providers</string>
|
||||||
|
<string name="plugins_memory_provider_label">Memory Provider</string>
|
||||||
|
<string name="plugins_context_engine_label">Context Engine</string>
|
||||||
|
<string name="plugins_provider_defaults">(config defaults)</string>
|
||||||
|
<string name="plugins_remove_confirm_title">Remove Plugin</string>
|
||||||
|
<string name="plugins_remove_confirm_text">This will remove the "%1$s" plugin from your agent.</string>
|
||||||
|
<string name="plugins_auth_required">Auth Required</string>
|
||||||
|
<string name="plugins_auth_command_hint">Auth command: %1$s</string>
|
||||||
|
<string name="plugins_dashboard_slots">Dashboard slots: %1$s</string>
|
||||||
|
<string name="plugins_orphan_heading">Orphaned Dashboard Plugins</string>
|
||||||
|
<string name="plugins_show_in_sidebar">Show in sidebar</string>
|
||||||
|
<string name="plugins_hide_from_sidebar">Hide from sidebar</string>
|
||||||
|
|
||||||
<!-- McpServers Screen -->
|
<!-- McpServers Screen -->
|
||||||
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
||||||
|
|||||||
Reference in new issue
Block a user