Basic error stuff

This commit is contained in:
Minecon724 2025-06-22 13:33:27 +02:00
commit 9d6e7e5fd0
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
6 changed files with 135 additions and 24 deletions

View file

@ -1,7 +1,6 @@
package eu.m724.chatapp.activity.chat
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@ -30,6 +29,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
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.Warning
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
@ -41,7 +41,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
@ -60,7 +59,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -99,8 +100,12 @@ class ChatActivity : ComponentActivity() {
val snackbarHostState = remember { SnackbarHostState() }
val onSend = {
if (chatState.composerValue.isNotBlank() && !uiState.requestInProgress) {
viewModel.sendMessage(chatState.composerValue)
if (!uiState.requestInProgress) {
if (chatState.composerValue.isNotBlank()) {
viewModel.sendMessage(chatState.composerValue)
} else {
viewModel.repeatLastRequest()
}
}
}
@ -135,18 +140,20 @@ class ChatActivity : ComponentActivity() {
// scroll to the last user message
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
} else {
if (uiState.messages.isNotEmpty()) {
if (uiState.messages.size > 1) {
// scroll to the last user message too
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
}
// if the composer is visible (message is short enough), focus on it
// if the message is long, we let the user read it
threadViewLazyListState.layoutInfo.visibleItemsInfo.firstOrNull {
it.key == "composer"
}?.let {
chatState.requestFocus()
softwareKeyboardController?.show() // TODO perhaps it's pointless to focus since we can click on the toolbar? maybe make it configurable
if (uiState.lastResponseError == null) {
// Opens the composer only when it's doesn't hinder the user's ability to read the response.
// We don't do that if the user might want to restart the response
threadViewLazyListState.layoutInfo.visibleItemsInfo.firstOrNull {
it.key == "composer"
}?.let {
chatState.requestFocus()
softwareKeyboardController?.show() // TODO perhaps it's pointless to focus since we can click on the toolbar? maybe make it configurable
}
}
}
}
@ -155,6 +162,8 @@ class ChatActivity : ComponentActivity() {
viewModel.uiEvents.collect { event ->
when (event) {
is ChatActivityUiEvent.Error -> {
// TODO maybe we should restore the composer from the previous prompt
// TODO add a smart action, for example check api key if unauthorized etc
snackbarHostState.showSnackbar(
@ -191,7 +200,10 @@ fun ChatScreen(
},
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState
hostState = snackbarHostState,
modifier = Modifier
.imePadding()
.padding(bottom = 80.dp) // Excuse the magic value. This is the approximate height of the toolbar + AI warning.
)
}
) { innerPadding ->
@ -264,7 +276,8 @@ fun ChatScreenContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatToolBar(
canSend = chatState.composerValue.isNotBlank() && !uiState.requestInProgress,
canSend = (chatState.composerValue.isNotBlank() || uiState.lastResponseError != null) && !uiState.requestInProgress,
canRestart = chatState.composerValue.isBlank() && uiState.lastResponseError != null,
onSend = onSend,
onEmptySpaceClick = onRequestFocus
)
@ -308,6 +321,14 @@ fun ThreadView(
}
}
if (uiState.lastResponseError != null) {
item(key = "error") {
ResponseErrorDisplay(
error = uiState.lastResponseError
)
}
}
item(key = "composer") {
if (!uiState.requestInProgress) {
ChatMessageComposer(
@ -360,6 +381,36 @@ fun ChatMessagePrompt(
)
}
@Composable
fun ResponseErrorDisplay(
error: ChatResponseError,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.height(18.dp)
.padding(horizontal = 4.dp),
imageVector = Icons.Default.Warning,
contentDescription = stringResource(R.string.response_error_icon_description),
tint = MaterialTheme.colorScheme.error
)
val errorMessage = when (error) {
is ChatResponseError.LengthLimit -> stringResource(R.string.response_error_length_limit)
is ChatResponseError.Error -> stringResource(R.string.response_error_generic)
}
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error
)
}
}
@Composable
fun ChatMessageResponse(
content: String,
@ -397,6 +448,7 @@ fun ChatMessageComposer(
fun ChatToolBar(
modifier: Modifier = Modifier,
canSend: Boolean,
canRestart: Boolean,
onSend: () -> Unit,
onEmptySpaceClick: () -> Unit
) {
@ -429,10 +481,17 @@ fun ChatToolBar(
disabledContentColor = sendButtonColor
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.button_send_icon_description)
)
if (canRestart) {
Icon(
painter = painterResource(R.drawable.outline_restart_alt_24),
contentDescription = stringResource(R.string.button_send_restart_icon_description)
)
} else {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.button_send_icon_description)
)
}
}
}
}

View file

@ -13,5 +13,11 @@ data class ChatActivityUiState(
*/
val requestInProgress: Boolean = false,
val lastResponseError: ChatResponseError? = null,
/**
* All messages in the chat
*/
val messages: List<ChatMessage> = emptyList()
)
)

View file

@ -7,6 +7,7 @@ import eu.m724.chatapp.api.AiApiService
import eu.m724.chatapp.api.data.request.completion.ChatCompletionRequest
import eu.m724.chatapp.api.data.response.completion.ChatCompletionResponseEvent
import eu.m724.chatapp.api.data.response.completion.ChatMessage
import eu.m724.chatapp.api.data.response.completion.CompletionFinishReason
import eu.m724.chatapp.api.retrofit.sse.SseEvent
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@ -32,14 +33,36 @@ class ChatActivityViewModel @Inject constructor(
private val messages = mutableListOf<ChatMessage>()
/**
* Repeat the last prompt. There is no reliable way to continue a response.
*/
fun repeatLastRequest() {
var lastUserMessage = messages.removeLast()
if (lastUserMessage.role == ChatMessage.Role.Assistant) {
// If we just removed an Assistant message, we must also remove the respective User message
lastUserMessage = messages.removeLast()
}
sendMessage(lastUserMessage.content)
}
/**
* Send a new message.
*/
fun sendMessage(promptContent: String) {
var responseContent = ""
var error: ChatResponseError? = null
if (messages.lastOrNull()?.role == ChatMessage.Role.User) {
messages.removeLast()
} // If there was an error and no response was generated, this shouldn't be a follow-up
messages.add(ChatMessage(
role = ChatMessage.Role.User,
content = promptContent
))
var responseContent = ""
_uiState.update {
it.copy(
requestInProgress = true,
@ -48,14 +71,15 @@ class ChatActivityViewModel @Inject constructor(
content = responseContent
),
chatTitle = it.chatTitle ?: promptContent,
lastResponseError = null
)
}
aiApiService.getChatCompletion(ChatCompletionRequest(
model = "i-model",
model = "fre-model",
messages = messages,
temperature = 1.0f,
maxTokens = 128,
maxTokens = 4,
frequencyPenalty = 0.0f,
presencePenalty = 0.0f
)).onEach { event ->
@ -77,6 +101,10 @@ class ChatActivityViewModel @Inject constructor(
)
}
}
if (choice.finishReason == CompletionFinishReason.Length) {
error = ChatResponseError.LengthLimit
}
}
}
is SseEvent.Closed -> {
@ -92,13 +120,16 @@ class ChatActivityViewModel @Inject constructor(
}
}
}.catch { exception ->
messages.removeFirst() // Removing the new user message
// a message is not added here
error = ChatResponseError.Error(exception.message)
_uiEvents.send(ChatActivityUiEvent.Error(exception.toString()))
}.onCompletion {
_uiState.update {
it.copy(
requestInProgress = false,
lastResponseError = error,
messages = messages.toList()
)
}

View file

@ -0,0 +1,6 @@
package eu.m724.chatapp.activity.chat
sealed interface ChatResponseError { // TODO does this belong here?
data object LengthLimit: ChatResponseError
data class Error(val message: String?): ChatResponseError
}

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440,838Q319,823 239.5,732.5Q160,642 160,520Q160,454 186,393.5Q212,333 260,288L317,345Q279,379 259.5,424Q240,469 240,520Q240,608 296,675.5Q352,743 440,758L440,838ZM520,838L520,758Q607,742 663.5,675Q720,608 720,520Q720,420 650,350Q580,280 480,280L477,280L521,324L465,380L325,240L465,100L521,156L477,200L480,200Q614,200 707,293Q800,386 800,520Q800,641 720.5,731.5Q641,822 520,838Z"/>
</vector>

View file

@ -2,5 +2,9 @@
<string name="app_name">Chat</string>
<string name="title_new_conversation">Start a new conversation</string>
<string name="button_send_icon_description">Send message</string>
<string name="button_send_restart_icon_description">Restart response</string>
<string name="composer_placeholder_type">Type your message…</string>
<string name="response_error_icon_description">Error responding</string>
<string name="response_error_length_limit">Too long</string>
<string name="response_error_generic">Fatal error</string>
</resources>