diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt index 82fe99a..3fc5770 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt @@ -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) + ) + } } } } diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt index 1cbb3b5..28366a7 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt @@ -13,5 +13,11 @@ data class ChatActivityUiState( */ val requestInProgress: Boolean = false, + val lastResponseError: ChatResponseError? = null, + + /** + * All messages in the chat + */ val messages: List = emptyList() -) \ No newline at end of file +) + diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt index ed03f11..3a841e4 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt @@ -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() + /** + * 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() ) } diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatResponseError.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatResponseError.kt new file mode 100644 index 0000000..87735e2 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatResponseError.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_restart_alt_24.xml b/app/src/main/res/drawable/outline_restart_alt_24.xml new file mode 100644 index 0000000..bbd8f9a --- /dev/null +++ b/app/src/main/res/drawable/outline_restart_alt_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8980927..cc6a630 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,9 @@ Chat Start a new conversation Send message + Restart response Type your message… + Error responding + Too long + Fatal error \ No newline at end of file