Initial commit of HermesControl Android app
This commit is contained in:
commit
d6ff3de594
92 files changed
+10004
No files matched your search
@@ -0,0 +1,106 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
|
||||
*
|
||||
* The Hermes TUI gateway sends events as JSON-RPC **notifications** (no `id`
|
||||
* field). The `method` is always `"event"` and the event type lives in
|
||||
* `params.type`. The event payload is in `params.payload`.
|
||||
*
|
||||
* Regular RPC responses have an `id` and either a `result` or `error`.
|
||||
*/
|
||||
object EventParser {
|
||||
private const val TAG = "EventParser"
|
||||
|
||||
fun parse(
|
||||
response: JsonRpcResponse,
|
||||
rawJson: String = "",
|
||||
): WsEvent {
|
||||
// ── RPC response (has id) ────────────────────────────────────────
|
||||
val id = response.id
|
||||
if (id != null) {
|
||||
return if (response.error != null) {
|
||||
WsEvent.RpcError(id, response.error)
|
||||
} else {
|
||||
WsEvent.RpcResult(id, response.result)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification / event (no id, has method) ─────────────────────
|
||||
val params = response.params ?: return WsEvent.Unknown(rawJson)
|
||||
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val payload = params["payload"] as? Map<String, Any?>
|
||||
|
||||
val sessionId = payload?.get("session_id") as? String
|
||||
|
||||
return when (eventType) {
|
||||
"gateway.ready" -> {
|
||||
WsEvent.GatewayReady(payload)
|
||||
}
|
||||
|
||||
"session.info" -> {
|
||||
WsEvent.SessionInfo(payload)
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
WsEvent.MessageStart(sessionId)
|
||||
}
|
||||
|
||||
"message.token" -> {
|
||||
val token = payload?.get("text") as? String ?: ""
|
||||
WsEvent.MessageToken(token, sessionId)
|
||||
}
|
||||
|
||||
"thinking.delta" -> {
|
||||
val token = payload?.get("text") as? String ?: ""
|
||||
WsEvent.ThinkingDelta(token, sessionId)
|
||||
}
|
||||
|
||||
"message.complete" -> {
|
||||
val text = payload?.get("text") as? String ?: ""
|
||||
WsEvent.MessageComplete(text, sessionId)
|
||||
}
|
||||
|
||||
"message.done" -> {
|
||||
WsEvent.MessageDone(sessionId)
|
||||
}
|
||||
|
||||
"tool.start" -> {
|
||||
val name = payload?.get("name") as? String
|
||||
WsEvent.ToolStart(name, payload)
|
||||
}
|
||||
|
||||
"tool.complete" -> {
|
||||
val name = payload?.get("name") as? String
|
||||
WsEvent.ToolComplete(name, payload)
|
||||
}
|
||||
|
||||
"clarify.request" -> {
|
||||
val text = payload?.get("text") as? String
|
||||
val rawOptions = payload?.get("options")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val options = (rawOptions as? List<*>)?.filterIsInstance<String>()
|
||||
WsEvent.ClarifyRequest(text, options)
|
||||
}
|
||||
|
||||
"status.update" -> {
|
||||
val status = payload?.get("status") as? String
|
||||
WsEvent.StatusUpdate(status, payload)
|
||||
}
|
||||
|
||||
"session.updated" -> {
|
||||
WsEvent.SessionUpdated(payload)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown event type: $eventType")
|
||||
WsEvent.Unknown(rawJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface.
|
||||
*
|
||||
* Connects to `ws://HOST:PORT/api/ws?token=TOKEN`, auto-reconnects with
|
||||
* exponential backoff, and emits parsed [WsEvent]s via [events] SharedFlow
|
||||
* as well as direct callbacks.
|
||||
*/
|
||||
object HermesWsClient {
|
||||
private const val TAG = "HermesWsClient"
|
||||
|
||||
// ── Backoff settings ─────────────────────────────────────────────────
|
||||
|
||||
private const val INITIAL_BACKOFF_MS = 1_000L
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
|
||||
// ── Internal state (all access through synchronized / atomic) ────────
|
||||
|
||||
private val gson = Gson()
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val connected = AtomicBoolean(false)
|
||||
private val intentionalClose = AtomicBoolean(false)
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var currentBackoff = INITIAL_BACKOFF_MS
|
||||
|
||||
private val okHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// ── Public observable stream ─────────────────────────────────────────
|
||||
|
||||
private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
||||
|
||||
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
|
||||
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
|
||||
|
||||
// ── Callbacks (legacy / convenience) ─────────────────────────────────
|
||||
|
||||
var onMessage: ((WsEvent) -> Unit)? = null
|
||||
var onConnected: (() -> Unit)? = null
|
||||
var onDisconnected: ((reason: String?) -> Unit)? = null
|
||||
var onError: ((Throwable) -> Unit)? = null
|
||||
|
||||
// ── Connection helpers ────────────────────────────────────────────────
|
||||
|
||||
val isConnected: Boolean get() = connected.get()
|
||||
|
||||
/** Open a WebSocket connection using settings from [AuthManager]. */
|
||||
fun connect() {
|
||||
if (connected.get()) {
|
||||
Log.d(TAG, "Already connected — skipping")
|
||||
return
|
||||
}
|
||||
intentionalClose.set(false)
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
openSocket()
|
||||
}
|
||||
|
||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||
fun disconnect() {
|
||||
intentionalClose.set(true)
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
connected.set(false)
|
||||
}
|
||||
|
||||
// ── Send helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request with the given [method] and optional [params].
|
||||
* @return the request id used (can be matched against [WsEvent.RpcResult]).
|
||||
*/
|
||||
fun send(
|
||||
method: String,
|
||||
params: Map<String, Any> = emptyMap(),
|
||||
): String {
|
||||
val id = requestId.incrementAndGet().toString()
|
||||
val request = JsonRpcRequest(id = id, method = method, params = params)
|
||||
val json = gson.toJson(request)
|
||||
Log.d(TAG, "→ $json")
|
||||
webSocket?.send(json) ?: Log.w(TAG, "send() called while disconnected")
|
||||
return id
|
||||
}
|
||||
|
||||
/** Convenience: submit a user prompt to an existing session. */
|
||||
fun sendMessage(
|
||||
sessionId: String,
|
||||
text: String,
|
||||
): String =
|
||||
send(
|
||||
method = WsMethods.PROMPT_SUBMIT,
|
||||
params = mapOf("session_id" to sessionId, "text" to text),
|
||||
)
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun openSocket() {
|
||||
val url = AuthManager.wsUrl()
|
||||
Log.d(TAG, "Connecting to $url")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (intentionalClose.get()) return
|
||||
if (!AuthManager.isAutoReconnect()) {
|
||||
Log.d(TAG, "Auto-reconnect disabled")
|
||||
return
|
||||
}
|
||||
val delay = currentBackoff
|
||||
currentBackoff =
|
||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||
.toLong()
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
Log.d(TAG, "Reconnecting in ${delay}ms …")
|
||||
|
||||
// Simple thread-based scheduling; could be replaced with coroutine delay.
|
||||
Thread {
|
||||
try {
|
||||
Thread.sleep(delay)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (!intentionalClose.get() && !connected.get()) {
|
||||
openSocket()
|
||||
}
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "hermes-ws-reconnect"
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(event: WsEvent) {
|
||||
_events.tryEmit(event)
|
||||
onMessage?.invoke(event)
|
||||
}
|
||||
|
||||
// ── Listener ─────────────────────────────────────────────────────────
|
||||
|
||||
private class WsListenerImpl : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
Log.i(TAG, "WebSocket opened")
|
||||
connected.set(true)
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
onConnected?.invoke()
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
ws: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
Log.d(TAG, "← $text")
|
||||
try {
|
||||
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
|
||||
val event = EventParser.parse(rpc, text)
|
||||
emit(event)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse message", e)
|
||||
emit(WsEvent.Unknown(text))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
ws: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
Log.d(TAG, "WebSocket closing: $code $reason")
|
||||
ws.close(1000, null)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
ws: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
Log.i(TAG, "WebSocket closed: $code $reason")
|
||||
connected.set(false)
|
||||
onDisconnected?.invoke(reason)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
ws: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
Log.e(TAG, "WebSocket failure: ${t.message}", t)
|
||||
connected.set(false)
|
||||
onError?.invoke(t)
|
||||
onDisconnected?.invoke(t.message)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
/**
|
||||
* Outgoing JSON-RPC 2.0 request sent over the WebSocket.
|
||||
*/
|
||||
data class JsonRpcRequest(
|
||||
val jsonrpc: String = "2.0",
|
||||
val id: String,
|
||||
val method: String,
|
||||
val params: Map<String, Any> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Incoming JSON-RPC 2.0 response **or** server-pushed notification.
|
||||
*
|
||||
* - RPC result/error → [id] is non-null, [result] or [error] is populated.
|
||||
* - Event notification → [id] is null, [method] is `"event"`, event data in [params].
|
||||
*/
|
||||
data class JsonRpcResponse(
|
||||
val jsonrpc: String?,
|
||||
val id: String?,
|
||||
val result: Any?,
|
||||
val error: JsonRpcError?,
|
||||
val method: String?,
|
||||
val params: Map<String, Any?>?,
|
||||
)
|
||||
|
||||
data class JsonRpcError(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: Any? = null,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
||||
sealed class WsEvent {
|
||||
// ── Gateway lifecycle ────────────────────────────────────────────────
|
||||
|
||||
data class GatewayReady(
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Session information ──────────────────────────────────────────────
|
||||
|
||||
data class SessionInfo(
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Message streaming ────────────────────────────────────────────────
|
||||
|
||||
data class MessageStart(
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageToken(
|
||||
val token: String,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class ThinkingDelta(
|
||||
val token: String,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageComplete(
|
||||
val text: String,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageDone(
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Tool execution ───────────────────────────────────────────────────
|
||||
|
||||
data class ToolStart(
|
||||
val name: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
data class ToolComplete(
|
||||
val name: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Interactive ──────────────────────────────────────────────────────
|
||||
|
||||
data class ClarifyRequest(
|
||||
val text: String?,
|
||||
val options: List<String>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
data class StatusUpdate(
|
||||
val status: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
data class SessionUpdated(
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── RPC responses ────────────────────────────────────────────────────
|
||||
|
||||
data class RpcResult(
|
||||
val id: String,
|
||||
val result: Any?,
|
||||
) : WsEvent()
|
||||
|
||||
data class RpcError(
|
||||
val id: String,
|
||||
val error: JsonRpcError,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Fallback ─────────────────────────────────────────────────────────
|
||||
|
||||
data class Unknown(
|
||||
val raw: String,
|
||||
) : WsEvent()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
/** JSON-RPC method name constants used with [HermesWsClient.send]. */
|
||||
object WsMethods {
|
||||
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"
|
||||
const val CLARIFY_RESPOND = "clarify.respond"
|
||||
const val APPROVAL_RESPOND = "approval.respond"
|
||||
}
|
||||
Reference in new issue
Block a user