Basic error stuff
This commit is contained in:
parent
0ed1aa26b9
commit
9d6e7e5fd0
6 changed files with 135 additions and 24 deletions
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
5
app/src/main/res/drawable/outline_restart_alt_24.xml
Normal file
5
app/src/main/res/drawable/outline_restart_alt_24.xml
Normal 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>
|
|
@ -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>
|
Loading…
Add table
Add a link
Reference in a new issue