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(
|
||||
|
||||
Reference in new issue
Block a user