diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 863a6346b687..2223fe2692e7 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -9,6 +9,7 @@ package com.nextcloud.client.assistant import android.app.Activity import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -55,6 +56,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nextcloud.client.assistant.chat.ChatContent +import com.nextcloud.client.assistant.chat.ChatViewModel import com.nextcloud.client.assistant.conversation.ConversationScreen import com.nextcloud.client.assistant.conversation.ConversationViewModel import com.nextcloud.client.assistant.conversation.repository.MockConversationRemoteRepository @@ -92,11 +94,13 @@ private const val PULL_TO_REFRESH_DELAY = 1500L fun AssistantScreen( composeViewModel: ComposeViewModel, viewModel: AssistantViewModel, + chatViewModel: ChatViewModel, conversationViewModel: ConversationViewModel, capability: OCCapability, activity: Activity ) { val selectedText by composeViewModel.selectedText.collectAsState() + val sessionTitle by chatViewModel.sessionTitle.collectAsState() val sessionId by viewModel.sessionId.collectAsState() val messageId by viewModel.snackbarMessageId.collectAsState() val screenOverlayState by viewModel.screenOverlayState.collectAsState() @@ -140,12 +144,8 @@ fun AssistantScreen( } } - LaunchedEffect(sessionId) { + LaunchedEffect(Unit) { viewModel.startPolling(sessionId) - - sessionId?.let { - viewModel.fetchChatMessages(it) - } } DisposableEffect(Unit) { @@ -164,8 +164,10 @@ fun AssistantScreen( scope.launch { pagerState.scrollToPage(AssistantPage.Content.id) } - }, openChat = { newSessionId -> - viewModel.initSessionId(newSessionId) + }, openChat = { conversation -> + viewModel.updateInputBarText("") + chatViewModel.updateSessionTitle(conversation.timestamp) + chatViewModel.selectConversation(conversation.id) taskTypes.getChat()?.let { chatTaskType -> viewModel.selectTaskType(chatTaskType) } @@ -184,9 +186,9 @@ fun AssistantScreen( scope.launch { delay(PULL_TO_REFRESH_DELAY) - val newSessionId = sessionId - if (newSessionId != null) { - viewModel.fetchChatMessages(newSessionId) + val currentSessionId = sessionId + if (currentSessionId != null) { + chatViewModel.selectConversation(currentSessionId) } else { viewModel.fetchTaskList() } @@ -194,14 +196,33 @@ fun AssistantScreen( } ), topBar = { - taskTypes?.let { - TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task -> - viewModel.selectTaskType(task) - }, navigateToConversationList = { - scope.launch { - pagerState.scrollToPage(AssistantPage.Conversation.id) + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + taskTypes?.let { + TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task -> + viewModel.selectTaskType(task) + }, navigateToConversationList = { + scope.launch { + pagerState.scrollToPage(AssistantPage.Conversation.id) + } + }) + } + + if (selectedTaskType?.isChat() == true && sessionTitle != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = sessionTitle!!, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) } - }) + } } }, bottomBar = { @@ -209,7 +230,8 @@ fun AssistantScreen( InputBar( sessionId, selectedTaskType, - viewModel + viewModel, + chatViewModel ) } }, @@ -257,7 +279,7 @@ fun AssistantScreen( AssistantScreenState.ChatContent -> { ChatContent( - viewModel = viewModel, + chatViewModel = chatViewModel, modifier = Modifier.padding(paddingValues) ) } @@ -301,9 +323,15 @@ fun AssistantScreen( @Suppress("LongMethod") @Composable -private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) { +private fun InputBar( + sessionId: Long?, + selectedTaskType: TaskTypeData?, + viewModel: AssistantViewModel, + chatViewModel: ChatViewModel +) { val scope = rememberCoroutineScope() val text by viewModel.inputBarText.collectAsState() + val chatUIState by chatViewModel.uiState.collectAsState() Surface( tonalElevation = 3.dp, @@ -349,9 +377,9 @@ private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewMode val taskType = selectedTaskType ?: return@IconButton if (taskType.isChat()) { if (sessionId != null) { - viewModel.sendChatMessage(content = text, sessionId) + chatViewModel.sendMessage(content = text, sessionId = sessionId) } else { - viewModel.createConversation(text) + chatViewModel.startNewConversation(content = text) } } else { viewModel.createTask(input = text, taskType = taskType) @@ -361,12 +389,19 @@ private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewMode delay(CHAT_INPUT_DELAY) viewModel.updateInputBarText("") } - } + }, + enabled = chatUIState.canSend() ) { Icon( painter = painterResource(id = R.drawable.ic_send), contentDescription = stringResource(R.string.assistant_screen_send_message), - tint = MaterialTheme.colorScheme.primary + tint = if (chatUIState.canSend()) { + MaterialTheme.colorScheme.primary + } else { + colorResource( + R.color.disabled_text + ) + } ) } } @@ -498,6 +533,7 @@ private fun AssistantScreenPreview() { composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(false), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), activity = ComposeActivity(), capability = OCCapability().apply { versionMayor = 30 @@ -517,6 +553,7 @@ private fun AssistantEmptyScreenPreview() { composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(true), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), activity = ComposeActivity(), capability = OCCapability().apply { versionMayor = 30 diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt index 8134df9fbe58..989bd26a1c97 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt @@ -14,11 +14,8 @@ import com.nextcloud.client.assistant.model.ScreenOverlayState import com.nextcloud.client.assistant.repository.local.AssistantLocalRepository import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND -import com.nextcloud.utils.extensions.isHuman import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage -import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest import com.owncloud.android.lib.resources.assistant.v2.model.Task import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData import kotlinx.coroutines.Dispatchers @@ -75,14 +72,7 @@ class AssistantViewModel( private val _filteredTaskList = MutableStateFlow?>(null) val filteredTaskList: StateFlow?> = _filteredTaskList - private val _chatMessages = MutableStateFlow>(listOf()) - val chatMessages: StateFlow> = _chatMessages - - private val _isAssistantAnswering = MutableStateFlow(false) - val isAssistantAnswering: StateFlow = _isAssistantAnswering - private var pollingJob: Job? = null - private var currentChatTaskId: String? = null init { observeScreenState() @@ -97,20 +87,8 @@ class AssistantViewModel( try { while (isActive) { delay(POLLING_INTERVAL_MS) - val taskType = _selectedTaskType.value ?: continue - - if (taskType.isChat() && sessionId != null) { - Log_OC.d(TAG, "Polling chat messages, sessionId: $sessionId") - - if (currentChatTaskId == null) { - remoteRepository.generateSession(sessionId.toString())?.let { - currentChatTaskId = it.taskId.toString() - } - } - - fetchNewChatMessage(sessionId) - } else if (!taskType.isChat()) { + if (!taskType.isChat()) { Log_OC.d(TAG, "Polling task list") pollTaskList() } @@ -143,37 +121,13 @@ class AssistantViewModel( } } - fun fetchNewChatMessage(sessionId: Long) = viewModelScope.launch(Dispatchers.IO) { - val taskId = currentChatTaskId ?: return@launch - val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: return@launch - - _chatMessages.update { current -> - val messageExists = current.any { - it.id == newMessage.id || - (it.timestamp == newMessage.timestamp && it.content == newMessage.content) - } - - if (messageExists) { - current - } else { - if (!newMessage.isHuman()) { - _isAssistantAnswering.update { - false - } - } - current + newMessage - } - } - } - private fun observeScreenState() { viewModelScope.launch { combine( selectedTask, _selectedTaskType, - _chatMessages, _filteredTaskList - ) { selectedTask, selectedTaskType, chats, tasks -> + ) { selectedTask, selectedTaskType, tasks -> val isChat = selectedTaskType?.isChat() == true val isTranslation = selectedTaskType?.isTranslate() == true && selectedTask?.isTranslate() == true @@ -183,11 +137,9 @@ class AssistantViewModel( isTranslation -> AssistantScreenState.Translation(selectedTask) - isChat && chats.isEmpty() -> AssistantScreenState.emptyChatList() - isChat -> AssistantScreenState.ChatContent - !isChat && (tasks == null || tasks.isEmpty()) -> AssistantScreenState.emptyTaskList() + !isChat && tasks.isNullOrEmpty() -> AssistantScreenState.emptyTaskList() else -> { if (!_isTranslationTask.value) { @@ -203,48 +155,6 @@ class AssistantViewModel( } } - // region chat - fun sendChatMessage(content: String, sessionId: Long) = viewModelScope.launch(Dispatchers.IO) { - val request = ChatMessageRequest( - sessionId = sessionId.toString(), - role = "human", - content = content, - timestamp = System.currentTimeMillis() / MILLIS_PER_SECOND, - firstHumanMessage = _chatMessages.value.isEmpty() - ) - - remoteRepository.sendChatMessage(request)?.let { newMessage -> - _chatMessages.update { messages -> - messages + newMessage - } - _isAssistantAnswering.update { - true - } - } ?: updateSnackbarMessage(R.string.assistant_screen_chat_create_error) - } - - fun fetchChatMessages(sessionId: Long) = viewModelScope.launch(Dispatchers.IO) { - remoteRepository.fetchChatMessages(sessionId)?.let { messageList -> - _chatMessages.update { - messageList - } - } ?: updateSnackbarMessage(R.string.assistant_screen_chat_fetch_error) - } - - fun createConversation(title: String) = viewModelScope.launch(Dispatchers.IO) { - remoteRepository.createConversation(title)?.let { result -> - initSessionId(result.session.id) - sendChatMessage(title, result.session.id) - } - } - - fun initSessionId(value: Long) { - Log_OC.d(TAG, "session id updated: $value") - currentChatTaskId = null - _sessionId.update { value } - } - // endregion - // region task fun createTask(input: String, taskType: TaskTypeData) = viewModelScope.launch(Dispatchers.IO) { val result = remoteRepository.createTask(input, taskType) @@ -273,15 +183,6 @@ class AssistantViewModel( if (!task.isChat()) { fetchTaskList() - return - } - - // only task chat type needs to be handled differently - val sessionId = _sessionId.value ?: return - if (_chatMessages.value.isEmpty()) { - fetchChatMessages(sessionId) - } else { - fetchNewChatMessage(sessionId) } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt index f88f54e2a176..7c5f11dd205b 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt @@ -29,10 +29,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -47,12 +52,11 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nextcloud.client.assistant.AssistantViewModel import com.nextcloud.utils.TimeConstants -import com.nextcloud.utils.extensions.isHuman import com.nextcloud.utils.extensions.time import com.owncloud.android.R import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage @@ -63,25 +67,90 @@ private val CHAT_BUBBLE_CORNER_RADIUS = 8.dp private val ASSISTANT_ICON_SIZE = 40.dp @Composable -fun ChatContent(viewModel: AssistantViewModel, modifier: Modifier = Modifier) { - val chatMessages by viewModel.chatMessages.collectAsState() - val isAssistantAnswering by viewModel.isAssistantAnswering.collectAsState() +fun ChatContent(chatViewModel: ChatViewModel, modifier: Modifier = Modifier) { + val uiState by chatViewModel.uiState.collectAsState() val listState = rememberLazyListState() - LaunchedEffect(chatMessages) { - listState.animateScrollToItem(listState.layoutInfo.totalItemsCount) + val messages = uiState.messages() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.size - 1) + } } + when (uiState) { + is ChatUIState.Loading -> { + ChatLoadingContent(modifier) + } + + is ChatUIState.Empty -> { + ChatEmptyContent(modifier) + } + + is ChatUIState.Error -> { + val errorState = uiState as ChatUIState.Error + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState, + bottomSlot = { + ChatErrorBanner(errorState.errorType) + } + ) + } + + is ChatUIState.RetryAvailable -> { + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState, + bottomSlot = { + RetryButton(onClick = { chatViewModel.retryResponseGeneration() }) + } + ) + } + + is ChatUIState.Thinking -> { + ChatMessageList( + messages = messages, + showTypingIndicator = true, + modifier = modifier, + listState = listState + ) + } + + is ChatUIState.Content, + is ChatUIState.Sending -> { + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState + ) + } + } +} + +@Composable +private fun ChatMessageList( + messages: List, + showTypingIndicator: Boolean, + modifier: Modifier = Modifier, + listState: LazyListState, + bottomSlot: (@Composable () -> Unit)? = null +) { LazyColumn( modifier = modifier .fillMaxSize() .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.Bottom, - reverseLayout = false, state = listState ) { - items(chatMessages, key = { it.id }) { message -> - if (message.isHuman()) { + items(messages, key = { it.id }) { message -> + if (message.role == "human") { UserMessageItem(message) } else { AssistantMessageItem(message) @@ -89,12 +158,102 @@ fun ChatContent(viewModel: AssistantViewModel, modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(8.dp)) } - if (isAssistantAnswering) { + if (showTypingIndicator) { item { AssistantTypingIndicator() Spacer(modifier = Modifier.height(8.dp)) } } + + bottomSlot?.let { + item { it() } + } + } +} + +@Composable +private fun ChatLoadingContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ChatEmptyContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_assistant), + contentDescription = null, + tint = colorResource(R.color.secondary_text_color), + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.assistant_screen_empty_content_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = colorResource(R.color.text_color) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.assistant_screen_empty_content_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = colorResource(R.color.secondary_text_color) + ) + } + } +} + +@Composable +private fun ChatErrorBanner(errorType: ChatErrorType) { + val messageRes = when (errorType) { + ChatErrorType.LoadMessages -> R.string.assistant_screen_chat_fetch_error + ChatErrorType.SendMessage -> R.string.assistant_screen_chat_create_error + ChatErrorType.GenerateResponse -> R.string.assistant_screen_chat_generate_error + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp) + ) { + Text( + text = stringResource(messageRes), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun RetryButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Button(onClick = onClick) { + Text(text = stringResource(R.string.assistant_screen_retry_response_generation)) + } } } @@ -106,11 +265,8 @@ private fun AssistantTypingIndicator() { .fillMaxWidth(), contentAlignment = Alignment.CenterStart ) { - Row( - verticalAlignment = Alignment.Bottom - ) { + Row(verticalAlignment = Alignment.Bottom) { AnimatedAssistantIcon() - Box( modifier = Modifier .padding(start = 8.dp, end = 16.dp) @@ -147,9 +303,7 @@ private fun AnimatedAssistantIcon() { modifier = Modifier .size(ASSISTANT_ICON_SIZE) .clip(CircleShape) - .background( - color = colorResource(R.color.bg_message_bubble) - ), + .background(color = colorResource(R.color.bg_message_bubble)), contentAlignment = Alignment.Center ) { Image( @@ -157,8 +311,7 @@ private fun AnimatedAssistantIcon() { contentDescription = null, contentScale = ContentScale.Crop, alignment = Alignment.Center, - modifier = Modifier - .scale(scale) + modifier = Modifier.scale(scale) ) } } @@ -166,7 +319,6 @@ private fun AnimatedAssistantIcon() { @Composable private fun TypingAnimation() { val infiniteTransition = rememberInfiniteTransition(label = "typing_animation") - val alpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1f, @@ -177,10 +329,7 @@ private fun TypingAnimation() { label = "typing_alpha" ) - Column( - modifier = Modifier.padding(8.dp), - horizontalAlignment = Alignment.Start - ) { + Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.Start) { Text( text = stringResource(R.string.assistant_thinking), style = TextStyle( @@ -199,16 +348,12 @@ private fun AssistantMessageItem(message: ChatMessage) { .fillMaxWidth(), contentAlignment = Alignment.CenterStart ) { - Row( - verticalAlignment = Alignment.Bottom - ) { + Row(verticalAlignment = Alignment.Bottom) { Box( modifier = Modifier .size(ASSISTANT_ICON_SIZE) .clip(CircleShape) - .background( - color = colorResource(R.color.bg_message_bubble) - ), + .background(color = colorResource(R.color.bg_message_bubble)), contentAlignment = Alignment.Center ) { Image( @@ -218,7 +363,6 @@ private fun AssistantMessageItem(message: ChatMessage) { alignment = Alignment.Center ) } - Box( modifier = Modifier .padding(start = 8.dp, end = 16.dp) @@ -230,9 +374,7 @@ private fun AssistantMessageItem(message: ChatMessage) { bottomEnd = CHAT_BUBBLE_CORNER_RADIUS ) ) - .background( - color = colorResource(R.color.bg_message_bubble) - ) + .background(color = colorResource(R.color.bg_message_bubble)) ) { MessageTextItem(message) } @@ -248,48 +390,35 @@ private fun UserMessageItem(message: ChatMessage) { .fillMaxWidth(), contentAlignment = Alignment.CenterEnd ) { - Row( - verticalAlignment = Alignment.Bottom - ) { - Box( - modifier = Modifier - .padding(start = 16.dp, end = 8.dp) - .defaultMinSize(minHeight = MIN_CHAT_HEIGHT) - .clip( - RoundedCornerShape( - topEnd = CHAT_BUBBLE_CORNER_RADIUS, - topStart = CHAT_BUBBLE_CORNER_RADIUS, - bottomStart = CHAT_BUBBLE_CORNER_RADIUS - ) + Box( + modifier = Modifier + .padding(start = 16.dp, end = 8.dp) + .defaultMinSize(minHeight = MIN_CHAT_HEIGHT) + .clip( + RoundedCornerShape( + topEnd = CHAT_BUBBLE_CORNER_RADIUS, + topStart = CHAT_BUBBLE_CORNER_RADIUS, + bottomStart = CHAT_BUBBLE_CORNER_RADIUS ) - .background(color = colorResource(R.color.bg_message_bubble)) - ) { - MessageTextItem(message) - } + ) + .background(color = colorResource(R.color.bg_message_bubble)) + ) { + MessageTextItem(message) } } } @Composable private fun MessageTextItem(message: ChatMessage) { - Column( - modifier = Modifier.padding(8.dp), - horizontalAlignment = Alignment.Start - ) { + Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.Start) { Text( text = message.content, - style = TextStyle( - color = colorResource(R.color.text_color), - fontSize = 16.sp - ) + style = TextStyle(color = colorResource(R.color.text_color), fontSize = 16.sp) ) Spacer(modifier = Modifier.height(4.dp)) Text( text = message.time(), - style = TextStyle( - color = colorResource(R.color.secondary_text_color), - fontSize = 12.sp - ), + style = TextStyle(color = colorResource(R.color.secondary_text_color), fontSize = 12.sp), modifier = Modifier.align(Alignment.End) ) } @@ -313,18 +442,8 @@ private fun MessageTextItemPreview() { id = 2, sessionId = 101, role = "assistant", - content = "I'm good! Here’s a message from yesterday.", - timestamp = Instant.now().minusSeconds(86_400).epochSecond, // 1 day ago - ocpTaskId = null, - sources = "", - attachments = emptyList() - ), - ChatMessage( - id = 3, - sessionId = 101, - role = "human", - content = "And an older one from last week.", - timestamp = Instant.now().minusSeconds(7 * 86_400).epochSecond, // 7 days ago + content = "I'm good! Here's a message from yesterday.", + timestamp = Instant.now().minusSeconds(86_400).epochSecond, ocpTaskId = null, sources = "", attachments = emptyList() diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt new file mode 100644 index 000000000000..9bab64242fc8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +sealed class ChatErrorType { + data object LoadMessages : ChatErrorType() + data object SendMessage : ChatErrorType() + data object GenerateResponse : ChatErrorType() +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt new file mode 100644 index 000000000000..b2cefa8031f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage + +sealed class ChatUIState { + data object Loading : ChatUIState() + data object Empty : ChatUIState() + data class Content(val messages: List) : ChatUIState() + data class Sending(val messages: List) : ChatUIState() + data class Thinking(val messages: List) : ChatUIState() + data class RetryAvailable(val messages: List) : ChatUIState() + data class Error(val messages: List, val errorType: ChatErrorType) : ChatUIState() + + fun messages(): List = when (this) { + is Content -> messages + is Sending -> messages + is Thinking -> messages + is RetryAvailable -> messages + is Error -> messages + is Loading, is Empty -> emptyList() + } + + fun canSend(): Boolean = (this == Empty || this is Content) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt new file mode 100644 index 000000000000..017a434602af --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -0,0 +1,207 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository +import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND +import com.nextcloud.utils.date.DateFormatPattern +import com.nextcloud.utils.date.DateFormatter +import com.nextcloud.utils.extensions.isHuman +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : ViewModel() { + + companion object { + private const val POLLING_INTERVAL_MS = 4_000L + } + + private val _uiState = MutableStateFlow(ChatUIState.Empty) + val uiState: StateFlow = _uiState + + private val _sessionTitle = MutableStateFlow(null) + val sessionTitle: StateFlow = _sessionTitle + + private val _sessionId = MutableStateFlow(null) + val sessionId: StateFlow = _sessionId + + private var currentMessages: List = emptyList() + private var currentChatTaskId: String? = null + private var pollingJob: Job? = null + + fun selectConversation(sessionId: Long) { + _sessionId.update { sessionId } + currentChatTaskId = null + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { ChatUIState.Loading } + + fetchAllMessages(sessionId) + + val session = remoteRepository.checkSession(sessionId.toString()) + currentChatTaskId = session?.taskId?.toString() + + val lastMessageIsHuman = currentMessages.lastOrNull()?.isHuman() == true + + // update session title if exists + session?.sessionTitle?.let { + _sessionTitle.update { + it + } + } + + when { + currentChatTaskId != null && currentChatTaskId != "0" -> startPolling(sessionId) + + lastMessageIsHuman -> _uiState.update { ChatUIState.RetryAvailable(currentMessages) } + + else -> _uiState.update { + if (currentMessages.isEmpty()) { + ChatUIState.Empty + } else { + ChatUIState.Content(currentMessages) + } + } + } + } + } + + fun startPolling(sessionId: Long) { + stopPolling() + _uiState.update { ChatUIState.Thinking(currentMessages) } + + pollingJob = viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + delay(POLLING_INTERVAL_MS) + fetchNewChatMessage(sessionId) + } + } + } + + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + private suspend fun fetchAllMessages(sessionId: Long) { + val messages = remoteRepository.fetchChatMessages(sessionId) + if (messages != null) { + currentMessages = messages + _uiState.update { + if (messages.isEmpty()) ChatUIState.Empty else ChatUIState.Content(messages) + } + } else { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.LoadMessages) } + } + } + + private suspend fun fetchNewChatMessage(sessionId: Long) { + val taskId = currentChatTaskId ?: return + val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: return + + val alreadyExists = currentMessages.any { + it.id == newMessage.id || + (it.timestamp == newMessage.timestamp && it.content == newMessage.content) + } + + if (!alreadyExists && !newMessage.isHuman()) { + currentMessages = currentMessages + newMessage + stopPolling() + _uiState.update { ChatUIState.Content(currentMessages) } + } + } + + fun sendMessage(content: String, sessionId: Long) { + _uiState.update { ChatUIState.Sending(currentMessages) } + viewModelScope.launch(Dispatchers.IO) { + sendMessageInternal(content, sessionId) + } + } + + private suspend fun sendMessageInternal(content: String, sessionId: Long) { + val request = ChatMessageRequest( + sessionId = sessionId.toString(), + role = "human", + content = content, + timestamp = System.currentTimeMillis() / MILLIS_PER_SECOND, + firstHumanMessage = currentMessages.isEmpty() + ) + + val sentMessage = remoteRepository.sendChatMessage(request) + if (sentMessage != null) { + currentMessages = currentMessages + sentMessage + stopPolling() + generateSession(sessionId) + startPolling(sessionId) + } else { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } + } + } + + private suspend fun generateSession(sessionId: Long) { + remoteRepository.generateSession(sessionId.toString())?.let { + currentChatTaskId = it.taskId.toString() + } + } + + fun retryResponseGeneration() { + val sessionId = _sessionId.value ?: return + viewModelScope.launch(Dispatchers.IO) { + generateSession(sessionId) + startPolling(sessionId) + } + } + + fun startNewConversation(content: String) { + if (_sessionId.value != null) { + sendMessage(content, _sessionId.value!!) + return + } + + _uiState.update { ChatUIState.Sending(currentMessages) } + + viewModelScope.launch(Dispatchers.IO) { + val result = remoteRepository.createConversation(content) ?: run { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } + return@launch + } + + val newSessionId = result.session.id + _sessionId.update { newSessionId } + currentChatTaskId = null + sendMessageInternal(content, newSessionId) + } + } + + fun updateSessionTitle(timestamp: Long) { + val newSessionTitle = DateFormatter + .timestampToDateRepresentation( + timestamp, + DateFormatPattern.MonthDayYearTime + ) + + _sessionTitle.update { + newSessionTitle + } + } + + override fun onCleared() { + super.onCleared() + stopPolling() + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 29e37fabade3..d1235c0a6ef5 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -9,6 +9,7 @@ package com.nextcloud.client.assistant.conversation +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,13 +36,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.nextcloud.client.assistant.conversation.model.ConversationScreenState import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet import com.owncloud.android.R @@ -61,7 +60,7 @@ import com.owncloud.android.lib.resources.assistant.chat.model.Conversation @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable -fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Long) -> Unit) { +fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Conversation) -> Unit) { val screenState by viewModel.screenState.collectAsState() val errorMessageId by viewModel.errorMessageId.collectAsState() val conversations by viewModel.conversations.collectAsState() @@ -108,6 +107,7 @@ fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, open floatingActionButton = { FloatingActionButton(onClick = { viewModel.createConversation(null, onResult = { + viewModel.selectConversation(null) openChat(it) }) }) { @@ -160,31 +160,34 @@ private fun ConversationList( viewModel: ConversationViewModel, conversations: List, modifier: Modifier = Modifier, - openChat: (Long) -> Unit + openChat: (Conversation) -> Unit ) { - var selectedConversationId by remember { mutableLongStateOf(-1L) } + var showConversationActions by remember { mutableStateOf(false) } + val selectedConversationId by viewModel.selectedConversationId.collectAsState() LazyColumn( modifier = modifier .fillMaxSize() - .padding(16.dp), + .padding(vertical = 16.dp), verticalArrangement = Arrangement.Bottom ) { items(conversations) { conversation -> ConversationListItem( conversation = conversation, + isSelected = (conversation.id == selectedConversationId), onClick = { - openChat(conversation.id) + viewModel.selectConversation(conversation.id) + openChat(conversation) }, onLongPressed = { - selectedConversationId = conversation.id + showConversationActions = true } ) Spacer(modifier = Modifier.height(4.dp)) } } - if (selectedConversationId != -1L) { + if (showConversationActions) { val currentId = selectedConversationId val bottomSheetAction = listOf( @@ -194,42 +197,56 @@ private fun ConversationList( ) { val sessionId: String = currentId.toString() viewModel.deleteConversation(sessionId) - selectedConversationId = -1L + showConversationActions = false } ) MoreActionsBottomSheet( actions = bottomSheetAction, - onDismiss = { selectedConversationId = -1L } + onDismiss = { showConversationActions = false } ) } } @Composable -private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit, onLongPressed: () -> Unit) { - Surface( +private fun ConversationListItem( + conversation: Conversation, + isSelected: Boolean, + onClick: () -> Unit, + onLongPressed: () -> Unit +) { + Box( modifier = Modifier .fillMaxWidth() + .background( + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + ) + .height(52.dp) .combinedClickable( onClick = onClick, onLongClick = onLongPressed - ) + ), + contentAlignment = Alignment.CenterStart ) { - Row( + Text( + text = conversation.titleRepresentation(), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSurface + } else { + colorResource(R.color.text_color) + }, + textAlign = TextAlign.Start, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = conversation.titleRepresentation(), - style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = colorResource(R.color.text_color), - modifier = Modifier.weight(1f) - ) - } + .padding(horizontal = 8.dp) + ) } } @@ -237,15 +254,39 @@ private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit @Composable private fun ConversationListPreview() { Column { - ConversationListItem(Conversation(1L, "User1", "Who is Al Pacino?", 1762847286L, "", null), { - }, { - }) + ConversationListItem( + Conversation( + 1L, + "User1", + "Who is Al Pacino?", + 1762847286L, + "", + null + ), + false, + { + }, + { + } + ) Spacer(modifier = Modifier.height(8.dp)) - ConversationListItem(Conversation(2L, "User1", "What is JetpackCompose?", 1761847286L, "", null), { - }, { - }) + ConversationListItem( + Conversation( + 2L, + "User1", + "What is JetpackCompose?", + 1761847286L, + "", + null + ), + false, + { + }, + { + } + ) Spacer(modifier = Modifier.height(8.dp)) } diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt index 68e4ff6358eb..a887f31f5ab5 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt @@ -23,6 +23,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class ConversationViewModel(private val remoteRepository: ConversationRemoteRepository) : ViewModel() { + private val _selectedConversationId = MutableStateFlow(null) + val selectedConversationId: StateFlow = _selectedConversationId + private val _errorMessageId = MutableStateFlow(null) val errorMessageId: StateFlow = _errorMessageId @@ -32,6 +35,12 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo private val _conversations = MutableStateFlow>(listOf()) val conversations: StateFlow> = _conversations.asStateFlow() + fun selectConversation(value: Long?) { + _selectedConversationId.update { + value + } + } + fun fetchConversations() { _screenState.update { ConversationScreenState.Loading @@ -63,7 +72,7 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo } } - fun createConversation(title: String?, onResult: (Long) -> Unit) { + fun createConversation(title: String?, onResult: (Conversation) -> Unit) { viewModelScope.launch(Dispatchers.IO) { val timestamp = System.currentTimeMillis().div(MILLIS_PER_SECOND) val newConversation = remoteRepository.createConversation(title, timestamp) @@ -71,7 +80,7 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo _conversations.update { listOf(newConversation.session) + it } - onResult(newConversation.session.id) + onResult(newConversation.session) } else { _errorMessageId.update { R.string.conversation_screen_create_error_title diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt index dfa429d2f15a..f2e28c31b48e 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.nextcloud.client.assistant.AssistantScreen import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.chat.ChatViewModel import com.nextcloud.client.assistant.conversation.ConversationViewModel import com.nextcloud.client.assistant.conversation.repository.ConversationRemoteRepositoryImpl import com.nextcloud.client.assistant.repository.local.AssistantLocalRepositoryImpl @@ -129,15 +130,17 @@ class ComposeActivity : DrawerActivity() { val dao = NextcloudDatabase.instance().assistantDao() val sessionId = (currentScreen as? ComposeDestination.AssistantScreen)?.sessionId val client = nextcloudClient ?: return + val remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities) AssistantScreen( composeViewModel = composeViewModel, viewModel = AssistantViewModel( accountName = userAccountManager.user.accountName, - remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities), + remoteRepository = remoteRepository, localRepository = AssistantLocalRepositoryImpl(dao), sessionIdArg = sessionId ), + chatViewModel = ChatViewModel(remoteRepository), conversationViewModel = ConversationViewModel( remoteRepository = ConversationRemoteRepositoryImpl(client) ), diff --git a/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt index a3852d67af25..7324596e3a59 100644 --- a/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt +++ b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt @@ -16,5 +16,10 @@ enum class DateFormatPattern(val pattern: String) { /** * Aug 3 */ - MonthWithDate("MMM d") + MonthWithDate("MMM d"), + + /** + * March 03, 2026 14:38 + */ + MonthDayYearTime("MMMM dd, yyyy HH:mm") } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1965da59731..e4f1d21778ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,12 +59,13 @@ The task output is not ready yet. Failed to send a message Failed to fetch chat messages + Failed to generate response + Retry response generation Unable to fetch task list, please check your internet connection. Task list is empty. Check assistant app configuration. Delete task Are you sure you want to delete this task? Delete Task - Choose an option Task created An error occurred while creating the task Task deleted @@ -107,7 +108,6 @@ Hello there! What can I help you with today? Try sending a message to spark a conversation. - Recommended files Assistant