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 041aa82a52..bcd3713550 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 @@ -8,72 +8,19 @@ part 'chat_user_message_bloc.freezed.dart'; class ChatUserMessageBloc extends Bloc { ChatUserMessageBloc({ - required dynamic message, - }) : super(ChatUserMessageState.initial(message)) { + required this.questionStream, + required String text, + }) : super(ChatUserMessageState.initial(text)) { + _dispatch(); + _startListening(); + } + + final QuestionStream? questionStream; + + void _dispatch() { on( (event, emit) { event.when( - initial: () { - if (state.stream != null) { - if (!isClosed) { - add(ChatUserMessageEvent.updateText(state.stream!.text)); - } - } - - state.stream?.listen( - onData: (text) { - if (!isClosed) { - add(ChatUserMessageEvent.updateText(text)); - } - }, - onMessageId: (messageId) { - if (!isClosed) { - add(ChatUserMessageEvent.updateMessageId(messageId)); - } - }, - onError: (error) { - if (!isClosed) { - add(ChatUserMessageEvent.receiveError(error.toString())); - } - }, - onFileIndexStart: (indexName) { - Log.debug("index start: $indexName"); - }, - onFileIndexEnd: (indexName) { - Log.info("index end: $indexName"); - }, - onFileIndexFail: (indexName) { - Log.debug("index fail: $indexName"); - }, - onIndexStart: () { - if (!isClosed) { - add( - const ChatUserMessageEvent.updateQuestionState( - QuestionMessageState.indexStart(), - ), - ); - } - }, - onIndexEnd: () { - if (!isClosed) { - add( - const ChatUserMessageEvent.updateQuestionState( - QuestionMessageState.indexEnd(), - ), - ); - } - }, - onDone: () { - if (!isClosed) { - add( - const ChatUserMessageEvent.updateQuestionState( - QuestionMessageState.finish(), - ), - ); - } - }, - ); - }, updateText: (String text) { emit(state.copyWith(text: text)); }, @@ -88,11 +35,66 @@ class ChatUserMessageBloc }, ); } + + void _startListening() { + questionStream?.listen( + onData: (text) { + if (!isClosed) { + add(ChatUserMessageEvent.updateText(text)); + } + }, + onMessageId: (messageId) { + if (!isClosed) { + add(ChatUserMessageEvent.updateMessageId(messageId)); + } + }, + onError: (error) { + if (!isClosed) { + add(ChatUserMessageEvent.receiveError(error.toString())); + } + }, + onFileIndexStart: (indexName) { + Log.debug("index start: $indexName"); + }, + onFileIndexEnd: (indexName) { + Log.info("index end: $indexName"); + }, + onFileIndexFail: (indexName) { + Log.debug("index fail: $indexName"); + }, + onIndexStart: () { + if (!isClosed) { + add( + const ChatUserMessageEvent.updateQuestionState( + QuestionMessageState.indexStart(), + ), + ); + } + }, + onIndexEnd: () { + if (!isClosed) { + add( + const ChatUserMessageEvent.updateQuestionState( + QuestionMessageState.indexEnd(), + ), + ); + } + }, + onDone: () { + if (!isClosed) { + add( + const ChatUserMessageEvent.updateQuestionState( + QuestionMessageState.finish(), + ), + ); + } + }, + ); + } } @freezed class ChatUserMessageEvent with _$ChatUserMessageEvent { - const factory ChatUserMessageEvent.initial() = Initial; const factory ChatUserMessageEvent.updateText(String text) = _UpdateText; const factory ChatUserMessageEvent.updateQuestionState( QuestionMessageState newState, @@ -106,17 +108,14 @@ class ChatUserMessageEvent with _$ChatUserMessageEvent { class ChatUserMessageState with _$ChatUserMessageState { const factory ChatUserMessageState({ required String text, - QuestionStream? stream, - String? messageId, - @Default(QuestionMessageState.finish()) QuestionMessageState messageState, + required String? messageId, + required QuestionMessageState messageState, }) = _ChatUserMessageState; - factory ChatUserMessageState.initial( - dynamic message, - ) => - ChatUserMessageState( - text: message is String ? message : "", - stream: message is QuestionStream ? message : null, + factory ChatUserMessageState.initial(String message) => ChatUserMessageState( + text: message, + messageId: null, + messageState: const QuestionMessageState.finish(), ); } 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 deleted file mode 100644 index ad1d3f8a87..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_core/flutter_chat_core.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'chat_message_service.dart'; - -part 'chat_user_message_bubble_bloc.freezed.dart'; - -class ChatUserMessageBubbleBloc - extends Bloc { - ChatUserMessageBubbleBloc({ - required Message message, - }) : super( - ChatUserMessageBubbleState.initial( - message, - _getFiles(message.metadata), - ), - ) { - on( - (event, emit) async { - event.when( - initial: () {}, - ); - }, - ); - } -} - -List _getFiles(Map? metadata) { - if (metadata == null) { - return []; - } - final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?; - final files = metadata[messageChatFileListKey] as List?; - - if (refSourceMetadata != null) { - return chatFilesFromMetadataString(refSourceMetadata); - } - return files ?? []; -} - -@freezed -class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent { - const factory ChatUserMessageBubbleEvent.initial() = Initial; -} - -@freezed -class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState { - const factory ChatUserMessageBubbleState({ - required Message message, - required List files, - }) = _ChatUserMessageBubbleState; - - factory ChatUserMessageBubbleState.initial( - Message message, - List files, - ) => - ChatUserMessageBubbleState(message: message, files: files); -} 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 adf722e6ef..916698ea13 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -4,7 +4,6 @@ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart'; 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'; @@ -233,27 +232,18 @@ class _ChatContentPage extends StatelessWidget { } if (message.author.id == userProfile.id.toString()) { - final stream = message.metadata?["$QuestionStream"]; - return ChatUserMessageBubble( - key: ValueKey(message.id), + return ChatUserMessageWidget( + user: message.author, message: message, - child: ChatUserMessageWidget( - user: message.author, - message: stream is QuestionStream ? stream : message.text, - ), + isCurrentUser: true, ); } if (isOtherUserMessage(message)) { - final stream = message.metadata?["$QuestionStream"]; - return ChatUserMessageBubble( - key: ValueKey(message.id), + return ChatUserMessageWidget( + user: message.author, message: message, isCurrentUser: false, - child: ChatUserMessageWidget( - user: message.author, - message: stream is QuestionStream ? stream : message.text, - ), ); } @@ -263,7 +253,6 @@ class _ChatContentPage extends StatelessWidget { message.metadata?[messageRefSourceJsonStringKey] as String?; return BlocSelector( - key: ValueKey(message.id), selector: (state) { final chatController = context.read().chatController; final messages = chatController.messages.where((e) { 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 3a3f21ba47..85d8467851 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 @@ -4,7 +4,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/startup/startup.dart'; @@ -17,13 +16,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:universal_platform/universal_platform.dart'; +import '../chat_avatar.dart'; import '../layout_define.dart'; /// Wraps an AI response message with the avatar and actions. On desktop, /// the actions will be displayed below the response if the response is the -/// last message in the chat. For other AI responses, the actions will be shown -/// on hover. On mobile, the actions will be displayed in a bottom sheet on -/// long press. +/// last message in the chat. For the others, the actions will be shown on hover +/// On mobile, the actions will be displayed in a bottom sheet on long press. class ChatAIMessageBubble extends StatelessWidget { const ChatAIMessageBubble({ super.key, 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 a4ba99bead..e860df8729 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 @@ -1,9 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; @@ -11,57 +8,50 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:universal_platform/universal_platform.dart'; +import '../chat_avatar.dart'; +import '../layout_define.dart'; + class ChatUserMessageBubble extends StatelessWidget { const ChatUserMessageBubble({ super.key, required this.message, required this.child, - this.isCurrentUser = true, + required this.isCurrentUser, + this.files = const [], }); final Message message; final Widget child; final bool isCurrentUser; + final List files; @override Widget build(BuildContext context) { - if (context.read().state.members[message.author.id] == - null) { - context - .read() - .add(ChatMemberEvent.getMemberInfo(message.author.id)); - } + context + .read() + .add(ChatMemberEvent.getMemberInfo(message.author.id)); - return BlocProvider( - create: (context) => ChatUserMessageBubbleBloc( - message: message, - ), - child: BlocBuilder( - builder: (context, state) { - return Padding( - padding: UniversalPlatform.isMobile - ? const EdgeInsets.symmetric(horizontal: 16) - : EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (state.files.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.only(right: 32), - child: _MessageFileList(files: state.files), - ), - const VSpace(6), - ], - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: getChildren(context), - ), - ], + return Padding( + padding: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 16) + : EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (files.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(right: 32), + child: _MessageFileList(files: files), ), - ); - }, + const VSpace(6), + ], + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: getChildren(context), + ), + ], ), ); } 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 122c5395dd..ae0d2863ce 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 @@ -1,3 +1,6 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -5,33 +8,63 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'user_message_bubble.dart'; + class ChatUserMessageWidget extends StatelessWidget { const ChatUserMessageWidget({ super.key, required this.user, required this.message, + required this.isCurrentUser, }); final User user; - final dynamic message; + final TextMessage message; + final bool isCurrentUser; @override Widget build(BuildContext context) { + final stream = message.metadata?["$QuestionStream"]; + final messageText = stream is QuestionStream ? stream.text : message.text; + return BlocProvider( - create: (context) => ChatUserMessageBloc(message: message) - ..add(const ChatUserMessageEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return Opacity( - opacity: state.messageState.isFinish ? 1.0 : 0.8, - child: TextMessageText( - text: state.text, - ), - ); - }, + create: (context) => ChatUserMessageBloc( + text: messageText, + questionStream: stream, + ), + child: ChatUserMessageBubble( + message: message, + isCurrentUser: isCurrentUser, + files: _getFiles(), + child: BlocBuilder( + builder: (context, state) { + return Opacity( + opacity: state.messageState.isFinish ? 1.0 : 0.8, + child: TextMessageText( + text: state.text, + ), + ); + }, + ), ), ); } + + List _getFiles() { + if (message.metadata == null) { + return const []; + } + + final refSourceMetadata = + message.metadata?[messageRefSourceJsonStringKey] as String?; + if (refSourceMetadata != null) { + return chatFilesFromMetadataString(refSourceMetadata); + } + + final chatFileList = + message.metadata![messageChatFileListKey] as List?; + return chatFileList ?? []; + } } /// Widget to reuse the markdown capabilities, e.g., for previews.