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
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue