From 6d63e26805b26a7d027d32f3c59c90e46bf49379 Mon Sep 17 00:00:00 2001 From: Taewan Park Date: Wed, 26 Jun 2024 23:43:04 +0900 Subject: [PATCH 1/4] Fix saved message order due to duplicate message ids This fixed weird looking message structures as well --- .../presentation/ui/chat/ChatViewModel.kt | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatViewModel.kt index 57d6c01..3a9268d 100644 --- a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatViewModel.kt @@ -35,6 +35,8 @@ class ChatViewModel @Inject constructor( private val enabledPlatformString: String = checkNotNull(savedStateHandle["enabledPlatforms"]) val enabledPlatformsInChat = enabledPlatformString.split(',').map { s -> ApiType.valueOf(s) } private lateinit var chatRoom: ChatRoom + private val currentTimeStamp: Long + get() = System.currentTimeMillis() / 1000 private val _enabledPlatformsInApp = MutableStateFlow(listOf()) val enabledPlatformsInApp = _enabledPlatformsInApp.asStateFlow() @@ -84,7 +86,7 @@ class ChatViewModel @Inject constructor( fun askQuestion() { Log.d("Question: ", _question.value) - _userMessage.update { it.copy(content = _question.value) } + _userMessage.update { it.copy(content = _question.value, createdAt = currentTimeStamp) } _question.update { "" } completeChat() } @@ -109,22 +111,22 @@ class ChatViewModel @Inject constructor( enabledPlatformsInChat.forEach { apiType -> when (apiType) { message.platformType -> {} - else -> restoreMessageState(apiType, previousAnswers) + else -> restoreMessageState(apiType, previousAnswers) // Restore messages that are not retrying } } when (message.platformType) { ApiType.OPENAI -> { - _openAIMessage.update { it.copy(content = "") } + _openAIMessage.update { it.copy(id = message.id, content = "", createdAt = currentTimeStamp) } completeOpenAIChat() } ApiType.ANTHROPIC -> { - _anthropicMessage.update { it.copy(content = "") } + _anthropicMessage.update { it.copy(id = message.id, content = "", createdAt = currentTimeStamp) } completeAnthropicChat() } ApiType.GOOGLE -> { - _googleMessage.update { it.copy(content = "") } + _googleMessage.update { it.copy(id = message.id, content = "", createdAt = currentTimeStamp) } completeGoogleChat() } @@ -137,10 +139,10 @@ class ChatViewModel @Inject constructor( private fun addMessage(message: Message) = _messages.update { it + listOf(message) } private fun clearQuestionAndAnswers() { - _userMessage.update { it.copy(content = "") } - _openAIMessage.update { it.copy(content = "") } - _anthropicMessage.update { it.copy(content = "") } - _googleMessage.update { it.copy(content = "") } + _userMessage.update { it.copy(id = 0, content = "") } + _openAIMessage.update { it.copy(id = 0, content = "") } + _anthropicMessage.update { it.copy(id = 0, content = "") } + _googleMessage.update { it.copy(id = 0, content = "") } } private fun completeChat() { @@ -183,8 +185,16 @@ class ChatViewModel @Inject constructor( private fun fetchMessages() { viewModelScope.launch { + // If the room isn't new if (chatRoomId != 0) { _messages.update { chatRepository.fetchMessages(chatRoomId) } + return@launch + } + + // When message id should sync after saving chats + if (chatRoom.id != 0) { + _messages.update { chatRepository.fetchMessages(chatRoom.id) } + return@launch } } } @@ -212,9 +222,12 @@ class ChatViewModel @Inject constructor( openAIFlow.collect { chunk -> when (chunk) { is ApiState.Success -> _openAIMessage.update { it.copy(content = it.content + chunk.textChunk) } - ApiState.Done -> updateLoadingState(ApiType.OPENAI, LoadingState.Idle) + ApiState.Done -> { + _openAIMessage.update { it.copy(createdAt = currentTimeStamp) } + updateLoadingState(ApiType.OPENAI, LoadingState.Idle) + } is ApiState.Error -> { - _openAIMessage.update { it.copy(content = "Error: ${chunk.message}") } + _openAIMessage.update { it.copy(content = "Error: ${chunk.message}", createdAt = currentTimeStamp) } updateLoadingState(ApiType.OPENAI, LoadingState.Idle) } @@ -227,9 +240,12 @@ class ChatViewModel @Inject constructor( anthropicFlow.collect { chunk -> when (chunk) { is ApiState.Success -> _anthropicMessage.update { it.copy(content = it.content + chunk.textChunk) } - ApiState.Done -> updateLoadingState(ApiType.ANTHROPIC, LoadingState.Idle) + ApiState.Done -> { + _anthropicMessage.update { it.copy(createdAt = currentTimeStamp) } + updateLoadingState(ApiType.ANTHROPIC, LoadingState.Idle) + } is ApiState.Error -> { - _anthropicMessage.update { it.copy(content = "Error: ${chunk.message}") } + _anthropicMessage.update { it.copy(content = "Error: ${chunk.message}", createdAt = currentTimeStamp) } updateLoadingState(ApiType.ANTHROPIC, LoadingState.Idle) } @@ -242,9 +258,12 @@ class ChatViewModel @Inject constructor( googleFlow.collect { chunk -> when (chunk) { is ApiState.Success -> _googleMessage.update { it.copy(content = it.content + chunk.textChunk) } - ApiState.Done -> updateLoadingState(ApiType.GOOGLE, LoadingState.Idle) + ApiState.Done -> { + _googleMessage.update { it.copy(createdAt = currentTimeStamp) } + updateLoadingState(ApiType.GOOGLE, LoadingState.Idle) + } is ApiState.Error -> { - _googleMessage.update { it.copy(content = "Error: ${chunk.message}") } + _googleMessage.update { it.copy(content = "Error: ${chunk.message}", createdAt = currentTimeStamp) } updateLoadingState(ApiType.GOOGLE, LoadingState.Idle) } @@ -261,6 +280,7 @@ class ChatViewModel @Inject constructor( syncQuestionAndAnswers() Log.d("message", "${_messages.value}") chatRoom = chatRepository.saveChat(chatRoom, _messages.value) + fetchMessages() // For syncing message ids } clearQuestionAndAnswers() } @@ -274,9 +294,9 @@ class ChatViewModel @Inject constructor( if (message == null) return when (apiType) { - ApiType.OPENAI -> _openAIMessage.update { it.copy(content = message.content) } - ApiType.ANTHROPIC -> _anthropicMessage.update { it.copy(content = message.content) } - ApiType.GOOGLE -> _googleMessage.update { it.copy(content = message.content) } + ApiType.OPENAI -> _openAIMessage.update { message } + ApiType.ANTHROPIC -> _anthropicMessage.update { message } + ApiType.GOOGLE -> _googleMessage.update { message } } } From 909ee7d13a95b42fc8e71210ded61413f859a818 Mon Sep 17 00:00:00 2001 From: Taewan Park Date: Wed, 26 Jun 2024 23:51:02 +0900 Subject: [PATCH 2/4] Sync scroll state of opponent bubbles when answer is generating Previously, we just used `rememberScrollState()` so after the answer is generated completely, another ScrollState would replace the older one, making it bad UX. --- .../presentation/ui/chat/ChatScreen.kt | 21 ++++++++++++++++--- .../gptmobile/util/ScrollStateSaver.kt | 9 ++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/ScrollStateSaver.kt diff --git a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt index a74ffe4..71befed 100644 --- a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt @@ -1,6 +1,7 @@ package dev.chungjungsoo.gptmobile.presentation.ui.chat import android.util.Log +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -19,7 +20,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons @@ -37,8 +37,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -59,6 +61,7 @@ import dev.chungjungsoo.gptmobile.R import dev.chungjungsoo.gptmobile.data.database.entity.Message import dev.chungjungsoo.gptmobile.data.model.ApiType import dev.chungjungsoo.gptmobile.util.collectManagedState +import dev.chungjungsoo.gptmobile.util.multiScrollStateSaver @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -89,6 +92,18 @@ fun ChatScreen( val canUseChat = (chatViewModel.enabledPlatformsInChat.toSet() - appEnabledPlatforms.toSet()).isEmpty() val groupedMessages = remember(messages) { groupMessages(messages) } val latestMessageIndex = groupedMessages.keys.maxOrNull() ?: 0 + val chatBubbleScrollStates = rememberSaveable(saver = multiScrollStateSaver) { MutableList(latestMessageIndex + 1) { ScrollState(0) } } + + LaunchedEffect(latestMessageIndex) { + val opponentBubbles = ((latestMessageIndex + 1) / 2) + 1 + val scrollStatesToAdd = opponentBubbles - chatBubbleScrollStates.size + + if (scrollStatesToAdd > 0) { + repeat(scrollStatesToAdd) { + chatBubbleScrollStates.add(ScrollState(0)) + } + } + } Scaffold( modifier = Modifier @@ -138,7 +153,7 @@ fun ChatScreen( Row( modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) + .horizontalScroll(chatBubbleScrollStates[(key - 1) / 2]) ) { Spacer(modifier = Modifier.width(8.dp)) groupedMessages[key]!!.sortedByDescending { it.platformType }.forEach { m -> @@ -178,7 +193,7 @@ fun ChatScreen( Row( modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) + .horizontalScroll(chatBubbleScrollStates[(latestMessageIndex + 1) / 2]) ) { Spacer(modifier = Modifier.width(8.dp)) chatViewModel.enabledPlatformsInChat.sortedDescending().forEach { apiType -> diff --git a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/ScrollStateSaver.kt b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/ScrollStateSaver.kt new file mode 100644 index 0000000..a2c47b6 --- /dev/null +++ b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/ScrollStateSaver.kt @@ -0,0 +1,9 @@ +package dev.chungjungsoo.gptmobile.util + +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.saveable.Saver + +val multiScrollStateSaver: Saver, *> = Saver( + save = { it.map { scrollState -> scrollState.value } }, + restore = { it.map { i -> ScrollState(i) }.toMutableList() } +) From c642dfa6bc912f5248b9b047c2b69dde262b473c Mon Sep 17 00:00:00 2001 From: Taewan Park Date: Thu, 27 Jun 2024 00:12:56 +0900 Subject: [PATCH 3/4] Scroll to the bottom when question is asked or answer is done loading --- .../chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt index 71befed..011edbf 100644 --- a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt @@ -105,6 +105,10 @@ fun ChatScreen( } } + LaunchedEffect(isIdle) { + listState.animateScrollToItem(groupedMessages.keys.size) + } + Scaffold( modifier = Modifier .fillMaxSize() From 06976b847986f829a0e05d44cd282efba6f909c2 Mon Sep 17 00:00:00 2001 From: Taewan Park Date: Thu, 27 Jun 2024 02:04:41 +0900 Subject: [PATCH 4/4] Add extra scroll state when chat screen is initializing This can prevent occasional index out of range errors --- .../chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt index 011edbf..c2fecf4 100644 --- a/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/chat/ChatScreen.kt @@ -92,7 +92,7 @@ fun ChatScreen( val canUseChat = (chatViewModel.enabledPlatformsInChat.toSet() - appEnabledPlatforms.toSet()).isEmpty() val groupedMessages = remember(messages) { groupMessages(messages) } val latestMessageIndex = groupedMessages.keys.maxOrNull() ?: 0 - val chatBubbleScrollStates = rememberSaveable(saver = multiScrollStateSaver) { MutableList(latestMessageIndex + 1) { ScrollState(0) } } + val chatBubbleScrollStates = rememberSaveable(saver = multiScrollStateSaver) { MutableList(latestMessageIndex + 2) { ScrollState(0) } } LaunchedEffect(latestMessageIndex) { val opponentBubbles = ((latestMessageIndex + 1) / 2) + 1