* 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:
1 parent
846a9380e2
commit
d181851670
37 files changed
+2299
-448
No files matched your search
@@ -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<McpServerConfigInput>?,
|
||||
val keep_skills: Boolean?,
|
||||
val hub_skills: List<String>?,
|
||||
)
|
||||
|
||||
data class McpServerConfigInput(
|
||||
val name: String,
|
||||
val transport: String,
|
||||
val url: String? = null,
|
||||
val command: String? = null,
|
||||
val args: List<String>? = null,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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<List<Skill>>
|
||||
|
||||
@POST("api/skills/hub/search")
|
||||
suspend fun searchSkillsHub(
|
||||
@Query("q") q: String,
|
||||
): Response<List<HubSkill>>
|
||||
|
||||
@PUT("api/skills/toggle")
|
||||
suspend fun toggleSkill(
|
||||
@Body body: ToggleSkillRequest,
|
||||
@@ -202,6 +211,11 @@ interface HermesApiService {
|
||||
@GET("api/profiles")
|
||||
suspend fun getProfiles(): Response<ProfilesResponse>
|
||||
|
||||
@POST("api/profiles")
|
||||
suspend fun createProfile(
|
||||
@Body body: CreateProfileRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/profiles/active")
|
||||
suspend fun getActiveProfile(): Response<ActiveProfileResponse>
|
||||
|
||||
@@ -227,6 +241,18 @@ interface HermesApiService {
|
||||
@Body body: UpdateProfileModelRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/profiles/{name}/clone")
|
||||
suspend fun cloneProfile(
|
||||
@Path("name") name: String,
|
||||
@Body body: CloneProfileRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("api/profiles/{name}/description")
|
||||
suspend fun updateProfileDescription(
|
||||
@Path("name") name: String,
|
||||
@Body body: UpdateProfileDescriptionRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/tools/toolsets")
|
||||
suspend fun getToolsets(): Response<List<Toolset>>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in new issue
Block a user