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,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()
}
}
}