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,96 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* Singleton that manages encrypted storage of the Hermes dashboard token
|
||||
* and connection settings.
|
||||
*
|
||||
* Must call [init] with a Context before any other method.
|
||||
*/
|
||||
object AuthManager {
|
||||
private const val PREFS_FILE = "hermes_secure_prefs"
|
||||
|
||||
private const val KEY_TOKEN = "auth_token"
|
||||
private const val KEY_HOST = "host"
|
||||
private const val KEY_PORT = "port"
|
||||
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
||||
|
||||
private const val DEFAULT_HOST = "127.0.0.1"
|
||||
private const val DEFAULT_PORT = 9119
|
||||
private const val DEFAULT_AUTO_RECONNECT = true
|
||||
|
||||
@Volatile
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
/**
|
||||
* Initialise the encrypted preferences.
|
||||
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (prefs != null) return
|
||||
synchronized(this) {
|
||||
if (prefs != null) return
|
||||
val masterKey =
|
||||
MasterKey
|
||||
.Builder(context.applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
prefs =
|
||||
EncryptedSharedPreferences.create(
|
||||
context.applicationContext,
|
||||
PREFS_FILE,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requirePrefs(): SharedPreferences =
|
||||
prefs ?: throw IllegalStateException(
|
||||
"AuthManager not initialised – call AuthManager.init(context) first",
|
||||
)
|
||||
|
||||
// ── Token ────────────────────────────────────────────────────────────
|
||||
|
||||
fun getToken(): String? = requirePrefs().getString(KEY_TOKEN, null)
|
||||
|
||||
fun setToken(token: String?) {
|
||||
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
// ── Host ─────────────────────────────────────────────────────────────
|
||||
|
||||
fun getHost(): String = requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST
|
||||
|
||||
fun setHost(host: String) {
|
||||
requirePrefs().edit().putString(KEY_HOST, host).apply()
|
||||
}
|
||||
|
||||
// ── Port ─────────────────────────────────────────────────────────────
|
||||
|
||||
fun getPort(): Int = requirePrefs().getInt(KEY_PORT, DEFAULT_PORT)
|
||||
|
||||
fun setPort(port: Int) {
|
||||
requirePrefs().edit().putInt(KEY_PORT, port).apply()
|
||||
}
|
||||
|
||||
// ── Auto-reconnect ───────────────────────────────────────────────────
|
||||
|
||||
fun isAutoReconnect(): Boolean = requirePrefs().getBoolean(KEY_AUTO_RECONNECT, DEFAULT_AUTO_RECONNECT)
|
||||
|
||||
fun setAutoReconnect(enabled: Boolean) {
|
||||
requirePrefs().edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply()
|
||||
}
|
||||
|
||||
/** Convenience: build the base URL from current host + port. */
|
||||
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
|
||||
|
||||
/** Convenience: build the WebSocket URL with token query param. */
|
||||
fun wsUrl(): String = "ws://${getHost()}:${getPort()}/api/ws?token=${getToken().orEmpty()}"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class AchievementsResponse(
|
||||
val achievements: List<Achievement>,
|
||||
)
|
||||
|
||||
data class Achievement(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val category: String?,
|
||||
val kind: String?,
|
||||
val icon: String?,
|
||||
val unlocked: Boolean,
|
||||
val discovered: Boolean,
|
||||
val state: String?,
|
||||
val tier: String?,
|
||||
val progress: Double?,
|
||||
val next_tier: String?,
|
||||
val next_threshold: Double?,
|
||||
val progress_pct: Double?,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class CronJob(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val schedule: String?,
|
||||
val state: String?,
|
||||
val last_run_status: String?,
|
||||
val next_run: String?,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class ProfilesResponse(
|
||||
val profiles: List<ProfileInfo>,
|
||||
)
|
||||
|
||||
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?,
|
||||
)
|
||||
|
||||
data class ActiveProfileResponse(
|
||||
val active: String,
|
||||
val current: String?,
|
||||
)
|
||||
|
||||
data class SetActiveProfileRequest(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class ProfileSoulResponse(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
data class UpdateProfileSoulRequest(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
data class UpdateProfileModelRequest(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
)
|
||||
|
||||
data class UpdateProfileDescriptionRequest(
|
||||
val description: String,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class SessionListResponse(
|
||||
val sessions: List<SessionInfo>,
|
||||
)
|
||||
|
||||
data class SessionInfo(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val created_at: String?,
|
||||
val message_count: Int?,
|
||||
val status: String?,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class SessionMessagesResponse(
|
||||
val messages: List<SessionMessage>,
|
||||
)
|
||||
|
||||
data class SessionMessage(
|
||||
val role: String?,
|
||||
val content: String?,
|
||||
val timestamp: String?,
|
||||
val type: String?,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class Skill(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val category: String?,
|
||||
val enabled: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class StatusResponse(
|
||||
val version: String?,
|
||||
val gateway_running: Boolean?,
|
||||
val active_sessions: Int?,
|
||||
val auth_required: Boolean?,
|
||||
val gateway_platforms: Map<String, PlatformStatus>?,
|
||||
)
|
||||
|
||||
data class PlatformStatus(
|
||||
val state: String?,
|
||||
val error_code: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
/**
|
||||
* Wrapper around the /api/system/stats response.
|
||||
*
|
||||
* The payload shape can change across Hermes versions, so we keep it
|
||||
* flexible with a Map and provide convenience accessors for the
|
||||
* most commonly used fields.
|
||||
*/
|
||||
data class SystemStatsResponse(
|
||||
val cpu: Map<String, Any?>? = null,
|
||||
val memory: Map<String, Any?>? = null,
|
||||
val disk: Map<String, Any?>? = null,
|
||||
val uptime: Any? = null,
|
||||
val extra: Map<String, Any?>? = null,
|
||||
) {
|
||||
/** CPU usage percentage, if available. */
|
||||
val cpuPercent: Double?
|
||||
get() = (cpu?.get("percent") as? Number)?.toDouble()
|
||||
|
||||
/** Memory usage percentage, if available. */
|
||||
val memoryPercent: Double?
|
||||
get() = (memory?.get("percent") as? Number)?.toDouble()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class ToggleSkillRequest(
|
||||
val name: String,
|
||||
val enabled: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class Toolset(
|
||||
val name: String,
|
||||
val label: String?,
|
||||
val description: String?,
|
||||
val enabled: Boolean,
|
||||
val available: Boolean?,
|
||||
val configured: Boolean?,
|
||||
val tools: List<String>?,
|
||||
)
|
||||
|
||||
data class ToolsetToggleRequest(
|
||||
val enabled: Boolean,
|
||||
val profile: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Provides a Retrofit-backed [HermesApiService].
|
||||
*
|
||||
* The client is lazily built using the current [AuthManager] settings and
|
||||
* can be rebuilt at any time via [rebuild] (e.g. after the user changes
|
||||
* host / port / token).
|
||||
*/
|
||||
object ApiClient {
|
||||
@Volatile
|
||||
private var retrofit: Retrofit? = null
|
||||
|
||||
@Volatile
|
||||
private var service: HermesApiService? = null
|
||||
|
||||
/** The current [HermesApiService] instance. Lazily created on first access. */
|
||||
val hermesApi: HermesApiService
|
||||
get() {
|
||||
return service ?: synchronized(this) {
|
||||
service ?: buildService().also { service = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-rebuild the Retrofit client (e.g. after settings change). */
|
||||
fun rebuild() {
|
||||
synchronized(this) {
|
||||
retrofit = null
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun buildService(): HermesApiService {
|
||||
val logging =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
val authInterceptor =
|
||||
Interceptor { chain ->
|
||||
val token = AuthManager.getToken()
|
||||
val request =
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
val okHttp =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val rf =
|
||||
Retrofit
|
||||
.Builder()
|
||||
.baseUrl(AuthManager.baseUrl())
|
||||
.client(okHttp)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.also { retrofit = it }
|
||||
|
||||
return rf.create(HermesApiService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.model.AchievementsResponse
|
||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import com.m57.hermescontrol.data.model.StatusResponse
|
||||
import com.m57.hermescontrol.data.model.SystemStatsResponse
|
||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface HermesApiService {
|
||||
@GET("api/status")
|
||||
suspend fun getStatus(): Response<StatusResponse>
|
||||
|
||||
@GET("api/sessions")
|
||||
suspend fun getSessions(): Response<SessionListResponse>
|
||||
|
||||
@GET("api/sessions/{id}/messages")
|
||||
suspend fun getSessionMessages(
|
||||
@Path("id") sessionId: String,
|
||||
): Response<SessionMessagesResponse>
|
||||
|
||||
@GET("api/system/stats")
|
||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
||||
|
||||
@GET("api/config")
|
||||
suspend fun getConfig(): Response<Map<String, Any?>>
|
||||
|
||||
@GET("api/skills")
|
||||
suspend fun getSkills(): Response<List<Skill>>
|
||||
|
||||
@PUT("api/skills/toggle")
|
||||
suspend fun toggleSkill(
|
||||
@Body body: ToggleSkillRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/cron/jobs")
|
||||
suspend fun getCronJobs(): Response<List<CronJob>>
|
||||
|
||||
@POST("api/cron/jobs/{id}/pause")
|
||||
suspend fun pauseCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/cron/jobs/{id}/resume")
|
||||
suspend fun resumeCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/cron/jobs/{id}/trigger")
|
||||
suspend fun triggerCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("api/cron/jobs/{id}")
|
||||
suspend fun deleteCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/gateway/start")
|
||||
suspend fun startGateway(): Response<Unit>
|
||||
|
||||
@POST("api/gateway/stop")
|
||||
suspend fun stopGateway(): Response<Unit>
|
||||
|
||||
@POST("api/gateway/restart")
|
||||
suspend fun restartGateway(): Response<Unit>
|
||||
|
||||
@GET("api/profiles")
|
||||
suspend fun getProfiles(): Response<ProfilesResponse>
|
||||
|
||||
@GET("api/profiles/active")
|
||||
suspend fun getActiveProfile(): Response<ActiveProfileResponse>
|
||||
|
||||
@POST("api/profiles/active")
|
||||
suspend fun setActiveProfile(
|
||||
@Body body: SetActiveProfileRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/profiles/{name}/soul")
|
||||
suspend fun getProfileSoul(
|
||||
@Path("name") name: String,
|
||||
): Response<ProfileSoulResponse>
|
||||
|
||||
@PUT("api/profiles/{name}/soul")
|
||||
suspend fun updateProfileSoul(
|
||||
@Path("name") name: String,
|
||||
@Body body: UpdateProfileSoulRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("api/profiles/{name}/model")
|
||||
suspend fun updateProfileModel(
|
||||
@Path("name") name: String,
|
||||
@Body body: UpdateProfileModelRequest,
|
||||
): 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>>
|
||||
|
||||
@PUT("api/tools/toolsets/{name}")
|
||||
suspend fun toggleToolset(
|
||||
@Path("name") name: String,
|
||||
@Body body: ToolsetToggleRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/plugins/hermes-achievements/achievements")
|
||||
suspend fun getAchievements(): Response<AchievementsResponse>
|
||||
}
|
||||
@@ -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