Initial commit of HermesControl Android app

This commit is contained in:
Hy4ri committed 2026-06-16 00:00:36 +03:00
commit d6ff3de594
92 files changed
+10004

No files matched your search

@@ -0,0 +1,32 @@
package com.m57.hermescontrol
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.HermesControlTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AuthManager.init(this)
enableEdgeToEdge()
setContent {
HermesControlTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
MainNavigation()
}
}
}
}
}
@@ -0,0 +1,200 @@
package com.m57.hermescontrol
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent
@Composable
fun MainNavigation() {
val hasToken = !AuthManager.getToken().isNullOrBlank()
val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen
val backStack = rememberNavBackStack(startScreen)
NavigationController.backStack = backStack
val currentScreen = backStack.lastOrNull() ?: startScreen
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val gesturesEnabled =
currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen ||
currentScreen == GatewayScreen || currentScreen == ProfilesScreen ||
currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen
val openDrawer = { scope.launch { drawerState.open() } }
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = gesturesEnabled,
drawerContent = {
if (gesturesEnabled) {
ModalDrawerSheet {
Text(
text = "Hermes Control",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleLarge,
)
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Chat") },
selected = currentScreen == ChatScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ChatScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Skills") },
selected = currentScreen == SkillsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(SkillsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Cron Jobs") },
selected = currentScreen == CronJobsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(CronJobsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Profiles") },
selected = currentScreen == ProfilesScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ProfilesScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Toolsets") },
selected = currentScreen == ToolsetsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ToolsetsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Achievements") },
selected = currentScreen == AchievementsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(AchievementsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Gateway") },
selected = currentScreen == GatewayScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(GatewayScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
},
) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
entryProvider {
entry<ConnectScreen> {
ConnectScreenContent(
onConnected = {
backStack.clear()
backStack.add(ChatScreen)
},
modifier = Modifier.safeDrawingPadding(),
)
}
entry<ChatScreen> {
ChatScreenContent(
onNavigateToSettings = {
backStack.add(SettingsScreen)
},
onOpenDrawer = { openDrawer() },
)
}
entry<SettingsScreen> {
SettingsScreenContent(
onBack = {
backStack.removeLastOrNull()
},
)
}
entry<SkillsScreen> {
SkillsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<CronJobsScreen> {
CronJobsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ProfilesScreen> {
ProfilesScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ToolsetsScreen> {
ToolsetsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<AchievementsScreen> {
AchievementsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<GatewayScreen> {
GatewayScreenContent(
onOpenDrawer = { openDrawer() },
)
}
},
)
}
}
@@ -0,0 +1,26 @@
package com.m57.hermescontrol
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
object NavigationController {
var backStack: NavBackStack<NavKey>? = null
fun navigateTo(key: NavKey) {
val stack = backStack ?: return
if (stack.lastOrNull() == key) return
if (key == ChatScreen || key == SkillsScreen || key == CronJobsScreen) {
stack.clear()
}
stack.add(key)
}
fun add(key: NavKey) {
backStack?.add(key)
}
fun goBack() {
backStack?.removeLastOrNull()
}
}
@@ -0,0 +1,22 @@
package com.m57.hermescontrol
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable data object ConnectScreen : NavKey
@Serializable data object ChatScreen : NavKey
@Serializable data object SettingsScreen : NavKey
@Serializable data object SkillsScreen : NavKey
@Serializable data object CronJobsScreen : NavKey
@Serializable data object GatewayScreen : NavKey
@Serializable data object ProfilesScreen : NavKey
@Serializable data object ToolsetsScreen : NavKey
@Serializable data object AchievementsScreen : NavKey
@@ -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"
}
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
// Primary - Deep Purple
val HermesPurple = Color(0xFF6B4EFF)
val HermesPurpleLight = Color(0xFF9D8AFF)
val HermesPurpleDark = Color(0xFF4A35B3)
val HermesPurple80 = Color(0xFFCFC3FF)
// Secondary - Amber / Gold
val HermesAmber = Color(0xFFFFB300)
val HermesAmberLight = Color(0xFFFFE54C)
val HermesAmberDark = Color(0xFFC68400)
val HermesAmber80 = Color(0xFFFFE082)
// Dark theme surfaces
val DarkBackground = Color(0xFF121212)
val DarkSurface = Color(0xFF1E1E2E)
val DarkSurfaceVariant = Color(0xFF2A2A3C)
val DarkOnSurface = Color(0xFFE6E1E5)
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
// Light theme surfaces
val LightBackground = Color(0xFFFFFBFE)
val LightSurface = Color(0xFFFFFBFE)
val LightSurfaceVariant = Color(0xFFE7E0EC)
val LightOnSurface = Color(0xFF1C1B1F)
val LightOnSurfaceVariant = Color(0xFF49454F)
// Status colors
val StatusGreen = Color(0xFF4CAF50)
val StatusRed = Color(0xFFEF5350)
val StatusYellow = Color(0xFFFFC107)
// Chat bubble colors
val UserBubble = Color(0xFF6B4EFF)
val UserBubbleDark = Color(0xFF5A3FE0)
val AssistantBubble = Color(0xFF2A2A3C)
val AssistantBubbleLight = Color(0xFFE7E0EC)
val SystemMessageColor = Color(0xFF9E9E9E)
val ToolChipColor = Color(0xFF37474F)
val ToolChipColorLight = Color(0xFFECEFF1)
@@ -0,0 +1,78 @@
package com.m57.hermescontrol.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
enum class ThemePreference { SYSTEM, LIGHT, DARK }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
private val HermesDarkColorScheme =
darkColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = HermesPurpleDark,
onPrimaryContainer = HermesPurple80,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = HermesAmberDark,
onSecondaryContainer = HermesAmber80,
tertiary = HermesAmberLight,
background = DarkBackground,
onBackground = DarkOnSurface,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
error = Color(0xFFCF6679),
onError = Color.Black,
outline = Color(0xFF938F99),
)
private val HermesLightColorScheme =
lightColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = HermesPurple80,
onPrimaryContainer = HermesPurpleDark,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = HermesAmber80,
onSecondaryContainer = HermesAmberDark,
tertiary = HermesAmberDark,
background = LightBackground,
onBackground = LightOnSurface,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
error = Color(0xFFB00020),
onError = Color.White,
outline = Color(0xFF79747E),
)
@Composable
fun HermesControlTheme(
themePreference: ThemePreference = LocalThemePreference.current,
content: @Composable () -> Unit,
) {
val darkTheme =
when (themePreference) {
ThemePreference.SYSTEM -> isSystemInDarkTheme()
ThemePreference.LIGHT -> false
ThemePreference.DARK -> true
}
val colorScheme = if (darkTheme) HermesDarkColorScheme else HermesLightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,36 @@
package com.m57.hermescontrol.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,203 @@
package com.m57.hermescontrol.ui.achievements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AchievementsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadAchievements()
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Agent Achievements") },
actions = {
IconButton(onClick = { viewModel.loadAchievements() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.achievements.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.achievements.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadAchievements() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.achievements) { achievement ->
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (achievement.unlocked) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = achievement.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
achievement.category?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Display Icon/Emoji/Tier
val badgeText =
when {
achievement.unlocked -> achievement.tier ?: "🏆"
achievement.discovered -> "🔒 Discovered"
else -> "❓ Secret"
}
Text(
text = badgeText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color =
if (achievement.unlocked) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
achievement.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
)
}
if (achievement.discovered && !achievement.unlocked) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text =
"Progress: ${achievement.progress?.toInt() ?: 0} / " +
"${achievement.next_threshold?.toInt() ?: 0}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "${achievement.progress_pct?.toInt() ?: 0}%",
style = MaterialTheme.typography.bodySmall,
)
}
LinearProgressIndicator(
progress = { pct.coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,58 @@
package com.m57.hermescontrol.ui.achievements
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class AchievementsUiState(
val isLoading: Boolean = false,
val achievements: List<Achievement> = emptyList(),
val errorMessage: String? = null,
)
class AchievementsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(AchievementsUiState())
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
fun loadAchievements() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getAchievements()
}
if (response.isSuccessful) {
_uiState.update {
it.copy(
isLoading = false,
achievements = response.body()?.achievements.orEmpty(),
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load achievements: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load achievements: ${e.message}",
)
}
}
}
}
}
@@ -0,0 +1,385 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ChatBubble(
message: ChatMessage,
isDarkTheme: Boolean,
modifier: Modifier = Modifier,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val maxBubbleWidth = screenWidth * 0.80f
AnimatedVisibility(
visible = true,
enter =
fadeIn() +
expandVertically(
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
),
) {
when (message.role) {
MessageRole.USER -> UserBubble(message, maxBubbleWidth, modifier)
MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, modifier)
MessageRole.SYSTEM -> SystemBubble(message, modifier)
MessageRole.TOOL -> ToolBubble(message, isDarkTheme, modifier)
}
}
}
@Composable
private fun UserBubble(
message: ChatMessage,
maxWidth: androidx.compose.ui.unit.Dp,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp),
contentAlignment = Alignment.CenterEnd,
) {
Surface(
modifier =
Modifier
.widthIn(max = maxWidth)
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = 16.dp,
bottomEnd = 4.dp,
),
),
color = UserBubble,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
Text(
text = message.content,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = formatTimestamp(message.timestamp),
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun AssistantBubble(
message: ChatMessage,
maxWidth: androidx.compose.ui.unit.Dp,
isDarkTheme: Boolean,
modifier: Modifier = Modifier,
) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
val textColor = MaterialTheme.colorScheme.onSurface
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp),
contentAlignment = Alignment.CenterStart,
) {
Surface(
modifier =
Modifier
.widthIn(max = maxWidth)
.animateContentSize()
.clip(
RoundedCornerShape(
topStart = 4.dp,
topEnd = 16.dp,
bottomStart = 16.dp,
bottomEnd = 16.dp,
),
),
color = bubbleColor,
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
RichText(
text = message.content,
textColor = textColor,
)
}
if (!message.isStreaming) {
Text(
text = formatTimestamp(message.timestamp),
color = textColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall,
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
)
}
}
}
}
}
@Composable
private fun SystemBubble(
message: ChatMessage,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
fontStyle = FontStyle.Italic,
color = SystemMessageColor,
),
)
}
}
@Composable
private fun ToolBubble(
message: ChatMessage,
isDarkTheme: Boolean,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
val contentColor = if (isDarkTheme) Color.White else Color.Black
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
onClick = { expanded = !expanded },
colors = CardDefaults.cardColors(containerColor = chipColor),
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier =
Modifier
.animateContentSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val (icon, tint) =
when (message.toolStatus) {
ToolStatus.RUNNING -> Icons.Filled.Build to MaterialTheme.colorScheme.secondary
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle to StatusGreen
ToolStatus.FAILED -> Icons.Filled.Error to StatusRed
null -> Icons.Filled.Build to contentColor.copy(alpha = 0.6f)
}
if (message.toolStatus == ToolStatus.RUNNING) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.secondary,
)
} else {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = tint,
)
}
Text(
text = message.toolName ?: "Tool",
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
fontFamily = FontFamily.Monospace,
),
)
}
if (expanded && message.content.isNotBlank()) {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
}
/**
* Simple rich text renderer supporting **bold**, `inline code`, and ```code blocks```.
*/
@Composable
private fun RichText(
text: String,
textColor: Color,
) {
val annotated =
remember(text) {
buildAnnotatedString {
var i = 0
val src = text
while (i < src.length) {
when {
// Code block: ```...```
src.startsWith("```", i) -> {
val end = src.indexOf("```", i + 3)
if (end != -1) {
val code = src.substring(i + 3, end).trimStart('\n').trimEnd('\n')
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(code)
}
i = end + 3
} else {
append(src[i])
i++
}
}
// Inline code: `...`
src[i] == '`' -> {
val end = src.indexOf('`', i + 1)
if (end != -1) {
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(src.substring(i + 1, end))
}
i = end + 1
} else {
append(src[i])
i++
}
}
// Bold: **...**
src.startsWith("**", i) -> {
val end = src.indexOf("**", i + 2)
if (end != -1) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(src.substring(i + 2, end))
}
i = end + 2
} else {
append(src[i])
i++
}
}
else -> {
append(src[i])
i++
}
}
}
}
}
Text(
text = annotated,
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
)
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.ui.chat
import java.util.UUID
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
val content: String,
val timestamp: Long = System.currentTimeMillis(),
val isStreaming: Boolean = false,
val toolName: String? = null,
val toolStatus: ToolStatus? = null,
)
enum class MessageRole {
USER,
ASSISTANT,
SYSTEM,
TOOL,
}
enum class ToolStatus {
RUNNING,
COMPLETED,
FAILED,
}
@@ -0,0 +1,562 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
onNavigateToSettings: () -> Unit,
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ChatViewModel = viewModel { ChatViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var inputText by rememberSaveable { mutableStateOf("") }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.currentStreamingText) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.lastIndex)
}
}
// Show error as snackbar
LaunchedEffect(state.errorMessage) {
state.errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
// Clarify dialog
state.clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
)
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Hermes",
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
),
)
Spacer(modifier = Modifier.width(8.dp))
// Connection status dot
Box(
modifier =
Modifier
.size(10.dp)
.clip(CircleShape)
.background(if (state.isConnected) StatusGreen else StatusRed),
)
}
},
actions = {
// Session picker
Box {
IconButton(onClick = {
viewModel.loadSessions()
viewModel.toggleSessionPicker()
}) {
Icon(
imageVector = Icons.Filled.History,
contentDescription = "Sessions",
)
}
DropdownMenu(
expanded = state.showSessionPicker,
onDismissRequest = { viewModel.toggleSessionPicker() },
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("New Session")
}
},
onClick = {
viewModel.createNewSession()
viewModel.toggleSessionPicker()
},
)
if (state.sessions.isNotEmpty()) {
HorizontalDivider()
}
state.sessions.forEach { session ->
DropdownMenuItem(
text = {
Column {
Text(
text = session.title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
Text(
text = "${session.messageCount} messages",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
},
onClick = { viewModel.switchSession(session.id) },
trailingIcon = {
if (session.id == state.currentSessionId) {
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = "Current",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
},
)
}
}
}
// Settings
IconButton(onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Settings",
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
snackbarHost = {
SnackbarHost(snackbarHostState) { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
}
},
bottomBar = {
ChatInputBar(
inputText = inputText,
onInputChange = { inputText = it },
onSend = {
viewModel.sendMessage(inputText)
inputText = ""
},
onInterrupt = viewModel::interruptSession,
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
)
},
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.messages.isEmpty() && !state.isLoading) {
// Empty state
EmptyState()
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
items(
items = state.messages,
key = { it.id },
) { message ->
ChatBubble(
message = message,
isDarkTheme = isDark,
)
}
// Thinking indicator
if (state.isThinking) {
item(key = "thinking") {
ThinkingIndicator(state.thinkingText)
}
}
}
// Loading overlay
AnimatedVisibility(
visible = state.isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp),
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Connecting to Hermes…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}
@Composable
private fun EmptyState() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "⚡",
fontSize = 48.sp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Ready to chat",
style =
MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Text(
text = "Send a message to start a conversation",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
),
)
}
}
}
@Composable
private fun ThinkingIndicator(thinkingText: String) {
val infiniteTransition = rememberInfiniteTransition(label = "thinking")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "thinking_alpha",
)
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "🤔",
fontSize = 14.sp,
modifier = Modifier.graphicsLayer { this.alpha = alpha },
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (thinkingText.isNotBlank()) {
val display = thinkingText.takeLast(100)
"Thinking: $display…"
} else {
"Thinking…"
},
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
maxLines = 2,
modifier = Modifier.animateContentSize(),
)
}
}
}
}
@Composable
private fun ChatInputBar(
inputText: String,
onInputChange: (String) -> Unit,
onSend: () -> Unit,
onInterrupt: () -> Unit,
isAgentTyping: Boolean,
isConnected: Boolean,
) {
val canSend = inputText.isNotBlank() && !isAgentTyping && isConnected
AnimatedVisibility(
visible = true,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(8.dp),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = inputText,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(max = 140.dp),
placeholder = {
Text(
if (!isConnected) {
"Not connected…"
} else if (isAgentTyping) {
"Waiting for response…"
} else {
"Type a message…"
},
)
},
enabled = isConnected,
maxLines = 5,
shape = RoundedCornerShape(20.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.width(8.dp))
if (isAgentTyping) {
// Interrupt button
FilledTonalButton(
onClick = onInterrupt,
modifier = Modifier.size(48.dp),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = "Interrupt",
tint = MaterialTheme.colorScheme.error,
)
}
} else {
// Send button
FilledTonalButton(
onClick = onSend,
enabled = canSend,
modifier = Modifier.size(48.dp),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
)
}
}
}
}
}
}
@Composable
private fun ClarifyDialog(
clarify: ClarifyUi,
onOptionSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Clarification Needed") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = clarify.text)
Spacer(modifier = Modifier.height(8.dp))
clarify.options.forEach { option ->
FilledTonalButton(
onClick = { onOptionSelected(option) },
modifier = Modifier.fillMaxWidth(),
) {
Text(option)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Dismiss")
}
},
)
}
@@ -0,0 +1,422 @@
package com.m57.hermescontrol.ui.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
val currentSessionId: String? = null,
val sessions: List<SessionUi> = emptyList(),
val isConnected: Boolean = false,
val isAgentTyping: Boolean = false,
val isThinking: Boolean = false,
val thinkingText: String = "",
val isLoading: Boolean = false,
val currentStreamingText: String = "",
val errorMessage: String? = null,
val clarifyRequest: ClarifyUi? = null,
val showSessionPicker: Boolean = false,
)
data class SessionUi(
val id: String,
val title: String,
val messageCount: Int = 0,
)
data class ClarifyUi(
val text: String,
val options: List<String>,
)
class ChatViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val wsClient = HermesWsClient
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
init {
connectWebSocket()
}
private fun connectWebSocket() {
val token = AuthManager.getToken() ?: return
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch(Dispatchers.IO) {
wsClient.connect()
}
viewModelScope.launch {
wsClient.events.collect { event ->
handleWsEvent(event)
}
}
}
private fun handleWsEvent(event: WsEvent) {
when (event) {
is WsEvent.GatewayReady -> {
_uiState.update { it.copy(isConnected = true, isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
// Create a new session if none exists
if (_uiState.value.currentSessionId == null) {
createNewSession()
}
}
is WsEvent.SessionInfo -> {
// We may receive session info after creation
}
is WsEvent.MessageStart -> {
_uiState.update {
it.copy(
isAgentTyping = true,
currentStreamingText = "",
isThinking = false,
thinkingText = "",
)
}
}
is WsEvent.MessageToken -> {
_uiState.update { state ->
val newText = state.currentStreamingText + event.token
// Update or create the streaming message
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
if (streamingIdx >= 0) {
messages[streamingIdx] = messages[streamingIdx].copy(content = newText)
} else {
messages.add(
ChatMessage(
role = MessageRole.ASSISTANT,
content = newText,
isStreaming = true,
),
)
}
state.copy(
messages = messages,
currentStreamingText = newText,
isThinking = false,
)
}
}
is WsEvent.ThinkingDelta -> {
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = state.thinkingText + event.token,
)
}
}
is WsEvent.MessageComplete -> {
_uiState.update { state ->
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
if (streamingIdx >= 0) {
messages[streamingIdx] =
messages[streamingIdx].copy(
content = event.text,
isStreaming = false,
)
} else {
messages.add(
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
),
)
}
state.copy(
messages = messages,
isAgentTyping = false,
currentStreamingText = "",
isThinking = false,
thinkingText = "",
)
}
}
is WsEvent.MessageDone -> {
_uiState.update {
it.copy(isAgentTyping = false, isThinking = false, thinkingText = "")
}
}
is WsEvent.ToolStart -> {
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
content = event.data.toString(),
toolName = event.name,
toolStatus = ToolStatus.RUNNING,
)
_uiState.update { state ->
state.copy(messages = state.messages + toolMessage)
}
}
is WsEvent.ToolComplete -> {
_uiState.update { state ->
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx >= 0) {
messages[toolIdx] =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = event.data.toString(),
)
}
state.copy(messages = messages)
}
}
is WsEvent.ClarifyRequest -> {
_uiState.update {
it.copy(
clarifyRequest =
ClarifyUi(
text = event.text.orEmpty(),
options = event.options.orEmpty(),
),
)
}
}
is WsEvent.StatusUpdate -> {
// Can be used for general status
}
is WsEvent.SessionUpdated -> {
// Refresh sessions list
loadSessions()
}
is WsEvent.RpcResult -> {
handleRpcResult(event.id, event.result)
}
is WsEvent.RpcError -> {
handleRpcError(event.id, event.error)
}
is WsEvent.Unknown -> {
// Handle or ignore unknown/unhandled events
}
}
}
@Suppress("UNCHECKED_CAST")
private fun handleRpcResult(
id: String,
result: Any?,
) {
val method = pendingRequests.remove(id) ?: return
when (method) {
WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return
val sessionId = resultMap["session_id"] as? String ?: return
_uiState.update {
it.copy(
currentSessionId = sessionId,
isLoading = false,
messages = it.messages,
)
}
addSystemMessage("Session created")
loadSessions()
}
WsMethods.SESSION_LIST -> {
val resultMap = result as? Map<String, Any?> ?: return
val sessionsList = resultMap["sessions"] as? List<Map<String, Any?>> ?: return
val sessions =
sessionsList.map { s ->
SessionUi(
id = s["id"] as? String ?: "",
title = s["title"] as? String ?: "Untitled",
messageCount = (s["message_count"] as? Double)?.toInt() ?: 0,
)
}
_uiState.update { it.copy(sessions = sessions) }
}
WsMethods.SESSION_RESUME -> {
_uiState.update { it.copy(isLoading = false) }
addSystemMessage("Session resumed")
}
WsMethods.SESSION_INTERRUPT -> {
_uiState.update {
it.copy(isAgentTyping = false, isThinking = false)
}
addSystemMessage("Session interrupted")
}
}
}
private fun handleRpcError(
id: String,
error: Any?,
) {
val method = pendingRequests.remove(id)
val errorMsg =
when (error) {
is Map<*, *> -> error["message"] as? String ?: error.toString()
else -> error.toString()
}
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Error${if (method != null) " ($method)" else ""}: $errorMsg",
)
}
}
fun sendMessage(text: String) {
if (text.isBlank()) return
val sessionId = _uiState.value.currentSessionId ?: return
// Add user message to UI
val userMessage =
ChatMessage(
role = MessageRole.USER,
content = text,
)
_uiState.update { state ->
state.copy(
messages = state.messages + userMessage,
isAgentTyping = true,
)
}
viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.sendMessage(sessionId, text)
pendingRequests[requestId] = WsMethods.PROMPT_SUBMIT
}
}
fun interruptSession() {
val sessionId = _uiState.value.currentSessionId ?: return
viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send(
WsMethods.SESSION_INTERRUPT,
mapOf("session_id" to sessionId),
)
pendingRequests[requestId] = WsMethods.SESSION_INTERRUPT
}
}
fun createNewSession() {
_uiState.update { it.copy(isLoading = true, messages = emptyList()) }
viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.send(WsMethods.SESSION_CREATE)
pendingRequests[requestId] = WsMethods.SESSION_CREATE
}
}
fun loadSessions() {
viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.send(WsMethods.SESSION_LIST)
pendingRequests[requestId] = WsMethods.SESSION_LIST
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
_uiState.update {
it.copy(
isLoading = true,
messages = emptyList(),
currentSessionId = sessionId,
showSessionPicker = false,
)
}
viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId),
)
pendingRequests[requestId] = WsMethods.SESSION_RESUME
}
}
fun toggleSessionPicker() {
_uiState.update { it.copy(showSessionPicker = !it.showSessionPicker) }
}
fun dismissClarify() {
_uiState.update { it.copy(clarifyRequest = null) }
}
fun respondToClarify(option: String) {
_uiState.update { it.copy(clarifyRequest = null) }
sendMessage(option)
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
fun reconnect() {
_uiState.update {
it.copy(
isConnected = false,
isLoading = true,
errorMessage = null,
)
}
viewModelScope.launch(Dispatchers.IO) {
wsClient.disconnect()
}
viewModelScope.launch {
delay(500)
connectWebSocket()
}
}
private fun addSystemMessage(text: String) {
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
_uiState.update { it.copy(messages = it.messages + msg) }
}
override fun onCleared() {
super.onCleared()
viewModelScope.launch(Dispatchers.IO) {
wsClient.disconnect()
}
}
}
@@ -0,0 +1,95 @@
package com.m57.hermescontrol.ui.commands
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
data class QuickCommand(
val label: String,
val icon: ImageVector,
val action: () -> Unit,
)
@Composable
fun CommandPanel(
onStatusClick: () -> Unit,
onSessionsClick: () -> Unit,
onSystemStatsClick: () -> Unit,
onNewSessionClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val commands =
listOf(
QuickCommand("Status", Icons.Filled.Info, onStatusClick),
QuickCommand("Sessions", Icons.Filled.History, onSessionsClick),
QuickCommand("System Stats", Icons.Filled.Analytics, onSystemStatsClick),
QuickCommand("New Session", Icons.Filled.Add, onNewSessionClick),
)
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
elevation = CardDefaults.cardElevation(0.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = "Quick Commands",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
modifier = Modifier.padding(bottom = 6.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
commands.forEach { cmd ->
AssistChip(
onClick = cmd.action,
label = { Text(cmd.label, style = MaterialTheme.typography.labelMedium) },
leadingIcon = {
Icon(
imageVector = cmd.icon,
contentDescription = cmd.label,
modifier = Modifier.padding(0.dp),
)
},
colors =
AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
}
}
}
}
@@ -0,0 +1,256 @@
package com.m57.hermescontrol.ui.connect
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun ConnectScreen(
onConnected: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ConnectViewModel = viewModel { ConnectViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.connectionSuccess) {
if (state.connectionSuccess) {
onConnected()
}
}
var passwordVisible by remember { mutableStateOf(false) }
Box(
modifier =
modifier
.fillMaxSize()
.imePadding(),
contentAlignment = Alignment.Center,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Branding
Text(
text = "⚡",
fontSize = 64.sp,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Hermes",
style =
MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
),
)
Text(
text = "AI Agent Control",
style =
MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.height(32.dp))
// Connection Card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Connect to Hermes",
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
)
// Token field
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
placeholder = { Text("Enter your API token") },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
"Hide token"
} else {
"Show token"
},
)
}
},
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Host field
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
placeholder = { Text("127.0.0.1") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Port field
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
placeholder = { Text("9119") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Error message
AnimatedVisibility(
visible = state.errorMessage != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.errorMessage?.let { error ->
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
}
// Connect button
Button(
onClick = viewModel::connect,
enabled = !state.isConnecting,
modifier =
Modifier
.fillMaxWidth()
.height(52.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
if (state.isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text(
text = "Connect",
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
}
}
@@ -0,0 +1,119 @@
package com.m57.hermescontrol.ui.connect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ConnectUiState(
val token: String = "",
val host: String = "127.0.0.1",
val port: String = "9119",
val isConnecting: Boolean = false,
val connectionSuccess: Boolean = false,
val errorMessage: String? = null,
)
class ConnectViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ConnectUiState())
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
init {
// Load saved values
val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost()
val savedPort = AuthManager.getPort()
_uiState.update {
it.copy(
token = savedToken,
host = savedHost,
port = savedPort.toString(),
)
}
}
fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null) }
}
fun onPortChange(value: String) {
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, errorMessage = null) }
}
fun connect() {
val state = _uiState.value
if (state.token.isBlank()) {
_uiState.update { it.copy(errorMessage = "Token is required") }
return
}
if (state.host.isBlank()) {
_uiState.update { it.copy(errorMessage = "Host is required") }
return
}
val port = state.port.toIntOrNull()
if (port == null || port !in 1..65535) {
_uiState.update { it.copy(errorMessage = "Port must be between 1 and 65535") }
return
}
_uiState.update { it.copy(isConnecting = true, errorMessage = null) }
// Save settings before testing so ApiClient uses the right base URL
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
ApiClient.rebuild()
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getStatus()
}
if (response.isSuccessful) {
_uiState.update {
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
}
} else {
val code = response.code()
val msg =
when (code) {
401 -> {
AuthManager.setToken(null)
"Invalid token (401 Unauthorized)"
}
403 -> {
"Access denied (403 Forbidden)"
}
else -> {
"Server returned HTTP $code"
}
}
_uiState.update { it.copy(isConnecting = false, errorMessage = msg) }
}
} catch (e: Exception) {
val msg =
when {
e.message?.contains("timeout", true) == true -> "Connection timed out"
e.message?.contains("refused", true) == true -> "Connection refused – is Hermes running?"
e.message?.contains("resolve", true) == true -> "Could not resolve host"
else -> "Connection failed: ${e.message}"
}
_uiState.update { it.copy(isConnecting = false, errorMessage = msg) }
}
}
}
}
@@ -0,0 +1,156 @@
package com.m57.hermescontrol.ui.cron
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CronJobsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadCronJobs()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Cron Jobs") },
actions = {
IconButton(onClick = { viewModel.loadCronJobs() }) {
Text("Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.jobs) { job ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = job.name, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = "Schedule: ${job.schedule}", style = MaterialTheme.typography.bodyMedium)
Text(text = "Status: ${job.state}", style = MaterialTheme.typography.bodyMedium)
Text(
text = "Last Run: ${job.last_run_status ?: "Never"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Next Run: ${job.next_run ?: "N/A"}",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (job.state == "active") {
Button(onClick = { viewModel.pauseCronJob(job.id) }) {
Text("Pause")
}
} else {
Button(onClick = { viewModel.resumeCronJob(job.id) }) {
Text("Resume")
}
}
Button(onClick = { viewModel.triggerCronJob(job.id) }) {
Text("Trigger")
}
Button(
onClick = { viewModel.deleteCronJob(job.id) },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text("Delete")
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,151 @@
package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class CronJobsUiState(
val isLoading: Boolean = false,
val jobs: List<CronJob> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class CronJobsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CronJobsUiState())
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
fun loadCronJobs() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getCronJobs()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, jobs = response.body().orEmpty()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load cron jobs: ${e.message}") }
}
}
}
fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(
jobs =
state.jobs.map {
if (it.id == id) it.copy(state = "paused") else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.pauseCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to pause cron job: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertJobs(originalJobs, "Failed to pause cron job: ${e.message}")
}
}
}
fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(
jobs =
state.jobs.map {
if (it.id == id) it.copy(state = "active") else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.resumeCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to resume cron job: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertJobs(originalJobs, "Failed to resume cron job: ${e.message}")
}
}
}
fun triggerCronJob(id: String) {
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.triggerCronJob(id)
}
if (response.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Job triggered successfully") }
} else {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: HTTP ${response.code()}") }
}
} catch (e: Exception) {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${e.message}") }
}
}
}
fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id })
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.deleteCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to delete cron job: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertJobs(originalJobs, "Failed to delete cron job: ${e.message}")
}
}
}
private fun revertJobs(
originalJobs: List<CronJob>,
errorMsg: String,
) {
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -0,0 +1,306 @@
package com.m57.hermescontrol.ui.gateway
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GatewayScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadStatus()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Gateway Control") },
actions = {
IconButton(
onClick = { viewModel.loadStatus() },
enabled = !state.isLoading,
) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.status == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.status == null) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadStatus() }) {
Text("Retry")
}
}
} else {
val status = state.status
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val isRunning = status?.gateway_running == true
// Status Overview Card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isRunning) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = if (isRunning) "GATEWAY RUNNING" else "GATEWAY STOPPED",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
},
)
status?.version?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Version: $it",
style = MaterialTheme.typography.bodySmall,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
},
)
}
}
}
// Action Controls Card
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Controls",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { viewModel.startGateway() },
modifier = Modifier.weight(1f),
enabled = !isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50), // Green for start
contentColor = Color.White,
),
) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Start")
}
Button(
onClick = { viewModel.stopGateway() },
modifier = Modifier.weight(1f),
enabled = isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFFF44336), // Red for stop
contentColor = Color.White,
),
) {
Text("Stop")
}
}
Button(
onClick = { viewModel.restartGateway() },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
),
) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Restart")
}
if (state.isActionRunning) {
Spacer(modifier = Modifier.height(4.dp))
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
// Platforms Card
status?.gateway_platforms?.let { platforms ->
if (platforms.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Active Platforms",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
platforms.forEach { (platform, pStatus) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = platform.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
val stateText = pStatus.state?.uppercase() ?: "UNKNOWN"
val badgeColor =
when (stateText) {
"RUNNING" -> Color(0xFF4CAF50)
"STOPPED" -> Color(0xFFF44336)
"ERROR" -> Color(0xFFFF9800)
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = stateText,
style = MaterialTheme.typography.bodyMedium,
color = badgeColor,
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,106 @@
package com.m57.hermescontrol.ui.gateway
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class GatewayUiState(
val isLoading: Boolean = false,
val isActionRunning: Boolean = false,
val status: StatusResponse? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class GatewayViewModel : ViewModel() {
private val _uiState = MutableStateFlow(GatewayUiState())
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
fun loadStatus() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getStatus()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, status = response.body()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load status: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load status: ${e.message}",
)
}
}
}
}
fun startGateway() {
runGatewayAction("start") { ApiClient.hermesApi.startGateway() }
}
fun stopGateway() {
runGatewayAction("stop") { ApiClient.hermesApi.stopGateway() }
}
fun restartGateway() {
runGatewayAction("restart") { ApiClient.hermesApi.restartGateway() }
}
private fun runGatewayAction(
actionName: String,
apiCall: suspend () -> retrofit2.Response<Unit>,
) {
_uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch {
try {
val response = withContext(Dispatchers.IO) { apiCall() }
if (response.isSuccessful) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Gateway ${actionName}ed successfully",
)
}
loadStatus()
} else {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Failed to $actionName gateway: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Failed to $actionName gateway: ${e.message}",
)
}
}
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -0,0 +1,341 @@
package com.m57.hermescontrol.ui.profiles
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
var tempModelProvider by remember { mutableStateOf("") }
var tempModelName by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.loadProfiles()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Agent Profiles") },
actions = {
IconButton(onClick = { viewModel.loadProfiles() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.profiles.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.profiles.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadProfiles() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
onClick = {
if (!isActive) {
viewModel.selectActiveProfile(profile.name)
}
},
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
profile.description?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Model: ${profile.model ?: "None"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Provider: ${profile.provider ?: "None"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
soulEditProfileName = profile.name
viewModel.loadSoul(profile.name)
},
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = null,
modifier = Modifier.width(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit Soul")
}
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
},
) {
Text("Set Model")
}
}
}
}
}
}
}
}
// Soul Edit Dialog
soulEditProfileName?.let { profileName ->
var soulText by remember(state.selectedSoulContent) {
mutableStateOf(state.selectedSoulContent ?: "")
}
AlertDialog(
onDismissRequest = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
title = { Text("Edit Soul ($profileName)") },
text = {
Box(modifier = Modifier.fillMaxWidth()) {
if (state.isLoadingSoul) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
OutlinedTextField(
value = soulText,
onValueChange = { soulText = it },
modifier =
Modifier
.fillMaxWidth()
.height(200.dp),
label = { Text("Soul/Persona Prompts") },
maxLines = 10,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.saveSoul(profileName, soulText)
soulEditProfileName = null
},
enabled = !state.isLoadingSoul,
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
) {
Text("Cancel")
}
},
)
}
// Model Edit Dialog
modelEditProfileName?.let { profileName ->
AlertDialog(
onDismissRequest = { modelEditProfileName = null },
title = { Text("Switch Model ($profileName)") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = tempModelProvider,
onValueChange = { tempModelProvider = it },
label = { Text("Provider (e.g. nvidia, openai, local)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = tempModelName,
onValueChange = { tempModelName = it },
label = { Text("Model Name") },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
modelEditProfileName = null
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { modelEditProfileName = null }) {
Text("Cancel")
}
},
)
}
}
}
@@ -0,0 +1,201 @@
package com.m57.hermescontrol.ui.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ProfilesUiState(
val isLoading: Boolean = false,
val profiles: List<ProfileInfo> = emptyList(),
val activeProfileName: String? = null,
val selectedSoulContent: String? = null,
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class ProfilesViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProfilesUiState())
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val profilesRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getProfiles() }
val activeRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getActiveProfile() }
if (profilesRes.isSuccessful && activeRes.isSuccessful) {
_uiState.update {
it.copy(
isLoading = false,
profiles = profilesRes.body()?.profiles.orEmpty(),
activeProfileName = activeRes.body()?.active,
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage =
"Failed to load profiles/active: HTTP " +
"${profilesRes.code()} / ${activeRes.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Error loading profiles: ${e.message}",
)
}
}
}
}
fun selectActiveProfile(name: String) {
val originalActive = _uiState.value.activeProfileName
// Optimistically update active profile name
_uiState.update { it.copy(activeProfileName = name) }
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name))
}
if (res.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Switched to profile $name") }
loadProfiles()
} else {
_uiState.update {
it.copy(
activeProfileName = originalActive,
toastMessage = "Failed to switch profile: HTTP ${res.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
activeProfileName = originalActive,
toastMessage = "Failed to switch profile: ${e.message}",
)
}
}
}
}
fun loadSoul(profileName: String) {
_uiState.update { it.copy(isLoadingSoul = true, selectedSoulContent = null) }
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getProfileSoul(profileName)
}
if (res.isSuccessful) {
_uiState.update {
it.copy(
isLoadingSoul = false,
selectedSoulContent = res.body()?.content ?: "",
)
}
} else {
_uiState.update {
it.copy(
isLoadingSoul = false,
toastMessage = "Failed to load soul: HTTP ${res.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoadingSoul = false,
toastMessage = "Failed to load soul: ${e.message}",
)
}
}
}
}
fun saveSoul(
profileName: String,
content: String,
) {
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.updateProfileSoul(profileName, UpdateProfileSoulRequest(content))
}
if (res.isSuccessful) {
_uiState.update {
it.copy(
selectedSoulContent = null,
toastMessage = "Soul updated successfully",
)
}
loadProfiles()
} else {
_uiState.update {
it.copy(
toastMessage = "Failed to save soul: HTTP ${res.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
toastMessage = "Failed to save soul: ${e.message}",
)
}
}
}
}
fun updateModel(
profileName: String,
provider: String,
model: String,
) {
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.updateProfileModel(profileName, UpdateProfileModelRequest(provider, model))
}
if (res.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Model settings updated") }
loadProfiles()
} else {
_uiState.update { it.copy(toastMessage = "Failed to update model settings: HTTP ${res.code()}") }
}
} catch (e: Exception) {
_uiState.update { it.copy(toastMessage = "Failed to update model settings: ${e.message}") }
}
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
fun closeSoulDialog() {
_uiState.update { it.copy(selectedSoulContent = null) }
}
}
@@ -0,0 +1,343 @@
package com.m57.hermescontrol.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.ThemePreference
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var passwordVisible by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text(
"Settings",
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Connection section
SectionCard(title = "Connection") {
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
"Hide token"
} else {
"Show token"
},
)
}
},
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
}
// Behavior section
SectionCard(title = "Behavior") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Auto-Reconnect",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = "Automatically reconnect on disconnect",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = state.autoReconnect,
onCheckedChange = viewModel::onAutoReconnectChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
}
// Appearance section
SectionCard(title = "Appearance") {
Text(
text = "Theme",
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemePreference.entries.forEachIndexed { index, pref ->
SegmentedButton(
selected = state.themePreference == pref,
onClick = { viewModel.onThemeChange(pref) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = ThemePreference.entries.size,
),
) {
Text(
pref.name
.lowercase()
.replaceFirstChar { it.uppercase() },
)
}
}
}
}
// Test result
AnimatedVisibility(
visible = state.testResult != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.testResult?.let { result ->
Card(
colors =
CardDefaults.cardColors(
containerColor =
if (result.startsWith("✅")) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = result,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
// Save indicator
AnimatedVisibility(
visible = state.isSaved,
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = "✅ Settings saved",
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
),
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = viewModel::testConnection,
enabled = !state.isTesting,
modifier = Modifier.weight(1f),
) {
if (state.isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Text("Test Connection")
}
}
Button(
onClick = viewModel::save,
modifier = Modifier.weight(1f),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content()
}
}
}
@@ -0,0 +1,119 @@
package com.m57.hermescontrol.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.theme.ThemePreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SettingsUiState(
val host: String = "127.0.0.1",
val port: String = "9119",
val token: String = "",
val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val isTesting: Boolean = false,
val testResult: String? = null,
val isSaved: Boolean = false,
)
class SettingsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
loadSettings()
}
private fun loadSettings() {
_uiState.update {
it.copy(
host = AuthManager.getHost(),
port = AuthManager.getPort().toString(),
token = AuthManager.getToken() ?: "",
autoReconnect = AuthManager.isAutoReconnect(),
)
}
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
}
fun onPortChange(value: String) {
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, isSaved = false) }
}
fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), isSaved = false) }
}
fun onAutoReconnectChange(enabled: Boolean) {
_uiState.update { it.copy(autoReconnect = enabled, isSaved = false) }
}
fun onThemeChange(theme: ThemePreference) {
_uiState.update { it.copy(themePreference = theme, isSaved = false) }
}
fun save() {
val state = _uiState.value
val port = state.port.toIntOrNull() ?: 9119
AuthManager.setHost(state.host)
AuthManager.setPort(port)
AuthManager.setToken(state.token)
AuthManager.setAutoReconnect(state.autoReconnect)
ApiClient.rebuild()
_uiState.update { it.copy(isSaved = true, testResult = null) }
}
fun testConnection() {
val state = _uiState.value
if (state.token.isBlank()) {
_uiState.update { it.copy(testResult = "❌ Token is required") }
return
}
// Save first so ApiClient uses updated settings
save()
_uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getStatus()
}
if (response.isSuccessful) {
_uiState.update {
it.copy(isTesting = false, testResult = "✅ Connected successfully")
}
} else {
_uiState.update {
it.copy(
isTesting = false,
testResult = "❌ HTTP ${response.code()}: ${response.message()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isTesting = false,
testResult = "❌ ${e.message ?: "Connection failed"}",
)
}
}
}
}
}
@@ -0,0 +1,139 @@
package com.m57.hermescontrol.ui.skills
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SkillsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: SkillsViewModel = viewModel { SkillsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadSkills()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Skills Management") },
actions = {
IconButton(onClick = { viewModel.loadSkills() }) {
Text("Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.skills) { skill ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = skill.name, style = MaterialTheme.typography.titleMedium)
Text(
text = skill.category ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = skill.description ?: "",
style = MaterialTheme.typography.bodyMedium,
)
}
Switch(
checked = skill.enabled,
onCheckedChange = { viewModel.toggleSkill(skill) },
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,99 @@
package com.m57.hermescontrol.ui.skills
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SkillsUiState(
val isLoading: Boolean = false,
val skills: List<Skill> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class SkillsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
fun loadSkills() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getSkills()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, skills = response.body().orEmpty()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load skills: ${e.message}") }
}
}
}
fun toggleSkill(skill: Skill) {
val originalEnabled = skill.enabled
val targetEnabled = !originalEnabled
// Optimistically update
_uiState.update { state ->
state.copy(
skills =
state.skills.map {
if (it.name == skill.name) it.copy(enabled = targetEnabled) else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled))
}
if (!response.isSuccessful) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${e.message}")
}
}
}
private fun revertSkillToggle(
name: String,
originalEnabled: Boolean,
errorMsg: String,
) {
_uiState.update { state ->
state.copy(
skills =
state.skills.map {
if (it.name == name) it.copy(enabled = originalEnabled) else it
},
toastMessage = errorMsg,
)
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -0,0 +1,186 @@
package com.m57.hermescontrol.ui.toolsets
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolsetsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadToolsets()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Toolsets Manager") },
actions = {
IconButton(onClick = { viewModel.loadToolsets() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.toolsets.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.toolsets.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadToolsets() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.toolsets) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (toolset.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = toolset.label ?: toolset.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
toolset.description?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
toolset.tools?.let { tools ->
if (tools.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tools.forEach { tool ->
SuggestionChip(
onClick = {},
label = {
Text(
text = tool,
style = MaterialTheme.typography.labelSmall,
)
},
)
}
}
}
}
}
Switch(
checked = toolset.enabled,
onCheckedChange = { viewModel.toggleToolset(toolset) },
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,104 @@
package com.m57.hermescontrol.ui.toolsets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ToolsetsUiState(
val isLoading: Boolean = false,
val toolsets: List<Toolset> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class ToolsetsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ToolsetsUiState())
val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow()
fun loadToolsets() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getToolsets()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, toolsets = response.body().orEmpty()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load toolsets: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load toolsets: ${e.message}",
)
}
}
}
}
fun toggleToolset(toolset: Toolset) {
val originalEnabled = toolset.enabled
val targetEnabled = !originalEnabled
// Optimistic UI update
_uiState.update { state ->
state.copy(
toolsets =
state.toolsets.map {
if (it.name == toolset.name) it.copy(enabled = targetEnabled) else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled))
}
if (!response.isSuccessful) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${e.message}")
}
}
}
private fun revertToggle(
name: String,
originalEnabled: Boolean,
errorMsg: String,
) {
_uiState.update { state ->
state.copy(
toolsets =
state.toolsets.map {
if (it.name == name) it.copy(enabled = originalEnabled) else it
},
toastMessage = errorMsg,
)
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}