* feat(ws): add commands.catalog RPC constants and data models * feat(catalog): fetch commands.catalog on WS gateway ready, store in state * feat(commands): rewrite dispatcher to forward non-local commands via WS command.dispatch * feat(input): always-visible send button + compact stop icon, allow / commands mid-turn * test(commands): update tests for WS-based command dispatch * fix: add missing CommandCatalog + JsonRpcError imports * fix(test): assert errorMessage instead of messages for unknown command * chore: remove unused CommandDispatchResponse data class --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
ef4c14e550
commit
fafba3231b
6 files changed
+241
-98
No files matched your search
@@ -0,0 +1,22 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
/**
|
||||
* Response from `commands.catalog` RPC — full catalog of available
|
||||
* slash commands, subcommands, and alias mappings.
|
||||
*/
|
||||
data class CommandCatalog(
|
||||
val pairs: List<List<String>> = emptyList(),
|
||||
val sub: Map<String, List<String>> = emptyMap(),
|
||||
val canon: Map<String, String> = emptyMap(),
|
||||
val categories: List<CommandCategory> = emptyList(),
|
||||
val skillCount: Int = 0,
|
||||
val warning: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* A named category within the command catalog.
|
||||
*/
|
||||
data class CommandCategory(
|
||||
val name: String,
|
||||
val pairs: List<List<String>>,
|
||||
)
|
||||
@@ -2,16 +2,24 @@ package com.m57.hermescontrol.data.ws
|
||||
|
||||
/** JSON-RPC method name constants used with [HermesWsClient.send]. */
|
||||
object WsMethods {
|
||||
// ── Session ───────────────────────────────────────────────────────────
|
||||
const val SESSION_LIST = "session.list"
|
||||
const val SESSION_ACTIVE_LIST = "session.active_list"
|
||||
const val SESSION_STATUS = "session.status"
|
||||
const val SESSION_HISTORY = "session.history"
|
||||
const val SESSION_RESUME = "session.resume"
|
||||
const val SESSION_CREATE = "session.create"
|
||||
const val PROMPT_SUBMIT = "prompt.submit"
|
||||
const val SESSION_INTERRUPT = "session.interrupt"
|
||||
const val SESSION_DELETE = "session.delete"
|
||||
const val SESSION_TITLE = "session.title"
|
||||
|
||||
// ── Interaction ───────────────────────────────────────────────────────
|
||||
const val PROMPT_SUBMIT = "prompt.submit"
|
||||
const val CLARIFY_RESPOND = "clarify.respond"
|
||||
const val APPROVAL_RESPOND = "approval.respond"
|
||||
|
||||
// ── Commands catalog ──────────────────────────────────────────────────
|
||||
const val COMMANDS_CATALOG = "commands.catalog"
|
||||
const val COMMAND_DISPATCH = "command.dispatch"
|
||||
const val COMMAND_RESOLVE = "command.resolve"
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -368,7 +369,9 @@ private fun ChatInputBar(
|
||||
isAgentTyping: Boolean,
|
||||
isConnected: Boolean,
|
||||
) {
|
||||
val canSend = inputText.isNotBlank() && !isAgentTyping && isConnected
|
||||
// Allow sending slash commands even while agent is typing
|
||||
val isSlashCommand = inputText.startsWith("/")
|
||||
val canSend = inputText.isNotBlank() && isConnected && (!isAgentTyping || isSlashCommand)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
@@ -508,40 +511,41 @@ private fun ChatInputBar(
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
// Send button — always visible, enabled for slash commands mid-turn
|
||||
FilledTonalButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.testTag("send_button"),
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send_desc),
|
||||
)
|
||||
}
|
||||
|
||||
// Compact stop icon — visible only while agent is streaming
|
||||
if (isAgentTyping) {
|
||||
// Interrupt button
|
||||
FilledTonalButton(
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onInterrupt,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.size(32.dp)
|
||||
.testTag("interrupt_button"),
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = stringResource(R.string.content_desc_interrupt),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Send button
|
||||
FilledTonalButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.testTag("send_button"),
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send_desc),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
@@ -58,6 +59,8 @@ data class ChatUiState(
|
||||
// Cached settings
|
||||
val typingEffectEnabled: Boolean = true,
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
// Commands catalog
|
||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||
) {
|
||||
/** Convenience — derived from [connectionStatus]. */
|
||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||
@@ -196,6 +199,7 @@ class ChatViewModel(
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
fetchCommandCatalog()
|
||||
if (_uiState.value.currentSessionId == null) {
|
||||
val initial = initialSessionId
|
||||
if (!initial.isNullOrBlank()) {
|
||||
@@ -421,6 +425,55 @@ class ChatViewModel(
|
||||
streamingMessageId = null
|
||||
addSystemMessage("Session interrupted")
|
||||
}
|
||||
|
||||
WsMethods.COMMANDS_CATALOG -> {
|
||||
val map = result as? Map<*, *> ?: return
|
||||
val catalog = parseCommandCatalog(map)
|
||||
if (catalog != null) {
|
||||
_uiState.update { it.copy(commandCatalog = catalog) }
|
||||
}
|
||||
}
|
||||
|
||||
WsMethods.COMMAND_DISPATCH -> {
|
||||
handleDispatchResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun handleDispatchResult(result: Any?) {
|
||||
val map = result as? Map<*, *> ?: return
|
||||
val type = map["type"] as? String ?: return
|
||||
when (type) {
|
||||
"send" -> {
|
||||
val message = map["message"] as? String ?: ""
|
||||
submitPrompt(message)
|
||||
}
|
||||
|
||||
"exec" -> {
|
||||
val output = map["output"] as? String ?: map["message"] as? String ?: ""
|
||||
addAssistantMessage(output)
|
||||
}
|
||||
|
||||
"skill" -> {
|
||||
val message = map["message"] as? String ?: ""
|
||||
submitPrompt(message)
|
||||
}
|
||||
|
||||
"plugin" -> {
|
||||
val output = map["output"] as? String ?: ""
|
||||
addAssistantMessage(output)
|
||||
}
|
||||
|
||||
"alias" -> {
|
||||
val target = map["target"] as? String ?: return
|
||||
handleSlashCommand(target)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val output = map["output"] as? String ?: map.toString()
|
||||
addAssistantMessage(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,10 +550,6 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
when (val result = slashDispatcher.dispatch(command)) {
|
||||
is SlashResult.Message -> {
|
||||
addAssistantMessage(result.text)
|
||||
}
|
||||
|
||||
is SlashResult.Interrupt -> {
|
||||
interruptSession()
|
||||
}
|
||||
@@ -509,23 +558,45 @@ class ChatViewModel(
|
||||
createNewSession()
|
||||
}
|
||||
|
||||
is SlashResult.FetchStatus -> {
|
||||
fetchAndDisplayStatus()
|
||||
is SlashResult.RpcDispatch -> {
|
||||
dispatchViaRpc(command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SlashResult.FetchSessions -> {
|
||||
fetchAndDisplaySessions()
|
||||
}
|
||||
private fun dispatchViaRpc(command: String) {
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (sessionId == null) {
|
||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||
return
|
||||
}
|
||||
val parts = command.split(" ", limit = 2)
|
||||
val name = parts[0].lowercase().removePrefix("/")
|
||||
val arg = parts.getOrElse(1) { "" }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.COMMAND_DISPATCH,
|
||||
mapOf("name" to name, "arg" to arg, "session_id" to sessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.COMMAND_DISPATCH) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is SlashResult.FetchStats -> {
|
||||
fetchAndDisplayStats()
|
||||
}
|
||||
|
||||
is SlashResult.Unknown -> {
|
||||
addAssistantMessage(
|
||||
"Unknown command: `${result.cmd}`. Type `/help` to view a list of available commands.",
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Submits [text] as a prompt to the current session via WS, without
|
||||
* adding a duplicate user message. Used by [handleDispatchResult] when
|
||||
* a slash command resolves to a normal user prompt (e.g. `/queue` → "help me").
|
||||
*/
|
||||
private fun submitPrompt(text: String) {
|
||||
if (text.isBlank()) return
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
_uiState.update { it.copy(isAgentTyping = true) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.sendMessage(
|
||||
sessionId,
|
||||
text,
|
||||
onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,6 +738,15 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchCommandCatalog() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.COMMANDS_CATALOG,
|
||||
onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCurrentSession() {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
loadCachedMessages(sessionId)
|
||||
@@ -682,6 +762,18 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? {
|
||||
return try {
|
||||
val gson = com.google.gson.Gson()
|
||||
val json = gson.toJson(map)
|
||||
gson.fromJson(json, CommandCatalog::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse command catalog", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
|
||||
|
||||
@@ -5,59 +5,33 @@ package com.m57.hermescontrol.ui.chat
|
||||
* the ViewModel should take.
|
||||
*
|
||||
* Pure logic — no I/O, no Android dependencies.
|
||||
*
|
||||
* Only commands that MUST be handled client-side (immediate UX) are here.
|
||||
* Everything else is forwarded to the backend via [SlashResult.RpcDispatch].
|
||||
*/
|
||||
class SlashCommandDispatcher {
|
||||
fun dispatch(command: String): SlashResult {
|
||||
val parts = command.split(" ")
|
||||
val parts = command.split(" ", limit = 2)
|
||||
val cmd = parts[0].lowercase()
|
||||
|
||||
return when (cmd) {
|
||||
"/stop", "/interrupt" -> SlashResult.Interrupt
|
||||
"/new" -> SlashResult.NewSession
|
||||
"/help" -> SlashResult.Message(HELP_TEXT)
|
||||
"/status" -> SlashResult.FetchStatus
|
||||
"/sessions" -> SlashResult.FetchSessions
|
||||
"/stats", "/system" -> SlashResult.FetchStats
|
||||
else -> SlashResult.Unknown(cmd)
|
||||
else -> SlashResult.RpcDispatch
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val HELP_TEXT =
|
||||
"""
|
||||
**Available Commands:**
|
||||
• `/help` - Show this help menu
|
||||
• `/status` - Check gateway and platform status
|
||||
• `/sessions` - List all chat sessions
|
||||
• `/stats` or `/system` - Check system resource usage
|
||||
• `/new` - Create a new chat session
|
||||
• `/stop` or `/interrupt` - Interrupt the active run
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of dispatching a slash command.
|
||||
*/
|
||||
sealed class SlashResult {
|
||||
/** Display a static message in the chat. */
|
||||
data class Message(val text: String) : SlashResult()
|
||||
|
||||
/** Interrupt the active session. */
|
||||
/** Interrupt the active session (client-side immediate). */
|
||||
data object Interrupt : SlashResult()
|
||||
|
||||
/** Create a new session. */
|
||||
/** Create a new session (client-side immediate). */
|
||||
data object NewSession : SlashResult()
|
||||
|
||||
/** Fetch gateway status via REST and display. */
|
||||
data object FetchStatus : SlashResult()
|
||||
|
||||
/** Fetch session list via REST and display. */
|
||||
data object FetchSessions : SlashResult()
|
||||
|
||||
/** Fetch system stats via REST and display. */
|
||||
data object FetchStats : SlashResult()
|
||||
|
||||
/** Command not recognised. */
|
||||
data class Unknown(val cmd: String) : SlashResult()
|
||||
/** Forward to command.dispatch via WebSocket. */
|
||||
data object RpcDispatch : SlashResult()
|
||||
}
|
||||
Reference in new issue
Block a user