Basic error stuff

This commit is contained in:
Minecon724 2025-06-22 13:33:27 +02:00
commit 9d6e7e5fd0
Signed by untrusted user who does not match committer: m724
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 package eu.m724.chatapp.activity.chat
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -30,6 +29,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -41,7 +41,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass 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.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -99,8 +100,12 @@ class ChatActivity : ComponentActivity() {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val onSend = { val onSend = {
if (chatState.composerValue.isNotBlank() && !uiState.requestInProgress) { if (!uiState.requestInProgress) {
viewModel.sendMessage(chatState.composerValue) if (chatState.composerValue.isNotBlank()) {
viewModel.sendMessage(chatState.composerValue)
} else {
viewModel.repeatLastRequest()
}
} }
} }
@ -135,18 +140,20 @@ class ChatActivity : ComponentActivity() {
// scroll to the last user message // scroll to the last user message
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2) threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
} else { } else {
if (uiState.messages.isNotEmpty()) { if (uiState.messages.size > 1) {
// scroll to the last user message too // scroll to the last user message too
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2) threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
} }
// if the composer is visible (message is short enough), focus on it if (uiState.lastResponseError == null) {
// if the message is long, we let the user read it // Opens the composer only when it's doesn't hinder the user's ability to read the response.
threadViewLazyListState.layoutInfo.visibleItemsInfo.firstOrNull { // We don't do that if the user might want to restart the response
it.key == "composer" threadViewLazyListState.layoutInfo.visibleItemsInfo.firstOrNull {
}?.let { it.key == "composer"
chatState.requestFocus() }?.let {
softwareKeyboardController?.show() // TODO perhaps it's pointless to focus since we can click on the toolbar? maybe make it configurable 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 -> viewModel.uiEvents.collect { event ->
when (event) { when (event) {
is ChatActivityUiEvent.Error -> { 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 // TODO add a smart action, for example check api key if unauthorized etc
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
@ -191,7 +200,10 @@ fun ChatScreen(
}, },
snackbarHost = { snackbarHost = {
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 -> ) { innerPadding ->
@ -264,7 +276,8 @@ fun ChatScreenContent(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
ChatToolBar( 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, onSend = onSend,
onEmptySpaceClick = onRequestFocus onEmptySpaceClick = onRequestFocus
) )
@ -308,6 +321,14 @@ fun ThreadView(
} }
} }
if (uiState.lastResponseError != null) {
item(key = "error") {
ResponseErrorDisplay(
error = uiState.lastResponseError
)
}
}
item(key = "composer") { item(key = "composer") {
if (!uiState.requestInProgress) { if (!uiState.requestInProgress) {
ChatMessageComposer( 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 @Composable
fun ChatMessageResponse( fun ChatMessageResponse(
content: String, content: String,
@ -397,6 +448,7 @@ fun ChatMessageComposer(
fun ChatToolBar( fun ChatToolBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canSend: Boolean, canSend: Boolean,
canRestart: Boolean,
onSend: () -> Unit, onSend: () -> Unit,
onEmptySpaceClick: () -> Unit onEmptySpaceClick: () -> Unit
) { ) {
@ -429,10 +481,17 @@ fun ChatToolBar(
disabledContentColor = sendButtonColor disabledContentColor = sendButtonColor
) )
) { ) {
Icon( if (canRestart) {
imageVector = Icons.AutoMirrored.Filled.Send, Icon(
contentDescription = stringResource(R.string.button_send_icon_description) 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 requestInProgress: Boolean = false,
val lastResponseError: ChatResponseError? = null,
/**
* All messages in the chat
*/
val messages: List<ChatMessage> = emptyList() 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.request.completion.ChatCompletionRequest
import eu.m724.chatapp.api.data.response.completion.ChatCompletionResponseEvent 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.ChatMessage
import eu.m724.chatapp.api.data.response.completion.CompletionFinishReason
import eu.m724.chatapp.api.retrofit.sse.SseEvent import eu.m724.chatapp.api.retrofit.sse.SseEvent
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -32,14 +33,36 @@ class ChatActivityViewModel @Inject constructor(
private val messages = mutableListOf<ChatMessage>() 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) { 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( messages.add(ChatMessage(
role = ChatMessage.Role.User, role = ChatMessage.Role.User,
content = promptContent content = promptContent
)) ))
var responseContent = ""
_uiState.update { _uiState.update {
it.copy( it.copy(
requestInProgress = true, requestInProgress = true,
@ -48,14 +71,15 @@ class ChatActivityViewModel @Inject constructor(
content = responseContent content = responseContent
), ),
chatTitle = it.chatTitle ?: promptContent, chatTitle = it.chatTitle ?: promptContent,
lastResponseError = null
) )
} }
aiApiService.getChatCompletion(ChatCompletionRequest( aiApiService.getChatCompletion(ChatCompletionRequest(
model = "i-model", model = "fre-model",
messages = messages, messages = messages,
temperature = 1.0f, temperature = 1.0f,
maxTokens = 128, maxTokens = 4,
frequencyPenalty = 0.0f, frequencyPenalty = 0.0f,
presencePenalty = 0.0f presencePenalty = 0.0f
)).onEach { event -> )).onEach { event ->
@ -77,6 +101,10 @@ class ChatActivityViewModel @Inject constructor(
) )
} }
} }
if (choice.finishReason == CompletionFinishReason.Length) {
error = ChatResponseError.LengthLimit
}
} }
} }
is SseEvent.Closed -> { is SseEvent.Closed -> {
@ -92,13 +120,16 @@ class ChatActivityViewModel @Inject constructor(
} }
} }
}.catch { exception -> }.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())) _uiEvents.send(ChatActivityUiEvent.Error(exception.toString()))
}.onCompletion { }.onCompletion {
_uiState.update { _uiState.update {
it.copy( it.copy(
requestInProgress = false, requestInProgress = false,
lastResponseError = error,
messages = messages.toList() 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="app_name">Chat</string>
<string name="title_new_conversation">Start a new conversation</string> <string name="title_new_conversation">Start a new conversation</string>
<string name="button_send_icon_description">Send message</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="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> </resources>