feat(#441): add profile cloning and description editing (#463)

* feat(#441): add profile cloning and description editing

* feat(#441): add profile builder wizard and API enhancements

Introduce 5-step Profile Builder wizard into ProfilesScreen and add support for create/clone endpoints.

* fix(#441): restore imports and add ProfileInfo constructor defaults

* style: run ktlint format on codebase to fix styling errors

* fix(#441): address PR review comments including provider slug bug, validation, and strings

* fix(#441): remove unused McpServerConfigInput import

---------

Co-authored-by: Hermes Agent <hermes@agent.local>
This commit is contained in:
M57andHermes Agent authored and GitHub committed 2026-07-03 22:24:42 +03:00
1 parent 846a9380e2
commit d181851670
37 files changed
+2299 -448

No files matched your search

@@ -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 =
@@ -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<ChannelsUiState> = _uiState.asStateFlow()
@@ -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<String>()
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<String>()
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(
@@ -31,7 +31,6 @@ class ChatPersistenceRepository(
}
/** Load cached messages for a session from Room. */
suspend fun loadMessages(sessionId: String): List<ChatMessage> {
return dao.getMessagesForSession(sessionId).map { it.toUiModel() }
}
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
dao.getMessagesForSession(sessionId).map { it.toUiModel() }
}
@@ -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
}
/**
@@ -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()
}
}
@@ -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(
@@ -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<ConfigUiState> = _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(
@@ -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<String> =
skills.lines()
skills
.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
@@ -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<GatewayUiState> = _uiState.asStateFlow()
@@ -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]
@@ -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<KanbanUiState> = _uiState.asStateFlow()
@@ -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<KeysUiState> = _uiState.asStateFlow()
@@ -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
}
}
}
@@ -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<LogsUiState> = _uiState.asStateFlow()
@@ -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<McpServersUiState> = _uiState.asStateFlow()
@@ -305,7 +305,8 @@ private fun ModelSettingsSection(
) {
val auxOverrideCount =
auxTasks?.let { tasks ->
tasks.filterIsInstance<com.m57.hermescontrol.data.model.AuxiliaryTaskAssignment>()
tasks
.filterIsInstance<com.m57.hermescontrol.data.model.AuxiliaryTaskAssignment>()
.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]
@@ -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(
@@ -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) }
@@ -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<PairingUiState> = _uiState.asStateFlow()
@@ -51,7 +51,9 @@ data class PluginsUiState(
}
}
class PluginsViewModel : ViewModel(), ToastHost {
class PluginsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _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)
File diff suppressed because it is too large. Load diff
@@ -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<ModelProvider> = emptyList(),
val isLoadingBuilderData: Boolean = false,
val availableSkills: List<Skill> = emptyList(),
val hubSearchResults: List<HubSkill> = emptyList(),
val isSearchingHub: Boolean = false,
)
class ProfilesViewModel : ViewModel(), ToastHost {
class ProfilesViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ProfilesUiState())
val uiState: StateFlow<ProfilesUiState> = _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}",
)
}
}
}
}
}
}
@@ -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)) },
@@ -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,
)
}
},
)
},
@@ -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
@@ -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}") }
}
@@ -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<ToolsetsUiState> = _uiState.asStateFlow()
@@ -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<WebhooksUiState> = _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}")