From f82dabcc75f6dab29a74b5a4b0d93a23d174d0e3 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 20 Nov 2024 10:47:35 +0300 Subject: [PATCH] chore: bump flutter chat ui version (#6835) --- .../ai_chat/application/chat_bloc.dart | 595 ++++++++---------- .../ai_chat/application/chat_entity.dart | 15 +- .../application/chat_message_service.dart | 63 +- .../application/chat_user_message_bloc.dart | 6 +- .../chat_user_message_bubble_bloc.dart | 2 +- .../lib/plugins/ai_chat/chat_page.dart | 343 +++++----- .../presentation/animated_chat_list.dart | 405 ++++++++++++ .../chat_input/desktop_ai_prompt_input.dart | 15 +- .../chat_input/mobile_ai_prompt_input.dart | 14 +- .../presentation/chat_related_question.dart | 9 +- .../ai_chat/presentation/chat_theme.dart | 85 --- .../chat_user_invalid_message.dart | 2 +- .../ai_chat/presentation/layout_define.dart | 2 - .../message/ai_message_bubble.dart | 3 +- .../presentation/message/ai_text_message.dart | 2 +- .../message/user_message_bubble.dart | 2 +- .../message/user_text_message.dart | 21 +- .../presentation/scroll_to_bottom.dart | 88 +++ frontend/appflowy_flutter/pubspec.lock | 56 +- frontend/appflowy_flutter/pubspec.yaml | 5 +- .../flowy_icons/16x/ai_scroll_to_bottom.svg | 3 + 21 files changed, 1036 insertions(+), 700 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart delete mode 100644 frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart create mode 100644 frontend/resources/flowy_icons/16x/ai_scroll_to_bottom.svg diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index 82267b0915..7e4fd746db 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -1,47 +1,50 @@ import 'dart:async'; import 'dart:collection'; -import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; +import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:nanoid/nanoid.dart'; import 'chat_entity.dart'; import 'chat_message_listener.dart'; import 'chat_message_service.dart'; +import 'chat_message_stream.dart'; part 'chat_bloc.freezed.dart'; class ChatBloc extends Bloc { ChatBloc({ - required ViewPB view, - required UserProfilePB userProfile, - }) : listener = ChatMessageListener(chatId: view.id), - chatId = view.id, - super( - ChatState.initial(view, userProfile), - ) { + required this.chatId, + required this.userId, + }) : chatController = InMemoryChatController(), + listener = ChatMessageListener(chatId: chatId), + super(ChatState.initial()) { _startListening(); _dispatch(); + _init(); } - final ChatMessageListener listener; final String chatId; + final String userId; + final ChatMessageListener listener; + + final ChatController chatController; /// The last streaming message id String answerStreamMessageId = ''; String questionStreamMessageId = ''; + ChatMessagePB? lastSentMessage; + /// Using a temporary map to associate the real message ID with the last streaming message ID. /// /// When a message is streaming, it does not have a real message ID. To maintain the relationship @@ -51,11 +54,13 @@ class ChatBloc extends Bloc { /// is 3 (AI response). final HashMap temporaryMessageIDMap = HashMap(); + bool isLoadingPreviousMessages = false; + bool hasMorePreviousMessages = true; + AnswerStream? answerStream; + @override Future close() async { - if (state.answerStream != null) { - await state.answerStream?.dispose(); - } + await answerStream?.dispose(); await listener.stop(); return super.close(); } @@ -64,195 +69,159 @@ class ChatBloc extends Bloc { on( (event, emit) async { await event.when( - initialLoad: () { - final payload = LoadNextChatMessagePB( - chatId: state.view.id, - limit: Int64(10), - ); - AIEventLoadNextMessage(payload).send().then( - (result) { - result.fold((list) { - if (!isClosed) { - final messages = - list.messages.map(_createTextMessage).toList(); - add(ChatEvent.didLoadLatestMessages(messages)); - } - }, (err) { - Log.error("Failed to load messages: $err"); - }); - }, - ); - }, // Loading messages - startLoadingPrevMessage: () async { - Int64? beforeMessageId; - final oldestMessage = _getOldestMessage(); - if (oldestMessage != null) { - try { - beforeMessageId = Int64.parseInt(oldestMessage.id); - } catch (e) { - Log.error( - "Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}", - ); - } - } - _loadPrevMessage(beforeMessageId); - emit( - state.copyWith( - loadingPreviousStatus: const ChatLoadingState.loading(), - ), - ); - }, - didLoadPreviousMessages: (List messages, bool hasMore) { - Log.debug("did load previous messages: ${messages.length}"); - final onetimeMessages = _getOnetimeMessages(); - final allMessages = _permanentMessages(); - final uniqueMessages = {...allMessages, ...messages}.toList(); - - uniqueMessages.insertAll(0, onetimeMessages); - - emit( - state.copyWith( - messages: uniqueMessages, - loadingPreviousStatus: const ChatLoadingState.finish(), - hasMorePrevMessage: hasMore, - ), - ); - }, - didLoadLatestMessages: (List messages) { - final onetimeMessages = _getOnetimeMessages(); - final allMessages = _permanentMessages(); - final uniqueMessages = {...allMessages, ...messages}.toList(); - uniqueMessages.insertAll(0, onetimeMessages); - emit( - state.copyWith( - messages: uniqueMessages, - initialLoadingStatus: const ChatLoadingState.finish(), - ), - ); - }, - // streaming message - finishAnswerStreaming: () { - emit( - state.copyWith( - streamingState: const StreamingState.done(), - acceptRelatedQuestion: true, - canSendMessage: - state.sendingState == const SendMessageState.done(), - ), - ); - }, - didUpdateAnswerStream: (AnswerStream stream) { - emit(state.copyWith(answerStream: stream)); - }, - stopStream: () async { - if (state.answerStream == null) { - return; + didLoadLatestMessages: (List messages) async { + for (final message in messages) { + await chatController.insert(message, index: 0); } - final payload = StopStreamPB(chatId: chatId); - await AIEventStopStream(payload).send(); - final allMessages = _permanentMessages(); - if (state.streamingState != const StreamingState.done()) { - // If the streaming is not started, remove the message from the list - if (!state.answerStream!.hasStarted) { - allMessages.removeWhere( - (element) => element.id == answerStreamMessageId, - ); - answerStreamMessageId = ""; - } - - // when stop stream, we will set the answer stream to null. Which means the streaming - // is finished or canceled. + if (state.loadingState.isLoading) { emit( - state.copyWith( - messages: allMessages, - answerStream: null, - streamingState: const StreamingState.done(), - ), + state.copyWith(loadingState: const ChatLoadingState.finish()), ); } }, - receiveMessage: (Message message) { - final allMessages = _permanentMessages(); - // remove message with the same id - allMessages.removeWhere((element) => element.id == message.id); - allMessages.insert(0, message); + loadPreviousMessages: () { + if (isLoadingPreviousMessages) { + return; + } + + final oldestMessage = _getOldestMessage(); + + if (oldestMessage != null) { + final oldestMessageId = Int64.tryParseInt(oldestMessage.id); + if (oldestMessageId == null) { + Log.error("Failed to parse message_id: ${oldestMessage.id}"); + return; + } + isLoadingPreviousMessages = true; + _loadPreviousMessages(oldestMessageId); + } + }, + didLoadPreviousMessages: (messages, hasMore) { + Log.debug("did load previous messages: ${messages.length}"); + + for (final message in messages) { + chatController.insert(message, index: 0); + } + + isLoadingPreviousMessages = false; + hasMorePreviousMessages = hasMore; + }, + didFinishAnswerStream: () { emit( - state.copyWith( - messages: allMessages, - ), + state.copyWith(promptResponseState: PromptResponseState.ready), ); }, - startAnswerStreaming: (Message message) { - final allMessages = _permanentMessages(); - allMessages.insert(0, message); - emit( - state.copyWith( - messages: allMessages, - streamingState: const StreamingState.streaming(), - canSendMessage: false, - ), - ); - }, - sendMessage: (String message, Map? metadata) async { - unawaited(_startStreamingMessage(message, metadata, emit)); - final allMessages = _permanentMessages(); - emit( - state.copyWith( - lastSentMessage: null, - messages: allMessages, - relatedQuestions: [], - acceptRelatedQuestion: false, - sendingState: const SendMessageState.sending(), - canSendMessage: false, - ), - ); - }, - finishSending: (ChatMessagePB message) { - emit( - state.copyWith( - lastSentMessage: message, - sendingState: const SendMessageState.done(), - canSendMessage: - state.streamingState == const StreamingState.done(), - ), - ); - }, - failedSending: () { - emit( - state.copyWith( - messages: _permanentMessages()..removeAt(0), - sendingState: const SendMessageState.done(), - canSendMessage: true, - ), - ); - }, - // related question - didReceiveRelatedQuestion: (List questions) { + didReceiveRelatedQuestions: (List questions) { if (questions.isEmpty) { return; } - final allMessages = _permanentMessages(); - final message = CustomMessage( - metadata: OnetimeShotType.relatedQuestion.toMap(), + final metatdata = OnetimeShotType.relatedQuestion.toMap(); + metatdata['questions'] = questions; + + final message = TextMessage( + text: '', + metadata: metatdata, author: const User(id: systemUserId), - showStatus: false, id: systemUserId, + createdAt: DateTime.now(), ); - allMessages.insert(0, message); + + chatController.insert(message); + }, + receiveMessage: (Message message) { + final oldMessage = chatController.messages + .firstWhereOrNull((m) => m.id == message.id); + if (oldMessage == null) { + chatController.insert(message); + } else { + chatController.update(oldMessage, message); + } + }, + sendMessage: ( + String message, + Map? metadata, + ) { + final relatedQuestionMessages = chatController.messages.where( + (message) { + return onetimeMessageTypeFromMeta(message.metadata) == + OnetimeShotType.relatedQuestion; + }, + ).toList(); + + for (final message in relatedQuestionMessages) { + chatController.remove(message); + } + + _startStreamingMessage(message, metadata); + lastSentMessage = null; + emit( state.copyWith( - messages: allMessages, - relatedQuestions: questions, + promptResponseState: PromptResponseState.sendingQuestion, ), ); }, - clearRelatedQuestions: () { + finishSending: (ChatMessagePB message) { + lastSentMessage = message; emit( state.copyWith( - relatedQuestions: [], + promptResponseState: PromptResponseState.awaitingAnswer, + ), + ); + }, + stopStream: () async { + if (answerStream == null) { + return; + } + + // tell backend to stop + final payload = StopStreamPB(chatId: chatId); + await AIEventStopStream(payload).send(); + + // allow user input + emit( + state.copyWith( + promptResponseState: PromptResponseState.ready, + ), + ); + + // no need to remove old message if stream has started already + if (answerStream!.hasStarted) { + return; + } + + // remove the non-started message from the list + final message = chatController.messages.lastWhereOrNull( + (e) => e.id == answerStreamMessageId, + ); + if (message != null) { + await chatController.remove(message); + } + + // set answer stream to null + await answerStream?.dispose(); + answerStream = null; + answerStreamMessageId = ''; + }, + startAnswerStreaming: (Message message) { + chatController.insert(message); + emit( + state.copyWith( + promptResponseState: PromptResponseState.streamingAnswer, + ), + ); + }, + failedSending: () { + final lastMessage = chatController.messages.lastOrNull; + if (lastMessage != null) { + chatController.remove(lastMessage); + } + emit( + state.copyWith( + promptResponseState: PromptResponseState.ready, ), ); }, @@ -264,29 +233,31 @@ class ChatBloc extends Bloc { void _startListening() { listener.start( chatMessageCallback: (pb) { - if (!isClosed) { - // 3 mean message response from AI - if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) { - temporaryMessageIDMap[pb.messageId.toString()] = - answerStreamMessageId; - answerStreamMessageId = ""; - } - - // 1 mean message response from User - if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) { - temporaryMessageIDMap[pb.messageId.toString()] = - questionStreamMessageId; - questionStreamMessageId = ""; - } - - final message = _createTextMessage(pb); - add(ChatEvent.receiveMessage(message)); + if (isClosed) { + return; } + + // 3 mean message response from AI + if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) { + temporaryMessageIDMap[pb.messageId.toString()] = + answerStreamMessageId; + answerStreamMessageId = ''; + } + + // 1 mean message response from User + if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) { + temporaryMessageIDMap[pb.messageId.toString()] = + questionStreamMessageId; + questionStreamMessageId = ''; + } + + final message = _createTextMessage(pb); + add(ChatEvent.receiveMessage(message)); }, chatErrorMessageCallback: (err) { if (!isClosed) { Log.error("chat error: ${err.errorMessage}"); - add(const ChatEvent.finishAnswerStreaming()); + add(const ChatEvent.didFinishAnswerStream()); } }, latestMessageCallback: (list) { @@ -301,65 +272,70 @@ class ChatBloc extends Bloc { add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore)); } }, - finishStreamingCallback: () { - if (!isClosed) { - add(const ChatEvent.finishAnswerStreaming()); - // The answer strema will bet set to null after the streaming is finished or canceled. - // so if the answer stream is null, we will not get related question. - if (state.lastSentMessage != null && state.answerStream != null) { - final payload = ChatMessageIdPB( - chatId: chatId, - messageId: state.lastSentMessage!.messageId, - ); - // When user message was sent to the server, we start gettting related question - AIEventGetRelatedQuestion(payload).send().then((result) { - if (!isClosed) { - result.fold( - (list) { - if (state.acceptRelatedQuestion) { - add(ChatEvent.didReceiveRelatedQuestion(list.items)); - } - }, - (err) { - Log.error("Failed to get related question: $err"); - }, - ); - } - }); - } + finishStreamingCallback: () async { + if (isClosed) { + return; } + + add(const ChatEvent.didFinishAnswerStream()); + + // The answer stream will bet set to null after the streaming has + // finished, got cancelled, or errored. In this case, don't retrieve + // related questions. + if (answerStream == null || lastSentMessage == null) { + return; + } + + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: lastSentMessage!.messageId, + ); + await AIEventGetRelatedQuestion(payload).send().fold( + (list) { + if (!isClosed) { + add( + ChatEvent.didReceiveRelatedQuestions( + list.items.map((e) => e.content).toList(), + ), + ); + } + }, + (err) => Log.error("Failed to get related questions: $err"), + ); }, ); } -// Returns the list of messages that are not include one-time messages. - List _permanentMessages() { - final allMessages = state.messages.where((element) { - return !(element.metadata?.containsKey(onetimeShotType) == true); - }).toList(); - - return allMessages; + void _init() async { + final payload = LoadNextChatMessagePB( + chatId: chatId, + limit: Int64(10), + ); + await AIEventLoadNextMessage(payload).send().fold( + (list) { + if (!isClosed) { + final messages = list.messages.map(_createTextMessage).toList(); + add(ChatEvent.didLoadLatestMessages(messages)); + } + }, + (err) => Log.error("Failed to load messages: $err"), + ); } - List _getOnetimeMessages() { - final messages = state.messages.where((element) { - return (element.metadata?.containsKey(onetimeShotType) == true); - }).toList(); - - return messages; + bool _isOneTimeMessage(Message message) { + return message.metadata != null && + message.metadata!.containsKey(onetimeShotType); } + /// get the last message that is not a one-time message Message? _getOldestMessage() { - // get the last message that is not a one-time message - final message = state.messages.lastWhereOrNull((element) { - return !(element.metadata?.containsKey(onetimeShotType) == true); - }); - return message; + return chatController.messages + .firstWhereOrNull((message) => !_isOneTimeMessage(message)); } - void _loadPrevMessage(Int64? beforeMessageId) { + void _loadPreviousMessages(Int64? beforeMessageId) { final payload = LoadPrevChatMessagePB( - chatId: state.view.id, + chatId: chatId, limit: Int64(10), beforeMessageId: beforeMessageId, ); @@ -369,43 +345,36 @@ class ChatBloc extends Bloc { Future _startStreamingMessage( String message, Map? metadata, - Emitter emit, ) async { - if (state.answerStream != null) { - await state.answerStream?.dispose(); - } + await answerStream?.dispose(); - final answerStream = AnswerStream(); + answerStream = AnswerStream(); final questionStream = QuestionStream(); - add(ChatEvent.didUpdateAnswerStream(answerStream)); - - final payload = StreamChatPayloadPB( - chatId: state.view.id, - message: message, - messageType: ChatMessageTypePB.User, - questionStreamPort: Int64(questionStream.nativePort), - answerStreamPort: Int64(answerStream.nativePort), - metadata: await metadataPBFromMetadata(metadata), - ); + // add a streaming question message final questionStreamMessage = _createQuestionStreamMessage( questionStream, metadata, ); add(ChatEvent.receiveMessage(questionStreamMessage)); - // Stream message to the server - final result = await AIEventStreamMessage(payload).send(); - result.fold( - (ChatMessagePB question) { + final payload = StreamChatPayloadPB( + chatId: chatId, + message: message, + messageType: ChatMessageTypePB.User, + questionStreamPort: Int64(questionStream.nativePort), + answerStreamPort: Int64(answerStream!.nativePort), + metadata: await metadataPBFromMetadata(metadata), + ); + + // stream the question to the server + await AIEventStreamMessage(payload).send().fold( + (question) { if (!isClosed) { add(ChatEvent.finishSending(question)); - // final message = _createTextMessage(question); - // add(ChatEvent.receiveMessage(message)); - final streamAnswer = - _createAnswerStreamMessage(answerStream, question.messageId); + _createAnswerStreamMessage(answerStream!, question.messageId); add(ChatEvent.startAnswerStreaming(streamAnswer)); } }, @@ -417,11 +386,12 @@ class ChatBloc extends Bloc { metadata[sendMessageErrorKey] = err.msg; } - final error = CustomMessage( + final error = TextMessage( + text: '', metadata: metadata, author: const User(id: systemUserId), - showStatus: false, id: systemUserId, + createdAt: DateTime.now(), ); add(const ChatEvent.failedSending()); @@ -446,8 +416,7 @@ class ChatBloc extends Bloc { "chatId": chatId, }, id: streamMessageId, - showStatus: false, - createdAt: DateTime.now().millisecondsSinceEpoch, + createdAt: DateTime.now(), text: '', ); } @@ -457,24 +426,19 @@ class ChatBloc extends Bloc { Map? sentMetadata, ) { final now = DateTime.now(); - final timestamp = now.millisecondsSinceEpoch; - questionStreamMessageId = timestamp.toString(); - final Map metadata = {}; + questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString(); - // if (sentMetadata != null) { - // metadata[messageMetadataJsonStringKey] = sentMetadata; - // } + final Map metadata = { + "$QuestionStream": stream, + "chatId": chatId, + messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata), + }; - metadata["$QuestionStream"] = stream; - metadata["chatId"] = chatId; - metadata[messageChatFileListKey] = - chatFilesFromMessageMetadata(sentMetadata); return TextMessage( - author: User(id: state.userProfile.id.toString()), + author: User(id: userId), metadata: metadata, id: questionStreamMessageId, - showStatus: false, - createdAt: DateTime.now().millisecondsSinceEpoch, + createdAt: now, text: '', ); } @@ -491,8 +455,7 @@ class ChatBloc extends Bloc { author: User(id: message.authorId), id: messageId, text: message.content, - createdAt: message.createdAt.toInt() * 1000, - showStatus: false, + createdAt: message.createdAt.toDateTime(), metadata: { messageRefSourceJsonStringKey: message.metadata, }, @@ -502,8 +465,6 @@ class ChatBloc extends Bloc { @freezed class ChatEvent with _$ChatEvent { - const factory ChatEvent.initialLoad() = _InitialLoadMessage; - // send message const factory ChatEvent.sendMessage({ required String message, @@ -513,14 +474,14 @@ class ChatEvent with _$ChatEvent { _FinishSendMessage; const factory ChatEvent.failedSending() = _FailSendMessage; -// receive message + // receive message const factory ChatEvent.startAnswerStreaming(Message message) = _StartAnswerStreaming; const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage; - const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming; + const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream; -// loading messages - const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage; + // loading messages + const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages; const factory ChatEvent.didLoadPreviousMessages( List messages, bool hasMore, @@ -528,56 +489,24 @@ class ChatEvent with _$ChatEvent { const factory ChatEvent.didLoadLatestMessages(List messages) = _DidLoadMessages; -// related questions - const factory ChatEvent.didReceiveRelatedQuestion( - List questions, + // related questions + const factory ChatEvent.didReceiveRelatedQuestions( + List questions, ) = _DidReceiveRelatedQueston; - const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions; - const factory ChatEvent.didUpdateAnswerStream( - AnswerStream stream, - ) = _DidUpdateAnswerStream; const factory ChatEvent.stopStream() = _StopStream; } @freezed class ChatState with _$ChatState { const factory ChatState({ - required ViewPB view, - required List messages, - required UserProfilePB userProfile, - // When opening the chat, the initial loading status will be set as loading. - //After the initial loading is done, the status will be set as finished. - required ChatLoadingState initialLoadingStatus, - // When loading previous messages, the status will be set as loading. - // After the loading is done, the status will be set as finished. - required ChatLoadingState loadingPreviousStatus, - // When sending a user message, the status will be set as loading. - // After the message is sent, the status will be set as finished. - required StreamingState streamingState, - required SendMessageState sendingState, - // Indicate whether there are more previous messages to load. - required bool hasMorePrevMessage, - // The related questions that are received after the user message is sent. - required List relatedQuestions, - @Default(false) bool acceptRelatedQuestion, - // The last user message that is sent to the server. - ChatMessagePB? lastSentMessage, - AnswerStream? answerStream, - @Default(true) bool canSendMessage, + required ChatLoadingState loadingState, + required PromptResponseState promptResponseState, }) = _ChatState; - factory ChatState.initial(ViewPB view, UserProfilePB userProfile) => - ChatState( - view: view, - messages: [], - userProfile: userProfile, - initialLoadingStatus: const ChatLoadingState.finish(), - loadingPreviousStatus: const ChatLoadingState.finish(), - streamingState: const StreamingState.done(), - sendingState: const SendMessageState.done(), - hasMorePrevMessage: true, - relatedQuestions: [], + factory ChatState.initial() => const ChatState( + loadingState: ChatLoadingState.loading(), + promptResponseState: PromptResponseState.ready, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart index 0ef46f5661..73a85081ac 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart @@ -40,16 +40,11 @@ class ChatMessageRefSource { Map toJson() => _$ChatMessageRefSourceToJson(this); } -@freezed -class StreamingState with _$StreamingState { - const factory StreamingState.streaming() = _Streaming; - const factory StreamingState.done({FlowyError? error}) = _StreamDone; -} - -@freezed -class SendMessageState with _$SendMessageState { - const factory SendMessageState.sending() = _Sending; - const factory SendMessageState.done({FlowyError? error}) = _SendDone; +enum PromptResponseState { + ready, + sendingQuestion, + awaitingAnswer, + streamingAnswer, } class ChatFile extends Equatable { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart index d359d36a6c..047acd8704 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart @@ -7,7 +7,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:nanoid/nanoid.dart'; /// Indicate file source from appflowy document @@ -103,41 +103,44 @@ List messageReferenceSource(String? s) { Future> metadataPBFromMetadata( Map? map, ) async { + if (map == null) return []; + final List metadata = []; - if (map != null) { - for (final entry in map.entries) { - if (entry.value is ViewActionPage) { - if (entry.value.page is ViewPB) { - final view = entry.value.page as ViewPB; - if (view.layout.isDocumentView) { - final payload = OpenDocumentPayloadPB(documentId: view.id); - final result = await DocumentEventGetDocumentText(payload).send(); - result.fold((pb) { - metadata.add( - ChatMessageMetaPB( - id: view.id, - name: view.name, - data: pb.text, - dataType: ChatMessageMetaTypePB.Txt, - source: appflowySource, - ), - ); - }, (err) { - Log.error('Failed to get document text: $err'); - }); - } - } - } else if (entry.value is ChatFile) { + + for (final value in map.values) { + switch (value) { + case ViewActionPage(view: final view) when view.layout.isDocumentView: + final payload = OpenDocumentPayloadPB(documentId: view.id); + await DocumentEventGetDocumentText(payload).send().fold( + (pb) { + metadata.add( + ChatMessageMetaPB( + id: view.id, + name: view.name, + data: pb.text, + dataType: ChatMessageMetaTypePB.Txt, + source: appflowySource, + ), + ); + }, + (err) => Log.error('Failed to get document text: $err'), + ); + break; + case ChatFile( + filePath: final filePath, + fileName: final fileName, + fileType: final fileType, + ): metadata.add( ChatMessageMetaPB( id: nanoid(8), - name: entry.value.fileName, - data: entry.value.filePath, - dataType: entry.value.fileType, - source: entry.value.filePath, + name: fileName, + data: filePath, + dataType: fileType, + source: filePath, ), ); - } + break; } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart index d6918eab53..041aa82a52 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart @@ -9,11 +9,7 @@ class ChatUserMessageBloc extends Bloc { ChatUserMessageBloc({ required dynamic message, - }) : super( - ChatUserMessageState.initial( - message, - ), - ) { + }) : super(ChatUserMessageState.initial(message)) { on( (event, emit) { event.when( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart index c4571915df..ad1d3f8a87 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart @@ -1,6 +1,6 @@ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'chat_message_service.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart index aae0ba7823..adf722e6ef 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -9,27 +9,27 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_types/flutter_chat_types.dart'; -import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart' + hide ChatAnimatedListReversed; import 'package:styled_widget/styled_widget.dart'; import 'package:universal_platform/universal_platform.dart'; import 'application/chat_member_bloc.dart'; import 'application/chat_side_panel_bloc.dart'; +import 'presentation/animated_chat_list.dart'; import 'presentation/chat_input/desktop_ai_prompt_input.dart'; import 'presentation/chat_input/mobile_ai_prompt_input.dart'; import 'presentation/chat_side_panel.dart'; -import 'presentation/chat_theme.dart'; import 'presentation/chat_user_invalid_message.dart'; import 'presentation/chat_welcome_page.dart'; import 'presentation/layout_define.dart'; import 'presentation/message/ai_text_message.dart'; import 'presentation/message/user_text_message.dart'; +import 'presentation/scroll_to_bottom.dart'; class AIChatPage extends StatelessWidget { const AIChatPage({ @@ -59,9 +59,9 @@ class AIChatPage extends StatelessWidget { /// [ChatBloc] is used to handle chat messages including send/receive message BlocProvider( create: (_) => ChatBloc( - view: view, - userProfile: userProfile, - )..add(const ChatEvent.initialLoad()), + chatId: view.id, + userId: userProfile.id.toString(), + ), ), /// [AIPromptInputBloc] is used to handle the user prompt @@ -113,7 +113,7 @@ class _ChatContentPage extends StatelessWidget { return Row( children: [ Center( - child: buildChatWidget() + child: buildChatWidget(context) .constrained( maxWidth: 784, ) @@ -148,7 +148,7 @@ class _ChatContentPage extends StatelessWidget { Flexible( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 784), - child: buildChatWidget(), + child: buildChatWidget(context), ), ), ], @@ -167,187 +167,207 @@ class _ChatContentPage extends StatelessWidget { ); } - Widget buildChatWidget() { - return BlocBuilder( - builder: (context, state) { - return ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: BlocBuilder( - builder: (_, state) => state.initialLoadingStatus.isFinish - ? Chat( - messages: state.messages, - dateHeaderBuilder: (_) => const SizedBox.shrink(), - onSendPressed: (_) { - // We use custom bottom widget for chat input, so - // do not need to handle this event. - }, - customBottomWidget: _buildBottom(context), - user: types.User(id: userProfile.id.toString()), - theme: _buildTheme(context), - onEndReached: () async { - if (state.hasMorePrevMessage && - state.loadingPreviousStatus.isFinish) { - context - .read() - .add(const ChatEvent.startLoadingPrevMessage()); - } - }, - emptyState: TextFieldTapRegion( - child: ChatWelcomePage( - userProfile: userProfile, - onSelectedQuestion: (question) => context - .read() - .add(ChatEvent.sendMessage(message: question)), + Widget buildChatWidget(BuildContext context) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: BlocBuilder( + builder: (context, state) { + return state.loadingState.when( + loading: () { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + }, + finish: (_) { + final chatController = context.read().chatController; + return Column( + children: [ + Expanded( + child: Chat( + chatController: chatController, + user: User(id: userProfile.id.toString()), + darkTheme: ChatTheme.fromThemeData(Theme.of(context)), + theme: ChatTheme.fromThemeData(Theme.of(context)), + builders: Builders( + inputBuilder: (_) => const SizedBox.shrink(), + textMessageBuilder: _buildTextMessage, + chatMessageBuilder: _buildChatMessage, + scrollToBottomBuilder: _buildScrollToBottom, + chatAnimatedListBuilder: _buildChatAnimatedList, ), ), - messageWidthRatio: AIChatUILayout.messageWidthRatio, - textMessageBuilder: ( - textMessage, { - required messageWidth, - required showName, - }) => - _buildTextMessage(context, textMessage, state), - customMessageBuilder: (message, {required messageWidth}) { - final messageType = onetimeMessageTypeFromMeta( - message.metadata, - ); - - if (messageType == OnetimeShotType.invalidSendMesssage) { - return ChatInvalidUserMessage( - message: message, - ); - } - if (messageType == OnetimeShotType.relatedQuestion) { - return RelatedQuestionList( - relatedQuestions: state.relatedQuestions, - onQuestionSelected: (question) { - final bloc = context.read(); - bloc - ..add(ChatEvent.sendMessage(message: question)) - ..add(const ChatEvent.clearRelatedQuestions()); - }, - ); - } - - return const SizedBox.shrink(); - }, - bubbleBuilder: ( - child, { - required message, - required nextMessageInGroup, - }) => - _buildBubble(context, message, child), - ) - : const Center( - child: CircularProgressIndicator.adaptive(), ), - ), - ); - }, + _buildInput(context), + ], + ); + }, + ); + }, + ), ); } Widget _buildTextMessage( BuildContext context, TextMessage message, - ChatState state, ) { - if (message.author.id == userProfile.id.toString()) { - final stream = message.metadata?["$QuestionStream"]; - return ChatUserMessageWidget( - key: ValueKey(message.id), - user: message.author, - message: stream is QuestionStream ? stream : message.text, - ); - } else if (isOtherUserMessage(message)) { - final stream = message.metadata?["$QuestionStream"]; - return ChatUserMessageWidget( - key: ValueKey(message.id), - user: message.author, - message: stream is QuestionStream ? stream : message.text, - ); - } else { - final stream = message.metadata?["$AnswerStream"]; - final questionId = message.metadata?[messageQuestionIdKey]; - final refSourceJsonString = - message.metadata?[messageRefSourceJsonStringKey] as String?; + final messageType = onetimeMessageTypeFromMeta( + message.metadata, + ); - return BlocSelector( - key: ValueKey(message.id), - selector: (state) { - final messages = state.messages.where((e) { - final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata); - if (oneTimeMessageType == null) { - return true; - } - if (oneTimeMessageType - case OnetimeShotType.relatedQuestion || - OnetimeShotType.sendingMessage || - OnetimeShotType.invalidSendMesssage) { - return false; - } - return true; - }); - return messages.isEmpty ? false : messages.first.id == message.id; - }, - builder: (context, isLastMessage) { - return ChatAIMessageWidget( - user: message.author, - messageUserId: message.id, - message: message, - stream: stream is AnswerStream ? stream : null, - questionId: questionId, - chatId: view.id, - refSourceJsonString: refSourceJsonString, - isLastMessage: isLastMessage, - onSelectedMetadata: (metadata) { - context - .read() - .add(ChatSidePanelEvent.selectedMetadata(metadata)); - }, - ); + if (messageType == OnetimeShotType.invalidSendMesssage) { + return ChatInvalidUserMessage( + message: message, + ); + } + + if (messageType == OnetimeShotType.relatedQuestion) { + return RelatedQuestionList( + relatedQuestions: message.metadata!['questions'], + onQuestionSelected: (question) { + context + .read() + .add(ChatEvent.sendMessage(message: question)); }, ); } - } - Widget _buildBubble( - BuildContext context, - Message message, - Widget child, - ) { if (message.author.id == userProfile.id.toString()) { + final stream = message.metadata?["$QuestionStream"]; return ChatUserMessageBubble( + key: ValueKey(message.id), message: message, - child: child, + child: ChatUserMessageWidget( + user: message.author, + message: stream is QuestionStream ? stream : message.text, + ), ); - } else if (isOtherUserMessage(message)) { + } + + if (isOtherUserMessage(message)) { + final stream = message.metadata?["$QuestionStream"]; return ChatUserMessageBubble( + key: ValueKey(message.id), message: message, isCurrentUser: false, - child: child, + child: ChatUserMessageWidget( + user: message.author, + message: stream is QuestionStream ? stream : message.text, + ), ); - } else { - // The bubble is rendered in the child already - return child; } + + final stream = message.metadata?["$AnswerStream"]; + final questionId = message.metadata?[messageQuestionIdKey]; + final refSourceJsonString = + message.metadata?[messageRefSourceJsonStringKey] as String?; + + return BlocSelector( + key: ValueKey(message.id), + selector: (state) { + final chatController = context.read().chatController; + final messages = chatController.messages.where((e) { + final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata); + if (oneTimeMessageType == null) { + return true; + } + if (oneTimeMessageType + case OnetimeShotType.relatedQuestion || + OnetimeShotType.sendingMessage || + OnetimeShotType.invalidSendMesssage) { + return false; + } + return true; + }); + return messages.isEmpty ? false : messages.last.id == message.id; + }, + builder: (context, isLastMessage) { + return ChatAIMessageWidget( + user: message.author, + messageUserId: message.id, + message: message, + stream: stream is AnswerStream ? stream : null, + questionId: questionId, + chatId: view.id, + refSourceJsonString: refSourceJsonString, + isLastMessage: isLastMessage, + onSelectedMetadata: (metadata) { + context + .read() + .add(ChatSidePanelEvent.selectedMetadata(metadata)); + }, + ); + }, + ); } - Widget _buildBottom(BuildContext context) { + Widget _buildChatMessage( + BuildContext context, + Message message, + Animation animation, + Widget child, + ) { + return ChatMessage( + message: message, + animation: animation, + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: child, + ); + } + + Widget _buildScrollToBottom( + BuildContext context, + Animation animation, + VoidCallback onPressed, + ) { + return CustomScrollToBottom( + animation: animation, + onPressed: onPressed, + ); + } + + Widget _buildChatAnimatedList( + BuildContext context, + ScrollController scrollController, + ChatItem itemBuilder, + ) { + final bloc = context.read(); + + if (bloc.chatController.messages.isEmpty) { + return ChatWelcomePage( + userProfile: userProfile, + onSelectedQuestion: (question) { + bloc.add(ChatEvent.sendMessage(message: question)); + }, + ); + } + + return ChatAnimatedListReversed( + scrollController: scrollController, + itemBuilder: itemBuilder, + onLoadPreviousMessages: () { + bloc.add(const ChatEvent.loadPreviousMessages()); + }, + ); + } + + Widget _buildInput(BuildContext context) { return Padding( padding: AIChatUILayout.safeAreaInsets(context), child: BlocSelector( - selector: (state) => state.canSendMessage, + selector: (state) { + return state.promptResponseState == PromptResponseState.ready; + }, builder: (context, canSendMessage) { return UniversalPlatform.isDesktop ? DesktopAIPromptInput( chatId: view.id, indicateFocus: true, - onSubmitted: (message) { + onSubmitted: (text, metadata) { context.read().add( ChatEvent.sendMessage( - message: message.text, - metadata: message.metadata, + message: text, + metadata: metadata, ), ); }, @@ -358,11 +378,11 @@ class _ChatContentPage extends StatelessWidget { ) : MobileAIPromptInput( chatId: view.id, - onSubmitted: (message) { + onSubmitted: (text, metadata) { context.read().add( ChatEvent.sendMessage( - message: message.text, - metadata: message.metadata, + message: text, + metadata: metadata, ), ); }, @@ -375,11 +395,4 @@ class _ChatContentPage extends StatelessWidget { ), ); } - - AFDefaultChatTheme _buildTheme(BuildContext context) { - return AFDefaultChatTheme( - primaryColor: Theme.of(context).colorScheme.primary, - secondaryColor: AFThemeExtension.of(context).tint1, - ); - } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart new file mode 100644 index 0000000000..2f87999e65 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart @@ -0,0 +1,405 @@ +// ignore_for_file: implementation_imports + +import 'dart:async'; +import 'dart:math'; + +import 'package:diffutil_dart/diffutil.dart' as diffutil; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_chat_ui/src/scroll_to_bottom.dart'; +import 'package:flutter_chat_ui/src/utils/chat_input_height_notifier.dart'; +import 'package:flutter_chat_ui/src/utils/message_list_diff.dart'; + +class ChatAnimatedListReversed extends StatefulWidget { + const ChatAnimatedListReversed({ + super.key, + required this.scrollController, + required this.itemBuilder, + this.insertAnimationDuration = const Duration(milliseconds: 250), + this.removeAnimationDuration = const Duration(milliseconds: 250), + this.scrollToEndAnimationDuration = const Duration(milliseconds: 250), + this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250), + this.bottomPadding = 8, + this.onLoadPreviousMessages, + }); + + final ScrollController scrollController; + final ChatItem itemBuilder; + final Duration insertAnimationDuration; + final Duration removeAnimationDuration; + final Duration scrollToEndAnimationDuration; + final Duration scrollToBottomAppearanceDelay; + final double? bottomPadding; + final VoidCallback? onLoadPreviousMessages; + + @override + ChatAnimatedListReversedState createState() => + ChatAnimatedListReversedState(); +} + +class ChatAnimatedListReversedState extends State + with SingleTickerProviderStateMixin { + final GlobalKey _listKey = GlobalKey(); + late ChatController _chatController; + late List _oldList; + late StreamSubscription _operationsSubscription; + + late AnimationController _scrollToBottomController; + late Animation _scrollToBottomAnimation; + Timer? _scrollToBottomShowTimer; + + bool _userHasScrolled = false; + bool _isScrollingToBottom = false; + String _lastInsertedMessageId = ''; + + @override + void initState() { + super.initState(); + _chatController = Provider.of(context, listen: false); + // TODO: Add assert for messages having same id + _oldList = List.from(_chatController.messages); + _operationsSubscription = _chatController.operationsStream.listen((event) { + switch (event.type) { + case ChatOperationType.insert: + assert( + event.index != null, + 'Index must be provided when inserting a message.', + ); + assert( + event.message != null, + 'Message must be provided when inserting a message.', + ); + _onInserted(event.index!, event.message!); + _oldList = List.from(_chatController.messages); + break; + case ChatOperationType.remove: + assert( + event.index != null, + 'Index must be provided when removing a message.', + ); + assert( + event.message != null, + 'Message must be provided when removing a message.', + ); + _onRemoved(event.index!, event.message!); + _oldList = List.from(_chatController.messages); + break; + case ChatOperationType.set: + final newList = _chatController.messages; + + final updates = diffutil + .calculateDiff( + MessageListDiff(_oldList, newList), + ) + .getUpdatesWithData(); + + for (var i = updates.length - 1; i >= 0; i--) { + _onDiffUpdate(updates.elementAt(i)); + } + + _oldList = List.from(newList); + break; + default: + break; + } + }); + + _scrollToBottomController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _scrollToBottomAnimation = CurvedAnimation( + parent: _scrollToBottomController, + curve: Curves.easeInOut, + ); + + widget.scrollController.addListener(_handleLoadPreviousMessages); + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleLoadPreviousMessages(); + }); + } + + @override + void dispose() { + super.dispose(); + _scrollToBottomShowTimer?.cancel(); + _scrollToBottomController.dispose(); + _operationsSubscription.cancel(); + widget.scrollController.removeListener(_handleLoadPreviousMessages); + } + + @override + Widget build(BuildContext context) { + final builders = context.watch(); + + return NotificationListener( + onNotification: (notification) { + if (notification is UserScrollNotification) { + // When user scrolls up, save it to `_userHasScrolled` + if (notification.direction == ScrollDirection.forward) { + _userHasScrolled = true; + } else { + // When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false + if (notification.metrics.pixels == + notification.metrics.maxScrollExtent) { + _userHasScrolled = false; + } + } + } + + if (notification is ScrollUpdateNotification) { + _handleToggleScrollToBottom(); + } + + // Allow other listeners to get the notification + return false; + }, + child: Stack( + children: [ + CustomScrollView( + reverse: true, + controller: widget.scrollController, + slivers: [ + Consumer( + builder: (context, heightNotifier, child) { + return SliverPadding( + padding: EdgeInsets.only( + top: heightNotifier.height + (widget.bottomPadding ?? 0), + ), + ); + }, + ), + SliverAnimatedList( + key: _listKey, + initialItemCount: _chatController.messages.length, + itemBuilder: ( + BuildContext context, + int index, + Animation animation, + ) { + final message = _chatController.messages[ + max(_chatController.messages.length - 1 - index, 0)]; + return widget.itemBuilder( + context, + animation, + message, + ); + }, + ), + ], + ), + builders.scrollToBottomBuilder?.call( + context, + _scrollToBottomAnimation, + _handleScrollToBottom, + ) ?? + ScrollToBottom( + animation: _scrollToBottomAnimation, + onPressed: _handleScrollToBottom, + ), + ], + ), + ); + } + + void _initialScrollToEnd() async { + // Delay the scroll to the end animation so new message is painted, otherwise + // maxScrollExtent is not yet updated and the animation might not work. + await Future.delayed(widget.insertAnimationDuration); + + if (!widget.scrollController.hasClients || !mounted) return; + + if (widget.scrollController.offset > + widget.scrollController.position.minScrollExtent) { + if (widget.scrollToEndAnimationDuration == Duration.zero) { + widget.scrollController + .jumpTo(widget.scrollController.position.minScrollExtent); + } else { + await widget.scrollController.animateTo( + widget.scrollController.position.minScrollExtent, + duration: widget.scrollToEndAnimationDuration, + curve: Curves.linearToEaseOut, + ); + } + } + } + + void _subsequentScrollToEnd(Message data) async { + final user = Provider.of(context, listen: false); + + // In this case we only want to scroll to the bottom if user has not scrolled up + // or if the message is sent by the current user. + if (data.id == _lastInsertedMessageId && + widget.scrollController.offset > + widget.scrollController.position.minScrollExtent && + (user.id == data.author.id || !_userHasScrolled)) { + if (widget.scrollToEndAnimationDuration == Duration.zero) { + widget.scrollController + .jumpTo(widget.scrollController.position.minScrollExtent); + } else { + await widget.scrollController.animateTo( + widget.scrollController.position.minScrollExtent, + duration: widget.scrollToEndAnimationDuration, + curve: Curves.linearToEaseOut, + ); + } + + if (!widget.scrollController.hasClients || !mounted) return; + + // Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768 + // we need an additional jump to the end. Sometimes Flutter + // will not scroll to the very end. Sometimes it will not scroll to the + // very end even with this, so this is something that needs to be + // addressed by the Flutter team. + // + // Additionally here we have a check for the message id, because + // if new message arrives in the meantime it will trigger another + // scroll to the end animation, making this logic redundant. + if (data.id == _lastInsertedMessageId && + widget.scrollController.offset > + widget.scrollController.position.minScrollExtent && + (user.id == data.author.id || !_userHasScrolled)) { + widget.scrollController + .jumpTo(widget.scrollController.position.minScrollExtent); + } + } + } + + void _scrollToEnd(Message data) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (!widget.scrollController.hasClients || !mounted) return; + + // We need this condition because if scroll view is not yet scrollable, + // we want to wait for the insert animation to finish before scrolling to the end. + if (widget.scrollController.position.maxScrollExtent == 0) { + // Scroll view is not yet scrollable, scroll to the end if + // new message makes it scrollable. + _initialScrollToEnd(); + } else { + _subsequentScrollToEnd(data); + } + }, + ); + } + + void _handleScrollToBottom() { + _isScrollingToBottom = true; + _scrollToBottomController.reverse(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!widget.scrollController.hasClients || !mounted) return; + + if (widget.scrollToEndAnimationDuration == Duration.zero) { + widget.scrollController + .jumpTo(widget.scrollController.position.minScrollExtent); + } else { + await widget.scrollController.animateTo( + widget.scrollController.position.minScrollExtent, + duration: widget.scrollToEndAnimationDuration, + curve: Curves.linearToEaseOut, + ); + } + + if (!widget.scrollController.hasClients || !mounted) return; + + if (widget.scrollController.offset < + widget.scrollController.position.minScrollExtent) { + widget.scrollController.jumpTo( + widget.scrollController.position.minScrollExtent, + ); + } + + _isScrollingToBottom = false; + }); + } + + void _handleToggleScrollToBottom() { + if (!_isScrollingToBottom) { + _scrollToBottomShowTimer?.cancel(); + if (widget.scrollController.offset > + widget.scrollController.position.minScrollExtent) { + _scrollToBottomShowTimer = + Timer(widget.scrollToBottomAppearanceDelay, () { + if (mounted) { + _scrollToBottomController.forward(); + } + }); + } else { + if (_scrollToBottomController.status == AnimationStatus.completed) { + _scrollToBottomController.reverse(); + } + } + } + } + + void _onInserted(final int position, final Message data) { + final user = Provider.of(context, listen: false); + + // There is a scroll notification listener the controls the `_userHasScrolled` variable. + // However, when a new message is sent by the current user we want to + // set `_userHasScrolled` to false so that the scroll animation is triggered. + // + // Also, if for some reason `_userHasScrolled` is true and the user is not at the bottom of the list, + // set `_userHasScrolled` to false so that the scroll animation is triggered. + if (user.id == data.author.id || + (_userHasScrolled == true && + widget.scrollController.offset >= + widget.scrollController.position.maxScrollExtent)) { + _userHasScrolled = false; + } + + _listKey.currentState!.insertItem( + position, + duration: widget.insertAnimationDuration, + ); + + // Used later to trigger scroll to end only for the last inserted message. + _lastInsertedMessageId = data.id; + + if (user.id == data.author.id && position == _oldList.length) { + _scrollToEnd(data); + } + } + + void _onRemoved(final int position, final Message data) { + final visualPosition = max(_oldList.length - position - 1, 0); + _listKey.currentState!.removeItem( + visualPosition, + (context, animation) => widget.itemBuilder( + context, + animation, + data, + isRemoved: true, + ), + duration: widget.removeAnimationDuration, + ); + } + + void _onChanged(int position, Message oldData, Message newData) { + _onRemoved(position, oldData); + _listKey.currentState!.insertItem( + max(_oldList.length - position - 1, 0), + duration: widget.insertAnimationDuration, + ); + } + + void _onDiffUpdate(diffutil.DataDiffUpdate update) { + update.when( + insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data), + remove: (pos, data) => _onRemoved(pos, data), + change: (pos, oldData, newData) => _onChanged(pos, oldData, newData), + move: (_, __, ___) => throw UnimplementedError('unused'), + ); + } + + void _handleLoadPreviousMessages() { + if (widget.scrollController.offset >= + widget.scrollController.position.maxScrollExtent) { + widget.onLoadPreviousMessages?.call(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart index 4f30171752..86b863f8c5 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart @@ -12,8 +12,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:universal_platform/universal_platform.dart'; import 'ai_prompt_buttons.dart'; @@ -25,7 +23,6 @@ class DesktopAIPromptInput extends StatefulWidget { super.key, required this.chatId, required this.indicateFocus, - this.options = const InputOptions(), required this.isStreaming, required this.onStopStreaming, required this.onSubmitted, @@ -33,10 +30,9 @@ class DesktopAIPromptInput extends StatefulWidget { final String chatId; final bool indicateFocus; - final InputOptions options; final bool isStreaming; final void Function() onStopStreaming; - final void Function(types.PartialText) onSubmitted; + final void Function(String, Map) onSubmitted; @override State createState() => _DesktopAIPromptInputState(); @@ -56,7 +52,7 @@ class _DesktopAIPromptInputState extends State { void initState() { super.initState(); - _textController = InputTextFieldController() + _textController = TextEditingController() ..addListener(_handleTextControllerChange); _inputFocusNode = FocusNode( @@ -118,6 +114,7 @@ class _DesktopAIPromptInputState extends State { borderRadius: DesktopAIPromptSizes.promptFrameRadius, ), child: Column( + mainAxisSize: MainAxisSize.min, children: [ ConstrainedBox( constraints: BoxConstraints( @@ -209,11 +206,7 @@ class _DesktopAIPromptInputState extends State { ..addAll(mentionPageMetadata) ..addAll(fileMetadata); - final partialText = types.PartialText( - text: trimmedText, - metadata: metadata, - ); - widget.onSubmitted(partialText); + widget.onSubmitted(trimmedText, metadata); } void _handleTextControllerChange() { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart index c66dcfc453..3adb427350 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart @@ -12,8 +12,6 @@ import 'package:extended_text_field/extended_text_field.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'ai_prompt_buttons.dart'; import 'chat_input_span.dart'; @@ -23,17 +21,15 @@ class MobileAIPromptInput extends StatefulWidget { const MobileAIPromptInput({ super.key, required this.chatId, - this.options = const InputOptions(), required this.isStreaming, required this.onStopStreaming, required this.onSubmitted, }); final String chatId; - final InputOptions options; final bool isStreaming; final void Function() onStopStreaming; - final void Function(types.PartialText) onSubmitted; + final void Function(String, Map) onSubmitted; @override State createState() => _MobileAIPromptInputState(); @@ -50,7 +46,7 @@ class _MobileAIPromptInputState extends State { void initState() { super.initState(); - _textController = InputTextFieldController() + _textController = TextEditingController() ..addListener(_handleTextControllerChange); _inputFocusNode = FocusNode(); @@ -166,11 +162,7 @@ class _MobileAIPromptInputState extends State { ..addAll(mentionPageMetadata) ..addAll(fileMetadata); - final partialText = types.PartialText( - text: trimmedText, - metadata: metadata, - ); - widget.onSubmitted(partialText); + widget.onSubmitted(trimmedText, metadata); } void _handleTextControllerChange() { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart index 9f2fb2350b..79ade69749 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart @@ -1,6 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -16,7 +15,7 @@ class RelatedQuestionList extends StatelessWidget { }); final Function(String) onQuestionSelected; - final List relatedQuestions; + final List relatedQuestions; @override Widget build(BuildContext context) { @@ -57,14 +56,14 @@ class RelatedQuestionItem extends StatelessWidget { super.key, }); - final RelatedQuestionPB question; + final String question; final Function(String) onQuestionSelected; @override Widget build(BuildContext context) { return FlowyButton( text: FlowyText( - question.content, + question, lineHeight: 1.4, overflow: TextOverflow.ellipsis, ), @@ -76,7 +75,7 @@ class RelatedQuestionItem extends StatelessWidget { color: Theme.of(context).colorScheme.primary, size: const Size.square(16.0), ), - onTap: () => onQuestionSelected(question.content), + onTap: () => onQuestionSelected(question), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart deleted file mode 100644 index 4a70268424..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; - -/// Default chat theme which extends [ChatTheme]. -@immutable -class AFDefaultChatTheme extends ChatTheme { - const AFDefaultChatTheme({ - required super.primaryColor, - required super.secondaryColor, - }) : super( - backgroundColor: Colors.transparent, - // TODO: think how to offset the default 12 pixels set by chat package - bubbleMargin: const EdgeInsets.symmetric(vertical: 4.0), - messageMaxWidth: double.infinity, - // unused - dateDividerMargin: EdgeInsets.zero, - dateDividerTextStyle: const TextStyle(), - attachmentButtonIcon: null, - attachmentButtonMargin: null, - deliveredIcon: null, - documentIcon: null, - emptyChatPlaceholderTextStyle: const TextStyle(), - errorColor: error, - errorIcon: null, - inputBackgroundColor: neutral0, - inputSurfaceTintColor: neutral0, - inputElevation: 0, - inputBorderRadius: BorderRadius.zero, - inputContainerDecoration: null, - inputMargin: EdgeInsets.zero, - inputPadding: EdgeInsets.zero, - inputTextColor: neutral7, - inputTextCursorColor: null, - inputTextDecoration: const InputDecoration(), - inputTextStyle: const TextStyle(), - messageBorderRadius: 0, - messageInsetsHorizontal: 0, - messageInsetsVertical: 0, - receivedEmojiMessageTextStyle: const TextStyle(), - receivedMessageBodyBoldTextStyle: null, - receivedMessageBodyCodeTextStyle: null, - receivedMessageBodyLinkTextStyle: null, - receivedMessageBodyTextStyle: const TextStyle(), - receivedMessageDocumentIconColor: primary, - receivedMessageCaptionTextStyle: const TextStyle(), - receivedMessageLinkDescriptionTextStyle: const TextStyle(), - receivedMessageLinkTitleTextStyle: const TextStyle(), - seenIcon: null, - sendButtonIcon: null, - sendButtonMargin: null, - sendingIcon: null, - sentEmojiMessageTextStyle: const TextStyle(), - sentMessageBodyBoldTextStyle: null, - sentMessageBodyCodeTextStyle: null, - sentMessageBodyLinkTextStyle: null, - sentMessageBodyTextStyle: const TextStyle(), - sentMessageCaptionTextStyle: const TextStyle(), - sentMessageDocumentIconColor: neutral7, - sentMessageLinkDescriptionTextStyle: const TextStyle(), - sentMessageLinkTitleTextStyle: const TextStyle(), - statusIconPadding: EdgeInsets.zero, - systemMessageTheme: const SystemMessageTheme( - margin: EdgeInsets.zero, - textStyle: TextStyle(), - ), - typingIndicatorTheme: const TypingIndicatorTheme( - animatedCirclesColor: neutral1, - animatedCircleSize: 0.0, - bubbleBorder: BorderRadius.zero, - bubbleColor: neutral7, - countAvatarColor: primary, - countTextColor: secondary, - multipleUserTextStyle: TextStyle(), - ), - unreadHeaderTheme: const UnreadHeaderTheme( - color: secondary, - textStyle: TextStyle(), - ), - userAvatarImageBackgroundColor: Colors.transparent, - userAvatarNameColors: colors, - userAvatarTextStyle: const TextStyle(), - userNameTextStyle: const TextStyle(), - highlightMessageColor: null, - ); -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart index 991e49ea33..a60955c2f3 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart @@ -3,7 +3,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:universal_platform/universal_platform.dart'; class ChatInvalidUserMessage extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart index 1cd7fd4eb6..871146a7e7 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class AIChatUILayout { - static double get messageWidthRatio => 0.94; // Chat adds extra 0.06 - static EdgeInsets safeAreaInsets(BuildContext context) { final query = MediaQuery.of(context); return UniversalPlatform.isMobile diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart index 75945bb542..3a3f21ba47 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart @@ -14,7 +14,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:universal_platform/universal_platform.dart'; import '../layout_define.dart'; @@ -341,7 +341,6 @@ class CopyButton extends StatelessWidget { child: FlowyIconButton( width: DesktopAIConvoSizes.actionBarIconSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, - fillColor: Theme.of(context).cardColor, radius: DesktopAIConvoSizes.actionBarIconRadius, icon: FlowySvg( FlowySvgs.copy_s, diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart index 74d9c4187e..da507160a0 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart @@ -13,7 +13,7 @@ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:universal_platform/universal_platform.dart'; import 'ai_message_bubble.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart index 9410820137..a4ba99bead 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart @@ -8,7 +8,7 @@ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:universal_platform/universal_platform.dart'; class ChatUserMessageBubble extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart index f28708a4ef..122c5395dd 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart @@ -3,7 +3,7 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; class ChatUserMessageWidget extends StatelessWidget { const ChatUserMessageWidget({ @@ -22,20 +22,11 @@ class ChatUserMessageWidget extends StatelessWidget { ..add(const ChatUserMessageEvent.initial()), child: BlocBuilder( builder: (context, state) { - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: TextMessageText( - text: state.text, - ), - ), - if (!state.messageState.isFinish) ...[ - const HSpace(6), - const CircularProgressIndicator.adaptive(), - ], - ], + return Opacity( + opacity: state.messageState.isFinish ? 1.0 : 0.8, + child: TextMessageText( + text: state.text, + ), ); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart new file mode 100644 index 0000000000..0cfa2efe7b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart @@ -0,0 +1,88 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +const BorderRadius _borderRadius = BorderRadius.all(Radius.circular(16)); + +class CustomScrollToBottom extends StatelessWidget { + const CustomScrollToBottom({ + super.key, + required this.animation, + required this.onPressed, + }); + + final Animation animation; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + + return Positioned( + bottom: 24, + left: 0, + right: 0, + child: Center( + child: ScaleTransition( + scale: animation, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: Theme.of(context).dividerColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: _borderRadius, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 8), + blurRadius: 16, + spreadRadius: 8, + color: isLightMode + ? const Color(0x0F1F2329) + : Theme.of(context).shadowColor.withOpacity(0.06), + ), + BoxShadow( + offset: const Offset(0, 4), + blurRadius: 8, + color: isLightMode + ? const Color(0x141F2329) + : Theme.of(context).shadowColor.withOpacity(0.08), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x1F1F2329) + : Theme.of(context).shadowColor.withOpacity(0.12), + ), + ], + ), + child: Material( + borderRadius: _borderRadius, + color: Colors.transparent, + borderOnForeground: false, + child: InkWell( + overlayColor: WidgetStateProperty.all( + AFThemeExtension.of(context).lightGreyHover, + ), + borderRadius: _borderRadius, + onTap: onPressed, + child: const SizedBox.square( + dimension: 32, + child: Center( + child: FlowySvg( + FlowySvgs.ai_scroll_to_bottom_s, + size: Size.square(20), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index a970d280b7..c27af2c406 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -386,6 +386,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.2" + cross_cache: + dependency: transitive + description: + name: cross_cache + sha256: ed30348320a7fefe4195c26cfcbabc76b7108ce3d364c4dd7c1b1c681a4cfe28 + url: "https://pub.dev" + source: hosted + version: "0.0.2" cross_file: dependency: "direct main" description: @@ -459,13 +467,29 @@ packages: source: hosted version: "0.4.1" diffutil_dart: - dependency: transitive + dependency: "direct main" description: name: diffutil_dart sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" url: "https://pub.dev" source: hosted version: "4.0.1" + dio: + dependency: transitive + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" dotted_border: dependency: "direct main" description: @@ -692,8 +716,16 @@ packages: url: "https://github.com/LucasXu0/flutter_cache_manager.git" source: git version: "3.3.1" - flutter_chat_types: + flutter_chat_core: dependency: "direct main" + description: + name: flutter_chat_core + sha256: "14557aaac7c71b80c279eca41781d214853940cf01727934c742b5845c42dd1e" + url: "https://pub.dev" + source: hosted + version: "0.0.2" + flutter_chat_types: + dependency: transitive description: name: flutter_chat_types sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 @@ -704,10 +736,10 @@ packages: dependency: "direct main" description: name: flutter_chat_ui - sha256: "168a4231464ad00a17ea5f0813f1b58393bdd4035683ea4dc37bbe26be62891e" + sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292" url: "https://pub.dev" source: hosted - version: "1.6.15" + version: "2.0.0-dev.1" flutter_driver: dependency: transitive description: flutter @@ -767,14 +799,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" - flutter_parsed_text: - dependency: transitive - description: - name: flutter_parsed_text - sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2" - url: "https://pub.dev" - source: hosted - version: "2.2.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -1523,14 +1547,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" - photo_view: - dependency: transitive - description: - name: photo_view - sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" - url: "https://pub.dev" - source: hosted - version: "0.15.0" pixel_snap: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 1df225af6d..c4f06fbfb5 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: # Desktop Drop uses Cross File (XFile) data type desktop_drop: ^0.4.4 device_info_plus: + diffutil_dart: ^4.0.1 dotted_border: ^2.0.0+3 easy_localization: ^3.0.2 envied: ^0.5.2 @@ -66,8 +67,8 @@ dependencies: flutter_animate: ^4.5.0 flutter_bloc: ^8.1.3 flutter_cache_manager: ^3.3.1 - flutter_chat_types: ^3.6.2 - flutter_chat_ui: ^1.6.13 + flutter_chat_core: ^0.0.2 + flutter_chat_ui: 2.0.0-dev.1 flutter_emoji_mart: git: url: https://github.com/LucasXu0/emoji_mart.git diff --git a/frontend/resources/flowy_icons/16x/ai_scroll_to_bottom.svg b/frontend/resources/flowy_icons/16x/ai_scroll_to_bottom.svg new file mode 100644 index 0000000000..e91acbda42 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_scroll_to_bottom.svg @@ -0,0 +1,3 @@ + + +