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

This commit is contained in:
M57 authored and GitHub committed 2026-06-19 23:58:34 +03:00
1 parent 022e9de195
commit 46b24593f9
27 files changed
+399 -15

No files matched your search

@@ -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 category: String?,
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.ProfilesResponse
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.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
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.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
@@ -52,6 +54,17 @@ import retrofit2.http.Path
import retrofit2.http.Query
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")
suspend fun getStatus(): Response<StatusResponse>
@@ -4,19 +4,32 @@ import retrofit2.Response
import java.io.IOException
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 {
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 {
@@ -40,6 +40,7 @@ class AchievementsViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -37,6 +37,7 @@ class ChannelsViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, platforms = result.data.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -63,6 +64,7 @@ class ChannelsViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "$platformId configured successfully") }
loadPlatforms()
}
is NetworkResult.Failure -> {
_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()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
return try {
val element = com.google.gson.JsonParser.parseString(trimmed)
val element =
com.google.gson.JsonParser
.parseString(trimmed)
if (element.isJsonObject) {
val obj = element.asJsonObject
@@ -310,10 +312,25 @@ private fun parseToolOutput(content: String): ParsedToolOutput? {
val hasExitCode = obj.has("exit_code") || obj.has("exitCode")
if (hasStdout || hasStderr || hasExitCode) {
val stdout = obj.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val stderr = obj.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val stdout =
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 error = obj.get("error")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val error =
obj
.get("error")
?.takeIf { !it.isJsonNull }
?.asString
?.takeIf { it.isNotEmpty() }
ParsedToolOutput(
isTerminal = true,
@@ -450,6 +450,7 @@ class ChatViewModel(
""".trimIndent()
addAssistantMessage(statusText)
}
is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve status: ${result.error.message}")
}
@@ -472,6 +473,7 @@ class ChatViewModel(
}
addAssistantMessage("**Sessions List:**\n$sessionsStr")
}
is NetworkResult.Failure -> {
addAssistantMessage("Failed to list sessions: ${result.error.message}")
}
@@ -500,6 +502,7 @@ class ChatViewModel(
""".trimIndent()
addAssistantMessage(statsText)
}
is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve system stats: ${result.error.message}")
}
@@ -627,6 +630,7 @@ class ChatViewModel(
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -44,6 +44,7 @@ class ConfigViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -73,6 +74,7 @@ class ConfigViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -89,6 +89,7 @@ class ConnectViewModel : ViewModel() {
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
}
}
is NetworkResult.Failure -> {
val msg =
when (val err = result.error) {
@@ -98,10 +99,17 @@ class ConnectViewModel : ViewModel() {
AuthManager.setToken(null)
"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 -> {
val causeMessage = err.cause.message ?: ""
when {
@@ -111,6 +119,7 @@ class ConnectViewModel : ViewModel() {
else -> "Connection failed: ${err.cause.message}"
}
}
is NetworkError.Unknown -> {
val causeMessage = err.cause.message ?: ""
when {
@@ -36,6 +36,7 @@ class CronJobsViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, jobs = result.data.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -102,6 +103,7 @@ class CronJobsViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Job triggered successfully") }
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") }
}
@@ -37,6 +37,7 @@ class GatewayViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, status = result.data) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -78,6 +79,7 @@ class GatewayViewModel : ViewModel() {
}
loadStatus()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -47,6 +47,7 @@ class KanbanViewModel : ViewModel() {
selectBoard(currentBoard)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -92,6 +93,7 @@ class KanbanViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -129,6 +131,7 @@ class KanbanViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Task created successfully") }
selectBoard(board)
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") }
}
@@ -44,6 +44,7 @@ class KeysViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -71,6 +72,7 @@ class KeysViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_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") }
loadKeys()
}
is NetworkResult.Failure -> {
_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()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -43,6 +43,7 @@ class McpServersViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -83,6 +84,7 @@ class McpServersViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
}
@@ -106,6 +108,7 @@ class McpServersViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -135,6 +138,7 @@ class McpServersViewModel : ViewModel() {
}
loadServers()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -67,6 +67,7 @@ class ModelViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -98,6 +99,7 @@ class ModelViewModel : ViewModel() {
}
return@launch
}
is NetworkResult.Success -> {
val activeProfileName = activeProfileNameResResult.data.active
val updateResResult =
@@ -114,6 +116,7 @@ class ModelViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") }
loadModelOptions()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -39,6 +39,7 @@ class PairingViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, pairing = result.data) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -92,6 +93,7 @@ class PairingViewModel : ViewModel() {
}
loadPairing()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -37,6 +37,7 @@ class PluginsViewModel : ViewModel() {
val plugins = result.data.plugins.orEmpty()
_uiState.update { it.copy(isLoading = false, plugins = plugins) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -98,6 +99,7 @@ class PluginsViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Plugin installed successfully") }
loadPlugins()
}
is NetworkResult.Failure -> {
_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") }
loadPlugins()
}
is NetworkResult.Failure -> {
_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") }
loadPlugins()
}
is NetworkResult.Failure -> {
_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") }
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -107,6 +108,7 @@ class ProfilesViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -143,6 +145,7 @@ class ProfilesViewModel : ViewModel() {
}
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -174,6 +177,7 @@ class ProfilesViewModel : ViewModel() {
_uiState.update { it.copy(toastMessage = "Model settings updated") }
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -156,6 +156,7 @@ class SettingsViewModel : ViewModel() {
it.copy(isTesting = false, testResult = "✅ Connected successfully")
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -2,20 +2,29 @@ package com.m57.hermescontrol.ui.skills
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -25,8 +34,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.LocalSpacing
@@ -161,10 +174,24 @@ fun SkillsScreen(
}
}
}
Switch(
checked = skill.enabled,
onCheckedChange = { viewModel.toggleSkill(skill) },
)
Row(
verticalAlignment = Alignment.CenterVertically,
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 errorMessage: 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() {
@@ -37,6 +42,7 @@ class SkillsViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, skills = result.data.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
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() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -51,6 +51,7 @@ class SystemViewModel : ViewModel() {
// Doctor is optional; many servers do not expose it.
loadDoctorReport()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -87,6 +88,7 @@ class SystemViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Backup triggered successfully") }
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${result.error.message}") }
}
@@ -37,6 +37,7 @@ class ToolsetsViewModel : ViewModel() {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, toolsets = result.data.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -48,6 +48,7 @@ class WebhooksViewModel : ViewModel() {
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -78,6 +79,7 @@ class WebhooksViewModel : ViewModel() {
}
loadWebhooks()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(