feat(#58): implement skill markdown/yaml editor UI (#121)

This commit is contained in:
M57
2026-06-19 23:58:34 +03:00
committed by GitHub
parent 022e9de195
commit 46b24593f9
27 changed files with 399 additions and 15 deletions
@@ -0,0 +1,5 @@
package com.m57.hermescontrol.data.model
data class SaveSkillContentRequest(
val content: String,
)
@@ -5,4 +5,5 @@ data class Skill(
val description: String?, val description: String?,
val category: String?, val category: String?,
val enabled: Boolean, val enabled: Boolean,
val content: String? = null,
) )
@@ -0,0 +1,6 @@
package com.m57.hermescontrol.data.model
data class SkillContentResponse(
val name: String,
val content: String,
)
@@ -26,10 +26,12 @@ 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
import com.m57.hermescontrol.data.model.RawConfigResponse import com.m57.hermescontrol.data.model.RawConfigResponse
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
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
import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest
@@ -52,6 +54,17 @@ import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
interface HermesApiService { interface HermesApiService {
@GET("api/skills/{name}/content")
suspend fun getSkillContent(
@Path("name") name: String,
): Response<SkillContentResponse>
@PUT("api/skills/{name}/content")
suspend fun saveSkillContent(
@Path("name") name: String,
@Body body: SaveSkillContentRequest,
): Response<Unit>
@GET("api/status") @GET("api/status")
suspend fun getStatus(): Response<StatusResponse> suspend fun getStatus(): Response<StatusResponse>
@@ -4,19 +4,32 @@ import retrofit2.Response
import java.io.IOException import java.io.IOException
sealed interface NetworkResult<out T> { sealed interface NetworkResult<out T> {
data class Success<out T>(val data: T) : NetworkResult<T> data class Success<out T>(
val data: T,
) : NetworkResult<T>
data class Failure(val error: NetworkError) : NetworkResult<Nothing> data class Failure(
val error: NetworkError,
) : NetworkResult<Nothing>
} }
sealed interface NetworkError { sealed interface NetworkError {
val message: String val message: String
data class Http(val code: Int, override val message: String) : NetworkError data class Http(
val code: Int,
override val message: String,
) : NetworkError
data class Connection(override val message: String, val cause: IOException) : NetworkError data class Connection(
override val message: String,
val cause: IOException,
) : NetworkError
data class Unknown(override val message: String, val cause: Throwable) : NetworkError data class Unknown(
override val message: String,
val cause: Throwable,
) : NetworkError
} }
fun mapHttpError(code: Int): NetworkError { fun mapHttpError(code: Int): NetworkError {
@@ -40,6 +40,7 @@ class AchievementsViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -37,6 +37,7 @@ class ChannelsViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, platforms = result.data.orEmpty()) } _uiState.update { it.copy(isLoading = false, platforms = result.data.orEmpty()) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -63,6 +64,7 @@ class ChannelsViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "$platformId configured successfully") } _uiState.update { it.copy(toastMessage = "$platformId configured successfully") }
loadPlatforms() loadPlatforms()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to configure platform: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to configure platform: ${result.error.message}") }
} }
@@ -301,7 +301,9 @@ private fun parseToolOutput(content: String): ParsedToolOutput? {
val trimmed = content.trim() val trimmed = content.trim()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
return try { return try {
val element = com.google.gson.JsonParser.parseString(trimmed) val element =
com.google.gson.JsonParser
.parseString(trimmed)
if (element.isJsonObject) { if (element.isJsonObject) {
val obj = element.asJsonObject val obj = element.asJsonObject
@@ -310,10 +312,25 @@ private fun parseToolOutput(content: String): ParsedToolOutput? {
val hasExitCode = obj.has("exit_code") || obj.has("exitCode") val hasExitCode = obj.has("exit_code") || obj.has("exitCode")
if (hasStdout || hasStderr || hasExitCode) { if (hasStdout || hasStderr || hasExitCode) {
val stdout = obj.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } val stdout =
val stderr = obj.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } obj
.get("stdout")
?.takeIf { !it.isJsonNull }
?.asString
?.takeIf { it.isNotEmpty() }
val stderr =
obj
.get("stderr")
?.takeIf { !it.isJsonNull }
?.asString
?.takeIf { it.isNotEmpty() }
val exitCode = (obj.get("exit_code") ?: obj.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt val exitCode = (obj.get("exit_code") ?: obj.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt
val error = obj.get("error")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } val error =
obj
.get("error")
?.takeIf { !it.isJsonNull }
?.asString
?.takeIf { it.isNotEmpty() }
ParsedToolOutput( ParsedToolOutput(
isTerminal = true, isTerminal = true,
@@ -450,6 +450,7 @@ class ChatViewModel(
""".trimIndent() """.trimIndent()
addAssistantMessage(statusText) addAssistantMessage(statusText)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve status: ${result.error.message}") addAssistantMessage("Failed to retrieve status: ${result.error.message}")
} }
@@ -472,6 +473,7 @@ class ChatViewModel(
} }
addAssistantMessage("**Sessions List:**\n$sessionsStr") addAssistantMessage("**Sessions List:**\n$sessionsStr")
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
addAssistantMessage("Failed to list sessions: ${result.error.message}") addAssistantMessage("Failed to list sessions: ${result.error.message}")
} }
@@ -500,6 +502,7 @@ class ChatViewModel(
""".trimIndent() """.trimIndent()
addAssistantMessage(statsText) addAssistantMessage(statsText)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve system stats: ${result.error.message}") addAssistantMessage("Failed to retrieve system stats: ${result.error.message}")
} }
@@ -627,6 +630,7 @@ class ChatViewModel(
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -44,6 +44,7 @@ class ConfigViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -73,6 +74,7 @@ class ConfigViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -89,6 +89,7 @@ class ConnectViewModel : ViewModel() {
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null) it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
val msg = val msg =
when (val err = result.error) { when (val err = result.error) {
@@ -98,10 +99,17 @@ class ConnectViewModel : ViewModel() {
AuthManager.setToken(null) AuthManager.setToken(null)
"Invalid token (401 Unauthorized)" "Invalid token (401 Unauthorized)"
} }
403 -> "Access denied (403 Forbidden)"
else -> "Server returned HTTP ${err.code}" 403 -> {
"Access denied (403 Forbidden)"
}
else -> {
"Server returned HTTP ${err.code}"
}
} }
} }
is NetworkError.Connection -> { is NetworkError.Connection -> {
val causeMessage = err.cause.message ?: "" val causeMessage = err.cause.message ?: ""
when { when {
@@ -111,6 +119,7 @@ class ConnectViewModel : ViewModel() {
else -> "Connection failed: ${err.cause.message}" else -> "Connection failed: ${err.cause.message}"
} }
} }
is NetworkError.Unknown -> { is NetworkError.Unknown -> {
val causeMessage = err.cause.message ?: "" val causeMessage = err.cause.message ?: ""
when { when {
@@ -36,6 +36,7 @@ class CronJobsViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, jobs = result.data.orEmpty()) } _uiState.update { it.copy(isLoading = false, jobs = result.data.orEmpty()) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -102,6 +103,7 @@ class CronJobsViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Job triggered successfully") } _uiState.update { it.copy(toastMessage = "Job triggered successfully") }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") }
} }
@@ -37,6 +37,7 @@ class GatewayViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, status = result.data) } _uiState.update { it.copy(isLoading = false, status = result.data) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -78,6 +79,7 @@ class GatewayViewModel : ViewModel() {
} }
loadStatus() loadStatus()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -47,6 +47,7 @@ class KanbanViewModel : ViewModel() {
selectBoard(currentBoard) selectBoard(currentBoard)
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -92,6 +93,7 @@ class KanbanViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -129,6 +131,7 @@ class KanbanViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Task created successfully") } _uiState.update { it.copy(toastMessage = "Task created successfully") }
selectBoard(board) selectBoard(board)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") }
} }
@@ -44,6 +44,7 @@ class KeysViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -71,6 +72,7 @@ class KeysViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to reveal key: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to reveal key: ${result.error.message}") }
} }
@@ -100,6 +102,7 @@ class KeysViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Key updated successfully") } _uiState.update { it.copy(toastMessage = "Key updated successfully") }
loadKeys() loadKeys()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to update key: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to update key: ${result.error.message}") }
} }
@@ -37,6 +37,7 @@ class LogsViewModel : ViewModel() {
val logsList = body.lines ?: body.logs ?: emptyList() val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) } _uiState.update { it.copy(isLoading = false, logs = logsList) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -43,6 +43,7 @@ class McpServersViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -83,6 +84,7 @@ class McpServersViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}") revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
} }
@@ -106,6 +108,7 @@ class McpServersViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -135,6 +138,7 @@ class McpServersViewModel : ViewModel() {
} }
loadServers() loadServers()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -67,6 +67,7 @@ class ModelViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -98,6 +99,7 @@ class ModelViewModel : ViewModel() {
} }
return@launch return@launch
} }
is NetworkResult.Success -> { is NetworkResult.Success -> {
val activeProfileName = activeProfileNameResResult.data.active val activeProfileName = activeProfileNameResResult.data.active
val updateResResult = val updateResResult =
@@ -114,6 +116,7 @@ class ModelViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") } _uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") }
loadModelOptions() loadModelOptions()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -39,6 +39,7 @@ class PairingViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, pairing = result.data) } _uiState.update { it.copy(isLoading = false, pairing = result.data) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -92,6 +93,7 @@ class PairingViewModel : ViewModel() {
} }
loadPairing() loadPairing()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -37,6 +37,7 @@ class PluginsViewModel : ViewModel() {
val plugins = result.data.plugins.orEmpty() val plugins = result.data.plugins.orEmpty()
_uiState.update { it.copy(isLoading = false, plugins = plugins) } _uiState.update { it.copy(isLoading = false, plugins = plugins) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -98,6 +99,7 @@ class PluginsViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Plugin installed successfully") } _uiState.update { it.copy(toastMessage = "Plugin installed successfully") }
loadPlugins() loadPlugins()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to install plugin: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to install plugin: ${result.error.message}") }
} }
@@ -116,6 +118,7 @@ class PluginsViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } _uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
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}") }
} }
@@ -134,6 +137,7 @@ class PluginsViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") } _uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
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}") }
} }
@@ -79,6 +79,7 @@ class ProfilesViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Switched to profile $name") } _uiState.update { it.copy(toastMessage = "Switched to profile $name") }
loadProfiles() loadProfiles()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -107,6 +108,7 @@ class ProfilesViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -143,6 +145,7 @@ class ProfilesViewModel : ViewModel() {
} }
loadProfiles() loadProfiles()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -174,6 +177,7 @@ class ProfilesViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Model settings updated") } _uiState.update { it.copy(toastMessage = "Model settings updated") }
loadProfiles() loadProfiles()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -156,6 +156,7 @@ class SettingsViewModel : ViewModel() {
it.copy(isTesting = false, testResult = "✅ Connected successfully") it.copy(isTesting = false, testResult = "✅ Connected successfully")
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -2,20 +2,29 @@ package com.m57.hermescontrol.ui.skills
import android.widget.Toast import android.widget.Toast
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
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.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
@@ -25,8 +34,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
@@ -161,10 +174,24 @@ fun SkillsScreen(
} }
} }
} }
Switch( Row(
checked = skill.enabled, verticalAlignment = Alignment.CenterVertically,
onCheckedChange = { viewModel.toggleSkill(skill) }, horizontalArrangement = Arrangement.spacedBy(8.dp),
) ) {
IconButton(
onClick = { viewModel.loadSkillContent(skill.name) },
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Edit Skill File",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = skill.enabled,
onCheckedChange = { viewModel.toggleSkill(skill) },
)
}
} }
} }
} }
@@ -172,4 +199,155 @@ fun SkillsScreen(
} }
} }
} }
state.editingSkillName?.let { skillName ->
SkillEditorDialog(
skillName = skillName,
isLoading = state.isLoadingContent,
initialContent = state.skillContent,
isSaving = state.isSavingContent,
saveSuccess = state.saveContentSuccess,
onSave = { content -> viewModel.saveSkillContent(skillName, content) },
onDismiss = { viewModel.clearEditor() },
onClearSaveSuccess = { viewModel.clearSaveSuccess() },
)
}
}
@Composable
fun SkillEditorDialog(
skillName: String,
isLoading: Boolean,
initialContent: String?,
isSaving: Boolean,
saveSuccess: Boolean,
onSave: (String) -> Unit,
onDismiss: () -> Unit,
onClearSaveSuccess: () -> Unit,
) {
var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) }
var showDiscardConfirm by remember { mutableStateOf(false) }
val hasChanges =
remember(initialContent, contentText) {
val original = initialContent.orEmpty()
original != contentText
}
LaunchedEffect(saveSuccess) {
if (saveSuccess) {
onClearSaveSuccess()
onDismiss()
}
}
Dialog(
onDismissRequest = {
if (hasChanges) {
showDiscardConfirm = true
} else {
onDismiss()
}
},
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Card(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
HermesScaffold(
title = { Text("Edit: $skillName") },
onBack = {
if (hasChanges) {
showDiscardConfirm = true
} else {
onDismiss()
}
},
showBack = true,
actions = {
if (!isLoading) {
IconButton(
onClick = { onSave(contentText) },
enabled = !isSaving,
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(0.6f),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = "Save Changes",
)
}
}
}
},
) { padding ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(padding),
) {
when {
isLoading -> {
LoadingState()
}
else -> {
OutlinedTextField(
value = contentText,
onValueChange = { contentText = it },
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
textStyle =
TextStyle(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
placeholder = {
Text("Enter skill content...")
},
)
}
}
}
}
}
}
if (showDiscardConfirm) {
AlertDialog(
onDismissRequest = { showDiscardConfirm = false },
title = { Text("Discard changes?") },
text = { Text("You have unsaved changes. Are you sure you want to discard them?") },
confirmButton = {
TextButton(
onClick = {
showDiscardConfirm = false
onDismiss()
},
) {
Text("Discard")
}
},
dismissButton = {
TextButton(onClick = { showDiscardConfirm = false }) {
Text("Cancel")
}
},
)
}
} }
@@ -20,6 +20,11 @@ data class SkillsUiState(
val skills: List<Skill> = emptyList(), val skills: List<Skill> = emptyList(),
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
val isLoadingContent: Boolean = false,
val editingSkillName: String? = null,
val skillContent: String? = null,
val isSavingContent: Boolean = false,
val saveContentSuccess: Boolean = false,
) )
class SkillsViewModel : ViewModel() { class SkillsViewModel : ViewModel() {
@@ -37,6 +42,7 @@ class SkillsViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, skills = result.data.orEmpty()) } _uiState.update { it.copy(isLoading = false, skills = result.data.orEmpty()) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -90,6 +96,99 @@ class SkillsViewModel : ViewModel() {
} }
} }
fun loadSkillContent(skillName: String) {
_uiState.update {
it.copy(
isLoadingContent = true,
editingSkillName = skillName,
skillContent = null,
saveContentSuccess = false,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingContent = false,
skillContent = result.data?.content.orEmpty(),
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoadingContent = false,
editingSkillName = null,
toastMessage = "Failed to load skill content: ${result.error.message}",
)
}
}
}
}
}
fun saveSkillContent(
skillName: String,
content: String,
) {
_uiState.update { it.copy(isSavingContent = true, saveContentSuccess = false) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.saveSkillContent(
skillName,
com.m57.hermescontrol.data.model
.SaveSkillContentRequest(content),
)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSavingContent = false,
saveContentSuccess = true,
skillContent = content,
toastMessage = "Skill saved successfully",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSavingContent = false,
toastMessage = "Failed to save skill content: ${result.error.message}",
)
}
}
}
}
}
fun clearEditor() {
_uiState.update {
it.copy(
editingSkillName = null,
skillContent = null,
isLoadingContent = false,
isSavingContent = false,
saveContentSuccess = false,
)
}
}
fun clearSaveSuccess() {
_uiState.update { it.copy(saveContentSuccess = false) }
}
fun clearToast() { fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
@@ -51,6 +51,7 @@ class SystemViewModel : ViewModel() {
// Doctor is optional; many servers do not expose it. // Doctor is optional; many servers do not expose it.
loadDoctorReport() loadDoctorReport()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -87,6 +88,7 @@ class SystemViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Backup triggered successfully") } _uiState.update { it.copy(toastMessage = "Backup triggered successfully") }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${result.error.message}") }
} }
@@ -37,6 +37,7 @@ class ToolsetsViewModel : ViewModel() {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, toolsets = result.data.orEmpty()) } _uiState.update { it.copy(isLoading = false, toolsets = result.data.orEmpty()) }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -48,6 +48,7 @@ class WebhooksViewModel : ViewModel() {
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -78,6 +79,7 @@ class WebhooksViewModel : ViewModel() {
} }
loadWebhooks() loadWebhooks()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(