feat(memory): dedicated Memory screen (moved out of System tab) (#835)
* feat(memory): dedicated Memory screen — providers, builtin files & reset (moved from System tab) * feat(memory): move self-improvement & learning activity section from System tab to Memory screen * fix(memory): remove top padding, single-line reset buttons, providers spacing * fix(memory): tighten provider card padding * fix(memory): distinct AutoStories icon for Memory tab * chore: ignore local debug apk * feat(ui): fresh icon set for drawer screens * feat(ui): custom Neurology icon for Memory tab * fix(ui): theme-driven fill for NeurologyIcon (checkColorLiterals) The custom Neurology vector used SolidColor(Color.Black), tripping the hardcoded-color guard (issue #622). The fill only contributes alpha — Icon()'s tint replaces the RGB at draw time, mirroring Material's own icon set — so the literal belongs in theme/ like every other hardcoded color: new IconDefaultFill token, scoped there by the guard's design. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
fd5d851405
commit
7d00d7b81b
13 files changed
+764
-313
No files matched your search
@@ -28,3 +28,4 @@ ktlint
|
|||||||
# direnv
|
# direnv
|
||||||
.direnv/
|
.direnv/
|
||||||
hermes-chat-redesign.html
|
hermes-chat-redesign.html
|
||||||
|
app-debug-835.apk
|
||||||
@@ -39,6 +39,8 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object McpServersScreen : NavKey
|
@Serializable data object McpServersScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object MemoryScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object WebhooksScreen : NavKey
|
@Serializable data object WebhooksScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ModelScreen : NavKey
|
@Serializable data object ModelScreen : NavKey
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.m57.hermescontrol
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
|
||||||
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.BarChart
|
import androidx.compose.material.icons.filled.BarChart
|
||||||
@@ -11,23 +10,27 @@ import androidx.compose.material.icons.filled.Bolt
|
|||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Code
|
import androidx.compose.material.icons.filled.Code
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
|
||||||
import androidx.compose.material.icons.filled.Devices
|
import androidx.compose.material.icons.filled.Devices
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
import androidx.compose.material.icons.filled.EmojiEvents
|
import androidx.compose.material.icons.filled.EmojiEvents
|
||||||
import androidx.compose.material.icons.filled.Extension
|
import androidx.compose.material.icons.filled.Extension
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.HistoryEdu
|
import androidx.compose.material.icons.filled.HistoryEdu
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Inventory2
|
||||||
import androidx.compose.material.icons.filled.Key
|
import androidx.compose.material.icons.filled.Key
|
||||||
import androidx.compose.material.icons.filled.Memory
|
import androidx.compose.material.icons.filled.Memory
|
||||||
|
import androidx.compose.material.icons.filled.Power
|
||||||
import androidx.compose.material.icons.filled.Psychology
|
import androidx.compose.material.icons.filled.Psychology
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.ViewKanban
|
||||||
import androidx.compose.material.icons.filled.Webhook
|
import androidx.compose.material.icons.filled.Webhook
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import com.m57.hermescontrol.ui.common.NeurologyIcon
|
||||||
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
||||||
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
|
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
|
||||||
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
|
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
|
||||||
@@ -41,6 +44,7 @@ import com.m57.hermescontrol.ui.kanban.KanbanScreen as KanbanScreenContent
|
|||||||
import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent
|
import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent
|
||||||
import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent
|
import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent
|
||||||
import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
|
import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
|
||||||
|
import com.m57.hermescontrol.ui.memory.MemoryScreen as MemoryScreenContent
|
||||||
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
|
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
|
||||||
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
|
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
|
||||||
import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent
|
import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent
|
||||||
@@ -107,13 +111,13 @@ object ScreenRegistry {
|
|||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
KanbanScreen,
|
KanbanScreen,
|
||||||
R.string.screen_kanban,
|
R.string.screen_kanban,
|
||||||
Icons.Filled.Dashboard,
|
Icons.Filled.ViewKanban,
|
||||||
DrawerSection.AUTOMATE,
|
DrawerSection.AUTOMATE,
|
||||||
) { sessionId, openDrawer -> KanbanScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> KanbanScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
SkillsScreen,
|
SkillsScreen,
|
||||||
R.string.screen_skills,
|
R.string.screen_skills,
|
||||||
Icons.Filled.Extension,
|
Icons.Filled.Inventory2,
|
||||||
DrawerSection.CONFIGURE,
|
DrawerSection.CONFIGURE,
|
||||||
) { sessionId, openDrawer -> SkillsScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> SkillsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
@@ -125,7 +129,7 @@ object ScreenRegistry {
|
|||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
PluginsScreen,
|
PluginsScreen,
|
||||||
R.string.screen_plugins,
|
R.string.screen_plugins,
|
||||||
Icons.Filled.Memory,
|
Icons.Filled.Extension,
|
||||||
DrawerSection.CONFIGURE,
|
DrawerSection.CONFIGURE,
|
||||||
) { sessionId, openDrawer -> PluginsScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> PluginsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
@@ -137,9 +141,15 @@ object ScreenRegistry {
|
|||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
McpServersScreen,
|
McpServersScreen,
|
||||||
R.string.screen_mcp_servers,
|
R.string.screen_mcp_servers,
|
||||||
Icons.Filled.Dashboard,
|
Icons.Filled.Power,
|
||||||
DrawerSection.CONFIGURE,
|
DrawerSection.CONFIGURE,
|
||||||
) { sessionId, openDrawer -> McpServersScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> McpServersScreenContent(onOpenDrawer = openDrawer) },
|
||||||
|
ScreenDefinition(
|
||||||
|
MemoryScreen,
|
||||||
|
R.string.screen_memory,
|
||||||
|
NeurologyIcon,
|
||||||
|
DrawerSection.CONFIGURE,
|
||||||
|
) { sessionId, openDrawer -> MemoryScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
ModelScreen,
|
ModelScreen,
|
||||||
R.string.screen_models,
|
R.string.screen_models,
|
||||||
@@ -161,7 +171,7 @@ object ScreenRegistry {
|
|||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
ChannelsScreen,
|
ChannelsScreen,
|
||||||
R.string.screen_channels,
|
R.string.screen_channels,
|
||||||
Icons.AutoMirrored.Filled.ListAlt,
|
Icons.Filled.Sensors,
|
||||||
DrawerSection.CONFIGURE,
|
DrawerSection.CONFIGURE,
|
||||||
) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
@@ -179,7 +189,7 @@ object ScreenRegistry {
|
|||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
SystemScreen,
|
SystemScreen,
|
||||||
R.string.screen_system,
|
R.string.screen_system,
|
||||||
Icons.Filled.Info,
|
Icons.Filled.Dns,
|
||||||
DrawerSection.INSPECT,
|
DrawerSection.INSPECT,
|
||||||
) { sessionId, openDrawer -> SystemScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> SystemScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
|
|||||||
@@ -106,3 +106,10 @@ val CodeDiffDeleteBg = Color(0x26FF5C5C)
|
|||||||
val CodeDiffDeleteText = Color(0xFFFF5C5C)
|
val CodeDiffDeleteText = Color(0xFFFF5C5C)
|
||||||
val CodeDiffHunkBg = Color(0x264DA8FF)
|
val CodeDiffHunkBg = Color(0x264DA8FF)
|
||||||
val CodeDiffHunkText = Color(0xFF4DA8FF)
|
val CodeDiffHunkText = Color(0xFF4DA8FF)
|
||||||
|
|
||||||
|
// ── Custom vector icons ─────────────────────────────────────────────
|
||||||
|
// Default fill for hand-declared ImageVector icons (e.g. NeurologyIcon).
|
||||||
|
// Mirrors Material's own icon set: vectors are drawn opaque black and
|
||||||
|
// Icon()'s tint (LocalContentColor) replaces the RGB at draw time — only
|
||||||
|
// the alpha channel survives, so this must stay opaque.
|
||||||
|
val IconDefaultFill = Color.Black
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.m57.hermescontrol.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.theme.IconDefaultFill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Symbols "neurology" (outlined, 24dp) — the glyph ships on
|
||||||
|
* fonts.google.com but is NOT in the material-icons-extended snapshot the
|
||||||
|
* app depends on, so it is declared here as a custom vector from Google's
|
||||||
|
* original path data (viewBox 0 -960 960 960, translated into compose space).
|
||||||
|
*/
|
||||||
|
val NeurologyIcon: ImageVector by lazy {
|
||||||
|
ImageVector.Builder(
|
||||||
|
name = "Neurology",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 960f,
|
||||||
|
viewportHeight = 960f,
|
||||||
|
).apply {
|
||||||
|
addGroup(
|
||||||
|
name = "translate",
|
||||||
|
rotate = 0f,
|
||||||
|
pivotX = 0f,
|
||||||
|
pivotY = 0f,
|
||||||
|
scaleX = 1f,
|
||||||
|
scaleY = 1f,
|
||||||
|
translationX = 0f,
|
||||||
|
translationY = 960f,
|
||||||
|
clipPathData = emptyList(),
|
||||||
|
)
|
||||||
|
addPath(
|
||||||
|
pathData = addPathNodes(PATH),
|
||||||
|
pathFillType = PathFillType.NonZero,
|
||||||
|
name = "NeurologyPath",
|
||||||
|
fill = SolidColor(IconDefaultFill),
|
||||||
|
fillAlpha = 1f,
|
||||||
|
stroke = null,
|
||||||
|
strokeAlpha = 1f,
|
||||||
|
strokeLineWidth = 1f,
|
||||||
|
strokeLineCap = StrokeCap.Butt,
|
||||||
|
strokeLineJoin = StrokeJoin.Miter,
|
||||||
|
strokeLineMiter = 4f,
|
||||||
|
trimPathStart = 0f,
|
||||||
|
trimPathEnd = 1f,
|
||||||
|
trimPathOffset = 0f,
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PATH =
|
||||||
|
"M390-120q-51 0-88-35.5T260-241q-60-8-100-53t-40-106q0-21 5.5-41.5T142-480q-11-18-16.5-38t-5.5-42q0-61 40-105.5t99-52.5q3-51 41-86.5t90-35.5q26 0 48.5 10t41.5 27q18-17 41-27t49-10q52 0 89.5 35t40.5 86q59 8 99.5 53T840-560q0 22-5.5 42T818-480q11 18 16.5 38.5T840-400q0 62-40.5 106.5T699-241q-5 50-41.5 85.5T570-120q-25 0-48.5-9.5T480-156q-19 17-42 26.5t-48 9.5Zm130-590v460q0 21 14.5 35.5T570-200q20 0 34.5-16t15.5-36q-21-8-38.5-21.5T550-306q-10-14-7.5-30t16.5-26q14-10 30-7.5t26 16.5q11 16 28 24.5t37 8.5q33 0 56.5-23.5T760-400q0-5-.5-10t-2.5-10q-17 10-36.5 15t-40.5 5q-17 0-28.5-11.5T640-440q0-17 11.5-28.5T680-480q33 0 56.5-23.5T760-560q0-33-23.5-56T680-640q-11 18-28.5 31.5T613-587q-16 6-31-1t-20-23q-5-16 1.5-31t22.5-20q15-5 24.5-18t9.5-30q0-21-14.5-35.5T570-760q-21 0-35.5 14.5T520-710Zm-80 460v-460q0-21-14.5-35.5T390-760q-21 0-35.5 14.5T340-710q0 16 9 29.5t24 18.5q16 5 23 20t2 31q-6 16-21 23t-31 1q-21-8-38.5-21.5T279-640q-32 1-55.5 24.5T200-560q0 33 23.5 56.5T280-480q17 0 28.5 11.5T320-440q0 17-11.5 28.5T280-400q-21 0-40.5-5T203-420q-2 5-2.5 10t-.5 10q0 33 23.5 56.5T280-320q20 0 37-8.5t28-24.5q10-14 26-16.5t30 7.5q14 10 16.5 26t-7.5 30q-14 19-32 33t-39 22q1 20 16 35.5t35 15.5q21 0 35.5-14.5T440-250Zm40-230Z"
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
@file:OptIn(
|
||||||
|
androidx.compose.material3.ExperimentalMaterial3Api::class,
|
||||||
|
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
|
||||||
|
)
|
||||||
|
|
||||||
|
package com.m57.hermescontrol.ui.memory
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.MemoryProviderDetailKey
|
||||||
|
import com.m57.hermescontrol.NavigationController
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryProviderStatusRow
|
||||||
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
|
import com.m57.hermescontrol.ui.common.StatCard
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel
|
||||||
|
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory management home — owns the memory surface that used to live in the
|
||||||
|
* System tab (active provider, builtin files + reset) plus the provider list
|
||||||
|
* that drills into per-provider config/setup (issue #783).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MemoryScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
|
viewModel: MemoryViewModel = viewModel { MemoryViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var resetTarget by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||||
|
|
||||||
|
resetTarget?.let { target ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { resetTarget = null },
|
||||||
|
title = { Text(stringResource(R.string.memory_reset_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.memory_reset_confirm_desc, target)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
resetTarget = null
|
||||||
|
viewModel.resetMemory(target)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { resetTarget = null }) {
|
||||||
|
Text(stringResource(R.string.system_confirm_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HermesScaffold(
|
||||||
|
title = { Text(stringResource(R.string.screen_memory)) },
|
||||||
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
|
isRefreshing = state.isLoading,
|
||||||
|
onRefresh = { viewModel.load() },
|
||||||
|
modifier = modifier,
|
||||||
|
) { paddingValues ->
|
||||||
|
when {
|
||||||
|
state.isLoading && state.memory == null -> {
|
||||||
|
SkeletonListState(modifier = Modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.errorMessage != null && state.memory == null -> {
|
||||||
|
ErrorState(
|
||||||
|
message = state.errorMessage ?: "",
|
||||||
|
onRetry = { viewModel.load() },
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.memory == null -> {
|
||||||
|
EmptyState(
|
||||||
|
title = stringResource(R.string.memory_empty_title),
|
||||||
|
subtitle = stringResource(R.string.memory_empty_desc),
|
||||||
|
onAction = { viewModel.load() },
|
||||||
|
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
MemoryContent(
|
||||||
|
memory = state.memory!!,
|
||||||
|
learningGraph = state.learningGraph,
|
||||||
|
resetting = state.resetting,
|
||||||
|
onResetRequest = { resetTarget = it },
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemoryContent(
|
||||||
|
memory: com.m57.hermescontrol.data.model.MemoryResponse,
|
||||||
|
learningGraph: LearningGraphResponse?,
|
||||||
|
resetting: String?,
|
||||||
|
onResetRequest: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = listContentPadding,
|
||||||
|
verticalArrangement = listItemSpacing,
|
||||||
|
) {
|
||||||
|
// Active provider + builtin files
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
if (memory.active.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_builtin),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_active, memory.active),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
memory.builtin_files?.let { files ->
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.memory_builtin_sizes,
|
||||||
|
files.memory?.let { formatBytes(it) } ?: "?",
|
||||||
|
files.user?.let { formatBytes(it) } ?: "?",
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onResetRequest("memory") },
|
||||||
|
enabled = resetting == null,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_reset_memory),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onResetRequest("user") },
|
||||||
|
enabled = resetting == null,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_reset_user),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onResetRequest("all") },
|
||||||
|
enabled = resetting == null,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_reset_all),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
if (memory.providers.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_providers_heading).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(memory.providers, key = { it.name }) { provider ->
|
||||||
|
MemoryProviderRow(
|
||||||
|
provider = provider,
|
||||||
|
isActive = provider.name == memory.active,
|
||||||
|
onClick = {
|
||||||
|
NavigationController.navigateTo(
|
||||||
|
MemoryProviderDetailKey(
|
||||||
|
name = provider.name,
|
||||||
|
label = provider.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-Improvement & learning activity (moved from System tab)
|
||||||
|
item {
|
||||||
|
SelfImprovementSection(graph = learningGraph)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelfImprovementSection(graph: LearningGraphResponse?) {
|
||||||
|
SectionHeader(title = stringResource(R.string.memory_sec_self_improvement))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_self_improvement_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (graph == null || graph.nodes.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.memory_self_improvement_no_activity),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val skillNodes = graph.nodes.filter { it.kind == "skill" }
|
||||||
|
val memoryNodes = graph.nodes.filter { it.kind == "memory" }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
StatCard(
|
||||||
|
label = "Learned Skills",
|
||||||
|
value = "${skillNodes.size}",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
label = "Memories & Facts",
|
||||||
|
value = "${memoryNodes.size}",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Recent Agent Self-Improvement Events",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
val sortedNodes =
|
||||||
|
graph.nodes
|
||||||
|
.filter { it.timestamp != null || it.kind == "skill" }
|
||||||
|
.sortedByDescending { it.timestamp ?: 0L }
|
||||||
|
.take(8)
|
||||||
|
|
||||||
|
sortedNodes.forEach { node ->
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val icon = if (node.kind == "skill") "⚡" else "🧠"
|
||||||
|
Text(text = icon, fontSize = 16.sp)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = node.label,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val cat = node.category ?: node.kind
|
||||||
|
val creator = node.createdBy?.let { " • by $it" } ?: ""
|
||||||
|
Text(
|
||||||
|
text = "[$cat]$creator",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
node.useCount.takeIf { it > 0 }?.let { uses ->
|
||||||
|
StatusBadge(
|
||||||
|
text = "$uses uses",
|
||||||
|
status = StatusBadgeType.INFO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemoryProviderRow(
|
||||||
|
provider: MemoryProviderStatusRow,
|
||||||
|
isActive: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = provider.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (isActive) {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.memory_provider_active),
|
||||||
|
status = StatusBadgeType.SUCCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StatusBadge(
|
||||||
|
text = memoryProviderStatusLabel(provider.status),
|
||||||
|
status = memoryProviderStatusType(provider.status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (provider.description.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = provider.description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBytes(bytes: Long): String =
|
||||||
|
when {
|
||||||
|
bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||||
|
bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0)
|
||||||
|
else -> "$bytes B"
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.m57.hermescontrol.ui.memory
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class MemoryUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val memory: MemoryResponse? = null,
|
||||||
|
val learningGraph: LearningGraphResponse? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
val resetting: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory management home (moved out of the System tab) — active provider,
|
||||||
|
* builtin memory files with reset, and the provider list that drills into
|
||||||
|
* per-provider config/setup (issue #783). Deliberately no explicit IO hop:
|
||||||
|
* Retrofit suspend calls already run off the caller thread, keeping tests
|
||||||
|
* free of the static Dispatchers mock that bleeds across test classes.
|
||||||
|
*/
|
||||||
|
class MemoryViewModel : ViewModel(), ToastHost {
|
||||||
|
private val _uiState = MutableStateFlow(MemoryUiState())
|
||||||
|
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
if (_uiState.value.isLoading) return
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success ->
|
||||||
|
_uiState.update { it.copy(isLoading = false, memory = result.data) }
|
||||||
|
is NetworkResult.Failure ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = result.error.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadLearningGraph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLearningGraph() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() }
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
_uiState.update { it.copy(learningGraph = result.data) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetMemory(target: String) {
|
||||||
|
if (_uiState.value.resetting != null) return
|
||||||
|
_uiState.update { it.copy(resetting = target) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) }
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
resetting = null,
|
||||||
|
toastMessage = "Memory ($target) reset successfully",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NetworkResult.Failure ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
resetting = null,
|
||||||
|
toastMessage = "Failed to reset memory: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -455,7 +455,7 @@ private fun SetupStepRow(step: MemoryProviderSetupResult) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun memoryProviderStatusType(status: String): StatusBadgeType =
|
fun memoryProviderStatusType(status: String): StatusBadgeType =
|
||||||
when (status) {
|
when (status) {
|
||||||
"ready" -> StatusBadgeType.SUCCESS
|
"ready" -> StatusBadgeType.SUCCESS
|
||||||
"needs_config" -> StatusBadgeType.WARNING
|
"needs_config" -> StatusBadgeType.WARNING
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.Build
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.CloudDownload
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.DeleteForever
|
import androidx.compose.material.icons.filled.DeleteForever
|
||||||
import androidx.compose.material.icons.filled.Devices
|
import androidx.compose.material.icons.filled.Devices
|
||||||
import androidx.compose.material.icons.filled.HealthAndSafety
|
import androidx.compose.material.icons.filled.HealthAndSafety
|
||||||
@@ -49,7 +48,6 @@ 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.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -70,7 +68,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
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
|
||||||
@@ -152,29 +149,6 @@ fun SystemScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory reset confirmation
|
|
||||||
var resetTarget by remember { mutableStateOf<String?>(null) }
|
|
||||||
if (resetTarget != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { resetTarget = null },
|
|
||||||
title = { Text(stringResource(R.string.system_memory_reset_confirm_title)) },
|
|
||||||
text = { Text(stringResource(R.string.system_memory_reset_confirm_desc)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
resetTarget?.let { viewModel.resetMemory(it) }
|
|
||||||
resetTarget = null
|
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.action_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { resetTarget = null }) {
|
|
||||||
Text(stringResource(R.string.system_confirm_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Credential remove confirmation
|
// Credential remove confirmation
|
||||||
var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) }
|
var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) }
|
||||||
if (credToRemove != null) {
|
if (credToRemove != null) {
|
||||||
@@ -290,15 +264,9 @@ fun SystemScreen(
|
|||||||
// ── 3. Curator ─────────────────────────────────────────
|
// ── 3. Curator ─────────────────────────────────────────
|
||||||
curatorSection(state, spacing, statusColors, viewModel)
|
curatorSection(state, spacing, statusColors, viewModel)
|
||||||
|
|
||||||
// ── 3.5 Self-Improvement & Learning ───────────────────
|
|
||||||
selfImprovementSection(state, spacing, statusColors)
|
|
||||||
|
|
||||||
// ── 4. Gateway ─────────────────────────────────────────
|
// ── 4. Gateway ─────────────────────────────────────────
|
||||||
gatewaySection(state, spacing, statusColors, viewModel)
|
gatewaySection(state, spacing, statusColors, viewModel)
|
||||||
|
|
||||||
// ── 5. Memory ──────────────────────────────────────────
|
|
||||||
memorySection(state, spacing, resetTarget, { resetTarget = it }, viewModel)
|
|
||||||
|
|
||||||
// ── 6. Credentials ─────────────────────────────────────
|
// ── 6. Credentials ─────────────────────────────────────
|
||||||
credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it })
|
credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it })
|
||||||
|
|
||||||
@@ -712,122 +680,6 @@ private fun LazyListScope.curatorSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.selfImprovementSection(
|
|
||||||
state: SystemUiState,
|
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
|
||||||
statusColors: com.m57.hermescontrol.theme.HermesStatusColors,
|
|
||||||
) {
|
|
||||||
val graph = state.learningGraph
|
|
||||||
item {
|
|
||||||
SectionHeader(title = stringResource(R.string.system_sec_self_improvement))
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(spacing.md)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.system_self_improvement_desc),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
|
|
||||||
if (graph == null || graph.nodes.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.system_self_improvement_no_activity),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val skillNodes = graph.nodes.filter { it.kind == "skill" }
|
|
||||||
val memoryNodes = graph.nodes.filter { it.kind == "memory" }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
|
||||||
) {
|
|
||||||
StatCard(
|
|
||||||
label = "Learned Skills",
|
|
||||||
value = "${skillNodes.size}",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
StatCard(
|
|
||||||
label = "Memories & Facts",
|
|
||||||
value = "${memoryNodes.size}",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.md))
|
|
||||||
Text(
|
|
||||||
text = "Recent Agent Self-Improvement Events",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
|
|
||||||
val sortedNodes =
|
|
||||||
graph.nodes
|
|
||||||
.filter { it.timestamp != null || it.kind == "skill" }
|
|
||||||
.sortedByDescending { it.timestamp ?: 0L }
|
|
||||||
.take(8)
|
|
||||||
|
|
||||||
sortedNodes.forEach { node ->
|
|
||||||
Surface(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
val icon = if (node.kind == "skill") "⚡" else "🧠"
|
|
||||||
Text(text = icon, fontSize = 16.sp)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = node.label,
|
|
||||||
style =
|
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val cat = node.category ?: node.kind
|
|
||||||
val creator = node.createdBy?.let { " • by $it" } ?: ""
|
|
||||||
Text(
|
|
||||||
text = "[$cat]$creator",
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
node.useCount.takeIf { it > 0 }?.let { uses ->
|
|
||||||
StatusBadge(
|
|
||||||
text = "$uses uses",
|
|
||||||
status = StatusBadgeType.INFO,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListScope.gatewaySection(
|
private fun LazyListScope.gatewaySection(
|
||||||
state: SystemUiState,
|
state: SystemUiState,
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||||
@@ -980,100 +832,6 @@ private fun LazyListScope.gatewaySection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.memorySection(
|
|
||||||
state: SystemUiState,
|
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
|
||||||
resetTarget: String?,
|
|
||||||
onResetRequest: (String) -> Unit,
|
|
||||||
viewModel: SystemViewModel,
|
|
||||||
) {
|
|
||||||
state.memory?.let { memory ->
|
|
||||||
item {
|
|
||||||
SectionHeader(title = stringResource(R.string.system_sec_memory))
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(spacing.md)) {
|
|
||||||
// Active provider
|
|
||||||
memory.active?.let { active ->
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.system_memory_external, active),
|
|
||||||
value = "",
|
|
||||||
)
|
|
||||||
} ?: InfoRow(
|
|
||||||
label = stringResource(R.string.system_memory_builtin),
|
|
||||||
value = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Change in Plugins link
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.system_memory_change_plugins),
|
|
||||||
value = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Builtin file sizes
|
|
||||||
memory.builtin_files?.let { files ->
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
|
||||||
InfoRow(
|
|
||||||
label =
|
|
||||||
stringResource(
|
|
||||||
R.string.system_memory_builtin_label,
|
|
||||||
files.memory?.let { formatBytes(it) } ?: "?",
|
|
||||||
files.user?.let { formatBytes(it) } ?: "?",
|
|
||||||
),
|
|
||||||
value = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reset buttons
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
|
||||||
) {
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { onResetRequest("memory") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = actionButtonPadding,
|
|
||||||
) {
|
|
||||||
ActionButtonContent(
|
|
||||||
icon = Icons.Filled.Delete,
|
|
||||||
text = stringResource(R.string.system_memory_reset_memory),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { onResetRequest("user") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = actionButtonPadding,
|
|
||||||
) {
|
|
||||||
ActionButtonContent(
|
|
||||||
icon = Icons.Filled.Delete,
|
|
||||||
text = stringResource(R.string.system_memory_reset_user),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { onResetRequest("all") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = actionButtonPadding,
|
|
||||||
) {
|
|
||||||
ActionButtonContent(
|
|
||||||
icon = Icons.Filled.DeleteForever,
|
|
||||||
text = stringResource(R.string.system_memory_reset_all),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListScope.credentialsSection(
|
private fun LazyListScope.credentialsSection(
|
||||||
state: SystemUiState,
|
state: SystemUiState,
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import com.m57.hermescontrol.data.model.CuratorResponse
|
|||||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
import com.m57.hermescontrol.data.model.DoctorResponse
|
||||||
import com.m57.hermescontrol.data.model.HookResponse
|
import com.m57.hermescontrol.data.model.HookResponse
|
||||||
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
|
||||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
|
||||||
import com.m57.hermescontrol.data.model.PortalResponse
|
import com.m57.hermescontrol.data.model.PortalResponse
|
||||||
import com.m57.hermescontrol.data.model.StatusResponse
|
import com.m57.hermescontrol.data.model.StatusResponse
|
||||||
import com.m57.hermescontrol.data.model.SystemStatsResponse
|
import com.m57.hermescontrol.data.model.SystemStatsResponse
|
||||||
@@ -40,8 +38,6 @@ data class SystemUiState(
|
|||||||
val stats: SystemStatsResponse? = null,
|
val stats: SystemStatsResponse? = null,
|
||||||
val portal: PortalResponse? = null,
|
val portal: PortalResponse? = null,
|
||||||
val curator: CuratorResponse? = null,
|
val curator: CuratorResponse? = null,
|
||||||
val memory: MemoryResponse? = null,
|
|
||||||
val learningGraph: LearningGraphResponse? = null,
|
|
||||||
val credentials: List<CredentialPoolProvider> = emptyList(),
|
val credentials: List<CredentialPoolProvider> = emptyList(),
|
||||||
val checkpoints: CheckpointsResponse? = null,
|
val checkpoints: CheckpointsResponse? = null,
|
||||||
val hooks: HookResponse? = null,
|
val hooks: HookResponse? = null,
|
||||||
@@ -101,8 +97,7 @@ class SystemViewModel :
|
|||||||
val statusDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } }
|
val statusDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } }
|
||||||
val portalDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPortal() } }
|
val portalDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPortal() } }
|
||||||
val curatorDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } }
|
val curatorDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } }
|
||||||
val memoryDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } }
|
|
||||||
val learningDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
|
|
||||||
val credDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } }
|
val credDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } }
|
||||||
val checkpointsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCheckpoints() } }
|
val checkpointsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCheckpoints() } }
|
||||||
val hooksDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } }
|
val hooksDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } }
|
||||||
@@ -114,8 +109,7 @@ class SystemViewModel :
|
|||||||
val statusResult = statusDeferred.await()
|
val statusResult = statusDeferred.await()
|
||||||
val portalResult = portalDeferred.await()
|
val portalResult = portalDeferred.await()
|
||||||
val curatorResult = curatorDeferred.await()
|
val curatorResult = curatorDeferred.await()
|
||||||
val memoryResult = memoryDeferred.await()
|
|
||||||
val learningResult = learningDeferred.await()
|
|
||||||
val credResult = credDeferred.await()
|
val credResult = credDeferred.await()
|
||||||
val checkpointsResult = checkpointsDeferred.await()
|
val checkpointsResult = checkpointsDeferred.await()
|
||||||
val hooksResult = hooksDeferred.await()
|
val hooksResult = hooksDeferred.await()
|
||||||
@@ -129,8 +123,6 @@ class SystemViewModel :
|
|||||||
status = (statusResult as? NetworkResult.Success)?.data,
|
status = (statusResult as? NetworkResult.Success)?.data,
|
||||||
portal = (portalResult as? NetworkResult.Success)?.data,
|
portal = (portalResult as? NetworkResult.Success)?.data,
|
||||||
curator = (curatorResult as? NetworkResult.Success)?.data,
|
curator = (curatorResult as? NetworkResult.Success)?.data,
|
||||||
memory = (memoryResult as? NetworkResult.Success)?.data,
|
|
||||||
learningGraph = (learningResult as? NetworkResult.Success)?.data,
|
|
||||||
credentials =
|
credentials =
|
||||||
((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(),
|
((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(),
|
||||||
checkpoints = (checkpointsResult as? NetworkResult.Success)?.data,
|
checkpoints = (checkpointsResult as? NetworkResult.Success)?.data,
|
||||||
@@ -148,7 +140,6 @@ class SystemViewModel :
|
|||||||
"status" to statusResult,
|
"status" to statusResult,
|
||||||
"portal" to portalResult,
|
"portal" to portalResult,
|
||||||
"curator" to curatorResult,
|
"curator" to curatorResult,
|
||||||
"memory" to memoryResult,
|
|
||||||
"credentials" to credResult,
|
"credentials" to credResult,
|
||||||
"checkpoints" to checkpointsResult,
|
"checkpoints" to checkpointsResult,
|
||||||
"hooks" to hooksResult,
|
"hooks" to hooksResult,
|
||||||
@@ -193,15 +184,6 @@ class SystemViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMemory() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } }
|
|
||||||
if (result is NetworkResult.Success) {
|
|
||||||
_uiState.update { it.copy(memory = result.data) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadStatus() {
|
fun loadStatus() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } }
|
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } }
|
||||||
@@ -318,27 +300,6 @@ class SystemViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Memory actions ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun resetMemory(target: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) }
|
|
||||||
}
|
|
||||||
when (result) {
|
|
||||||
is NetworkResult.Success -> {
|
|
||||||
_uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") }
|
|
||||||
loadMemory()
|
|
||||||
}
|
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to reset memory: ${result.error.message}") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Credential pool actions ────────────────────────────────────────
|
// ── Credential pool actions ────────────────────────────────────────
|
||||||
|
|
||||||
fun updateCredProvider(v: String) {
|
fun updateCredProvider(v: String) {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
<string name="screen_plugins">Plugins</string>
|
<string name="screen_plugins">Plugins</string>
|
||||||
<string name="screen_config">Config</string>
|
<string name="screen_config">Config</string>
|
||||||
<string name="screen_mcp_servers">MCP Servers</string>
|
<string name="screen_mcp_servers">MCP Servers</string>
|
||||||
|
<string name="screen_memory">Memory</string>
|
||||||
<string name="screen_models">Models</string>
|
<string name="screen_models">Models</string>
|
||||||
<string name="screen_pairing">Pairing</string>
|
<string name="screen_pairing">Pairing</string>
|
||||||
<string name="screen_keys">Keys</string>
|
<string name="screen_keys">Keys</string>
|
||||||
@@ -366,9 +367,9 @@
|
|||||||
<!-- Curator section -->
|
<!-- Curator section -->
|
||||||
<string name="system_sec_curator">Skill curator</string>
|
<string name="system_sec_curator">Skill curator</string>
|
||||||
<!-- Self-Improvement & Learning Activity -->
|
<!-- Self-Improvement & Learning Activity -->
|
||||||
<string name="system_sec_self_improvement">Self-Improvement & Learning Activity</string>
|
<string name="memory_sec_self_improvement">Self-Improvement & Learning Activity</string>
|
||||||
<string name="system_self_improvement_desc">Chronological log of agent skills, memories, and fact store updates learned over time.</string>
|
<string name="memory_self_improvement_desc">Chronological log of agent skills, memories, and fact store updates learned over time.</string>
|
||||||
<string name="system_self_improvement_no_activity">No self-improvement events recorded yet.</string>
|
<string name="memory_self_improvement_no_activity">No self-improvement events recorded yet.</string>
|
||||||
<string name="system_status_active">active</string>
|
<string name="system_status_active">active</string>
|
||||||
<string name="system_status_paused">paused</string>
|
<string name="system_status_paused">paused</string>
|
||||||
<string name="system_status_disabled">disabled</string>
|
<string name="system_status_disabled">disabled</string>
|
||||||
@@ -388,16 +389,18 @@
|
|||||||
<string name="system_gateway_stop">Stop</string>
|
<string name="system_gateway_stop">Stop</string>
|
||||||
|
|
||||||
<!-- Memory section -->
|
<!-- Memory section -->
|
||||||
<string name="system_sec_memory">Memory</string>
|
<string name="memory_active">Active: %1$s</string>
|
||||||
<string name="system_memory_external">External provider: %1$s</string>
|
<string name="memory_builtin">Built-in memory</string>
|
||||||
<string name="system_memory_builtin">built-in only</string>
|
<string name="memory_builtin_sizes">MEMORY.md: %1$s · USER.md: %2$s</string>
|
||||||
<string name="system_memory_change_plugins">Change in Plugins</string>
|
<string name="memory_reset_memory">Reset MEMORY.md</string>
|
||||||
<string name="system_memory_builtin_label">Built-in files — MEMORY.md: %1$s · USER.md: %2$s</string>
|
<string name="memory_reset_user">Reset USER.md</string>
|
||||||
<string name="system_memory_reset_memory">Reset MEMORY.md</string>
|
<string name="memory_reset_all">Reset all</string>
|
||||||
<string name="system_memory_reset_user">Reset USER.md</string>
|
<string name="memory_reset_confirm_title">Reset memory</string>
|
||||||
<string name="system_memory_reset_all">Reset all</string>
|
<string name="memory_reset_confirm_desc">This permanently erases the %1$s built-in memory file(s). This cannot be undone.</string>
|
||||||
<string name="system_memory_reset_confirm_title">Reset memory</string>
|
<string name="memory_empty_title">No memory data</string>
|
||||||
<string name="system_memory_reset_confirm_desc">This permanently erases the selected built-in memory files. This cannot be undone.</string>
|
<string name="memory_empty_desc">Could not load memory status.</string>
|
||||||
|
<string name="memory_providers_heading">Providers</string>
|
||||||
|
<string name="memory_provider_active">Active</string>
|
||||||
|
|
||||||
<!-- Credential pool section -->
|
<!-- Credential pool section -->
|
||||||
<string name="system_sec_credentials">Credential pool</string>
|
<string name="system_sec_credentials">Credential pool</string>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.m57.hermescontrol.ui.memory
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryProviderStatusRow
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory management home (moved out of the System tab). No
|
||||||
|
* mockkStatic(Dispatchers) — the ViewModel does no explicit IO hop, so
|
||||||
|
* setMain(StandardTestDispatcher) alone is deterministic.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MemoryViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private lateinit var mockApi: HermesApiService
|
||||||
|
|
||||||
|
private val memoryResponse =
|
||||||
|
MemoryResponse(
|
||||||
|
active = "web",
|
||||||
|
builtin_files =
|
||||||
|
com.m57.hermescontrol.data.model.BuiltinFileSizes(
|
||||||
|
memory = 1024,
|
||||||
|
user = 2048,
|
||||||
|
),
|
||||||
|
providers =
|
||||||
|
listOf(
|
||||||
|
MemoryProviderStatusRow(name = "web", status = "ready"),
|
||||||
|
MemoryProviderStatusRow(name = "tts", status = "needs_config"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
mockkObject(ApiClient)
|
||||||
|
mockApi = mockk(relaxed = true)
|
||||||
|
every { ApiClient.hermesApi } returns mockApi
|
||||||
|
coEvery { mockApi.getMemory() } returns Response.success(memoryResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load fetches memory status with providers`() {
|
||||||
|
val vm = MemoryViewModel()
|
||||||
|
vm.load()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals("web", state.memory?.active)
|
||||||
|
assertEquals(2, state.memory?.providers?.size)
|
||||||
|
assertEquals(1024L, state.memory?.builtin_files?.memory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load also fetches learning graph for self-improvement section`() {
|
||||||
|
val graph = com.m57.hermescontrol.data.model.LearningGraphResponse(nodes = emptyList())
|
||||||
|
coEvery { mockApi.getLearningGraph() } returns Response.success(graph)
|
||||||
|
val vm = MemoryViewModel()
|
||||||
|
vm.load()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockApi.getLearningGraph() }
|
||||||
|
assertEquals(graph, vm.uiState.value.learningGraph)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load failure surfaces error message`() {
|
||||||
|
coEvery { mockApi.getMemory() } returns
|
||||||
|
Response.error(500, "boom".toResponseBody())
|
||||||
|
val vm = MemoryViewModel()
|
||||||
|
vm.load()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(vm.uiState.value.memory)
|
||||||
|
assertTrue(vm.uiState.value.errorMessage.orEmpty().isNotBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resetMemory posts target and reloads`() {
|
||||||
|
coEvery { mockApi.resetMemory(mapOf("target" to "all")) } returns
|
||||||
|
Response.success(emptyMap<String, String>())
|
||||||
|
val vm = MemoryViewModel()
|
||||||
|
vm.load()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
vm.resetMemory("all")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockApi.resetMemory(mapOf("target" to "all")) }
|
||||||
|
assertEquals("Memory (all) reset successfully", vm.uiState.value.toastMessage)
|
||||||
|
// One load + one reload after reset.
|
||||||
|
coVerify(exactly = 2) { mockApi.getMemory() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resetMemory failure shows error toast`() {
|
||||||
|
coEvery { mockApi.resetMemory(mapOf("target" to "memory")) } returns
|
||||||
|
Response.error(500, "boom".toResponseBody())
|
||||||
|
val vm = MemoryViewModel()
|
||||||
|
vm.resetMemory("memory")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory"))
|
||||||
|
assertNull(vm.uiState.value.resetting)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,11 +48,4 @@ class SystemViewModelTest {
|
|||||||
viewModel.closeUpdateConfirm()
|
viewModel.closeUpdateConfirm()
|
||||||
assertFalse(viewModel.uiState.value.updateConfirmOpen)
|
assertFalse(viewModel.uiState.value.updateConfirmOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `initial uiState has null learningGraph`() {
|
|
||||||
val viewModel = SystemViewModel()
|
|
||||||
|
|
||||||
org.junit.Assert.assertNull(viewModel.uiState.value.learningGraph)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user