diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index 084e4253..cdd90e33 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -6,14 +6,15 @@ data class ProfilesResponse( data class ProfileInfo( val name: String, - val path: String?, - val is_default: Boolean?, - val model: String?, - val provider: String?, - val has_env: Boolean?, - val skill_count: Int?, - val gateway_running: Boolean?, - val description: String?, + val path: String? = null, + val is_default: Boolean? = null, + val model: String? = null, + val provider: String? = null, + val has_env: Boolean? = null, + val skill_count: Int? = null, + val gateway_running: Boolean? = null, + val description: String? = null, + val description_auto: Boolean? = null, ) data class ActiveProfileResponse( @@ -37,3 +38,29 @@ data class UpdateProfileModelRequest( val provider: String, val model: String, ) + +data class CloneProfileRequest( + val name: String, +) + +data class UpdateProfileDescriptionRequest( + val description: String, +) + +data class CreateProfileRequest( + val name: String, + val description: String?, + val provider: String?, + val model: String?, + val mcp_servers: List?, + val keep_skills: Boolean?, + val hub_skills: List?, +) + +data class McpServerConfigInput( + val name: String, + val transport: String, + val url: String? = null, + val command: String? = null, + val args: List? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt index ed0a461a..b4b8f54c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt @@ -7,3 +7,9 @@ data class Skill( val enabled: Boolean, val content: String? = null, ) + +data class HubSkill( + val name: String, + val description: String?, + val source: String?, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 8c10c235..00204f38 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -9,9 +9,11 @@ import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.CheckpointsResponse +import com.m57.hermescontrol.data.model.CloneProfileRequest import com.m57.hermescontrol.data.model.ConfigSchemaResponse import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.CreateCronJobRequest +import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.CredentialPoolResponse @@ -25,6 +27,7 @@ import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.HookResponse +import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask @@ -67,6 +70,7 @@ import com.m57.hermescontrol.data.model.Toolset import com.m57.hermescontrol.data.model.ToolsetToggleRequest import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.model.UpdateCronJobRequest +import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest @@ -146,6 +150,11 @@ interface HermesApiService { @GET("api/skills") suspend fun getSkills(): Response> + @POST("api/skills/hub/search") + suspend fun searchSkillsHub( + @Query("q") q: String, + ): Response> + @PUT("api/skills/toggle") suspend fun toggleSkill( @Body body: ToggleSkillRequest, @@ -202,6 +211,11 @@ interface HermesApiService { @GET("api/profiles") suspend fun getProfiles(): Response + @POST("api/profiles") + suspend fun createProfile( + @Body body: CreateProfileRequest, + ): Response + @GET("api/profiles/active") suspend fun getActiveProfile(): Response @@ -227,6 +241,18 @@ interface HermesApiService { @Body body: UpdateProfileModelRequest, ): Response + @POST("api/profiles/{name}/clone") + suspend fun cloneProfile( + @Path("name") name: String, + @Body body: CloneProfileRequest, + ): Response + + @PUT("api/profiles/{name}/description") + suspend fun updateProfileDescription( + @Path("name") name: String, + @Body body: UpdateProfileDescriptionRequest, + ): Response + @GET("api/tools/toolsets") suspend fun getToolsets(): Response> diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index 494c1b28..3fc9fd89 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -49,7 +49,10 @@ fun mapHttpError( val detail = if (errorBody != null) { try { - val parsed = com.google.gson.JsonParser.parseString(errorBody).asJsonObject + val parsed = + com.google.gson.JsonParser + .parseString(errorBody) + .asJsonObject parsed.get("detail")?.asString } catch (_: Exception) { null diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt index 0964ce0f..0e7cadfd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt @@ -59,7 +59,11 @@ import com.m57.hermescontrol.ui.common.listItemSpacing // ── State → badge colour / label ────────────────────────────────────── -private data class StateStyle(val label: String, val color: Color, val icon: ImageVector) +private data class StateStyle( + val label: String, + val color: Color, + val icon: ImageVector, +) @Composable private fun platformStateStyle(state: String): StateStyle = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index ee4b3469..bf6bbe58 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -26,7 +26,9 @@ data class ChannelsUiState( val testingId: String? = null, ) -class ChannelsViewModel : ViewModel(), ToastHost { +class ChannelsViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(ChannelsUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 7f4766ff..3f4a0add 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -537,7 +537,9 @@ fun parseToolOutput( 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) return null val obj = element.asJsonObject @@ -566,7 +568,10 @@ fun parseToolOutput( val truncated = if (raw.length > 100) raw.take(100) + "…" else raw if (truncated.isNotBlank()) "${config.summaryPrefix}$truncated" else null } else { - null ?: obj.get("context")?.takeIf { !it.isJsonNull }?.asString + null ?: obj + .get("context") + ?.takeIf { !it.isJsonNull } + ?.asString ?.takeIf { it.isNotEmpty() } ?.let { "${config.summaryPrefix}$it" } } @@ -576,11 +581,13 @@ fun parseToolOutput( // build a clean one-line summary and formatted item list. if (resolvedToolName == "todo") { val todosArray = - dataSource.get("todos") + dataSource + .get("todos") ?.takeIf { !it.isJsonNull && it.isJsonArray } ?.asJsonArray val summaryObj = - dataSource.get("summary") + dataSource + .get("summary") ?.takeIf { !it.isJsonNull && it.isJsonObject } ?.asJsonObject @@ -605,21 +612,23 @@ fun parseToolOutput( } val formattedTodos = - todosArray?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val id = item.get("id")?.asString ?: "" - val content = item.get("content")?.asString ?: "" - val status = item.get("status")?.asString ?: "pending" - val marker = - when (status) { - "completed" -> "[x]" - "in_progress" -> "[>]" - "cancelled" -> "[~]" - else -> "[ ]" - } - "$marker $id. $content" - }?.joinToString("\n")?.takeIf { it.isNotEmpty() } + todosArray + ?.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val item = element.asJsonObject + val id = item.get("id")?.asString ?: "" + val content = item.get("content")?.asString ?: "" + val status = item.get("status")?.asString ?: "pending" + val marker = + when (status) { + "completed" -> "[x]" + "in_progress" -> "[>]" + "cancelled" -> "[~]" + else -> "[ ]" + } + "$marker $id. $content" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble @@ -645,16 +654,28 @@ fun parseToolOutput( // Try both key shapes: results[] (probe/search/reason/related/contradict) and facts[] (list) val factsArray = - dataSource.get("results") + dataSource + .get("results") ?.takeIf { !it.isJsonNull && it.isJsonArray } ?.asJsonArray - ?: dataSource.get("facts") + ?: dataSource + .get("facts") ?.takeIf { !it.isJsonNull && it.isJsonArray } ?.asJsonArray - val count = dataSource.get("count")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() ?: 0 + val count = + dataSource + .get("count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() ?: 0 val status = dataSource.get("status")?.takeIf { !it.isJsonNull }?.asString - val factId = dataSource.get("fact_id")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() + val factId = + dataSource + .get("fact_id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() val removed = dataSource.get("removed")?.takeIf { !it.isJsonNull }?.asBoolean val updated = dataSource.get("updated")?.takeIf { !it.isJsonNull }?.asBoolean @@ -677,26 +698,28 @@ fun parseToolOutput( // Format each fact as a compact block: #ID + content on first line, meta on indented second line val formattedFacts = - factsArray?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val fid = item.get("fact_id")?.asDouble?.toInt() ?: 0 - val content = item.get("content")?.asString ?: "" - val category = item.get("category")?.asString - val trust = item.get("trust_score")?.asDouble - val tags = item.get("tags")?.asString?.takeIf { it.isNotEmpty() } + factsArray + ?.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val item = element.asJsonObject + val fid = item.get("fact_id")?.asDouble?.toInt() ?: 0 + val content = item.get("content")?.asString ?: "" + val category = item.get("category")?.asString + val trust = item.get("trust_score")?.asDouble + val tags = item.get("tags")?.asString?.takeIf { it.isNotEmpty() } - val firstLine = "#$fid $content" - val metaParts = mutableListOf() - if (category != null) metaParts.add("[$category]") - if (trust != null) metaParts.add("trust: ${"%.2f".format(trust)}") - if (tags != null) metaParts.add("🏷️ $tags") - if (metaParts.isNotEmpty()) { - "$firstLine\n ${metaParts.joinToString(" ")}" - } else { - firstLine - } - }?.joinToString("\n")?.takeIf { it.isNotEmpty() } + val firstLine = "#$fid $content" + val metaParts = mutableListOf() + if (category != null) metaParts.add("[$category]") + if (trust != null) metaParts.add("trust: ${"%.2f".format(trust)}") + if (tags != null) metaParts.add("🏷️ $tags") + if (metaParts.isNotEmpty()) { + "$firstLine\n ${metaParts.joinToString(" ")}" + } else { + firstLine + } + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } val factMainOutput = when { @@ -732,8 +755,18 @@ fun parseToolOutput( dataSource.get("results")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray val messagesArray = dataSource.get("messages")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val count = dataSource.get("count")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() - val msgCount = dataSource.get("message_count")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() + val count = + dataSource + .get("count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val msgCount = + dataSource + .get("message_count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() val truncated = dataSource.get("truncated")?.takeIf { !it.isJsonNull }?.asBoolean // Build summary line @@ -744,70 +777,93 @@ fun parseToolOutput( 60, )}" } ?: ""}" + "scroll" -> "📜 ${messagesArray?.size() ?: 0} messages (scroll)" + "read" -> "📖 ${msgCount ?: messagesArray?.size() ?: 0} messages${ if (truncated == true) " (truncated)" else "" }" + "browse" -> "📋 ${count ?: 0} recent sessions" + else -> "🔍 session_search" } // Format results (discover / browse) val formattedResults = - resultsArray?.mapIndexedNotNull { idx, element -> - if (!element.isJsonObject) return@mapIndexedNotNull null - val item = element.asJsonObject - val whenField = - item.get("when")?.takeIf { !it.isJsonNull }?.asString - ?: item.get("started_at")?.takeIf { !it.isJsonNull }?.asString - val source = item.get("source")?.takeIf { !it.isJsonNull }?.asString - val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString - val snippet = item.get("snippet")?.takeIf { !it.isJsonNull }?.asString - val preview = item.get("preview")?.takeIf { !it.isJsonNull }?.asString - val model = item.get("model")?.takeIf { !it.isJsonNull }?.asString - val sessionMsgCount = item.get("message_count")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() - val matchedRole = item.get("matched_role")?.takeIf { !it.isJsonNull }?.asString + resultsArray + ?.mapIndexedNotNull { idx, element -> + if (!element.isJsonObject) return@mapIndexedNotNull null + val item = element.asJsonObject + val whenField = + item.get("when")?.takeIf { !it.isJsonNull }?.asString + ?: item.get("started_at")?.takeIf { !it.isJsonNull }?.asString + val source = item.get("source")?.takeIf { !it.isJsonNull }?.asString + val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString + val snippet = item.get("snippet")?.takeIf { !it.isJsonNull }?.asString + val preview = item.get("preview")?.takeIf { !it.isJsonNull }?.asString + val model = item.get("model")?.takeIf { !it.isJsonNull }?.asString + val sessionMsgCount = + item + .get("message_count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val matchedRole = item.get("matched_role")?.takeIf { !it.isJsonNull }?.asString - val header = whenField?.let { "📅 $it" } ?: "" - val sourceTag = source?.let { "[$it]" } ?: "" - val titleLine = title?.let { "\n 📄 $it" } ?: "" - val modelLine = model?.let { "\n 🤖 $it" } ?: "" - val matchedLine = matchedRole?.let { "\n 🎯 matched: $it" } ?: "" - val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" - val snippetText = snippet ?: preview - val snippetLine = - snippetText?.let { - val clean = it.take(200).replace("\n", " ") - "\n ┃ $clean" - } ?: "" + val header = whenField?.let { "📅 $it" } ?: "" + val sourceTag = source?.let { "[$it]" } ?: "" + val titleLine = title?.let { "\n 📄 $it" } ?: "" + val modelLine = model?.let { "\n 🤖 $it" } ?: "" + val matchedLine = matchedRole?.let { "\n 🎯 matched: $it" } ?: "" + val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" + val snippetText = snippet ?: preview + val snippetLine = + snippetText?.let { + val clean = it.take(200).replace("\n", " ") + "\n ┃ $clean" + } ?: "" - "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" - }?.joinToString("\n")?.takeIf { it.isNotEmpty() } + "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } // Format messages (scroll / read) - val anchorId = dataSource.get("around_message_id")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() + val anchorId = + dataSource + .get("around_message_id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() val formattedMessages = - messagesArray?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val msgId = item.get("id")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() - val role = item.get("role")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val content = item.get("content")?.takeIf { !it.isJsonNull }?.asString ?: "" - val toolName = item.get("tool_name")?.takeIf { !it.isJsonNull }?.asString + messagesArray + ?.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val item = element.asJsonObject + val msgId = + item + .get("id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val role = item.get("role")?.takeIf { !it.isJsonNull }?.asString ?: "?" + val content = item.get("content")?.takeIf { !it.isJsonNull }?.asString ?: "" + val toolName = item.get("tool_name")?.takeIf { !it.isJsonNull }?.asString - val roleEmoji = - when (role) { - "user" -> "👤" - "assistant" -> "🤖" - "tool" -> "🔧" - else -> "❓" - } - val namePart = if (toolName != null) " ($toolName)" else "" - val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else "" - val cleanContent = content.take(300).replace("\n", " ") + val roleEmoji = + when (role) { + "user" -> "👤" + "assistant" -> "🤖" + "tool" -> "🔧" + else -> "❓" + } + val namePart = if (toolName != null) " ($toolName)" else "" + val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else "" + val cleanContent = content.take(300).replace("\n", " ") - "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" - }?.joinToString("\n")?.takeIf { it.isNotEmpty() } + "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } val ssMainOutput = formattedResults ?: formattedMessages @@ -856,31 +912,46 @@ fun parseToolOutput( if (resolvedToolName == "cronjob") { val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" val jobsArray = dataSource.get("jobs")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val jobId = dataSource.get("job_id")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() + val jobId = + dataSource + .get("job_id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() val jobName = dataSource.get("name")?.takeIf { !it.isJsonNull }?.asString val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString val summaryText = "🔄 $action${jobName?.let { ": $it" } ?: jobId?.let { " (ID: $it)" } ?: ""}" val mainOutput = when { - errorMsg != null -> "❌ $errorMsg" + errorMsg != null -> { + "❌ $errorMsg" + } + jobsArray != null -> { - jobsArray.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val j = el.asJsonObject - val name = j.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val schedule = j.get("schedule")?.takeIf { !it.isJsonNull }?.asString - val status = j.get("status")?.takeIf { !it.isJsonNull }?.asString - val lastRun = j.get("last_run")?.takeIf { !it.isJsonNull }?.asString - val parts = mutableListOf("📌 $name") - if (schedule != null) parts.add(" ⏱ $schedule") - if (status != null) parts.add(" 📊 $status") - if (lastRun != null) parts.add(" ⏮ $lastRun") - parts.joinToString("\n") - }.joinToString("\n\n") + jobsArray + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val j = el.asJsonObject + val name = j.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" + val schedule = j.get("schedule")?.takeIf { !it.isJsonNull }?.asString + val status = j.get("status")?.takeIf { !it.isJsonNull }?.asString + val lastRun = j.get("last_run")?.takeIf { !it.isJsonNull }?.asString + val parts = mutableListOf("📌 $name") + if (schedule != null) parts.add(" ⏱ $schedule") + if (status != null) parts.add(" 📊 $status") + if (lastRun != null) parts.add(" ⏮ $lastRun") + parts.joinToString("\n") + }.joinToString("\n\n") + } + + jobId != null -> { + "✅ $action (ID: $jobId)" + } + + else -> { + "✅ $action done" } - jobId != null -> "✅ $action (ID: $jobId)" - else -> "✅ $action done" } val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble return ParsedToolData( @@ -900,28 +971,49 @@ fun parseToolOutput( val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString val webArr = - dataSource.get("data")?.takeIf { !it.isJsonNull && it.isJsonObject } - ?.asJsonObject?.get("web")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + dataSource + .get("data") + ?.takeIf { !it.isJsonNull && it.isJsonObject } + ?.asJsonObject + ?.get("web") + ?.takeIf { !it.isJsonNull && it.isJsonArray } + ?.asJsonArray val summaryText = "🌐 $query${webArr?.let { " (${it.size()} results)" } ?: ""}" val mainOutput = when { - errorMsg != null -> "❌ $errorMsg" - webArr != null -> - webArr.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val item = el.asJsonObject - val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString ?: "" - val url = item.get("url")?.takeIf { !it.isJsonNull }?.asString - val desc = item.get("description")?.takeIf { !it.isJsonNull }?.asString - val pos = item.get("position")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() - val lines = mutableListOf("${pos?.let { "$it. " } ?: ""}$title") - if (desc != null) lines.add(" $desc") - if (url != null) lines.add(" 🔗 $url") - lines.joinToString("\n") - }.joinToString("\n\n") - !success -> "❌ Search failed" - else -> "No results" + errorMsg != null -> { + "❌ $errorMsg" + } + + webArr != null -> { + webArr + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val item = el.asJsonObject + val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString ?: "" + val url = item.get("url")?.takeIf { !it.isJsonNull }?.asString + val desc = item.get("description")?.takeIf { !it.isJsonNull }?.asString + val pos = + item + .get("position") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val lines = mutableListOf("${pos?.let { "$it. " } ?: ""}$title") + if (desc != null) lines.add(" $desc") + if (url != null) lines.add(" 🔗 $url") + lines.joinToString("\n") + }.joinToString("\n\n") + } + + !success -> { + "❌ Search failed" + } + + else -> { + "No results" + } } val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble return ParsedToolData( @@ -940,21 +1032,23 @@ fun parseToolOutput( val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString val skillsArr = (dataSource.get("skills") ?: dataSource.get("results")) - ?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + ?.takeIf { !it.isJsonNull && it.isJsonArray } + ?.asJsonArray val summaryText = "📚 ${skillsArr?.size() ?: 0} skills${category?.let { " ($it)" } ?: ""}" val mainOutput = - skillsArr?.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val s = el.asJsonObject - val name = s.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" - val desc = s.get("description")?.takeIf { !it.isJsonNull }?.asString - val cat = s.get("category")?.takeIf { !it.isJsonNull }?.asString - val lines = mutableListOf("📌 $name") - if (desc != null) lines.add(" $desc") - if (cat != null) lines.add(" [$cat]") - lines.joinToString("\n") - }?.joinToString("\n\n") ?: "📚 No skills found" + skillsArr + ?.mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val s = el.asJsonObject + val name = s.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" + val desc = s.get("description")?.takeIf { !it.isJsonNull }?.asString + val cat = s.get("category")?.takeIf { !it.isJsonNull }?.asString + val lines = mutableListOf("📌 $name") + if (desc != null) lines.add(" $desc") + if (cat != null) lines.add(" [$cat]") + lines.joinToString("\n") + }?.joinToString("\n\n") ?: "📚 No skills found" val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble return ParsedToolData( toolName = resolvedToolName ?: "", @@ -1018,27 +1112,42 @@ fun parseToolOutput( val summaryText = "⚙️ $action${procId?.let { ": $it" } ?: ""}" val mainOutput = when { - errorMsg != null -> "❌ $errorMsg" + errorMsg != null -> { + "❌ $errorMsg" + } + processesArr != null -> { - processesArr.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val p = el.asJsonObject - val pid = - p.get( - "session_id", - )?.takeIf { !it.isJsonNull }?.asString ?: p.get("id")?.asString ?: "?" - val pStatus = p.get("status")?.takeIf { !it.isJsonNull }?.asString - val cmd = p.get("command")?.takeIf { !it.isJsonNull }?.asString - val running = p.get("running")?.takeIf { !it.isJsonNull }?.asBoolean - val parts = mutableListOf("📌 $pid${cmd?.let { ": $it" } ?: ""}") - if (pStatus != null) parts.add(" Status: $pStatus") - if (running != null) parts.add(" Running: $running") - parts.joinToString("\n") - }.joinToString("\n\n") + processesArr + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val p = el.asJsonObject + val pid = + p + .get( + "session_id", + )?.takeIf { !it.isJsonNull } + ?.asString ?: p.get("id")?.asString ?: "?" + val pStatus = p.get("status")?.takeIf { !it.isJsonNull }?.asString + val cmd = p.get("command")?.takeIf { !it.isJsonNull }?.asString + val running = p.get("running")?.takeIf { !it.isJsonNull }?.asBoolean + val parts = mutableListOf("📌 $pid${cmd?.let { ": $it" } ?: ""}") + if (pStatus != null) parts.add(" Status: $pStatus") + if (running != null) parts.add(" Running: $running") + parts.joinToString("\n") + }.joinToString("\n\n") + } + + outputText != null -> { + "${status?.let { "Status: $it\n" } ?: ""}$outputText" + } + + status != null -> { + "✅ $status" + } + + else -> { + "✅ $action done" } - outputText != null -> "${status?.let { "Status: $it\n" } ?: ""}$outputText" - status != null -> "✅ $status" - else -> "✅ $action done" } val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble return ParsedToolData( @@ -1063,7 +1172,10 @@ fun parseToolOutput( val summaryText = "𝕏 $query${if (degraded == true) " (no citations)" else ""}" val mainOutput = when { - errorMsg != null -> "❌ $errorMsg" + errorMsg != null -> { + "❌ $errorMsg" + } + answer != null -> { val lines = mutableListOf(answer) if (citationsArr != null && citationsArr.size() > 0) { @@ -1080,7 +1192,10 @@ fun parseToolOutput( if (degraded == true) lines.add("\n⚠️ No citations — answer based on model's knowledge") lines.joinToString("\n") } - else -> "𝕏 No results" + + else -> { + "𝕏 No results" + } } val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble return ParsedToolData( @@ -1132,12 +1247,19 @@ fun parseToolOutput( val summaryText = "🕸️ ${urls?.size() ?: 0} page(s)" val mainOutput = when { - errorMsg != null -> "❌ $errorMsg" - content != null -> + errorMsg != null -> { + "❌ $errorMsg" + } + + content != null -> { content.take( 3000, ) + if (content.length > 3000) "\n... [${content.length - 3000} more chars]" else "" - else -> "🕸️ No content extracted" + } + + else -> { + "🕸️ No content extracted" + } } val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble return ParsedToolData( @@ -1154,20 +1276,26 @@ fun parseToolOutput( // ── Tool Search-specific formatting ── if (resolvedToolName == "tool_search") { val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" - val limit = argsObj?.get("limit")?.takeIf { !it.isJsonNull }?.asDouble?.toInt() + val limit = + argsObj + ?.get("limit") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() val matchesArr = dataSource.get("matches")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray val summaryText = "🔍 $query${matchesArr?.let { " (${it.size()} matches)" } ?: ""}" val mainOutput = - matchesArr?.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val m = el.asJsonObject - val name = m.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val desc = m.get("description")?.takeIf { !it.isJsonNull }?.asString - val lines = mutableListOf("🔧 $name") - if (desc != null) lines.add(" $desc") - lines.joinToString("\n") - }?.joinToString("\n\n") ?: "🔍 No matching tools" + matchesArr + ?.mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val m = el.asJsonObject + val name = m.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" + val desc = m.get("description")?.takeIf { !it.isJsonNull }?.asString + val lines = mutableListOf("🔧 $name") + if (desc != null) lines.add(" $desc") + lines.joinToString("\n") + }?.joinToString("\n\n") ?: "🔍 No matching tools" val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble return ParsedToolData( toolName = resolvedToolName ?: "", @@ -1232,20 +1360,21 @@ fun parseToolOutput( val summaryText = "📂 ${projectsArr?.size() ?: "?"} projects${activeId?.let { " (1 active)" } ?: ""}" val mainOutput = if (projectsArr != null) { - projectsArr.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val p = el.asJsonObject - val id = p.get("id")?.takeIf { !it.isJsonNull }?.asString ?: "" - val name = p.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" - val slug = p.get("slug")?.takeIf { !it.isJsonNull }?.asString - val path = p.get("primary_path")?.takeIf { !it.isJsonNull }?.asString - val isActive = p.get("active")?.takeIf { !it.isJsonNull }?.asBoolean ?: false - val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name") - if (slug != null) lines.add(" 🏷️ $slug") - if (path != null) lines.add(" 📍 $path") - if (isActive) lines.add(" ✅ ACTIVE PROJECT") - lines.joinToString("\n") - }.joinToString("\n\n") + projectsArr + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val p = el.asJsonObject + val id = p.get("id")?.takeIf { !it.isJsonNull }?.asString ?: "" + val name = p.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" + val slug = p.get("slug")?.takeIf { !it.isJsonNull }?.asString + val path = p.get("primary_path")?.takeIf { !it.isJsonNull }?.asString + val isActive = p.get("active")?.takeIf { !it.isJsonNull }?.asBoolean ?: false + val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name") + if (slug != null) lines.add(" 🏷️ $slug") + if (path != null) lines.add(" 📍 $path") + if (isActive) lines.add(" ✅ ACTIVE PROJECT") + lines.joinToString("\n") + }.joinToString("\n\n") } else { "" } @@ -1361,18 +1490,19 @@ fun parseToolOutput( if (isMultimodal) { val contentArr = dataSource.get("content")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - contentArr?.mapNotNull { el -> - if (el.isJsonObject) { - val eObj = el.asJsonObject - if (eObj.get("type")?.takeIf { !it.isJsonNull }?.asString == "text") { - eObj.get("text")?.takeIf { !it.isJsonNull }?.asString + contentArr + ?.mapNotNull { el -> + if (el.isJsonObject) { + val eObj = el.asJsonObject + if (eObj.get("type")?.takeIf { !it.isJsonNull }?.asString == "text") { + eObj.get("text")?.takeIf { !it.isJsonNull }?.asString + } else { + null + } } else { null } - } else { - null - } - }?.joinToString("\n") + }?.joinToString("\n") } else { null } @@ -1409,8 +1539,16 @@ fun parseToolOutput( } else if (appsArr != null) { appsArr.forEachIndexed { idx, el -> if (el.isJsonObject) { - val name = el.asJsonObject.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val pid = el.asJsonObject.get("pid")?.takeIf { !it.isJsonNull }?.asInt + val name = + el.asJsonObject + .get("name") + ?.takeIf { !it.isJsonNull } + ?.asString ?: "?" + val pid = + el.asJsonObject + .get("pid") + ?.takeIf { !it.isJsonNull } + ?.asInt append("${idx + 1}. $name") if (pid != null) append(" (PID: $pid)") append("\n") @@ -1428,9 +1566,21 @@ fun parseToolOutput( for (i in 0 until previewCount) { val el = elementsArr[i] if (el.isJsonObject) { - val idx = el.asJsonObject.get("index")?.takeIf { !it.isJsonNull }?.asInt - val role = el.asJsonObject.get("role")?.takeIf { !it.isJsonNull }?.asString - val label = el.asJsonObject.get("label")?.takeIf { !it.isJsonNull }?.asString + val idx = + el.asJsonObject + .get("index") + ?.takeIf { !it.isJsonNull } + ?.asInt + val role = + el.asJsonObject + .get("role") + ?.takeIf { !it.isJsonNull } + ?.asString + val label = + el.asJsonObject + .get("label") + ?.takeIf { !it.isJsonNull } + ?.asString append(" #$idx ${role ?: ""}${label?.let { " '$it'" } ?: ""}\n") } } @@ -1467,10 +1617,25 @@ fun parseToolOutput( val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode") if (hasStdout || hasStderr || hasExitCode) { - val stdout = dataSource.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } - val stderr = dataSource.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } + val stdout = + dataSource + .get("stdout") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } + val stderr = + dataSource + .get("stderr") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt - val error = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } + val error = + dataSource + .get("error") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble ParsedToolData( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt index b1439212..74bf7dc2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt @@ -31,7 +31,6 @@ class ChatPersistenceRepository( } /** Load cached messages for a session from Room. */ - suspend fun loadMessages(sessionId: String): List { - return dao.getMessagesForSession(sessionId).map { it.toUiModel() } - } + suspend fun loadMessages(sessionId: String): List = + dao.getMessagesForSession(sessionId).map { it.toUiModel() } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt index fafe77c1..89bc6a7f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -27,10 +27,14 @@ import com.m57.hermescontrol.R /** Sealed interface for navigation icon types — eliminates boolean-flag anti-pattern. */ sealed interface NavIcon { /** Hamburger menu icon that opens the drawer. */ - data class Menu(val onOpen: () -> Unit) : NavIcon + data class Menu( + val onOpen: () -> Unit, + ) : NavIcon /** Back arrow icon. */ - data class Back(val onBack: () -> Unit) : NavIcon + data class Back( + val onBack: () -> Unit, + ) : NavIcon } /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt index 99574921..4b6dee1e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt @@ -354,7 +354,9 @@ fun ToastEffect( val context = androidx.compose.ui.platform.LocalContext.current androidx.compose.runtime.LaunchedEffect(toastMessage) { toastMessage?.let { msg -> - android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show() + android.widget.Toast + .makeText(context, msg, android.widget.Toast.LENGTH_SHORT) + .show() onClearToast() } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index db78a582..3e08a980 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -336,7 +336,9 @@ private fun FormEditor( val isSearching = searchQuery.isNotBlank() val categoryCounts = remember(schema) { - schema.fields.values.groupingBy { it.category ?: "general" }.eachCount() + schema.fields.values + .groupingBy { it.category ?: "general" } + .eachCount() } // Non-schema config values — all dot-paths in the config that aren't in the schema @@ -400,17 +402,21 @@ private fun FormEditor( remember(schema, activeCategory, searchQuery) { if (isSearching) { val query = searchQuery.lowercase() - schema.fields.filter { (key, field) -> - val label = key.split(".").last().replace("_", " ") - key.lowercase().contains(query) || - label.contains(query) || - (field.description?.lowercase()?.contains(query) == true) || - (field.category?.lowercase()?.contains(query) == true) - }.entries.map { it.key to it.value } + schema.fields + .filter { (key, field) -> + val label = key.split(".").last().replace("_", " ") + key.lowercase().contains(query) || + label.contains(query) || + (field.description?.lowercase()?.contains(query) == true) || + (field.category?.lowercase()?.contains(query) == true) + }.entries + .map { it.key to it.value } } else { - schema.fields.filter { (_, field) -> - (field.category ?: "general") == activeCategory - }.entries.map { it.key to it.value } + schema.fields + .filter { (_, field) -> + (field.category ?: "general") == activeCategory + }.entries + .map { it.key to it.value } } } visibleFields.forEach { (key, field) -> @@ -521,7 +527,12 @@ private fun ConfigField( isModified: Boolean, onChange: (JsonElement) -> Unit, ) { - val label = key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() } + val label = + key + .split(".") + .last() + .replace("_", " ") + .replaceFirstChar { it.uppercase() } val description = field.description ?: key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() } Card( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt index d7393ff6..0879e4a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt @@ -38,7 +38,9 @@ data class ConfigUiState( val toastMessage: String? = null, ) -class ConfigViewModel : ViewModel(), ToastHost { +class ConfigViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(ConfigUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -147,6 +149,7 @@ class ConfigViewModel : ViewModel(), ToastHost { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -193,6 +196,7 @@ class ConfigViewModel : ViewModel(), ToastHost { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -231,6 +235,7 @@ class ConfigViewModel : ViewModel(), ToastHost { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index 549bf148..8e92928e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -130,6 +130,7 @@ class CronJobsViewModel : 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}") } } @@ -210,6 +211,7 @@ class CronJobsViewModel : ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -300,6 +302,7 @@ class CronJobsViewModel : closeEditor() loadCronJobs() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -340,7 +343,8 @@ class CronJobsViewModel : } private fun parseLines(skills: String): List = - skills.lines() + skills + .lines() .map { it.trim() } .filter { it.isNotEmpty() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt index 60deb3f4..97b84e75 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt @@ -24,7 +24,9 @@ data class GatewayUiState( val toastMessage: String? = null, ) -class GatewayViewModel : ViewModel(), ToastHost { +class GatewayViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(GatewayUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index e07a1716..33f43e4b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -155,8 +155,7 @@ fun KanbanScreen( contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - items(state.columns.size, key = { - index -> + items(state.columns.size, key = { index -> state.columns[index].name }) { columnIndex -> val column = state.columns[columnIndex] diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 0d25ff6c..c185e62f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -28,7 +28,9 @@ data class KanbanUiState( val toastMessage: String? = null, ) -class KanbanViewModel : ViewModel(), ToastHost { +class KanbanViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(KanbanUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt index a02778c5..c49c7b18 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt @@ -26,7 +26,9 @@ data class KeysUiState( val toastMessage: String? = null, ) -class KeysViewModel : ViewModel(), ToastHost { +class KeysViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(KeysUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt index cd4c827f..33ad7495 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt @@ -70,15 +70,25 @@ private enum class LogSeverity { private fun detectSeverity(line: String): LogSeverity? { val upper = line.uppercase() return when { - Regex("\\[ERROR\\]|\\bERROR\\b|\\|ERROR\\||^ERROR[\\s:]").containsMatchIn(upper) -> + Regex("\\[ERROR\\]|\\bERROR\\b|\\|ERROR\\||^ERROR[\\s:]").containsMatchIn(upper) -> { LogSeverity.ERROR - Regex("\\[WARN(?:ING)?\\]|\\bWARN\\b|\\|WARN(?:ING)?\\||^WARN(?:ING)?[\\s:]").containsMatchIn(upper) -> + } + + Regex("\\[WARN(?:ING)?\\]|\\bWARN\\b|\\|WARN(?:ING)?\\||^WARN(?:ING)?[\\s:]").containsMatchIn(upper) -> { LogSeverity.WARN - Regex("\\[INFO\\]|\\bINFO\\b|\\|INFO\\||^INFO[\\s:]").containsMatchIn(upper) -> + } + + Regex("\\[INFO\\]|\\bINFO\\b|\\|INFO\\||^INFO[\\s:]").containsMatchIn(upper) -> { LogSeverity.INFO - Regex("\\[DEBUG\\]|\\bDEBUG\\b|\\|DEBUG\\||^DEBUG[\\s:]").containsMatchIn(upper) -> + } + + Regex("\\[DEBUG\\]|\\bDEBUG\\b|\\|DEBUG\\||^DEBUG[\\s:]").containsMatchIn(upper) -> { LogSeverity.DEBUG - else -> null + } + + else -> { + null + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt index bc9c6473..c1f85356 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt @@ -22,7 +22,9 @@ data class LogsUiState( val toastMessage: String? = null, ) -class LogsViewModel : ViewModel(), ToastHost { +class LogsViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(LogsUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index d4db6bad..c53b2e49 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -25,7 +25,9 @@ data class McpServersUiState( val toastMessage: String? = null, ) -class McpServersViewModel : ViewModel(), ToastHost { +class McpServersViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(McpServersUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 0575264b..a4eb2098 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -305,7 +305,8 @@ private fun ModelSettingsSection( ) { val auxOverrideCount = auxTasks?.let { tasks -> - tasks.filterIsInstance() + tasks + .filterIsInstance() .count { a -> a.provider.isNotBlank() && a.provider != "auto" } } ?: 0 @@ -700,7 +701,9 @@ private fun MoaConfigDialog( } private sealed class MoaPickerTarget { - data class Reference(val index: Int) : MoaPickerTarget() + data class Reference( + val index: Int, + ) : MoaPickerTarget() data object Aggregator : MoaPickerTarget() } @@ -947,14 +950,18 @@ private fun MoaSlotPicker( com.m57.hermescontrol.data.model.MoaConfigPreset, ) -> com.m57.hermescontrol.data.model.MoaConfigPreset = { preset -> when (pickerTarget) { - is MoaPickerTarget.Aggregator -> preset.copy(aggregator = slot) - is MoaPickerTarget.Reference -> + is MoaPickerTarget.Aggregator -> { + preset.copy(aggregator = slot) + } + + is MoaPickerTarget.Reference -> { preset.copy( reference_models = preset.reference_models.toMutableList().also { it[pickerTarget.index] = slot }, ) + } } } val preset = draft.presets[selectedPreset] diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index 4d849420..52dd31a3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -152,6 +152,7 @@ class ModelViewModel : ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -224,6 +225,7 @@ class ModelViewModel : loadAll() } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -265,6 +267,7 @@ class ModelViewModel : } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -340,6 +343,7 @@ class ModelViewModel : } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -375,6 +379,7 @@ class ModelViewModel : } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -417,6 +422,7 @@ class ModelViewModel : ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -450,6 +456,7 @@ class ModelViewModel : } return@launch } + is NetworkResult.Success -> { val activeProfileName = activeProfileNameResResult.data.active val updateResResult = @@ -466,6 +473,7 @@ class ModelViewModel : _uiState.update { it.copy(toastMessage = "Successfully set profile model to $modelName") } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt index 74add1d9..14fa9d0e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt @@ -140,6 +140,7 @@ class PairingCodeEntryViewModel( ApiClient.rebuild() _uiState.update { it.copy(isConnecting = false, connectionSuccess = true) } } + is NetworkResult.Failure -> { val msg = buildErrorMessage(result) setError(msg) @@ -148,8 +149,8 @@ class PairingCodeEntryViewModel( } } - private fun buildErrorMessage(result: NetworkResult.Failure): String { - return when (val err = result.error) { + private fun buildErrorMessage(result: NetworkResult.Failure): String = + when (val err = result.error) { is NetworkError.Http -> { when (err.code) { 401 -> app.getString(R.string.connect_error_401) @@ -157,13 +158,19 @@ class PairingCodeEntryViewModel( else -> app.getString(R.string.connect_error_http_code, err.code) } } - is NetworkError.AuthExpired -> app.getString(R.string.connect_error_401) - is NetworkError.Connection -> + + is NetworkError.AuthExpired -> { + app.getString(R.string.connect_error_401) + } + + is NetworkError.Connection -> { app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "") - is NetworkError.Unknown -> + } + + is NetworkError.Unknown -> { app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "") + } } - } private fun setError(msg: String) { _uiState.update { it.copy(isConnecting = false, errorMessage = msg) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt index 4ea2c868..a77d2c28 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt @@ -26,7 +26,9 @@ data class PairingUiState( val toastMessage: String? = null, ) -class PairingViewModel : ViewModel(), ToastHost { +class PairingViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(PairingUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 09d88110..90d92114 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -51,7 +51,9 @@ data class PluginsUiState( } } -class PluginsViewModel : ViewModel(), ToastHost { +class PluginsViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(PluginsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -137,6 +139,7 @@ class PluginsViewModel : ViewModel(), ToastHost { } loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -177,6 +180,7 @@ class PluginsViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(providerBusy = false, toastMessage = "Plugin providers saved") } loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -201,6 +205,7 @@ class PluginsViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(rescanBusy = false, toastMessage = "Plugins rescanned") } loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -257,6 +262,7 @@ class PluginsViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") } } @@ -289,6 +295,7 @@ class PluginsViewModel : ViewModel(), ToastHost { clearRowBusy(name) loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") } clearRowBusy(name) @@ -310,6 +317,7 @@ class PluginsViewModel : ViewModel(), ToastHost { clearRowBusy(name) loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") } clearRowBusy(name) @@ -337,6 +345,7 @@ class PluginsViewModel : ViewModel(), ToastHost { clearRowBusy(plugin.name) loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to update visibility: ${result.error.message}") } clearRowBusy(plugin.name) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 68753378..890f0df2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.profiles +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,19 +12,34 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -36,12 +53,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.CreateProfileRequest +import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.data.model.McpServerConfigInput +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -63,6 +87,14 @@ fun ProfilesScreen( var tempModelProvider by remember { mutableStateOf("") } var tempModelName by remember { mutableStateOf("") } + var cloneProfileName by remember { mutableStateOf(null) } + var newCloneName by remember { mutableStateOf("") } + + var descEditProfileName by remember { mutableStateOf(null) } + var tempDescription by remember { mutableStateOf("") } + + var isBuildingProfile by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { viewModel.loadProfiles() } @@ -70,185 +102,325 @@ fun ProfilesScreen( ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) HermesScaffold( - title = { Text(stringResource(R.string.screen_profiles)) }, - navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadProfiles() }, + title = { + Text( + if (isBuildingProfile) { + "Create Profile" + } else { + stringResource(R.string.screen_profiles) + }, + ) + }, + navigationIcon = + if (isBuildingProfile) { + NavIcon.Back { isBuildingProfile = false } + } else { + onOpenDrawer?.let { NavIcon.Menu(it) } + }, + isRefreshing = if (isBuildingProfile) false else state.isLoading, + onRefresh = + if (isBuildingProfile) { + null + } else { + { viewModel.loadProfiles() } + }, + actions = { + if (!isBuildingProfile) { + IconButton(onClick = { + isBuildingProfile = true + viewModel.loadBuilderData() + }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Create Profile", + ) + } + } + }, ) { paddingValues -> - when { - state.isLoading && state.profiles.isEmpty() -> { - LoadingState(modifier = Modifier.padding(paddingValues)) - } + if (isBuildingProfile) { + ProfileBuilderView( + state = state, + viewModel = viewModel, + onCancel = { isBuildingProfile = false }, + ) + } else { + when { + state.isLoading && state.profiles.isEmpty() -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadProfiles() }, - modifier = Modifier.padding(paddingValues), - ) - } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadProfiles() }, + modifier = Modifier.padding(paddingValues), + ) + } - state.profiles.isEmpty() -> { - EmptyState( - title = stringResource(R.string.profiles_empty_title), - subtitle = stringResource(R.string.profiles_empty_desc), - onAction = { viewModel.loadProfiles() }, - actionLabel = stringResource(R.string.content_desc_refresh), - modifier = Modifier.padding(paddingValues), - ) - } + state.profiles.isEmpty() -> { + EmptyState( + title = stringResource(R.string.profiles_empty_title), + subtitle = stringResource(R.string.profiles_empty_desc), + onAction = { viewModel.loadProfiles() }, + actionLabel = stringResource(R.string.content_desc_refresh), + modifier = Modifier.padding(paddingValues), + ) + } - else -> { - Box(Modifier.fillMaxSize()) { - if (state.isLoading && state.profiles.isEmpty()) { - CircularProgressIndicator() - } else if (state.errorMessage != null && state.profiles.isEmpty()) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadProfiles() }) { - Text(stringResource(R.string.action_retry)) + else -> { + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.profiles.isEmpty()) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.profiles.isEmpty()) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadProfiles() }) { + Text(stringResource(R.string.action_retry)) + } } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.profiles, key = { it.name }) { profile -> - val isActive = profile.name == state.activeProfileName - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (isActive) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - onClick = { - if (!isActive) { - viewModel.selectActiveProfile(profile.name) - } - }, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.profiles, key = { it.name }) { profile -> + val isActive = profile.name == state.activeProfileName + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + onClick = { + if (!isActive) { + viewModel.selectActiveProfile(profile.name) + } + }, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = profile.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - profile.description?.let { - if (it.isNotBlank()) { - Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + val descriptionText = + if (!profile.description.isNullOrBlank()) { + profile.description + } else { + stringResource(R.string.profiles_description_placeholder) + } + val isPlaceholder = profile.description.isNullOrBlank() + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = descriptionText, + style = MaterialTheme.typography.bodyMedium, + fontStyle = + if (isPlaceholder) { + FontStyle.Italic + } else { + FontStyle.Normal + }, + color = + if (isPlaceholder) { + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f, + ) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + if (profile.description_auto == true) { + Spacer(modifier = Modifier.height(6.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme.errorContainer, + ).padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = + stringResource( + R.string.profiles_badge_auto_generated, + ), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + } + } + if (isActive) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = + stringResource( + R.string.profiles_content_desc_active, + ), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = + stringResource( + R.string.profiles_label_model, + profile.model ?: "None", + ), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = + stringResource( + R.string.profiles_label_provider, + profile.provider ?: "None", + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = + stringResource( + R.string.profiles_label_skills, + profile.skill_count ?: 0, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + profile.path?.let { Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.profiles_label_path, it), + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.8f, + ), ) } } } - if (isActive) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = - stringResource( - R.string.profiles_content_desc_active, - ), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp), - ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (!isActive) { + Button( + onClick = { viewModel.selectActiveProfile(profile.name) }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) { + Text(stringResource(R.string.profiles_action_activate)) + } } - } - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = - stringResource( - R.string.profiles_label_model, - profile.model ?: "None", - ), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = - stringResource( - R.string.profiles_label_provider, - profile.provider ?: "None", - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - if (!isActive) { - Button( - onClick = { viewModel.selectActiveProfile(profile.name) }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.profiles_action_activate)) - } - } + OutlinedButton( + onClick = { + soulEditProfileName = profile.name + viewModel.loadSoul(profile.name) + }, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = null, + modifier = Modifier.width(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.profiles_action_edit_soul)) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { - soulEditProfileName = profile.name - viewModel.loadSoul(profile.name) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = null, - modifier = Modifier.width(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.profiles_action_edit_soul)) - } + Button( + onClick = { + modelEditProfileName = profile.name + tempModelProvider = profile.provider ?: "" + tempModelName = profile.model ?: "" + }, + modifier = Modifier.padding(end = 8.dp), + ) { + Text(stringResource(R.string.profiles_action_set_model)) + } - Button( - onClick = { - modelEditProfileName = profile.name - tempModelProvider = profile.provider ?: "" - tempModelName = profile.model ?: "" - }, - ) { - Text(stringResource(R.string.profiles_action_set_model)) + var showMenu by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More options", + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { + Text( + stringResource( + R.string.profiles_action_edit_description, + ), + ) + }, + onClick = { + showMenu = false + descEditProfileName = profile.name + tempDescription = profile.description ?: "" + }, + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.profiles_action_clone), + ) + }, + onClick = { + showMenu = false + cloneProfileName = profile.name + newCloneName = "" + }, + ) + } + } } } } @@ -380,4 +552,1073 @@ fun ProfilesScreen( }, ) } + + if (cloneProfileName != null) { + AlertDialog( + onDismissRequest = { cloneProfileName = null }, + title = { + Text( + text = + stringResource( + R.string.profiles_title_clone, + cloneProfileName.orEmpty(), + ), + ) + }, + text = { + val errorMsg = if (newCloneName.isNotEmpty()) validateProfileName(newCloneName) else null + OutlinedTextField( + value = newCloneName, + onValueChange = { newCloneName = it }, + label = { Text(stringResource(R.string.profiles_label_new_name_input)) }, + isError = errorMsg != null, + supportingText = errorMsg?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { + val sourceName = cloneProfileName + if (sourceName != null && newCloneName.isNotBlank() && + validateProfileName(newCloneName) == null + ) { + viewModel.cloneProfile(sourceName, newCloneName) + } + cloneProfileName = null + }, + enabled = newCloneName.isNotBlank() && validateProfileName(newCloneName) == null, + ) { + Text(stringResource(R.string.profiles_action_clone)) + } + }, + dismissButton = { + TextButton( + onClick = { cloneProfileName = null }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (descEditProfileName != null) { + AlertDialog( + onDismissRequest = { descEditProfileName = null }, + title = { + Text( + text = + stringResource( + R.string.profiles_title_edit_description, + descEditProfileName.orEmpty(), + ), + ) + }, + text = { + OutlinedTextField( + value = tempDescription, + onValueChange = { tempDescription = it }, + label = { Text(stringResource(R.string.profiles_label_description_input)) }, + modifier = Modifier.fillMaxWidth(), + maxLines = 4, + ) + }, + confirmButton = { + TextButton( + onClick = { + val profileName = descEditProfileName + if (profileName != null) { + viewModel.updateProfileDescription(profileName, tempDescription) + } + descEditProfileName = null + }, + ) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton( + onClick = { descEditProfileName = null }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +// --------------------------------------------------------------------- +// Profile Builder Wizard +// --------------------------------------------------------------------- + +@Composable +fun ProfileBuilderView( + state: ProfilesUiState, + viewModel: ProfilesViewModel, + onCancel: () -> Unit, +) { + var step by remember { mutableStateOf(1) } + + // Step 1: Identity + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + + // Step 2: Model Config + var selectedProvider by remember { mutableStateOf("") } + var selectedModel by remember { mutableStateOf("") } + + // Step 3: Skills + var useDefaultSkills by remember { mutableStateOf(true) } + var selectedSkills by remember { mutableStateOf>(emptySet()) } + var addedHubSkills by remember { mutableStateOf>(emptyList()) } + + // Step 4: MCP Servers + var mcpServers by remember { mutableStateOf>(emptyList()) } + + val nameError = if (name.isNotBlank()) validateProfileName(name) else null + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Step Indicator / Stepper Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.profiles_builder_step_indicator, step), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = + when (step) { + 1 -> stringResource(R.string.profiles_builder_step_identity) + 2 -> stringResource(R.string.profiles_builder_step_model) + 3 -> stringResource(R.string.profiles_builder_step_skills) + 4 -> stringResource(R.string.profiles_builder_step_mcp) + else -> stringResource(R.string.profiles_builder_step_review) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + + // Horizontal Progress indicator + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (i in 1..5) { + Box( + modifier = + Modifier + .weight(1f) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background( + if (i <= step) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) + } + } + + Box( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) { + if (state.isLoadingBuilderData) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + when (step) { + 1 -> { + IdentityStep( + name = name, + onNameChange = { name = it }, + nameError = nameError, + description = description, + onDescriptionChange = { description = it }, + ) + } + + 2 -> { + ModelStep( + providers = state.modelProviders, + selectedProvider = selectedProvider, + onProviderChange = { + selectedProvider = it + selectedModel = "" + }, + selectedModel = selectedModel, + onModelChange = { selectedModel = it }, + ) + } + + 3 -> { + SkillsStep( + availableSkills = state.availableSkills, + useDefaultSkills = useDefaultSkills, + onUseDefaultSkillsChange = { useDefaultSkills = it }, + selectedSkills = selectedSkills, + onSelectedSkillsChange = { selectedSkills = it }, + addedHubSkills = addedHubSkills, + onAddedHubSkillsChange = { addedHubSkills = it }, + hubSearchResults = state.hubSearchResults, + isSearchingHub = state.isSearchingHub, + onSearchHub = viewModel::searchHub, + ) + } + + 4 -> { + McpStep( + mcpServers = mcpServers, + onMcpServersChange = { mcpServers = it }, + ) + } + + 5 -> { + ReviewStep( + name = name, + description = description, + provider = selectedProvider, + model = selectedModel, + useDefaultSkills = useDefaultSkills, + selectedSkills = selectedSkills, + addedHubSkills = addedHubSkills, + mcpServers = mcpServers, + ) + } + } + } + } + + // Stepper Navigation Footer + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (step > 1) { + OutlinedButton(onClick = { step-- }) { + Text(stringResource(R.string.profiles_builder_action_back)) + } + } else { + OutlinedButton(onClick = onCancel) { + Text(stringResource(R.string.action_cancel)) + } + } + + if (step < 5) { + Button( + onClick = { step++ }, + enabled = + when (step) { + 1 -> name.isNotBlank() && nameError == null + else -> true + }, + ) { + Text(stringResource(R.string.profiles_builder_action_next)) + } + } else { + Button( + onClick = { + val req = + CreateProfileRequest( + name = name, + description = description.ifBlank { null }, + provider = selectedProvider.ifBlank { null }, + model = selectedModel.ifBlank { null }, + mcp_servers = mcpServers.ifEmpty { null }, + keep_skills = if (useDefaultSkills) null else false, + hub_skills = addedHubSkills.ifEmpty { null }, + ) + viewModel.createProfile(req, onSuccess = onCancel) + }, + enabled = !state.isLoading, + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.profiles_builder_action_create)) + } + } + } + } + } +} + +private fun validateProfileName(name: String): String? { + if (!name.matches(Regex("^[a-zA-Z0-9_-]+$"))) { + return "Only letters, numbers, hyphens, and underscores allowed" + } + return null +} + +@Composable +private fun IdentityStep( + name: String, + onNameChange: (String) -> Unit, + nameError: String?, + description: String, + onDescriptionChange: (String) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.profiles_builder_label_profile_name)) }, + placeholder = { Text("my-agent-profile") }, + isError = nameError != null, + supportingText = nameError?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, + label = { Text(stringResource(R.string.profiles_builder_label_description)) }, + placeholder = { Text("Describe the purpose of this profile") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 4, + ) + } +} + +@Composable +private fun ModelStep( + providers: List, + selectedProvider: String, + onProviderChange: (String) -> Unit, + selectedModel: String, + onModelChange: (String) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.profiles_builder_title_provider), + style = MaterialTheme.typography.titleSmall, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + providers.forEach { provider -> + val isSelected = selectedProvider == provider.slug + OutlinedButton( + onClick = { onProviderChange(provider.slug) }, + modifier = Modifier.weight(1f), + colors = + if (isSelected) { + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + } else { + ButtonDefaults.outlinedButtonColors() + }, + ) { + Text(provider.name.uppercase()) + } + } + } + + if (selectedProvider.isNotBlank()) { + val providerObj = providers.find { it.slug == selectedProvider } + providerObj?.models?.let { models -> + Text( + text = stringResource(R.string.profiles_builder_title_model), + style = MaterialTheme.typography.titleSmall, + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + models.forEach { modelName -> + val isSelected = selectedModel == modelName + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable { onModelChange(modelName) }, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = modelName, + style = MaterialTheme.typography.bodyLarge, + ) + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.secondary, + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun SkillsStep( + availableSkills: List, + useDefaultSkills: Boolean, + onUseDefaultSkillsChange: (Boolean) -> Unit, + selectedSkills: Set, + onSelectedSkillsChange: (Set) -> Unit, + addedHubSkills: List, + onAddedHubSkillsChange: (List) -> Unit, + hubSearchResults: List, + isSearchingHub: Boolean, + onSearchHub: (String) -> Unit, +) { + var hubSearchQuery by remember { mutableStateOf("") } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = useDefaultSkills, + onCheckedChange = onUseDefaultSkillsChange, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = stringResource(R.string.profiles_builder_skills_default), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.profiles_builder_skills_default_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider() + + if (!useDefaultSkills) { + Text( + text = stringResource(R.string.profiles_builder_skills_local), + style = MaterialTheme.typography.titleSmall, + ) + LazyColumn( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(availableSkills) { skill -> + val isChecked = selectedSkills.contains(skill.name) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + if (isChecked) { + onSelectedSkillsChange(selectedSkills - skill.name) + } else { + onSelectedSkillsChange(selectedSkills + skill.name) + } + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { checked -> + if (checked == true) { + onSelectedSkillsChange(selectedSkills + skill.name) + } else { + onSelectedSkillsChange(selectedSkills - skill.name) + } + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = skill.name, + style = MaterialTheme.typography.bodyLarge, + ) + skill.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } else { + // Browse and add Skills from Hermes Hub + Text( + text = stringResource(R.string.profiles_builder_skills_hub_title), + style = MaterialTheme.typography.titleSmall, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = hubSearchQuery, + onValueChange = { hubSearchQuery = it }, + label = { Text(stringResource(R.string.profiles_builder_skills_search_hub)) }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { onSearchHub(hubSearchQuery) }, + enabled = hubSearchQuery.isNotBlank() && !isSearchingHub, + ) { + if (isSearchingHub) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + } + } + + if (addedHubSkills.isNotEmpty()) { + Text( + text = stringResource(R.string.profiles_builder_skills_added), + style = MaterialTheme.typography.labelMedium, + ) + LazyColumn( + modifier = + Modifier + .height(80.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(addedHubSkills) { skillName -> + Row( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = skillName, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton( + onClick = { onAddedHubSkillsChange(addedHubSkills - skillName) }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } + + if (hubSearchResults.isNotEmpty()) { + Text( + text = stringResource(R.string.profiles_builder_skills_search_results), + style = MaterialTheme.typography.labelMedium, + ) + LazyColumn( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(hubSearchResults) { skill -> + val isAdded = addedHubSkills.contains(skill.name) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = skill.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + skill.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!skill.source.isNullOrBlank()) { + Text( + text = "Source: ${skill.source}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontStyle = FontStyle.Italic, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (isAdded) { + onAddedHubSkillsChange(addedHubSkills - skill.name) + } else { + onAddedHubSkillsChange(addedHubSkills + skill.name) + } + }, + colors = + if (isAdded) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ) + } else { + ButtonDefaults.buttonColors() + }, + ) { + if (isAdded) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove", + modifier = Modifier.size(16.dp), + ) + } else { + Text("Add") + } + } + } + } + } + } + } + } +} + +@Composable +private fun McpStep( + mcpServers: List, + onMcpServersChange: (List) -> Unit, +) { + var showAddDialog by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.profiles_builder_mcp_title), + style = MaterialTheme.typography.titleSmall, + ) + Button(onClick = { showAddDialog = true }) { + Text(stringResource(R.string.profiles_builder_mcp_add)) + } + } + + if (mcpServers.isEmpty()) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.profiles_builder_mcp_none), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(mcpServers) { server -> + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = server.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Transport: ${server.transport.uppercase()}", + style = MaterialTheme.typography.bodySmall, + ) + if (server.transport == "sse") { + Text( + text = "URL: ${server.url}", + style = MaterialTheme.typography.labelSmall, + ) + } else { + Text( + text = "Command: ${server.command}", + style = MaterialTheme.typography.labelSmall, + ) + } + } + IconButton( + onClick = { onMcpServersChange(mcpServers - server) }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove Server", + ) + } + } + } + } + } + } + } + + if (showAddDialog) { + AddMcpDialog( + onDismiss = { showAddDialog = false }, + onAdd = { newServer -> + onMcpServersChange(mcpServers + newServer) + showAddDialog = false + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddMcpDialog( + onDismiss: () -> Unit, + onAdd: (McpServerConfigInput) -> Unit, +) { + var name by remember { mutableStateOf("") } + var transport by remember { mutableStateOf("sse") } + var url by remember { mutableStateOf("") } + var command by remember { mutableStateOf("") } + var argsInput by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.profiles_builder_mcp_dialog_title)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.profiles_builder_mcp_name)) }, + placeholder = { Text("postgres-mcp") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Text( + text = stringResource(R.string.profiles_builder_mcp_transport), + style = MaterialTheme.typography.labelMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { transport = "sse" }, + modifier = Modifier.weight(1f), + colors = + if (transport == "sse") { + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + } else { + ButtonDefaults.outlinedButtonColors() + }, + ) { + Text(stringResource(R.string.profiles_builder_mcp_transport_sse)) + } + + OutlinedButton( + onClick = { transport = "stdio" }, + modifier = Modifier.weight(1f), + colors = + if (transport == "stdio") { + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + } else { + ButtonDefaults.outlinedButtonColors() + }, + ) { + Text(stringResource(R.string.profiles_builder_mcp_transport_stdio)) + } + } + + if (transport == "sse") { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.profiles_builder_mcp_sse_url)) }, + placeholder = { Text("http://localhost:8000/sse") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } else { + OutlinedTextField( + value = command, + onValueChange = { command = it }, + label = { Text(stringResource(R.string.profiles_builder_mcp_command)) }, + placeholder = { Text("npx") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = argsInput, + onValueChange = { argsInput = it }, + label = { Text(stringResource(R.string.profiles_builder_mcp_args)) }, + placeholder = { + Text("-y, @modelcontextprotocol/server-postgres") + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val argsList = + if (argsInput.isNotBlank()) { + argsInput.split(",").map { it.trim() } + } else { + null + } + onAdd( + McpServerConfigInput( + name = name, + transport = transport, + url = if (transport == "sse") url else null, + command = if (transport == "stdio") command else null, + args = if (transport == "stdio") argsList else null, + ), + ) + }, + enabled = + name.isNotBlank() && ( + (transport == "sse" && url.isNotBlank()) || + (transport == "stdio" && command.isNotBlank()) + ), + ) { + Text(stringResource(R.string.profiles_builder_action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun ReviewStep( + name: String, + description: String, + provider: String, + model: String, + useDefaultSkills: Boolean, + selectedSkills: Set, + addedHubSkills: List, + mcpServers: List, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_builder_review_name), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + if (description.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.profiles_builder_review_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_builder_review_model_settings), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (provider.isNotBlank() && model.isNotBlank()) { + Text( + text = stringResource(R.string.profiles_builder_review_provider, provider), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.profiles_builder_review_model, model), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = stringResource(R.string.profiles_builder_review_system_defaults), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_builder_review_skills_title), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (useDefaultSkills) { + Text( + text = stringResource(R.string.profiles_builder_review_skills_default), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = stringResource(R.string.profiles_builder_review_skills_local, selectedSkills.size), + style = MaterialTheme.typography.bodyMedium, + ) + } + + if (addedHubSkills.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.profiles_builder_review_skills_hub, addedHubSkills.size), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_builder_review_mcp_title), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (mcpServers.isNotEmpty()) { + mcpServers.forEach { server -> + Text( + text = "- ${server.name} (${server.transport.uppercase()})", + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + Text( + text = stringResource(R.string.profiles_builder_review_mcp_none), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index 94880efd..47a96970 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -2,8 +2,14 @@ package com.m57.hermescontrol.ui.profiles import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.CloneProfileRequest +import com.m57.hermescontrol.data.model.CreateProfileRequest +import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.SetActiveProfileRequest +import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest import com.m57.hermescontrol.data.remote.ApiClient @@ -26,9 +32,17 @@ data class ProfilesUiState( val isLoadingSoul: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, + // Profile Builder states + val modelProviders: List = emptyList(), + val isLoadingBuilderData: Boolean = false, + val availableSkills: List = emptyList(), + val hubSearchResults: List = emptyList(), + val isSearchingHub: Boolean = false, ) -class ProfilesViewModel : ViewModel(), ToastHost { +class ProfilesViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(ProfilesUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -197,4 +211,174 @@ class ProfilesViewModel : ViewModel(), ToastHost { fun closeSoulDialog() { _uiState.update { it.copy(selectedSoulContent = null) } } + + fun cloneProfile( + sourceProfileName: String, + newProfileName: String, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.cloneProfile( + sourceProfileName, + CloneProfileRequest(newProfileName), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Profile '$sourceProfileName' cloned successfully to '$newProfileName'", + ) + } + loadProfiles() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to clone profile '$sourceProfileName': ${result.error.message}", + ) + } + } + } + } + } + + fun updateProfileDescription( + profileName: String, + description: String, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.updateProfileDescription( + profileName, + UpdateProfileDescriptionRequest(description), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Profile '$profileName' description updated", + ) + } + loadProfiles() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to update description for '$profileName': ${result.error.message}", + ) + } + } + } + } + } + + fun loadBuilderData() { + _uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) } + viewModelScope.launch { + val modelsResult = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getModelOptions() } + } + val skillsResult = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getSkills() } + } + + if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) { + _uiState.update { + it.copy( + isLoadingBuilderData = false, + modelProviders = modelsResult.data.providers, + availableSkills = skillsResult.data, + ) + } + } else { + val modelsError = (modelsResult as? NetworkResult.Failure)?.error?.message ?: "Success" + val skillsError = (skillsResult as? NetworkResult.Failure)?.error?.message ?: "Success" + _uiState.update { + it.copy( + isLoadingBuilderData = false, + errorMessage = "Failed to load builder data: Models: $modelsError, Skills: $skillsError", + ) + } + } + } + } + + fun searchHub(query: String) { + if (query.isBlank()) return + _uiState.update { it.copy(isSearchingHub = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.searchSkillsHub(query) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isSearchingHub = false, + hubSearchResults = result.data, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isSearchingHub = false, + toastMessage = "Failed to search skills hub: ${result.error.message}", + ) + } + } + } + } + } + + fun createProfile( + request: CreateProfileRequest, + onSuccess: () -> Unit, + ) { + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.createProfile(request) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Profile ${request.name} created successfully", + ) + } + loadProfiles() + onSuccess() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to create profile: ${result.error.message}", + ) + } + } + } + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index aa9b69a4..8d722499 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -245,7 +245,8 @@ fun SessionsScreen( val sessionTitle = state.sessions .find { it.id == sessionToDelete } - ?.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) + ?.title + ?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) AlertDialog( onDismissRequest = { viewModel.cancelDeleteSession() }, title = { Text(stringResource(R.string.sessions_delete_title)) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index a45b0a3b..c5a18cc2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -157,12 +157,14 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(16.dp)) } + SettingsTab.BEHAVIOR -> { BehaviorSection( autoReconnect = state.autoReconnect, onAutoReconnectChange = viewModel::onAutoReconnectChange, ) } + SettingsTab.APPEARANCE -> { AppearanceSection( themePreference = state.themePreference, @@ -190,6 +192,7 @@ fun SettingsScreen( onAddNavItem = viewModel::addNavItem, ) } + SettingsTab.ABOUT -> { // ── About section ───────────────────────────────────────── SectionCard(title = stringResource(R.string.settings_sec_about)) { @@ -876,30 +879,41 @@ private fun AppearanceSection( text = { Text( when (preset) { - ThemePreset.DEFAULT -> + ThemePreset.DEFAULT -> { stringResource( R.string.theme_preset_default, ) - ThemePreset.MONOCHROME -> + } + + ThemePreset.MONOCHROME -> { stringResource( R.string.theme_preset_monochrome, ) - ThemePreset.GRUVBOX -> + } + + ThemePreset.GRUVBOX -> { stringResource( R.string.theme_preset_gruvbox, ) - ThemePreset.CATPPUCCIN -> + } + + ThemePreset.CATPPUCCIN -> { stringResource( R.string.theme_preset_catppuccin, ) - ThemePreset.AMOLED -> + } + + ThemePreset.AMOLED -> { stringResource( R.string.theme_preset_amoled, ) - ThemePreset.NEON_NOIR -> + } + + ThemePreset.NEON_NOIR -> { stringResource( R.string.theme_preset_neon_noir, ) + } }, ) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 237bd7aa..6d382bdf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -88,14 +88,13 @@ import com.m57.hermescontrol.ui.common.ToastEffect // ── Helpers ───────────────────────────────────────────────────────────── -private fun formatBytes(bytes: Long): String { - return when { +private fun formatBytes(bytes: Long): String = + when { bytes < 1024 -> "$bytes B" bytes < 1024 * 1024 -> "${bytes / 1024} KB" bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" else -> "${"%.1f".format(bytes.toDouble() / (1024 * 1024 * 1024))} GB" } -} private fun formatDuration(totalSeconds: Double): String { val days = (totalSeconds / 86400).toInt() @@ -341,8 +340,7 @@ private fun LazyListScope.hostSection( stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) } stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) } stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) } - stats.python_version?.let { - python -> + stats.python_version?.let { python -> val impl = stats.python_impl?.let { " ($it)" } ?: "" InfoRow(stringResource(R.string.system_label_python), "$python$impl") } @@ -352,14 +350,21 @@ private fun LazyListScope.hostSection( val updateBadge = state.updateInfo?.let { info -> when { - info.update_available == true && info.behind != null && info.behind > 0 -> + info.update_available == true && info.behind != null && info.behind > 0 -> { " (${stringResource( R.string.system_version_update_available, )}: ${stringResource(R.string.system_version_behind, info.behind)})" - info.update_available == true -> " (${stringResource( - R.string.system_version_update_available, - )})" - else -> " (${stringResource(R.string.system_version_latest)})" + } + + info.update_available == true -> { + " (${stringResource( + R.string.system_version_update_available, + )})" + } + + else -> { + " (${stringResource(R.string.system_version_latest)})" + } } } ?: "" InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge") @@ -620,10 +625,12 @@ private fun LazyListScope.curatorSection( statusText = stringResource(R.string.system_status_disabled) statusType = StatusBadgeType.ERROR } + curator.paused == true -> { statusText = stringResource(R.string.system_status_paused) statusType = StatusBadgeType.WARNING } + else -> { statusText = stringResource(R.string.system_status_active) statusType = StatusBadgeType.SUCCESS diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index d31823f0..96b92ac6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -77,7 +77,9 @@ data class SystemUiState( val updateConfirmOpen: Boolean = false, ) -class SystemViewModel : ViewModel(), ToastHost { +class SystemViewModel : + ViewModel(), + ToastHost { companion object { private const val TAG = "SystemViewModel" } @@ -186,6 +188,7 @@ class SystemViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(toastMessage = "Gateway ${name}ed successfully") } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to $name gateway: ${result.error.message}") } } @@ -206,6 +209,7 @@ class SystemViewModel : ViewModel(), ToastHost { is NetworkResult.Success -> { _uiState.update { it.copy(checkingUpdate = false, updateInfo = result.data) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -251,6 +255,7 @@ class SystemViewModel : ViewModel(), ToastHost { } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to toggle curator: ${result.error.message}") } } @@ -278,6 +283,7 @@ class SystemViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to reset memory: ${result.error.message}") } } @@ -329,6 +335,7 @@ class SystemViewModel : ViewModel(), ToastHost { } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy(addingCred = false, toastMessage = "Failed to add credential: ${result.error.message}") @@ -352,6 +359,7 @@ class SystemViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(toastMessage = "Credential removed") } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to remove credential: ${result.error.message}") } } @@ -411,6 +419,7 @@ class SystemViewModel : ViewModel(), ToastHost { } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${result.error.message}") } } @@ -433,6 +442,7 @@ class SystemViewModel : ViewModel(), ToastHost { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Backup downloaded") } } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to download backup: ${result.error.message}") } } @@ -475,6 +485,7 @@ class SystemViewModel : ViewModel(), ToastHost { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -556,6 +567,7 @@ class SystemViewModel : ViewModel(), ToastHost { } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -582,6 +594,7 @@ class SystemViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(toastMessage = "Hook deleted") } loadAll() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to delete hook: ${result.error.message}") } } @@ -631,6 +644,7 @@ class SystemViewModel : ViewModel(), ToastHost { result.data?.name?.let { pollActionStatus(it) } _uiState.update { it.copy(toastMessage = "$label started") } } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "$label failed: ${result.error.message}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt index e25fa670..03fed24f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt @@ -24,7 +24,9 @@ data class ToolsetsUiState( val toastMessage: String? = null, ) -class ToolsetsViewModel : ViewModel(), ToastHost { +class ToolsetsViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(ToolsetsUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt index d76fbea8..bf39c48b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt @@ -42,7 +42,9 @@ data class WebhooksUiState( val togglingName: String? = null, ) -class WebhooksViewModel : ViewModel(), ToastHost { +class WebhooksViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(WebhooksUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -185,6 +187,7 @@ class WebhooksViewModel : ViewModel(), ToastHost { } loadWebhooks() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -225,6 +228,7 @@ class WebhooksViewModel : ViewModel(), ToastHost { } loadWebhooks() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to toggle \"$name\": ${result.error.message}") @@ -261,6 +265,7 @@ class WebhooksViewModel : ViewModel(), ToastHost { } loadWebhooks() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to delete \"${target.name}\": ${result.error.message}") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbe84631..437d0c1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -505,14 +505,70 @@ Connection profiles from Hermes will appear here. Model: %1$s Provider: %1$s + %1$d skills + Path: %1$s + Auto-generated Activate Edit Soul Set Model + Clone + Edit Description Active Profile Edit Soul Profile: %1$s Set Model: %1$s + Clone Profile: %1$s + Edit Description: %1$s Provider Model Name + New Profile Name + Description + No description provided. Describe this profile\'s role. + Step %1$d of 5 + Identity + Model Configuration + Skills selection + MCP Servers + Review & Create + Back + Next + Create Profile + Profile Name + Description + Select Provider (Optional) + Select Model + Include default skills bundle + Automatically bundles common system tools + Select Local Skills + Search Hub + Add Skills from Hermes Hub + Added Skills: + Search Results: + Configured MCP Servers + Add Server + Server Name + Transport Protocol + Add MCP Server + Stdio Command + SSE Server URL + Command + Arguments (comma-separated) + SSE URL + Environment Variables (KEY=VALUE, one per line) + Review Profile Details + Profile Name + Description + Provider: %1$s + Model: %1$s + Model Settings + Using system defaults + Skills Configuration + Full Default Bundle + %1$d local skills selected + %1$d hub skills added + MCP Servers + No MCP servers configured + No MCP servers configured yet + Add No plugins found diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 3a103fa5..b659306c 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -1005,7 +1005,8 @@ class E2eIntegrationTest { assertEquals( 50.0, - viewModel.uiState.value.stats?.cpu_percent, + viewModel.uiState.value.stats + ?.cpu_percent, ) viewModel.triggerBackup() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index c50f0462..fd286ad6 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -574,16 +574,26 @@ class ConnectViewModelTest { fun testMultipleProfiles_loadedOnInit() { val profiles = listOf( - com.m57.hermescontrol.data.model.ConnectionProfile("a", "Alpha", "10.0.0.1", 9119), - com.m57.hermescontrol.data.model.ConnectionProfile("b", "Beta", "10.0.0.2", 9220), + com.m57.hermescontrol.data.model + .ConnectionProfile("a", "Alpha", "10.0.0.1", 9119), + com.m57.hermescontrol.data.model + .ConnectionProfile("b", "Beta", "10.0.0.2", 9220), ) every { AuthManager.getConnectionProfiles() } returns profiles every { AuthManager.getSelectedProfileId() } returns null val viewModel = ConnectViewModel(mockApp) assertEquals(2, viewModel.uiState.value.profiles.size) - assertEquals("Alpha", viewModel.uiState.value.profiles[0].name) - assertEquals("Beta", viewModel.uiState.value.profiles[1].name) + assertEquals( + "Alpha", + viewModel.uiState.value.profiles[0] + .name, + ) + assertEquals( + "Beta", + viewModel.uiState.value.profiles[1] + .name, + ) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt index dcd8596d..8c6fe7f5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt @@ -67,7 +67,10 @@ class ModelViewModelTest { @Test fun `init with empty pinned models`() { val viewModel = createViewModel() - assertTrue(viewModel.uiState.value.pinnedModels.isEmpty()) + assertTrue( + viewModel.uiState.value.pinnedModels + .isEmpty(), + ) } @Test @@ -178,7 +181,10 @@ class ModelViewModelTest { viewModel.unpinModel("openai", "gpt-4") testDispatcher.scheduler.advanceUntilIdle() - assertTrue(viewModel.uiState.value.pinnedModels.isEmpty()) + assertTrue( + viewModel.uiState.value.pinnedModels + .isEmpty(), + ) // Re-pin same model after unpin viewModel.pinModel("openai", "gpt-4")