Initial commit of HermesControl Android app
This commit is contained in:
commit
d6ff3de594
92 files changed
+10004
No files matched your search
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user