diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 3e64bcd83f304..c1cdd7bc177a1 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -89,6 +89,7 @@ jobs: with: os: ${{ matrix.os }} flutter_version: ${{ env.FLUTTER_VERSION }} + DISABLE_CI_TEST_LOG: "true" rust_toolchain: ${{ env.RUST_TOOLCHAIN }} cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} rust_target: ${{ matrix.target }} @@ -202,6 +203,7 @@ jobs: - name: Run Flutter unit tests env: DISABLE_EVENT_LOG: true + DISABLE_CI_TEST_LOG: "true" working-directory: frontend run: | if [ "$RUNNER_OS" == "macOS" ]; then diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart index 1782c0d0ba19c..4659c98b5553e 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart @@ -18,6 +18,9 @@ void main() { // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, @@ -46,6 +49,9 @@ void main() { // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart index 158a8db882129..006d7ff0b6a08 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart @@ -39,6 +39,9 @@ void main() { await tester.tapAnonymousSignInButton(); for (final layout in ViewLayoutPB.values) { + if (layout == ViewLayoutPB.Chat) { + continue; + } // create a new page final name = 'AppFlowy_$layout'; await tester.createNewPageWithNameUnderParent( @@ -66,6 +69,8 @@ void main() { case ViewLayoutPB.Calendar: expect(find.byType(CalendarPage), findsOneWidget); break; + case ViewLayoutPB.Chat: + break; } await tester.openPage(gettingStarted); diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 5a2d069c36e5b..6f75b60adebe0 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -200,7 +200,7 @@ SPEC CHECKSUMS: file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 + fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 @@ -227,4 +227,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca -COCOAPODS: 1.15.2 +COCOAPODS: 1.11.3 diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart index a40c4d59deda3..8b9f1e70ff6e4 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -1,15 +1,17 @@ import 'dart:async'; import 'dart:convert'; +import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; extension MobileRouter on BuildContext { @@ -37,6 +39,9 @@ extension on ViewPB { return MobileCalendarScreen.routeName; case ViewLayoutPB.Board: return MobileBoardScreen.routeName; + case ViewLayoutPB.Chat: + return MobileChatScreen.routeName; + default: throw UnimplementedError('routeName for $this is not implemented'); } @@ -65,6 +70,11 @@ extension on ViewPB { MobileBoardScreen.viewId: id, MobileBoardScreen.viewTitle: name, }; + case ViewLayoutPB.Chat: + return { + MobileChatScreen.viewId: id, + MobileChatScreen.viewTitle: name, + }; default: throw UnimplementedError( 'queryParameters for $this is not implemented', diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index 22b46ced57d18..591f708546b4c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -8,6 +8,7 @@ import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; import 'package:appflowy/plugins/shared/sync_indicator.dart'; import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; @@ -154,7 +155,10 @@ class _MobileViewPageState extends State { (view) { final plugin = view.plugin(arguments: widget.arguments ?? const {}) ..init(); - return plugin.widgetBuilder.buildWidget(shrinkWrap: false); + return plugin.widgetBuilder.buildWidget( + shrinkWrap: false, + context: PluginContext(userProfile: state.userProfilePB), + ); }, (error) { return FlowyMobileStateContainer.error( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart new file mode 100644 index 0000000000000..31fcbdcdfd0b2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class MobileChatScreen extends StatelessWidget { + const MobileChatScreen({ + super.key, + required this.id, + this.title, + }); + + /// view id + final String id; + final String? title; + + static const routeName = '/chat'; + static const viewId = 'id'; + static const viewTitle = 'title'; + + @override + Widget build(BuildContext context) { + return MobileViewPage( + id: id, + title: title, + viewLayout: ViewLayoutPB.Chat, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart new file mode 100644 index 0000000000000..2c5c8f5b0e36b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart @@ -0,0 +1,42 @@ +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_ai_message_bloc.freezed.dart'; + +class ChatAIMessageBloc extends Bloc { + ChatAIMessageBloc({ + required Message message, + }) : super(ChatAIMessageState.initial(message)) { + on( + (event, emit) async { + await event.when( + initial: () async {}, + update: (userProfile, deviceId, states) {}, + ); + }, + ); + } +} + +@freezed +class ChatAIMessageEvent with _$ChatAIMessageEvent { + const factory ChatAIMessageEvent.initial() = Initial; + const factory ChatAIMessageEvent.update( + UserProfilePB userProfile, + String deviceId, + DocumentAwarenessStatesPB states, + ) = Update; +} + +@freezed +class ChatAIMessageState with _$ChatAIMessageState { + const factory ChatAIMessageState({ + required Message message, + }) = _ChatAIMessageState; + + factory ChatAIMessageState.initial(Message message) => + ChatAIMessageState(message: message); +} 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 new file mode 100644 index 0000000000000..17eaad8c9242a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -0,0 +1,423 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-chat/entities.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: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:freezed_annotation/freezed_annotation.dart'; +import 'package:nanoid/nanoid.dart'; +import 'chat_message_listener.dart'; + +part 'chat_bloc.freezed.dart'; + +const canRetryKey = "canRetry"; +const sendMessageErrorKey = "sendMessageError"; + +class ChatBloc extends Bloc { + ChatBloc({ + required ViewPB view, + required UserProfilePB userProfile, + }) : listener = ChatMessageListener(chatId: view.id), + chatId = view.id, + super( + ChatState.initial(view, userProfile), + ) { + _dispatch(); + + listener.start( + chatMessageCallback: _handleChatMessage, + lastUserSentMessageCallback: (message) { + if (!isClosed) { + add(ChatEvent.didSentUserMessage(message)); + } + }, + chatErrorMessageCallback: (err) { + if (!isClosed) { + Log.error("chat error: ${err.errorMessage}"); + final metadata = OnetimeShotType.serverStreamError.toMap(); + if (state.lastSentMessage != null) { + metadata[canRetryKey] = "true"; + } + final error = CustomMessage( + metadata: metadata, + author: const User(id: "system"), + id: 'system', + ); + add(ChatEvent.streaming([error])); + add(const ChatEvent.didFinishStreaming()); + } + }, + latestMessageCallback: (list) { + if (!isClosed) { + final messages = list.messages.map(_createChatMessage).toList(); + add(ChatEvent.didLoadLatestMessages(messages)); + } + }, + prevMessageCallback: (list) { + if (!isClosed) { + final messages = list.messages.map(_createChatMessage).toList(); + add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore)); + } + }, + finishAnswerQuestionCallback: () { + if (!isClosed) { + add(const ChatEvent.didFinishStreaming()); + if (state.lastSentMessage != null) { + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: state.lastSentMessage!.messageId, + ); + // When user message was sent to the server, we start gettting related question + ChatEventGetRelatedQuestion(payload).send().then((result) { + if (!isClosed) { + result.fold( + (list) { + add( + ChatEvent.didReceiveRelatedQuestion(list.items), + ); + }, + (err) { + Log.error("Failed to get related question: $err"); + }, + ); + } + }); + } + } + }, + ); + } + + final ChatMessageListener listener; + final String chatId; + + @override + Future close() { + listener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initialLoad: () { + final payload = LoadNextChatMessagePB( + chatId: state.view.id, + limit: Int64(10), + ); + ChatEventLoadNextMessage(payload).send(); + }, + startLoadingPrevMessage: () async { + Int64? beforeMessageId; + if (state.messages.isNotEmpty) { + beforeMessageId = Int64.parseInt(state.messages.last.id); + } + _loadPrevMessage(beforeMessageId); + emit( + state.copyWith( + loadingPreviousStatus: const LoadingState.loading(), + ), + ); + }, + didLoadPreviousMessages: (List messages, bool hasMore) { + Log.debug("did load previous messages: ${messages.length}"); + final uniqueMessages = {...state.messages, ...messages}.toList() + ..sort((a, b) => b.id.compareTo(a.id)); + emit( + state.copyWith( + messages: uniqueMessages, + loadingPreviousStatus: const LoadingState.finish(), + hasMorePrevMessage: hasMore, + ), + ); + }, + didLoadLatestMessages: (List messages) { + final uniqueMessages = {...state.messages, ...messages}.toList() + ..sort((a, b) => b.id.compareTo(a.id)); + emit( + state.copyWith( + messages: uniqueMessages, + initialLoadingStatus: const LoadingState.finish(), + ), + ); + }, + streaming: (List messages) { + final allMessages = _perminentMessages(); + allMessages.insertAll(0, messages); + emit(state.copyWith(messages: allMessages)); + }, + didFinishStreaming: () { + emit( + state.copyWith( + answerQuestionStatus: const LoadingState.finish(), + ), + ); + }, + sendMessage: (String message) async { + await _handleSentMessage(message, emit); + + // Create a loading indicator + final loadingMessage = + _loadingMessage(state.userProfile.id.toString()); + final allMessages = List.from(state.messages) + ..insert(0, loadingMessage); + + emit( + state.copyWith( + lastSentMessage: null, + messages: allMessages, + answerQuestionStatus: const LoadingState.loading(), + relatedQuestions: [], + ), + ); + }, + retryGenerate: () { + if (state.lastSentMessage == null) { + return; + } + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: state.lastSentMessage!.messageId, + ); + ChatEventGetAnswerForQuestion(payload).send().then((result) { + if (!isClosed) { + result.fold( + (answer) => _handleChatMessage(answer), + (err) { + Log.error("Failed to get answer: $err"); + }, + ); + } + }); + }, + didReceiveRelatedQuestion: (List questions) { + final allMessages = _perminentMessages(); + final message = CustomMessage( + metadata: OnetimeShotType.relatedQuestion.toMap(), + author: const User(id: "system"), + id: 'system', + ); + allMessages.insert(0, message); + emit( + state.copyWith( + messages: allMessages, + relatedQuestions: questions, + ), + ); + }, + clearReleatedQuestion: () { + emit( + state.copyWith( + relatedQuestions: [], + ), + ); + }, + didSentUserMessage: (ChatMessagePB message) { + emit( + state.copyWith( + lastSentMessage: message, + ), + ); + }, + ); + }, + ); + } + +// Returns the list of messages that are not include one-time messages. + List _perminentMessages() { + final allMessages = state.messages.where((element) { + return !(element.metadata?.containsKey(onetimeShotType) == true); + }).toList(); + + return allMessages; + } + + void _loadPrevMessage(Int64? beforeMessageId) { + final payload = LoadPrevChatMessagePB( + chatId: state.view.id, + limit: Int64(10), + beforeMessageId: beforeMessageId, + ); + ChatEventLoadPrevMessage(payload).send(); + } + + Future _handleSentMessage( + String message, + Emitter emit, + ) async { + final payload = SendChatPayloadPB( + chatId: state.view.id, + message: message, + messageType: ChatMessageTypePB.User, + ); + final result = await ChatEventSendMessage(payload).send(); + result.fold( + (_) {}, + (err) { + if (!isClosed) { + Log.error("Failed to send message: ${err.msg}"); + final metadata = OnetimeShotType.invalidSendMesssage.toMap(); + metadata[sendMessageErrorKey] = err.msg; + final error = CustomMessage( + metadata: metadata, + author: const User(id: "system"), + id: 'system', + ); + + add(ChatEvent.streaming([error])); + } + }, + ); + } + + void _handleChatMessage(ChatMessagePB pb) { + if (!isClosed) { + final message = _createChatMessage(pb); + final messages = pb.hasFollowing + ? [_loadingMessage(0.toString()), message] + : [message]; + add(ChatEvent.streaming(messages)); + } + } + + Message _loadingMessage(String id) { + return CustomMessage( + author: User(id: id), + metadata: OnetimeShotType.loading.toMap(), + // fake id + id: nanoid(), + ); + } + + Message _createChatMessage(ChatMessagePB message) { + final messageId = message.messageId.toString(); + return TextMessage( + author: User(id: message.authorId), + id: messageId, + text: message.content, + createdAt: message.createdAt.toInt(), + repliedMessage: _getReplyMessage(state.messages, messageId), + ); + } + + Message? _getReplyMessage(List messages, String messageId) { + return messages.firstWhereOrNull((element) => element?.id == messageId); + } +} + +@freezed +class ChatEvent with _$ChatEvent { + const factory ChatEvent.initialLoad() = _InitialLoadMessage; + const factory ChatEvent.sendMessage(String message) = _SendMessage; + const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage; + const factory ChatEvent.didLoadPreviousMessages( + List messages, + bool hasMore, + ) = _DidLoadPreviousMessages; + const factory ChatEvent.didLoadLatestMessages(List messages) = + _DidLoadMessages; + const factory ChatEvent.streaming(List messages) = _DidStreamMessage; + const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage; + const factory ChatEvent.didReceiveRelatedQuestion( + List questions, + ) = _DidReceiveRelatedQueston; + const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion; + const factory ChatEvent.retryGenerate() = _RetryGenerate; + const factory ChatEvent.didSentUserMessage(ChatMessagePB message) = + _DidSendUserMessage; +} + +@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 LoadingState 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 LoadingState 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 LoadingState answerQuestionStatus, + // 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, + // The last user message that is sent to the server. + ChatMessagePB? lastSentMessage, + }) = _ChatState; + + factory ChatState.initial(ViewPB view, UserProfilePB userProfile) => + ChatState( + view: view, + messages: [], + userProfile: userProfile, + initialLoadingStatus: const LoadingState.finish(), + loadingPreviousStatus: const LoadingState.finish(), + answerQuestionStatus: const LoadingState.finish(), + hasMorePrevMessage: true, + relatedQuestions: [], + ); +} + +@freezed +class LoadingState with _$LoadingState { + const factory LoadingState.loading() = _Loading; + const factory LoadingState.finish() = _Finish; +} + +enum OnetimeShotType { + unknown, + loading, + serverStreamError, + relatedQuestion, + invalidSendMesssage +} + +const onetimeShotType = "OnetimeShotType"; + +extension OnetimeMessageTypeExtension on OnetimeShotType { + static OnetimeShotType fromString(String value) { + switch (value) { + case 'OnetimeShotType.loading': + return OnetimeShotType.loading; + case 'OnetimeShotType.serverStreamError': + return OnetimeShotType.serverStreamError; + case 'OnetimeShotType.relatedQuestion': + return OnetimeShotType.relatedQuestion; + case 'OnetimeShotType.invalidSendMesssage': + return OnetimeShotType.invalidSendMesssage; + default: + Log.error('Unknown OnetimeShotType: $value'); + return OnetimeShotType.unknown; + } + } + + Map toMap() { + return { + onetimeShotType: toString(), + }; + } +} + +OnetimeShotType? onetimeMessageTypeFromMeta(Map? metadata) { + if (metadata == null) { + return null; + } + + for (final entry in metadata.entries) { + if (entry.key == onetimeShotType) { + return OnetimeMessageTypeExtension.fromString(entry.value as String); + } + } + return null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart new file mode 100644 index 0000000000000..3b40c18d36797 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import 'chat_notification.dart'; + +typedef ChatMessageCallback = void Function(ChatMessagePB message); +typedef ChatErrorMessageCallback = void Function(ChatMessageErrorPB message); +typedef LatestMessageCallback = void Function(ChatMessageListPB list); +typedef PrevMessageCallback = void Function(ChatMessageListPB list); + +class ChatMessageListener { + ChatMessageListener({required this.chatId}) { + _parser = ChatNotificationParser(id: chatId, callback: _callback); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + final String chatId; + StreamSubscription? _subscription; + ChatNotificationParser? _parser; + + ChatMessageCallback? chatMessageCallback; + ChatMessageCallback? lastUserSentMessageCallback; + ChatErrorMessageCallback? chatErrorMessageCallback; + LatestMessageCallback? latestMessageCallback; + PrevMessageCallback? prevMessageCallback; + void Function()? finishAnswerQuestionCallback; + + void start({ + ChatMessageCallback? chatMessageCallback, + ChatErrorMessageCallback? chatErrorMessageCallback, + LatestMessageCallback? latestMessageCallback, + PrevMessageCallback? prevMessageCallback, + ChatMessageCallback? lastUserSentMessageCallback, + void Function()? finishAnswerQuestionCallback, + }) { + this.chatMessageCallback = chatMessageCallback; + this.chatErrorMessageCallback = chatErrorMessageCallback; + this.latestMessageCallback = latestMessageCallback; + this.prevMessageCallback = prevMessageCallback; + this.lastUserSentMessageCallback = lastUserSentMessageCallback; + this.finishAnswerQuestionCallback = finishAnswerQuestionCallback; + } + + void _callback( + ChatNotification ty, + FlowyResult result, + ) { + result.map((r) { + switch (ty) { + case ChatNotification.DidReceiveChatMessage: + chatMessageCallback?.call(ChatMessagePB.fromBuffer(r)); + break; + case ChatNotification.LastUserSentMessage: + lastUserSentMessageCallback?.call(ChatMessagePB.fromBuffer(r)); + break; + case ChatNotification.StreamChatMessageError: + chatErrorMessageCallback?.call(ChatMessageErrorPB.fromBuffer(r)); + break; + case ChatNotification.DidLoadLatestChatMessage: + latestMessageCallback?.call(ChatMessageListPB.fromBuffer(r)); + break; + case ChatNotification.DidLoadPrevChatMessage: + prevMessageCallback?.call(ChatMessageListPB.fromBuffer(r)); + break; + case ChatNotification.FinishAnswerQuestion: + finishAnswerQuestionCallback?.call(); + break; + default: + break; + } + }); + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart new file mode 100644 index 0000000000000..194748858b466 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/notification_helper.dart'; +import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class ChatNotificationParser + extends NotificationParser { + ChatNotificationParser({ + super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == "Chat" ? ChatNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} + +typedef ChatNotificationHandler = Function( + ChatNotification ty, + FlowyResult result, +); + +class ChatNotificationListener { + ChatNotificationListener({ + required String objectId, + required ChatNotificationHandler handler, + }) : _parser = ChatNotificationParser(id: objectId, callback: handler) { + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + ChatNotificationParser? _parser; + StreamSubscription? _subscription; + + Future stop() async { + _parser = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_related_question_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_related_question_bloc.dart new file mode 100644 index 0000000000000..d6db6afc37773 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_related_question_bloc.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_message_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_related_question_bloc.freezed.dart'; + +class ChatRelatedMessageBloc + extends Bloc { + ChatRelatedMessageBloc({ + required String chatId, + }) : listener = ChatMessageListener(chatId: chatId), + super(ChatRelatedMessageState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + listener.start( + lastUserSentMessageCallback: (message) { + if (!isClosed) { + add(ChatRelatedMessageEvent.updateLastSentMessage(message)); + } + }, + ); + }, + didReceiveRelatedQuestion: (List questions) { + Log.debug("Related questions: $questions"); + emit( + state.copyWith( + relatedQuestions: questions, + ), + ); + }, + updateLastSentMessage: (ChatMessagePB message) { + final payload = + ChatMessageIdPB(chatId: chatId, messageId: message.messageId); + ChatEventGetRelatedQuestion(payload).send().then((result) { + if (!isClosed) { + result.fold( + (list) { + add( + ChatRelatedMessageEvent.didReceiveRelatedQuestion( + list.items, + ), + ); + }, + (err) { + Log.error("Failed to get related question: $err"); + }, + ); + } + }); + + emit( + state.copyWith( + lastSentMessage: message, + relatedQuestions: [], + ), + ); + }, + clear: () { + emit( + state.copyWith( + relatedQuestions: [], + ), + ); + }, + ); + }, + ); + } + + final ChatMessageListener listener; + @override + Future close() { + listener.stop(); + return super.close(); + } +} + +@freezed +class ChatRelatedMessageEvent with _$ChatRelatedMessageEvent { + const factory ChatRelatedMessageEvent.initial() = Initial; + const factory ChatRelatedMessageEvent.updateLastSentMessage( + ChatMessagePB message, + ) = _LastSentMessage; + const factory ChatRelatedMessageEvent.didReceiveRelatedQuestion( + List questions, + ) = _RelatedQuestion; + const factory ChatRelatedMessageEvent.clear() = _Clear; +} + +@freezed +class ChatRelatedMessageState with _$ChatRelatedMessageState { + const factory ChatRelatedMessageState({ + ChatMessagePB? lastSentMessage, + @Default([]) List relatedQuestions, + }) = _ChatRelatedMessageState; + + factory ChatRelatedMessageState.initial() => const ChatRelatedMessageState(); +} 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 new file mode 100644 index 0000000000000..d75b0533e2385 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart @@ -0,0 +1,44 @@ +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_user_message_bloc.freezed.dart'; + +class ChatUserMessageBloc + extends Bloc { + ChatUserMessageBloc({ + required Message message, + }) : super(ChatUserMessageState.initial(message)) { + on( + (event, emit) async { + await event.when( + initial: () async {}, + update: (userProfile, deviceId, states) {}, + ); + }, + ); + } +} + +@freezed +class ChatUserMessageEvent with _$ChatUserMessageEvent { + const factory ChatUserMessageEvent.initial() = Initial; + const factory ChatUserMessageEvent.update( + UserProfilePB userProfile, + String deviceId, + DocumentAwarenessStatesPB states, + ) = Update; +} + +@freezed +class ChatUserMessageState with _$ChatUserMessageState { + const factory ChatUserMessageState({ + required Message message, + WorkspaceMemberPB? member, + }) = _ChatUserMessageState; + + factory ChatUserMessageState.initial(Message message) => + ChatUserMessageState(message: message); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart new file mode 100644 index 0000000000000..1fa8efe3d82bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/ai_chat/chat_page.dart'; +import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AIChatPluginBuilder extends PluginBuilder { + @override + Plugin build(dynamic data) { + if (data is ViewPB) { + return AIChatPagePlugin(view: data); + } + + throw FlowyPluginException.invalidData; + } + + @override + String get menuName => "AIChat"; + + @override + FlowySvgData get icon => FlowySvgs.chat_ai_page_s; + + @override + PluginType get pluginType => PluginType.chat; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Chat; +} + +class AIChatPluginConfig implements PluginConfig { + @override + bool get creatable => false; +} + +class AIChatPagePlugin extends Plugin { + AIChatPagePlugin({ + required ViewPB view, + }) : notifier = ViewPluginNotifier(view: view); + + late final ViewInfoBloc _viewInfoBloc; + + @override + final ViewPluginNotifier notifier; + + @override + PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder( + bloc: _viewInfoBloc, + notifier: notifier, + ); + + @override + PluginId get id => notifier.view.id; + + @override + PluginType get pluginType => PluginType.chat; + + @override + void init() { + _viewInfoBloc = ViewInfoBloc(view: notifier.view) + ..add(const ViewInfoEvent.started()); + } +} + +class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder + with NavigationItem { + AIChatPagePluginWidgetBuilder({ + required this.bloc, + required this.notifier, + }); + + final ViewInfoBloc bloc; + final ViewPluginNotifier notifier; + int? deletedViewIndex; + + @override + Widget get leftBarItem => + ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view); + + @override + Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); + + @override + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + }) { + notifier.isDeleted.addListener(() { + final deletedView = notifier.isDeleted.value; + if (deletedView != null && deletedView.hasIndex()) { + deletedViewIndex = deletedView.index; + } + }); + + return BlocProvider.value( + value: bloc, + child: AIChatPage( + userProfile: context.userProfile!, + key: ValueKey(notifier.view.id), + view: notifier.view, + onDeleted: () => + context.onDeleted?.call(notifier.view, deletedViewIndex), + ), + ); + } + + @override + List get navigationItems => [this]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart new file mode 100644 index 0000000000000..481076dd5cf57 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -0,0 +1,332 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_ai_message.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_streaming_error_message.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_user_message.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +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/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat; +import 'package:flutter_chat_types/flutter_chat_types.dart' as types; + +import 'presentation/chat_input.dart'; +import 'presentation/chat_loading.dart'; +import 'presentation/chat_popmenu.dart'; +import 'presentation/chat_theme.dart'; +import 'presentation/chat_user_invalid_message.dart'; +import 'presentation/chat_welcome_page.dart'; + +class AIChatPage extends StatefulWidget { + const AIChatPage({ + super.key, + required this.view, + required this.onDeleted, + required this.userProfile, + }); + + final ViewPB view; + final VoidCallback onDeleted; + final UserProfilePB userProfile; + + @override + State createState() => _AIChatPageState(); +} + +class _AIChatPageState extends State { + late types.User _user; + + @override + void initState() { + super.initState(); + _user = types.User(id: widget.userProfile.id.toString()); + } + + @override + Widget build(BuildContext context) { + if (widget.userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { + return buildChatWidget(); + } else { + return Center( + child: FlowyText( + LocaleKeys.chat_unsupportedCloudPrompt.tr(), + fontSize: 20, + ), + ); + } + } + + Widget buildChatWidget() { + return SizedBox.expand( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 60), + child: BlocProvider( + create: (context) => ChatBloc( + view: widget.view, + userProfile: widget.userProfile, + )..add(const ChatEvent.initialLoad()), + child: BlocBuilder( + builder: (blocContext, state) { + return Chat( + messages: state.messages, + onAttachmentPressed: () {}, + onSendPressed: (types.PartialText message) { + // We use custom bottom widget for chat input, so + // do not need to handle this event. + }, + customBottomWidget: buildChatInput(blocContext), + user: _user, + theme: buildTheme(context), + customMessageBuilder: _customMessageBuilder, + onEndReached: () async { + if (state.hasMorePrevMessage && + state.loadingPreviousStatus != + const LoadingState.loading()) { + blocContext + .read() + .add(const ChatEvent.startLoadingPrevMessage()); + } + }, + emptyState: BlocBuilder( + builder: (context, state) { + return state.initialLoadingStatus == + const LoadingState.finish() + ? const ChatWelcomePage() + : const Center( + child: CircularProgressIndicator.adaptive(), + ); + }, + ), + messageWidthRatio: isMobile ? 0.8 : 0.86, + bubbleBuilder: ( + child, { + required message, + required nextMessageInGroup, + }) { + if (message.author.id == _user.id) { + return ChatUserMessageBubble( + message: message, + child: child, + ); + } else { + final messageType = onetimeMessageTypeFromMeta( + message.metadata, + ); + if (messageType == OnetimeShotType.serverStreamError) { + return ChatStreamingError( + message: message, + onRetryPressed: () { + blocContext + .read() + .add(const ChatEvent.retryGenerate()); + }, + ); + } + + if (messageType == OnetimeShotType.invalidSendMesssage) { + return ChatInvalidUserMessage( + message: message, + ); + } + + if (messageType == OnetimeShotType.relatedQuestion) { + return RelatedQuestionList( + onQuestionSelected: (question) { + blocContext + .read() + .add(ChatEvent.sendMessage(question)); + blocContext + .read() + .add(const ChatEvent.clearReleatedQuestion()); + }, + chatId: widget.view.id, + relatedQuestions: state.relatedQuestions, + ); + } + + return ChatAIMessageBubble( + message: message, + customMessageType: messageType, + child: child, + ); + } + }, + ); + }, + ), + ), + ), + ); + } + + Widget buildBubble(Message message, Widget child) { + final isAuthor = message.author.id == _user.id; + const borderRadius = BorderRadius.all(Radius.circular(6)); + + final childWithPadding = isAuthor + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: child, + ) + : Padding( + padding: const EdgeInsets.all(8), + child: child, + ); + + // If the message is from the author, we will decorate it with a different color + final decoratedChild = isAuthor + ? DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: !isAuthor || message.type == types.MessageType.image + ? AFThemeExtension.of(context).tint1 + : Theme.of(context).colorScheme.secondary, + ), + child: childWithPadding, + ) + : childWithPadding; + + // If the message is from the author, no further actions are needed + if (isAuthor) { + return ClipRRect( + borderRadius: borderRadius, + child: decoratedChild, + ); + } else { + if (isMobile) { + return ChatPopupMenu( + onAction: (action) { + switch (action) { + case ChatMessageAction.copy: + if (message is TextMessage) { + Clipboard.setData(ClipboardData(text: message.text)); + showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); + } + break; + } + }, + builder: (context) => + ClipRRect(borderRadius: borderRadius, child: decoratedChild), + ); + } else { + // Show hover effect only on desktop + return ClipRRect( + borderRadius: borderRadius, + child: ChatAIMessageHover( + message: message, + child: decoratedChild, + ), + ); + } + } + } + + Widget _customMessageBuilder( + types.CustomMessage message, { + required int messageWidth, + }) { + // iteration custom message type + final messageType = onetimeMessageTypeFromMeta(message.metadata); + if (messageType == null) { + return const SizedBox.shrink(); + } + + switch (messageType) { + case OnetimeShotType.loading: + return const ChatAILoading(); + default: + return const SizedBox.shrink(); + } + } + + Widget buildChatInput(BuildContext context) { + final query = MediaQuery.of(context); + final safeAreaInsets = isMobile + ? EdgeInsets.fromLTRB( + query.padding.left, + 0, + query.padding.right, + query.viewInsets.bottom + query.padding.bottom, + ) + : EdgeInsets.zero; + return Column( + children: [ + ClipRect( + child: Padding( + padding: safeAreaInsets, + child: ChatInput( + chatId: widget.view.id, + onSendPressed: (message) => onSendPressed(context, message.text), + ), + ), + ), + const VSpace(6), + Opacity( + opacity: 0.6, + child: FlowyText( + LocaleKeys.chat_aiMistakePrompt.tr(), + fontSize: 12, + ), + ), + ], + ); + } + + AFDefaultChatTheme buildTheme(BuildContext context) { + return AFDefaultChatTheme( + backgroundColor: AFThemeExtension.of(context).background, + primaryColor: Theme.of(context).colorScheme.primary, + secondaryColor: AFThemeExtension.of(context).tint1, + receivedMessageDocumentIconColor: Theme.of(context).primaryColor, + receivedMessageCaptionTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + receivedMessageBodyTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + receivedMessageLinkTitleTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + receivedMessageBodyLinkTextStyle: const TextStyle( + color: Colors.lightBlue, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + sentMessageBodyTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + sentMessageBodyLinkTextStyle: const TextStyle( + color: Colors.blue, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + inputElevation: 2, + ); + } + + void onSendPressed(BuildContext context, String message) { + context.read().add(ChatEvent.sendMessage(message)); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_ai_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_ai_message.dart new file mode 100644 index 0000000000000..5a8e65012d7a1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_ai_message.dart @@ -0,0 +1,197 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.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'; +import 'package:styled_widget/styled_widget.dart'; + +const _leftPadding = 16.0; + +class ChatAIMessageBubble extends StatelessWidget { + const ChatAIMessageBubble({ + super.key, + required this.message, + required this.child, + this.customMessageType, + }); + + final Message message; + final Widget child; + final OnetimeShotType? customMessageType; + + @override + Widget build(BuildContext context) { + const padding = EdgeInsets.symmetric(horizontal: _leftPadding); + final childWithPadding = Padding(padding: padding, child: child); + + return BlocProvider( + create: (context) => ChatAIMessageBloc(message: message), + child: BlocBuilder( + builder: (context, state) { + final widget = isMobile + ? _wrapPopMenu(childWithPadding) + : _wrapHover(childWithPadding); + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ChatBorderedCircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + child: const FlowySvg( + FlowySvgs.flowy_ai_chat_logo_s, + size: Size.square(24), + ), + ), + Expanded(child: widget), + ], + ); + }, + ), + ); + } + + ChatAIMessageHover _wrapHover(Padding child) { + return ChatAIMessageHover( + message: message, + customMessageType: customMessageType, + child: child, + ); + } + + ChatPopupMenu _wrapPopMenu(Padding childWithPadding) { + return ChatPopupMenu( + onAction: (action) { + if (action == ChatMessageAction.copy && message is TextMessage) { + Clipboard.setData(ClipboardData(text: (message as TextMessage).text)); + showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); + } + }, + builder: (context) => childWithPadding, + ); + } +} + +class ChatAIMessageHover extends StatefulWidget { + const ChatAIMessageHover({ + super.key, + required this.child, + required this.message, + this.customMessageType, + }); + + final Widget child; + final Message message; + final bool autoShowHover = true; + final OnetimeShotType? customMessageType; + + @override + State createState() => _ChatAIMessageHoverState(); +} + +class _ChatAIMessageHoverState extends State { + bool _isHover = false; + + @override + void initState() { + super.initState(); + _isHover = widget.autoShowHover ? false : true; + } + + @override + Widget build(BuildContext context) { + final List children = [ + DecoratedBox( + decoration: const BoxDecoration( + color: Colors.transparent, + borderRadius: Corners.s6Border, + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 40), + child: widget.child, + ), + ), + ]; + + if (_isHover) { + children.addAll(_buildOnHoverItems()); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => setState(() { + if (widget.autoShowHover) { + _isHover = true; + } + }), + onExit: (p) => setState(() { + if (widget.autoShowHover) { + _isHover = false; + } + }), + child: Stack( + alignment: AlignmentDirectional.centerStart, + children: children, + ), + ); + } + + List _buildOnHoverItems() { + final List children = []; + if (widget.customMessageType != null) { + // + } else { + if (widget.message is TextMessage) { + children.add( + CopyButton( + textMessage: widget.message as TextMessage, + ).positioned(left: _leftPadding, bottom: 0), + ); + } + } + + return children; + } +} + +class CopyButton extends StatelessWidget { + const CopyButton({ + super.key, + required this.textMessage, + }); + final TextMessage textMessage; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.settings_menu_clickToCopy.tr(), + child: FlowyIconButton( + width: 24, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fillColor: Theme.of(context).cardColor, + icon: FlowySvg( + FlowySvgs.ai_copy_s, + size: const Size.square(14), + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + Clipboard.setData(ClipboardData(text: textMessage.text)); + showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart new file mode 100644 index 0000000000000..141432520bb76 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/built_in_svgs.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:string_validator/string_validator.dart'; + +class ChatChatUserAvatar extends StatelessWidget { + const ChatChatUserAvatar({required this.userId, super.key}); + + final String userId; + + @override + Widget build(BuildContext context) { + return const ChatBorderedCircleAvatar(); + } +} + +class ChatBorderedCircleAvatar extends StatelessWidget { + const ChatBorderedCircleAvatar({ + super.key, + this.border = const BorderSide(), + this.backgroundImage, + this.backgroundColor, + this.child, + }); + + final BorderSide border; + final ImageProvider? backgroundImage; + final Color? backgroundColor; + final Widget? child; + + @override + Widget build(BuildContext context) { + return CircleAvatar( + backgroundColor: border.color, + child: ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: CircleAvatar( + backgroundImage: backgroundImage, + backgroundColor: backgroundColor, + child: child, + ), + ), + ); + } +} + +class ChatUserAvatar extends StatelessWidget { + const ChatUserAvatar({ + super.key, + required this.iconUrl, + required this.name, + required this.size, + this.isHovering = false, + }); + + final String iconUrl; + final String name; + final double size; + + // If true, a border will be applied on top of the avatar + final bool isHovering; + + @override + Widget build(BuildContext context) { + if (iconUrl.isEmpty) { + return _buildEmptyAvatar(context); + } else if (isURL(iconUrl)) { + return _buildUrlAvatar(context); + } else { + return _buildEmojiAvatar(context); + } + } + + Widget _buildEmptyAvatar(BuildContext context) { + final String nameOrDefault = _userName(name); + final Color color = ColorGenerator(name).toColor(); + const initialsCount = 2; + + // Taking the first letters of the name components and limiting to 2 elements + final nameInitials = nameOrDefault + .split(' ') + .where((element) => element.isNotEmpty) + .take(initialsCount) + .map((element) => element[0].toUpperCase()) + .join(); + + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: isHovering + ? Border.all( + color: _darken(color), + width: 4, + ) + : null, + ), + child: FlowyText.regular( + nameInitials, + color: Colors.black, + ), + ); + } + + Widget _buildUrlAvatar(BuildContext context) { + return SizedBox.square( + dimension: size, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isHovering + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 4, + ) + : null, + ), + child: ClipRRect( + borderRadius: Corners.s5Border, + child: CircleAvatar( + backgroundColor: Colors.transparent, + child: Image.network( + iconUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildEmptyAvatar(context), + ), + ), + ), + ), + ); + } + + Widget _buildEmojiAvatar(BuildContext context) { + return SizedBox.square( + dimension: size, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isHovering + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 4, + ) + : null, + ), + child: ClipRRect( + borderRadius: Corners.s5Border, + child: CircleAvatar( + backgroundColor: Colors.transparent, + child: builtInSVGIcons.contains(iconUrl) + ? FlowySvg( + FlowySvgData('emoji/$iconUrl'), + blendMode: null, + ) + : FlowyText.emoji(iconUrl), + ), + ), + ), + ); + } + + /// Return the user name, if the user name is empty, + /// return the default user name. + /// + String _userName(String name) => + name.isEmpty ? LocaleKeys.defaultUsername.tr() : name; + + /// Used to darken the generated color for the hover border effect. + /// The color is darkened by 15% - Hence the 0.15 value. + /// + Color _darken(Color color) { + final hsl = HSLColor.fromColor(color); + return hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart new file mode 100644 index 0000000000000..732726b952fea --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart @@ -0,0 +1,257 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; + +class ChatInput extends StatefulWidget { + /// Creates [ChatInput] widget. + const ChatInput({ + super.key, + this.isAttachmentUploading, + this.onAttachmentPressed, + required this.onSendPressed, + required this.chatId, + this.options = const InputOptions(), + }); + + final bool? isAttachmentUploading; + final VoidCallback? onAttachmentPressed; + final void Function(types.PartialText) onSendPressed; + final InputOptions options; + final String chatId; + + @override + State createState() => _ChatInputState(); +} + +/// [ChatInput] widget state. +class _ChatInputState extends State { + late final _inputFocusNode = FocusNode( + onKeyEvent: (node, event) { + if (event.physicalKey == PhysicalKeyboardKey.enter && + !HardwareKeyboard.instance.physicalKeysPressed.any( + (el) => { + PhysicalKeyboardKey.shiftLeft, + PhysicalKeyboardKey.shiftRight, + }.contains(el), + )) { + if (kIsWeb && _textController.value.isComposingRangeValid) { + return KeyEventResult.ignored; + } + if (event is KeyDownEvent) { + _handleSendPressed(); + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + }, + ); + + bool _sendButtonVisible = false; + late TextEditingController _textController; + + @override + void initState() { + super.initState(); + + _textController = + widget.options.textEditingController ?? InputTextFieldController(); + _handleSendButtonVisibilityModeChange(); + } + + void _handleSendButtonVisibilityModeChange() { + _textController.removeListener(_handleTextControllerChange); + if (widget.options.sendButtonVisibilityMode == + SendButtonVisibilityMode.hidden) { + _sendButtonVisible = false; + } else if (widget.options.sendButtonVisibilityMode == + SendButtonVisibilityMode.editing) { + _sendButtonVisible = _textController.text.trim() != ''; + _textController.addListener(_handleTextControllerChange); + } else { + _sendButtonVisible = true; + } + } + + void _handleSendPressed() { + final trimmedText = _textController.text.trim(); + if (trimmedText != '') { + final partialText = types.PartialText(text: trimmedText); + widget.onSendPressed(partialText); + + if (widget.options.inputClearMode == InputClearMode.always) { + _textController.clear(); + } + } + } + + void _handleTextControllerChange() { + if (_textController.value.isComposingRangeValid) { + return; + } + setState(() { + _sendButtonVisible = _textController.text.trim() != ''; + }); + } + + Widget _inputBuilder() { + const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); + const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); + const inputPadding = EdgeInsets.all(6); + + return Focus( + autofocus: !widget.options.autofocus, + child: Padding( + padding: inputPadding, + child: Material( + borderRadius: BorderRadius.circular(12), + color: isMobile + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + elevation: 0.6, + child: Row( + children: [ + if (widget.onAttachmentPressed != null) + AttachmentButton( + isLoading: widget.isAttachmentUploading ?? false, + onPressed: widget.onAttachmentPressed, + padding: buttonPadding, + ), + Expanded(child: _inputTextField(textPadding)), + _sendButton(buttonPadding), + ], + ), + ), + ), + ); + } + + Padding _inputTextField(EdgeInsets textPadding) { + return Padding( + padding: textPadding, + child: TextField( + controller: _textController, + focusNode: _inputFocusNode, + decoration: InputDecoration( + border: InputBorder.none, + hintText: LocaleKeys.chat_inputMessageHint.tr(), + hintStyle: TextStyle( + color: AFThemeExtension.of(context).textColor.withOpacity(0.5), + ), + ), + style: TextStyle( + color: AFThemeExtension.of(context).textColor, + ), + enabled: widget.options.enabled, + autocorrect: widget.options.autocorrect, + autofocus: widget.options.autofocus, + enableSuggestions: widget.options.enableSuggestions, + spellCheckConfiguration: const SpellCheckConfiguration(), + keyboardType: widget.options.keyboardType, + textCapitalization: TextCapitalization.sentences, + maxLines: 10, + minLines: 1, + onChanged: widget.options.onTextChanged, + onTap: widget.options.onTextFieldTap, + ), + ); + } + + ConstrainedBox _sendButton(EdgeInsets buttonPadding) { + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: buttonPadding.bottom + buttonPadding.top + 24, + ), + child: Visibility( + visible: _sendButtonVisible, + child: SendButton( + onPressed: _handleSendPressed, + padding: buttonPadding, + ), + ), + ); + } + + @override + void didUpdateWidget(covariant ChatInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.options.sendButtonVisibilityMode != + oldWidget.options.sendButtonVisibilityMode) { + _handleSendButtonVisibilityModeChange(); + } + } + + @override + void dispose() { + _inputFocusNode.dispose(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: () => _inputFocusNode.requestFocus(), + child: _inputBuilder(), + ); +} + +@immutable +class InputOptions { + const InputOptions({ + this.inputClearMode = InputClearMode.always, + this.keyboardType = TextInputType.multiline, + this.onTextChanged, + this.onTextFieldTap, + this.sendButtonVisibilityMode = SendButtonVisibilityMode.editing, + this.textEditingController, + this.autocorrect = true, + this.autofocus = false, + this.enableSuggestions = true, + this.enabled = true, + }); + + /// Controls the [ChatInput] clear behavior. Defaults to [InputClearMode.always]. + final InputClearMode inputClearMode; + + /// Controls the [ChatInput] keyboard type. Defaults to [TextInputType.multiline]. + final TextInputType keyboardType; + + /// Will be called whenever the text inside [TextField] changes. + final void Function(String)? onTextChanged; + + /// Will be called on [TextField] tap. + final VoidCallback? onTextFieldTap; + + /// Controls the visibility behavior of the [SendButton] based on the + /// [TextField] state inside the [ChatInput] widget. + /// Defaults to [SendButtonVisibilityMode.editing]. + final SendButtonVisibilityMode sendButtonVisibilityMode; + + /// Custom [TextEditingController]. If not provided, defaults to the + /// [InputTextFieldController], which extends [TextEditingController] and has + /// additional fatures like markdown support. If you want to keep additional + /// features but still need some methods from the default [TextEditingController], + /// you can create your own [InputTextFieldController] (imported from this lib) + /// and pass it here. + final TextEditingController? textEditingController; + + /// Controls the [TextInput] autocorrect behavior. Defaults to [true]. + final bool autocorrect; + + /// Whether [TextInput] should have focus. Defaults to [false]. + final bool autofocus; + + /// Controls the [TextInput] enableSuggestions behavior. Defaults to [true]. + final bool enableSuggestions; + + /// Controls the [TextInput] enabled behavior. Defaults to [true]. + final bool enabled; +} + +final isMobile = defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_loading.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_loading.dart new file mode 100644 index 0000000000000..4b5c9843826b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_loading.dart @@ -0,0 +1,69 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +class ChatAILoading extends StatelessWidget { + const ChatAILoading({super.key}); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: AFThemeExtension.of(context).lightGreyHover, + highlightColor: + AFThemeExtension.of(context).lightGreyHover.withOpacity(0.5), + period: const Duration(seconds: 3), + child: const ContentPlaceholder(), + ); + } +} + +class ContentPlaceholder extends StatelessWidget { + const ContentPlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 30, + height: 16.0, + margin: const EdgeInsets.only(bottom: 8.0), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).lightGreyHover, + borderRadius: BorderRadius.circular(4.0), + ), + ), + const HSpace(10), + Container( + width: 100, + height: 16.0, + margin: const EdgeInsets.only(bottom: 8.0), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).lightGreyHover, + borderRadius: BorderRadius.circular(4.0), + ), + ), + ], + ), + Container( + width: 140, + height: 16.0, + margin: const EdgeInsets.only(bottom: 8.0), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).lightGreyHover, + borderRadius: BorderRadius.circular(4.0), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_popmenu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_popmenu.dart new file mode 100644 index 0000000000000..6b0b50dcca034 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_popmenu.dart @@ -0,0 +1,70 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'package:flutter/material.dart'; + +class ChatPopupMenu extends StatefulWidget { + const ChatPopupMenu({ + super.key, + required this.onAction, + required this.builder, + }); + + final Function(ChatMessageAction) onAction; + final Widget Function(BuildContext context) builder; + + @override + State createState() => _ChatPopupMenuState(); +} + +class _ChatPopupMenuState extends State { + @override + Widget build(BuildContext context) { + return PopoverActionList( + asBarrier: true, + actions: ChatMessageAction.values + .map((action) => ChatMessageActionWrapper(action)) + .toList(), + buildChild: (controller) { + return GestureDetector( + onLongPress: () { + controller.show(); + }, + child: widget.builder(context), + ); + }, + onSelected: (action, controller) async { + widget.onAction(action.inner); + controller.close(); + }, + direction: PopoverDirection.bottomWithCenterAligned, + ); + } +} + +enum ChatMessageAction { + copy, +} + +class ChatMessageActionWrapper extends ActionCell { + ChatMessageActionWrapper(this.inner); + + final ChatMessageAction inner; + + @override + Widget? leftIcon(Color iconColor) => null; + + @override + String get name => inner.name; +} + +extension ChatMessageActionExtension on ChatMessageAction { + String get name { + switch (this) { + case ChatMessageAction.copy: + return LocaleKeys.document_plugins_contextMenu_copy.tr(); + } + } +} 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 new file mode 100644 index 0000000000000..f4709f2263792 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart @@ -0,0 +1,147 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_related_question_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +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'; + +class RelatedQuestionPage extends StatefulWidget { + const RelatedQuestionPage({ + required this.chatId, + required this.onQuestionSelected, + super.key, + }); + + final String chatId; + final Function(String) onQuestionSelected; + + @override + State createState() => _RelatedQuestionPageState(); +} + +class _RelatedQuestionPageState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ChatRelatedMessageBloc(chatId: widget.chatId) + ..add( + const ChatRelatedMessageEvent.initial(), + ), + child: BlocBuilder( + builder: (blocContext, state) { + return RelatedQuestionList( + chatId: widget.chatId, + onQuestionSelected: widget.onQuestionSelected, + relatedQuestions: state.relatedQuestions, + ); + }, + ), + ); + } +} + +class RelatedQuestionList extends StatelessWidget { + const RelatedQuestionList({ + required this.chatId, + required this.onQuestionSelected, + required this.relatedQuestions, + super.key, + }); + + final String chatId; + final Function(String) onQuestionSelected; + final List relatedQuestions; + + @override + Widget build(BuildContext context) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: relatedQuestions.length, + itemBuilder: (context, index) { + final question = relatedQuestions[index]; + if (index == 0) { + return Column( + children: [ + const Divider(height: 36), + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.ai_summary_generate_s, + size: Size.square(24), + ), + const HSpace(6), + FlowyText( + LocaleKeys.chat_relatedQuestion.tr(), + fontSize: 18, + ), + ], + ), + ), + const Divider(height: 6), + RelatedQuestionItem( + question: question, + onQuestionSelected: onQuestionSelected, + ), + ], + ); + } else { + return RelatedQuestionItem( + question: question, + onQuestionSelected: onQuestionSelected, + ); + } + }, + ); + } +} + +class RelatedQuestionItem extends StatefulWidget { + const RelatedQuestionItem({ + required this.question, + required this.onQuestionSelected, + super.key, + }); + + final RelatedQuestionPB question; + final Function(String) onQuestionSelected; + + @override + State createState() => _RelatedQuestionItemState(); +} + +class _RelatedQuestionItemState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + title: Text( + widget.question.content, + style: TextStyle( + color: _isHovered ? Theme.of(context).colorScheme.primary : null, + fontSize: 14, + ), + ), + onTap: () { + widget.onQuestionSelected(widget.question.content); + }, + trailing: FlowySvg( + FlowySvgs.add_m, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_streaming_error_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_streaming_error_message.dart new file mode 100644 index 0000000000000..c0552a7e8e418 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_streaming_error_message.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; + +class ChatStreamingError extends StatelessWidget { + const ChatStreamingError({ + required this.message, + required this.onRetryPressed, + super.key, + }); + + final void Function() onRetryPressed; + final Message message; + @override + Widget build(BuildContext context) { + final canRetry = message.metadata?[canRetryKey] != null; + + if (canRetry) { + return Column( + children: [ + const Divider(height: 4, thickness: 1), + const VSpace(16), + Center( + child: Column( + children: [ + _aiUnvaliable(), + const VSpace(10), + _retryButton(), + ], + ), + ), + ], + ); + } else { + return Center( + child: Column( + children: [ + const Divider(height: 20, thickness: 1), + Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyText( + LocaleKeys.chat_serverUnavailable.tr(), + fontSize: 14, + ), + ), + ], + ), + ); + } + } + + FlowyButton _retryButton() { + return FlowyButton( + radius: BorderRadius.circular(20), + useIntrinsicWidth: true, + text: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: FlowyText( + LocaleKeys.chat_regenerateAnswer.tr(), + fontSize: 14, + ), + ), + onTap: onRetryPressed, + iconPadding: 0, + leftIcon: const Icon( + Icons.refresh, + size: 20, + ), + ); + } + + Padding _aiUnvaliable() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyText( + LocaleKeys.chat_aiServerUnavailable.tr(), + fontSize: 14, + ), + ); + } +} 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 new file mode 100644 index 0000000000000..456ac0c184391 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; + +// For internal usage only. Use values from theme itself. + +/// See [ChatTheme.userAvatarNameColors]. +const colors = [ + Color(0xffff6767), + Color(0xff66e0da), + Color(0xfff5a2d9), + Color(0xfff0c722), + Color(0xff6a85e5), + Color(0xfffd9a6f), + Color(0xff92db6e), + Color(0xff73b8e5), + Color(0xfffd7590), + Color(0xffc78ae5), +]; + +/// Dark. +const dark = Color(0xff1f1c38); + +/// Error. +const error = Color(0xffff6767); + +/// N0. +const neutral0 = Color(0xff1d1c21); + +/// N1. +const neutral1 = Color(0xff615e6e); + +/// N2. +const neutral2 = Color(0xff9e9cab); + +/// N7. +const neutral7 = Color(0xffffffff); + +/// N7 with opacity. +const neutral7WithOpacity = Color(0x80ffffff); + +/// Primary. +const primary = Color(0xff6f61e8); + +/// Secondary. +const secondary = Color(0xfff5f5f7); + +/// Secondary dark. +const secondaryDark = Color(0xff2b2250); + +/// Default chat theme which extends [ChatTheme]. +@immutable +class AFDefaultChatTheme extends ChatTheme { + /// Creates a default chat theme. Use this constructor if you want to + /// override only a couple of properties, otherwise create a new class + /// which extends [ChatTheme]. + const AFDefaultChatTheme({ + super.attachmentButtonIcon, + super.attachmentButtonMargin, + super.backgroundColor = neutral7, + super.bubbleMargin, + super.dateDividerMargin = const EdgeInsets.only( + bottom: 32, + top: 16, + ), + super.dateDividerTextStyle = const TextStyle( + color: neutral2, + fontSize: 12, + fontWeight: FontWeight.w800, + height: 1.333, + ), + super.deliveredIcon, + super.documentIcon, + super.emptyChatPlaceholderTextStyle = const TextStyle( + color: neutral2, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + super.errorColor = error, + super.errorIcon, + super.inputBackgroundColor = neutral0, + super.inputSurfaceTintColor = neutral0, + super.inputElevation = 0, + super.inputBorderRadius = const BorderRadius.vertical( + top: Radius.circular(20), + ), + super.inputContainerDecoration, + super.inputMargin = EdgeInsets.zero, + super.inputPadding = const EdgeInsets.fromLTRB(14, 20, 14, 20), + super.inputTextColor = neutral7, + super.inputTextCursorColor, + super.inputTextDecoration = const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isCollapsed: true, + ), + super.inputTextStyle = const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + super.messageBorderRadius = 20, + super.messageInsetsHorizontal = 0, + super.messageInsetsVertical = 0, + super.messageMaxWidth = 1000, + super.primaryColor = primary, + super.receivedEmojiMessageTextStyle = const TextStyle(fontSize: 40), + super.receivedMessageBodyBoldTextStyle, + super.receivedMessageBodyCodeTextStyle, + super.receivedMessageBodyLinkTextStyle, + super.receivedMessageBodyTextStyle = const TextStyle( + color: neutral0, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + super.receivedMessageCaptionTextStyle = const TextStyle( + color: neutral2, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.333, + ), + super.receivedMessageDocumentIconColor = primary, + super.receivedMessageLinkDescriptionTextStyle = const TextStyle( + color: neutral0, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.428, + ), + super.receivedMessageLinkTitleTextStyle = const TextStyle( + color: neutral0, + fontSize: 16, + fontWeight: FontWeight.w800, + height: 1.375, + ), + super.secondaryColor = secondary, + super.seenIcon, + super.sendButtonIcon, + super.sendButtonMargin, + super.sendingIcon, + super.sentEmojiMessageTextStyle = const TextStyle(fontSize: 40), + super.sentMessageBodyBoldTextStyle, + super.sentMessageBodyCodeTextStyle, + super.sentMessageBodyLinkTextStyle, + super.sentMessageBodyTextStyle = const TextStyle( + color: neutral7, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + super.sentMessageCaptionTextStyle = const TextStyle( + color: neutral7WithOpacity, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.333, + ), + super.sentMessageDocumentIconColor = neutral7, + super.sentMessageLinkDescriptionTextStyle = const TextStyle( + color: neutral7, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.428, + ), + super.sentMessageLinkTitleTextStyle = const TextStyle( + color: neutral7, + fontSize: 16, + fontWeight: FontWeight.w800, + height: 1.375, + ), + super.statusIconPadding = const EdgeInsets.symmetric(horizontal: 4), + super.systemMessageTheme = const SystemMessageTheme( + margin: EdgeInsets.only( + bottom: 24, + top: 8, + left: 8, + right: 8, + ), + textStyle: TextStyle( + color: neutral2, + fontSize: 12, + fontWeight: FontWeight.w800, + height: 1.333, + ), + ), + super.typingIndicatorTheme = const TypingIndicatorTheme( + animatedCirclesColor: neutral1, + animatedCircleSize: 5.0, + bubbleBorder: BorderRadius.all(Radius.circular(27.0)), + bubbleColor: neutral7, + countAvatarColor: primary, + countTextColor: secondary, + multipleUserTextStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: neutral2, + ), + ), + super.unreadHeaderTheme = const UnreadHeaderTheme( + color: secondary, + textStyle: TextStyle( + color: neutral2, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.333, + ), + ), + super.userAvatarImageBackgroundColor = Colors.transparent, + super.userAvatarNameColors = colors, + super.userAvatarTextStyle = const TextStyle( + color: neutral7, + fontSize: 12, + fontWeight: FontWeight.w800, + height: 1.333, + ), + super.userNameTextStyle = const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + height: 1.333, + ), + super.highlightMessageColor, + }); +} 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 new file mode 100644 index 0000000000000..8ae9b91d32a31 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart @@ -0,0 +1,31 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; + +class ChatInvalidUserMessage extends StatelessWidget { + const ChatInvalidUserMessage({ + required this.message, + super.key, + }); + + final Message message; + @override + Widget build(BuildContext context) { + final errorMessage = message.metadata?[sendMessageErrorKey] ?? ""; + return Center( + child: Column( + children: [ + const Divider(height: 20, thickness: 1), + Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyText( + errorMessage, + fontSize: 14, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_message.dart new file mode 100644 index 0000000000000..f6e55b80905f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_message.dart @@ -0,0 +1,169 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class ChatUserMessageBubble extends StatelessWidget { + const ChatUserMessageBubble({ + super.key, + required this.message, + required this.child, + }); + + final Message message; + final Widget child; + + @override + Widget build(BuildContext context) { + const borderRadius = BorderRadius.all(Radius.circular(6)); + final backgroundColor = Theme.of(context).colorScheme.secondary; + + return BlocProvider( + create: (context) => ChatUserMessageBloc(message: message), + child: BlocBuilder( + builder: (context, state) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // _wrapHover( + Flexible( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: backgroundColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: child, + ), + ), + ), + // ), + BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ChatUserAvatar( + iconUrl: state.member?.avatarUrl ?? "", + name: state.member?.name ?? "", + size: 36, + ), + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class ChatUserMessageHover extends StatefulWidget { + const ChatUserMessageHover({ + super.key, + required this.child, + required this.message, + }); + + final Widget child; + final Message message; + final bool autoShowHover = true; + + @override + State createState() => _ChatUserMessageHoverState(); +} + +class _ChatUserMessageHoverState extends State { + bool _isHover = false; + + @override + void initState() { + super.initState(); + _isHover = widget.autoShowHover ? false : true; + } + + @override + Widget build(BuildContext context) { + final List children = [ + DecoratedBox( + decoration: const BoxDecoration( + color: Colors.transparent, + borderRadius: Corners.s6Border, + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 30), + child: widget.child, + ), + ), + ]; + + if (_isHover) { + if (widget.message is TextMessage) { + children.add( + EditButton( + textMessage: widget.message as TextMessage, + ).positioned(right: 0, bottom: 0), + ); + } + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => setState(() { + if (widget.autoShowHover) { + _isHover = true; + } + }), + onExit: (p) => setState(() { + if (widget.autoShowHover) { + _isHover = false; + } + }), + child: Stack( + alignment: AlignmentDirectional.centerStart, + children: children, + ), + ); + } +} + +class EditButton extends StatelessWidget { + const EditButton({ + super.key, + required this.textMessage, + }); + final TextMessage textMessage; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.settings_menu_clickToCopy.tr(), + child: FlowyIconButton( + width: 24, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fillColor: Theme.of(context).cardColor, + icon: FlowySvg( + FlowySvgs.ai_copy_s, + size: const Size.square(14), + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () {}, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart new file mode 100644 index 0000000000000..e6fc55a15a130 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class ChatWelcomePage extends StatelessWidget { + const ChatWelcomePage({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/blank/blank.dart b/frontend/appflowy_flutter/lib/plugins/blank/blank.dart index 9e4a6ba3735c9..1aec6b7037f6a 100644 --- a/frontend/appflowy_flutter/lib/plugins/blank/blank.dart +++ b/frontend/appflowy_flutter/lib/plugins/blank/blank.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; @@ -20,6 +21,9 @@ class BlankPluginBuilder extends PluginBuilder { @override PluginType get pluginType => PluginType.blank; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class BlankPluginConfig implements PluginConfig { @@ -47,7 +51,10 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder Widget tabBarItem(String pluginId) => leftBarItem; @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) => + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + }) => const BlankPage(); @override diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/board.dart b/frontend/appflowy_flutter/lib/plugins/database/board/board.dart index fb267220beebf..86851c4bbbb7f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/board.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/board.dart @@ -25,7 +25,7 @@ class BoardPluginBuilder implements PluginBuilder { PluginType get pluginType => PluginType.board; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Board; + ViewLayoutPB get layoutType => ViewLayoutPB.Board; } class BoardPluginConfig implements PluginConfig { diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart index 099714c3046a6..861d9f43039d0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart @@ -25,7 +25,7 @@ class CalendarPluginBuilder extends PluginBuilder { PluginType get pluginType => PluginType.calendar; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Calendar; + ViewLayoutPB get layoutType => ViewLayoutPB.Calendar; } class CalendarPluginConfig implements PluginConfig { diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart index 3a10835489f28..1f8816d92b329 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart @@ -25,7 +25,7 @@ class GridPluginBuilder implements PluginBuilder { PluginType get pluginType => PluginType.grid; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Grid; + ViewLayoutPB get layoutType => ViewLayoutPB.Grid; } class GridPluginConfig implements PluginConfig { diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart index 787e08760b0c6..aec6d954b412c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart @@ -243,11 +243,14 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + }) { notifier.isDeleted.addListener(() { final deletedView = notifier.isDeleted.value; if (deletedView != null && deletedView.hasIndex()) { - context?.onDeleted(notifier.view, deletedView.index); + context.onDeleted?.call(notifier.view, deletedView.index); } }); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart index 97e13a626aab0..ee64eb84af439 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart @@ -54,6 +54,7 @@ class _DatabaseViewWidgetState extends State { valueListenable: _layoutTypeChangeNotifier, builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget( shrinkWrap: widget.shrinkWrap, + context: PluginContext(), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart index ce3668b43d084..cd709e21544e8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart @@ -48,6 +48,9 @@ class DatabaseDocumentPluginBuilder extends PluginBuilder { @override PluginType get pluginType => PluginType.databaseDocument; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class DatabaseDocumentPlugin extends Plugin { @@ -98,7 +101,10 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder EdgeInsets get contentPadding => EdgeInsets.zero; @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + }) { return BlocBuilder( builder: (_, state) => DatabaseDocumentPage( key: ValueKey(documentId), diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index bd9e4891d297c..cb48cd1bba255 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -43,7 +43,7 @@ class DocumentPluginBuilder extends PluginBuilder { PluginType get pluginType => PluginType.document; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Document; + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class DocumentPlugin extends Plugin { @@ -107,7 +107,10 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder EdgeInsets get contentPadding => EdgeInsets.zero; @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + }) { notifier.isDeleted.addListener(() { final deletedView = notifier.isDeleted.value; if (deletedView != null && deletedView.hasIndex()) { @@ -121,7 +124,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder builder: (_, state) => DocumentPage( key: ValueKey(view.id), view: view, - onDeleted: () => context?.onDeleted(view, deletedViewIndex), + onDeleted: () => context.onDeleted?.call(view, deletedViewIndex), initialSelection: initialSelection, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart index a25609edd3573..e2f312373e9a4 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/trash.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart @@ -5,6 +5,7 @@ export "./src/trash_header.dart"; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; @@ -26,6 +27,9 @@ class TrashPluginBuilder extends PluginBuilder { @override PluginType get pluginType => PluginType.trash; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class TrashPluginConfig implements PluginConfig { @@ -59,7 +63,10 @@ class TrashPluginDisplay extends PluginWidgetBuilder { Widget? get rightBarItem => null; @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) => + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + }) => const TrashPage( key: ValueKey('TrashPage'), ); diff --git a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart index e675cf1459e65..b81860cd99c30 100644 --- a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart +++ b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart @@ -1,5 +1,6 @@ library flowy_plugin; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flutter/widgets.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -18,6 +19,7 @@ enum PluginType { board, calendar, databaseDocument, + chat, } typedef PluginId = String; @@ -57,7 +59,7 @@ abstract class PluginBuilder { /// The layoutType is used in the backend to determine the layout of the view. /// Currently, AppFlowy supports 4 layout types: Document, Grid, Board, Calendar. - ViewLayoutPB? get layoutType => ViewLayoutPB.Document; + ViewLayoutPB? get layoutType; } abstract class PluginConfig { @@ -71,14 +73,21 @@ abstract class PluginWidgetBuilder with NavigationItem { EdgeInsets get contentPadding => const EdgeInsets.symmetric(horizontal: 40, vertical: 28); - Widget buildWidget({PluginContext? context, required bool shrinkWrap}); + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + }); } class PluginContext { - PluginContext({required this.onDeleted}); + PluginContext({ + this.userProfile, + this.onDeleted, + }); // calls when widget of the plugin get deleted - final Function(ViewPB, int?) onDeleted; + final Function(ViewPB, int?)? onDeleted; + final UserProfilePB? userProfile; } void registerPlugin({required PluginBuilder builder, PluginConfig? config}) { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 6f1a338c3f593..65096984bd83a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; @@ -61,6 +62,7 @@ GoRouter generateRouter(Widget child) { _mobileGridScreenRoute(), _mobileBoardScreenRoute(), _mobileCalendarScreenRoute(), + _mobileChatScreenRoute(), // card detail page _mobileCardDetailScreenRoute(), _mobileDateCellEditScreenRoute(), @@ -488,6 +490,21 @@ GoRoute _mobileEditorScreenRoute() { ); } +GoRoute _mobileChatScreenRoute() { + return GoRoute( + path: MobileChatScreen.routeName, + parentNavigatorKey: AppGlobals.rootNavKey, + pageBuilder: (context, state) { + final id = state.uri.queryParameters[MobileChatScreen.viewId]!; + final title = state.uri.queryParameters[MobileChatScreen.viewTitle]; + + return MaterialExtendedPage( + child: MobileChatScreen(id: id, title: title), + ); + }, + ); +} + GoRoute _mobileGridScreenRoute() { return GoRoute( path: MobileGridScreen.routeName, diff --git a/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart b/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart index 3899959b027d7..9a75607d74270 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/ai_chat/chat.dart'; import 'package:appflowy/plugins/database/calendar/calendar.dart'; import 'package:appflowy/plugins/database/board/board.dart'; import 'package:appflowy/plugins/database/grid/grid.dart'; @@ -29,6 +30,14 @@ class PluginLoadTask extends LaunchTask { builder: DatabaseDocumentPluginBuilder(), config: DatabaseDocumentPluginConfig(), ); + registerPlugin( + builder: DatabaseDocumentPluginBuilder(), + config: DatabaseDocumentPluginConfig(), + ); + registerPlugin( + builder: AIChatPluginBuilder(), + config: AIChatPluginConfig(), + ); } @override diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index c845b2ddcc6e2..4835c795ba62e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/chat.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; @@ -44,6 +45,7 @@ extension ViewExtension on ViewPB { ViewLayoutPB.Calendar => FlowySvgs.calendar_s, ViewLayoutPB.Grid => FlowySvgs.grid_s, ViewLayoutPB.Document => FlowySvgs.document_s, + ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, _ => FlowySvgs.document_s, }, ); @@ -53,6 +55,7 @@ extension ViewExtension on ViewPB { ViewLayoutPB.Calendar => PluginType.calendar, ViewLayoutPB.Document => PluginType.document, ViewLayoutPB.Grid => PluginType.grid, + ViewLayoutPB.Chat => PluginType.chat, _ => throw UnimplementedError(), }; @@ -79,6 +82,8 @@ extension ViewExtension on ViewPB { pluginType: pluginType, initialSelection: initialSelection, ); + case ViewLayoutPB.Chat: + return AIChatPagePlugin(view: this); } throw UnimplementedError; } @@ -161,11 +166,13 @@ extension ViewLayoutExtension on ViewLayoutPB { ViewLayoutPB.Board => FlowySvgs.board_s, ViewLayoutPB.Calendar => FlowySvgs.date_s, ViewLayoutPB.Document => FlowySvgs.document_s, + ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, _ => throw Exception('Unknown layout type'), }; bool get isDocumentView => switch (this) { ViewLayoutPB.Document => true, + ViewLayoutPB.Chat || ViewLayoutPB.Grid || ViewLayoutPB.Board || ViewLayoutPB.Calendar => @@ -178,7 +185,7 @@ extension ViewLayoutExtension on ViewLayoutPB { ViewLayoutPB.Board || ViewLayoutPB.Calendar => true, - ViewLayoutPB.Document => false, + ViewLayoutPB.Document || ViewLayoutPB.Chat => false, _ => throw Exception('Unknown layout type'), }; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart index 6fce01aabc37e..5db9efb251028 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -153,6 +153,7 @@ class DesktopHomeScreen extends StatelessWidget { final homeStack = HomeStack( layout: layout, delegate: DesktopHomeScreenStackAdaptor(context), + userProfile: userProfile, ); final menu = _buildHomeSidebar( context, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart index 8fdfb9bb44d3b..405360f55b7a9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; @@ -29,10 +30,12 @@ class HomeStack extends StatelessWidget { super.key, required this.delegate, required this.layout, + required this.userProfile, }); final HomeStackDelegate delegate; final HomeLayout layout; + final UserProfilePB userProfile; @override Widget build(BuildContext context) { @@ -55,7 +58,11 @@ class HomeStack extends StatelessWidget { controller: pageController, children: state.pageManagers .map( - (pm) => PageStack(pageManager: pm, delegate: delegate), + (pm) => PageStack( + pageManager: pm, + delegate: delegate, + userProfile: userProfile, + ), ) .toList(), ), @@ -73,11 +80,13 @@ class PageStack extends StatefulWidget { super.key, required this.pageManager, required this.delegate, + required this.userProfile, }); final PageManager pageManager; final HomeStackDelegate delegate; + final UserProfilePB userProfile; @override State createState() => _PageStackState(); @@ -93,6 +102,7 @@ class _PageStackState extends State color: Theme.of(context).colorScheme.surface, child: FocusTraversalGroup( child: widget.pageManager.stackWidget( + userProfile: widget.userProfile, onDeleted: (view, index) { widget.delegate.didDeleteStackWidget(view, index); }, @@ -227,7 +237,10 @@ class PageManager { ); } - Widget stackWidget({required Function(ViewPB, int?) onDeleted}) { + Widget stackWidget({ + required UserProfilePB userProfile, + required Function(ViewPB, int?) onDeleted, + }) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: _notifier)], child: Consumer( @@ -239,7 +252,10 @@ class PageManager { if (pluginType == notifier.plugin.pluginType) { final builder = notifier.plugin.widgetBuilder; final pluginWidget = builder.buildWidget( - context: PluginContext(onDeleted: onDeleted), + context: PluginContext( + onDeleted: onDeleted, + userProfile: userProfile, + ), shrinkWrap: false, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart index 88aa1327e291c..8c9d8004703b1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart @@ -2,7 +2,9 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class MenuSharedState { - MenuSharedState({ViewPB? view}) { + MenuSharedState({ + ViewPB? view, + }) { _latestOpenView.value = view; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index a6c88b3059484..1e58b7703fff4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -681,6 +681,8 @@ class _SingleInnerViewItemState extends State { return LocaleKeys.newBoardText.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.newCalendarText.tr(); + case ViewLayoutPB.Chat: + return LocaleKeys.chat_newChat.tr(); } return LocaleKeys.newPageText.tr(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart index 68d179da50709..57730ada05d20 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart @@ -63,31 +63,48 @@ class ViewMoreActionButton extends StatelessWidget { List _buildActionTypes() { final List actionTypes = []; - switch (spaceType) { - case FolderSpaceType.favorite: - actionTypes.addAll([ - ViewMoreActionType.unFavorite, - ViewMoreActionType.divider, - ViewMoreActionType.rename, - ViewMoreActionType.openInNewTab, - ]); - break; - default: + + if (spaceType == FolderSpaceType.favorite) { + actionTypes.addAll([ + ViewMoreActionType.unFavorite, + ViewMoreActionType.divider, + ViewMoreActionType.rename, + ViewMoreActionType.openInNewTab, + ]); + } else { + actionTypes.add( + view.isFavorite + ? ViewMoreActionType.unFavorite + : ViewMoreActionType.favorite, + ); + + actionTypes.addAll([ + ViewMoreActionType.divider, + ViewMoreActionType.rename, + ]); + + // Chat doesn't change icon and duplicate + if (view.layout != ViewLayoutPB.Chat) { actionTypes.addAll([ - view.isFavorite - ? ViewMoreActionType.unFavorite - : ViewMoreActionType.favorite, - ViewMoreActionType.divider, - ViewMoreActionType.rename, ViewMoreActionType.changeIcon, ViewMoreActionType.duplicate, - ViewMoreActionType.delete, - ViewMoreActionType.divider, - ViewMoreActionType.collapseAllPages, - ViewMoreActionType.divider, - ViewMoreActionType.openInNewTab, ]); + } + + actionTypes.addAll([ + ViewMoreActionType.delete, + ViewMoreActionType.divider, + ]); + + // Chat doesn't change collapse + if (view.layout != ViewLayoutPB.Chat) { + actionTypes.add(ViewMoreActionType.collapseAllPages); + actionTypes.add(ViewMoreActionType.divider); + } + + actionTypes.add(ViewMoreActionType.openInNewTab); } + return actionTypes; } } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart index 21ace45b28fa2..cd95941a15261 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -16,6 +16,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-search/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-chat/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:ffi/ffi.dart'; import 'package:isolates/isolates.dart'; @@ -36,6 +37,7 @@ part 'dart_event/flowy-document/dart_event.dart'; part 'dart_event/flowy-config/dart_event.dart'; part 'dart_event/flowy-date/dart_event.dart'; part 'dart_event/flowy-search/dart_event.dart'; +part 'dart_event/flowy-chat/dart_event.dart'; enum FFIException { RequestIsEmpty, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index acd628a22204f..e99ee90f56a13 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -106,7 +106,7 @@ class FlowyButton extends StatelessWidget { } if (rightIcon != null) { - children.add(const HSpace(6)); + children.add(HSpace(iconPadding)); // No need to define the size of rightIcon. Just use its intrinsic width children.add(rightIcon!); } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index e4ddf13bfca75..2ee1b5eb065cb 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -393,6 +393,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + diffutil_dart: + dependency: transitive + description: + name: diffutil_dart + sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" + url: "https://pub.dev" + source: hosted + version: "4.0.1" dotted_border: dependency: "direct main" description: @@ -595,13 +603,21 @@ packages: source: git version: "3.3.1" flutter_chat_types: - dependency: transitive + dependency: "direct main" description: name: flutter_chat_types sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 url: "https://pub.dev" source: hosted version: "3.6.2" + flutter_chat_ui: + dependency: "direct main" + description: + name: flutter_chat_ui + sha256: "40fb37acc328dd179eadc3d67bf8bd2d950dc0da34464aa8d48e8707e0234c09" + url: "https://pub.dev" + source: hosted + version: "1.6.13" flutter_colorpicker: dependency: "direct main" description: @@ -661,6 +677,14 @@ 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: @@ -1473,6 +1497,14 @@ 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: @@ -1689,6 +1721,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.9" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" scrollable_positioned_list: dependency: "direct main" description: @@ -1810,6 +1850,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" simple_gesture_detector: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 22382d9e48524..92e82d0c193fa 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -135,9 +135,12 @@ dependencies: numerus: ^2.1.2 flutter_animate: ^4.5.0 permission_handler: ^11.3.1 + flutter_chat_ui: ^1.6.13 + flutter_chat_types: ^3.6.2 scaled_app: ^2.3.0 auto_size_text_field: ^2.2.3 reorderable_tabbar: ^1.0.6 + shimmer: ^3.0.0 dev_dependencies: flutter_lints: ^3.0.1 diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart index abff7256b2fed..e29c6a4a7345e 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart @@ -29,7 +29,7 @@ class AppFlowyBoardTest { return ViewBackendService.createView( parentViewId: app.id, name: "Test Board", - layoutType: builder.layoutType!, + layoutType: builder.layoutType, openAfterCreate: true, ).then((result) { return result.fold( diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart index b816a8b68e22d..d6d0351414408 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart @@ -213,6 +213,9 @@ void main() { const layouts = ViewLayoutPB.values; for (var i = 0; i < layouts.length; i++) { final layout = layouts[i]; + if (layout == ViewLayoutPB.Chat) { + continue; + } viewBloc.add( ViewEvent.createView( 'Test $layout', diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 2a1c42eaa10c1..d02983e577319 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -155,14 +155,14 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -179,12 +179,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "appflowy-ai-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" +dependencies = [ + "anyhow", + "bytes", + "futures", + "serde", + "serde_json", + "serde_repr", + "thiserror", +] + [[package]] name = "appflowy_tauri" version = "0.0.0" dependencies = [ "bytes", "dotenv", + "flowy-chat", "flowy-config", "flowy-core", "flowy-date", @@ -194,6 +209,7 @@ dependencies = [ "flowy-search", "flowy-user", "lib-dispatch", + "semver", "serde", "serde_json", "tauri", @@ -529,9 +545,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" dependencies = [ "serde", ] @@ -740,7 +756,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "again", "anyhow", @@ -770,6 +786,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "serde_urlencoded", "shared-entity", "thiserror", "tokio", @@ -786,7 +803,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "futures-channel", "futures-util", @@ -860,7 +877,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-trait", @@ -884,7 +901,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-trait", @@ -914,7 +931,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -933,7 +950,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "bytes", @@ -948,7 +965,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "chrono", @@ -986,7 +1003,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-stream", @@ -1025,7 +1042,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -1050,7 +1067,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "async-trait", @@ -1067,7 +1084,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -1296,7 +1313,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.6", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1407,7 +1424,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -1485,6 +1502,7 @@ dependencies = [ "diesel_derives", "libsqlite3-sys", "r2d2", + "serde_json", "time", ] @@ -1794,6 +1812,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "flowy-chat" +version = "0.1.0" +dependencies = [ + "bytes", + "dashmap", + "flowy-chat-pub", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "futures", + "lib-dispatch", + "lib-infra", + "protobuf", + "strum_macros 0.21.1", + "tokio", + "tracing", + "uuid", + "validator", +] + +[[package]] +name = "flowy-chat-pub" +version = "0.1.0" +dependencies = [ + "client-api", + "flowy-error", + "futures", + "lib-infra", +] + [[package]] name = "flowy-codegen" version = "0.1.0" @@ -1845,6 +1896,8 @@ dependencies = [ "collab-integrate", "collab-plugins", "diesel", + "flowy-chat", + "flowy-chat-pub", "flowy-config", "flowy-database-pub", "flowy-database2", @@ -2175,6 +2228,7 @@ dependencies = [ "collab-entity", "collab-folder", "collab-plugins", + "flowy-chat-pub", "flowy-database-pub", "flowy-document-pub", "flowy-encrypt", @@ -2195,6 +2249,7 @@ dependencies = [ "postgrest", "rand 0.8.5", "reqwest", + "semver", "serde", "serde_json", "thiserror", @@ -2777,7 +2832,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "futures-util", @@ -2794,7 +2849,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -3226,7 +3281,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "reqwest", @@ -4729,7 +4784,7 @@ checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2" dependencies = [ "bytes", "heck 0.4.1", - "itertools 0.11.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -4750,7 +4805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.47", @@ -5524,27 +5579,27 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" dependencies = [ "serde", ] [[package]] name = "serde" -version = "1.0.195" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.195" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", @@ -5714,13 +5769,15 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", + "appflowy-ai-client", "chrono", "collab-entity", "database-entity", + "futures", "gotrue-entity", "reqwest", "serde", @@ -6566,18 +6623,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" [[package]] name = "thiserror" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index 6b3a6710773c5..9cfcf94740024 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2"] } +diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -52,7 +52,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ef96b42e43c7b929a928f6c334967c7edffc1319" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "b36715dc2427e23a15fa81c629e1817d3dbf1e1a" } [dependencies] serde_json.workspace = true @@ -75,6 +75,7 @@ flowy-core = { path = "../../rust-lib/flowy-core", features = [ flowy-user = { path = "../../rust-lib/flowy-user", features = ["tauri_ts"] } flowy-config = { path = "../../rust-lib/flowy-config", features = ["tauri_ts"] } flowy-date = { path = "../../rust-lib/flowy-date", features = ["tauri_ts"] } +flowy-chat = { path = "../../rust-lib/flowy-chat", features = ["tauri_ts"] } flowy-error = { path = "../../rust-lib/flowy-error", features = [ "impl_from_sqlite", "impl_from_dispatch_error", @@ -105,10 +106,10 @@ default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] [patch.crates-io] -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } \ No newline at end of file +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } \ No newline at end of file diff --git a/frontend/appflowy_web/wasm-libs/Cargo.lock b/frontend/appflowy_web/wasm-libs/Cargo.lock index 8024a9ba1fe39..0ed88408715ca 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.lock +++ b/frontend/appflowy_web/wasm-libs/Cargo.lock @@ -209,14 +209,14 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -233,6 +233,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "appflowy-ai-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" +dependencies = [ + "anyhow", + "bytes", + "futures", + "serde", + "serde_json", + "serde_repr", + "thiserror", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -428,9 +442,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" dependencies = [ "serde", ] @@ -548,7 +562,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "again", "anyhow", @@ -578,6 +592,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "serde_urlencoded", "shared-entity", "thiserror", "tokio", @@ -594,7 +609,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "futures-channel", "futures-util", @@ -638,7 +653,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2c430e0#2c430e05fff6ca541cfef9836dc72e66a7847b6e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-trait", @@ -662,7 +677,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2c430e0#2c430e05fff6ca541cfef9836dc72e66a7847b6e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -681,7 +696,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2c430e0#2c430e05fff6ca541cfef9836dc72e66a7847b6e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "bytes", @@ -696,7 +711,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2c430e0#2c430e05fff6ca541cfef9836dc72e66a7847b6e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "chrono", @@ -734,7 +749,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2c430e0#2c430e05fff6ca541cfef9836dc72e66a7847b6e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-stream", @@ -772,7 +787,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -797,7 +812,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "async-trait", @@ -814,7 +829,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2c430e0#2c430e05fff6ca541cfef9836dc72e66a7847b6e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -1011,7 +1026,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -1241,6 +1256,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "flowy-chat-pub" +version = "0.1.0" +dependencies = [ + "client-api", + "flowy-error", + "futures", + "lib-infra", +] + [[package]] name = "flowy-codegen" version = "0.1.0" @@ -1463,6 +1488,7 @@ dependencies = [ "collab-entity", "collab-folder", "collab-plugins", + "flowy-chat-pub", "flowy-database-pub", "flowy-document-pub", "flowy-encrypt", @@ -1483,6 +1509,7 @@ dependencies = [ "postgrest", "rand 0.8.5", "reqwest", + "semver", "serde", "serde_json", "thiserror", @@ -1788,7 +1815,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "futures-util", @@ -1805,7 +1832,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -2106,7 +2133,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "reqwest", @@ -3617,15 +3644,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.195" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" dependencies = [ "serde_derive", ] @@ -3643,9 +3670,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.195" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" dependencies = [ "proc-macro2", "quote", @@ -3746,13 +3773,15 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", + "appflowy-ai-client", "chrono", "collab-entity", "database-entity", + "futures", "gotrue-entity", "reqwest", "serde", @@ -4033,18 +4062,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", @@ -4235,6 +4264,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5009,4 +5039,4 @@ dependencies = [ [[patch.unused]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2c430e0#2c430e05fff6ca541cfef9836dc72e66a7847b6e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" diff --git a/frontend/appflowy_web/wasm-libs/Cargo.toml b/frontend/appflowy_web/wasm-libs/Cargo.toml index e6637b4d31a33..01fc3fad33104 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.toml +++ b/frontend/appflowy_web/wasm-libs/Cargo.toml @@ -55,7 +55,7 @@ yrs = "0.18.8" # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ef96b42e43c7b929a928f6c334967c7edffc1319" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "b36715dc2427e23a15fa81c629e1817d3dbf1e1a" } @@ -70,10 +70,10 @@ opt-level = 3 codegen-units = 1 [patch.crates-io] -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } \ No newline at end of file +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.lock b/frontend/appflowy_web_app/src-tauri/Cargo.lock index 2bb44433edf77..23252b551c38d 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.lock +++ b/frontend/appflowy_web_app/src-tauri/Cargo.lock @@ -153,7 +153,7 @@ checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -170,12 +170,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "appflowy-ai-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" +dependencies = [ + "anyhow", + "bytes", + "futures", + "serde", + "serde_json", + "serde_repr", + "thiserror", +] + [[package]] name = "appflowy_tauri" version = "0.0.0" dependencies = [ "bytes", "dotenv", + "flowy-chat", "flowy-config", "flowy-core", "flowy-date", @@ -184,6 +199,7 @@ dependencies = [ "flowy-notification", "flowy-user", "lib-dispatch", + "semver", "serde", "serde_json", "tauri", @@ -714,7 +730,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "again", "anyhow", @@ -744,6 +760,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "serde_urlencoded", "shared-entity", "thiserror", "tokio", @@ -760,7 +777,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "futures-channel", "futures-util", @@ -843,7 +860,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-trait", @@ -867,7 +884,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-trait", @@ -897,7 +914,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -916,7 +933,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "bytes", @@ -931,7 +948,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "chrono", @@ -969,7 +986,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-stream", @@ -1008,7 +1025,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -1033,7 +1050,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "async-trait", @@ -1050,7 +1067,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -1283,7 +1300,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.10", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1394,7 +1411,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -1493,6 +1510,7 @@ dependencies = [ "diesel_derives", "libsqlite3-sys", "r2d2", + "serde_json", "time", ] @@ -1831,6 +1849,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "flowy-chat" +version = "0.1.0" +dependencies = [ + "bytes", + "dashmap", + "flowy-chat-pub", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "futures", + "lib-dispatch", + "lib-infra", + "protobuf", + "strum_macros 0.21.1", + "tokio", + "tracing", + "uuid", + "validator", +] + +[[package]] +name = "flowy-chat-pub" +version = "0.1.0" +dependencies = [ + "client-api", + "flowy-error", + "futures", + "lib-infra", +] + [[package]] name = "flowy-codegen" version = "0.1.0" @@ -1882,6 +1933,8 @@ dependencies = [ "collab-integrate", "collab-plugins", "diesel", + "flowy-chat", + "flowy-chat-pub", "flowy-config", "flowy-database-pub", "flowy-database2", @@ -2212,6 +2265,7 @@ dependencies = [ "collab-entity", "collab-folder", "collab-plugins", + "flowy-chat-pub", "flowy-database-pub", "flowy-document-pub", "flowy-encrypt", @@ -2232,6 +2286,7 @@ dependencies = [ "postgrest", "rand 0.8.5", "reqwest", + "semver", "serde", "serde_json", "thiserror", @@ -2851,7 +2906,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "futures-util", @@ -2868,7 +2923,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -3305,7 +3360,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "reqwest", @@ -4810,7 +4865,7 @@ checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2" dependencies = [ "bytes", "heck 0.4.1", - "itertools 0.11.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -4831,7 +4886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.55", @@ -5616,27 +5671,27 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" dependencies = [ "serde", ] [[package]] name = "serde" -version = "1.0.197" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" dependencies = [ "proc-macro2", "quote", @@ -5809,13 +5864,15 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", + "appflowy-ai-client", "chrono", "collab-entity", "database-entity", + "futures", "gotrue-entity", "reqwest", "serde", diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 54e09acf6c172..9db2fea415970 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2"] } +diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -52,7 +52,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ef96b42e43c7b929a928f6c334967c7edffc1319" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "b36715dc2427e23a15fa81c629e1817d3dbf1e1a" } [dependencies] serde_json.workspace = true @@ -89,6 +89,9 @@ flowy-document = { path = "../../rust-lib/flowy-document", features = [ flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ "tauri_ts", ] } +flowy-chat = { path = "../../rust-lib/flowy-chat", features = [ + "tauri_ts", +] } uuid = "1.5.0" tauri-plugin-deep-link = "0.1.2" @@ -104,10 +107,10 @@ default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] [patch.crates-io] -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } \ No newline at end of file +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/chat_ai_page.svg b/frontend/resources/flowy_icons/16x/chat_ai_page.svg new file mode 100644 index 0000000000000..e39815f94f483 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/chat_ai_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/flowy_ai_chat_logo.svg b/frontend/resources/flowy_icons/16x/flowy_ai_chat_logo.svg new file mode 100644 index 0000000000000..a719222956504 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/flowy_ai_chat_logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index b5f2bae5a0a5a..a56b6ff9b385e 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -147,6 +147,17 @@ "newGridText": "New grid", "newCalendarText": "New calendar", "newBoardText": "New board", + "chat": { + "newChat": "New chat", + "inputMessageHint": "Message AppFlowy AI", + "unsupportedCloudPrompt": "This feature is only available when using AppFlowy Cloud", + "relatedQuestion": "Related", + "serverUnavailable": "Service Temporarily Unavailable. Please try again later.", + "aiServerUnavailable": "There was an error generating a response.", + "clickToRetry": "Click to retry", + "regenerateAnswer": "Regenerate", + "aiMistakePrompt": "AI can make mistakes. Check important info." + }, "trash": { "text": "Trash", "restoreAll": "Restore All", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index d9a9d047e5fd5..9a48c6db270e3 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -156,14 +156,14 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -180,6 +180,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "appflowy-ai-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" +dependencies = [ + "anyhow", + "bytes", + "futures", + "serde", + "serde_json", + "serde_repr", + "thiserror", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -530,9 +544,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" dependencies = [ "serde", ] @@ -650,7 +664,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "again", "anyhow", @@ -680,6 +694,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "serde_urlencoded", "shared-entity", "thiserror", "tokio", @@ -696,7 +711,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "futures-channel", "futures-util", @@ -739,7 +754,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-trait", @@ -763,7 +778,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-trait", @@ -793,7 +808,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -812,7 +827,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "bytes", @@ -827,7 +842,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "chrono", @@ -865,7 +880,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "async-stream", @@ -904,7 +919,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "bincode", @@ -929,7 +944,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "async-trait", @@ -946,7 +961,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=0b52164#0b521646f5d609abe11a213ab063402c32496e9b" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=cca5135f0010fa5de22a298cbed939e21575538c#cca5135f0010fa5de22a298cbed939e21575538c" dependencies = [ "anyhow", "collab", @@ -1149,7 +1164,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1249,7 +1264,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -1340,6 +1355,7 @@ dependencies = [ "diesel_derives", "libsqlite3-sys", "r2d2", + "serde_json", "time", ] @@ -1487,6 +1503,8 @@ dependencies = [ "collab-folder", "collab-plugins", "dotenv", + "flowy-chat", + "flowy-chat-pub", "flowy-core", "flowy-database-pub", "flowy-database2", @@ -1629,6 +1647,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "flowy-chat" +version = "0.1.0" +dependencies = [ + "bytes", + "dashmap", + "flowy-chat-pub", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "futures", + "lib-dispatch", + "lib-infra", + "protobuf", + "strum_macros 0.21.1", + "tokio", + "tracing", + "uuid", + "validator", +] + +[[package]] +name = "flowy-chat-pub" +version = "0.1.0" +dependencies = [ + "client-api", + "flowy-error", + "futures", + "lib-infra", +] + [[package]] name = "flowy-codegen" version = "0.1.0" @@ -1681,6 +1732,8 @@ dependencies = [ "collab-plugins", "console-subscriber", "diesel", + "flowy-chat", + "flowy-chat-pub", "flowy-config", "flowy-database-pub", "flowy-database2", @@ -2017,6 +2070,7 @@ dependencies = [ "collab-folder", "collab-plugins", "dotenv", + "flowy-chat-pub", "flowy-database-pub", "flowy-document-pub", "flowy-encrypt", @@ -2464,7 +2518,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "futures-util", @@ -2481,7 +2535,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", @@ -2846,7 +2900,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "reqwest", @@ -3721,7 +3775,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -3741,7 +3795,6 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_macros 0.11.2", "phf_shared 0.11.2", ] @@ -3809,19 +3862,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" -dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", - "proc-macro2", - "quote", - "syn 2.0.47", -] - [[package]] name = "phf_shared" version = "0.8.0" @@ -4025,7 +4065,7 @@ checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2" dependencies = [ "bytes", "heck 0.4.1", - "itertools 0.11.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -4046,7 +4086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.47", @@ -4809,18 +4849,18 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.195" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.195" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" dependencies = [ "proc-macro2", "quote", @@ -4943,13 +4983,15 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef96b42e43c7b929a928f6c334967c7edffc1319#ef96b42e43c7b929a928f6c334967c7edffc1319" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=b36715dc2427e23a15fa81c629e1817d3dbf1e1a#b36715dc2427e23a15fa81c629e1817d3dbf1e1a" dependencies = [ "anyhow", "app-error", + "appflowy-ai-client", "chrono", "collab-entity", "database-entity", + "futures", "gotrue-entity", "reqwest", "serde", @@ -5454,18 +5496,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index bb728e820b102..c793b3ec4616e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -29,6 +29,7 @@ members = [ "build-tool/flowy-codegen", "build-tool/flowy-derive", "flowy-search-pub", + "flowy-chat", "flowy-chat-pub", ] resolver = "2" @@ -61,13 +62,15 @@ flowy-search = { workspace = true, path = "flowy-search" } flowy-search-pub = { workspace = true, path = "flowy-search-pub" } collab-integrate = { workspace = true, path = "collab-integrate" } flowy-date = { workspace = true, path = "flowy-date" } +flowy-chat = { workspace = true, path = "flowy-chat" } +flowy-chat-pub = { workspace = true, path = "flowy-chat-pub" } anyhow = "1.0" tracing = "0.1.40" bytes = "1.5.0" serde_json = "1.0.108" serde = "1.0.194" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2"] } +diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] } serde_repr = "0.1" parking_lot = "0.12" @@ -90,7 +93,7 @@ yrs = "0.18.8" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ef96b42e43c7b929a928f6c334967c7edffc1319" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "b36715dc2427e23a15fa81c629e1817d3dbf1e1a" } [profile.dev] opt-level = 1 @@ -129,10 +132,10 @@ rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec1 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "0b52164" } \ No newline at end of file +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "cca5135f0010fa5de22a298cbed939e21575538c" } diff --git a/frontend/rust-lib/dart-ffi/Cargo.toml b/frontend/rust-lib/dart-ffi/Cargo.toml index e17ede23e57cd..bca0489e7b7a6 100644 --- a/frontend/rust-lib/dart-ffi/Cargo.toml +++ b/frontend/rust-lib/dart-ffi/Cargo.toml @@ -32,8 +32,8 @@ lib-dispatch = { workspace = true } # Core #flowy-core = { workspace = true, features = ["profiling"] } -flowy-core = { workspace = true, features = ["verbose_log"] } -#flowy-core = { workspace = true } +#flowy-core = { workspace = true, features = ["verbose_log"] } +flowy-core = { workspace = true } flowy-notification = { workspace = true, features = ["dart"] } flowy-document = { workspace = true, features = ["dart"] } diff --git a/frontend/rust-lib/event-integration-test/Cargo.toml b/frontend/rust-lib/event-integration-test/Cargo.toml index d427a8f16c626..aca158bfaf44d 100644 --- a/frontend/rust-lib/event-integration-test/Cargo.toml +++ b/frontend/rust-lib/event-integration-test/Cargo.toml @@ -16,6 +16,7 @@ flowy-database-pub = { workspace = true } flowy-document = { path = "../flowy-document" } flowy-document-pub = { workspace = true } flowy-encrypt = { workspace = true } +flowy-chat = { workspace = true } lib-dispatch = { workspace = true } lib-infra = { workspace = true } flowy-server = { path = "../flowy-server" } @@ -56,6 +57,7 @@ chrono = "0.4.31" zip = "0.6.6" walkdir = "2.5.0" futures = "0.3.30" +flowy-chat-pub = { workspace = true } [features] default = ["supabase_cloud_test"] diff --git a/frontend/rust-lib/event-integration-test/src/chat_event.rs b/frontend/rust-lib/event-integration-test/src/chat_event.rs new file mode 100644 index 0000000000000..ca2c4911f3457 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/src/chat_event.rs @@ -0,0 +1,89 @@ +use crate::event_builder::EventBuilder; +use crate::EventIntegrationTest; +use flowy_chat::entities::{ + ChatMessageListPB, ChatMessageTypePB, LoadNextChatMessagePB, LoadPrevChatMessagePB, + SendChatPayloadPB, +}; +use flowy_chat::event_map::ChatEvent; +use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; +use flowy_folder::event_map::FolderEvent; + +impl EventIntegrationTest { + pub async fn create_chat(&self, parent_id: &str) -> ViewPB { + let payload = CreateViewPayloadPB { + parent_view_id: parent_id.to_string(), + name: "chat".to_string(), + desc: "".to_string(), + thumbnail: None, + layout: ViewLayoutPB::Chat, + initial_data: vec![], + meta: Default::default(), + set_as_current: true, + index: None, + section: None, + }; + EventBuilder::new(self.clone()) + .event(FolderEvent::CreateView) + .payload(payload) + .async_send() + .await + .parse::() + } + + pub async fn send_message( + &self, + chat_id: &str, + message: impl ToString, + message_type: ChatMessageTypePB, + ) { + let payload = SendChatPayloadPB { + chat_id: chat_id.to_string(), + message: message.to_string(), + message_type, + }; + + EventBuilder::new(self.clone()) + .event(ChatEvent::SendMessage) + .payload(payload) + .async_send() + .await; + } + + pub async fn load_prev_message( + &self, + chat_id: &str, + limit: i64, + before_message_id: Option, + ) -> ChatMessageListPB { + let payload = LoadPrevChatMessagePB { + chat_id: chat_id.to_string(), + limit, + before_message_id, + }; + EventBuilder::new(self.clone()) + .event(ChatEvent::LoadPrevMessage) + .payload(payload) + .async_send() + .await + .parse::() + } + + pub async fn load_next_message( + &self, + chat_id: &str, + limit: i64, + after_message_id: Option, + ) -> ChatMessageListPB { + let payload = LoadNextChatMessagePB { + chat_id: chat_id.to_string(), + limit, + after_message_id, + }; + EventBuilder::new(self.clone()) + .event(ChatEvent::LoadNextMessage) + .payload(payload) + .async_send() + .await + .parse::() + } +} diff --git a/frontend/rust-lib/event-integration-test/src/lib.rs b/frontend/rust-lib/event-integration-test/src/lib.rs index cddd304dde6ef..2ae16c74b3c87 100644 --- a/frontend/rust-lib/event-integration-test/src/lib.rs +++ b/frontend/rust-lib/event-integration-test/src/lib.rs @@ -24,6 +24,7 @@ use lib_dispatch::runtime::AFPluginRuntime; use crate::user_event::TestNotificationSender; +mod chat_event; pub mod database_event; pub mod document; pub mod document_event; diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index a2e4dda7c590d..40e8044806105 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -378,6 +378,26 @@ impl TestNotificationSender { rx } + pub fn subscribe_without_payload( + &self, + id: &str, + ty: impl Into + Send, + ) -> tokio::sync::mpsc::Receiver<()> { + let id = id.to_string(); + let (tx, rx) = tokio::sync::mpsc::channel::<()>(10); + let mut receiver = self.sender.subscribe(); + let ty = ty.into(); + af_spawn(async move { + // DatabaseNotification::DidUpdateDatabaseSnapshotState + while let Ok(value) = receiver.recv().await { + if value.id == id && value.ty == ty { + let _ = tx.send(()).await; + } + } + }); + rx + } + pub fn subscribe_with_condition(&self, id: &str, when: F) -> tokio::sync::mpsc::Receiver where T: TryFrom + Send + 'static, diff --git a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs new file mode 100644 index 0000000000000..1efe4c77d0c40 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs @@ -0,0 +1,161 @@ +use crate::util::receive_with_timeout; +use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::EventIntegrationTest; +use flowy_chat::entities::{ChatMessageListPB, ChatMessageTypePB}; +use flowy_chat::notification::ChatNotification; + +use flowy_chat_pub::cloud::ChatMessageType; +use futures_util::StreamExt; +use std::time::Duration; + +#[tokio::test] +async fn af_cloud_create_chat_message_test() { + user_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + + let current_workspace = test.get_current_workspace().await; + let view = test.create_chat(¤t_workspace.id).await; + let chat_id = view.id.clone(); + let chat_service = test.server_provider.get_server().unwrap().chat_service(); + for i in 0..10 { + let mut stream = chat_service + .send_chat_message( + ¤t_workspace.id, + &chat_id, + &format!("hello world {}", i), + ChatMessageType::System, + ) + .await + .unwrap(); + while let Some(message) = stream.next().await { + message.unwrap(); + } + } + let rx = test + .notification_sender + .subscribe::(&chat_id, ChatNotification::DidLoadLatestChatMessage); + let _ = test.load_next_message(&chat_id, 10, None).await; + let all = receive_with_timeout(rx, Duration::from_secs(30)) + .await + .unwrap(); + assert_eq!(all.messages.len(), 10); + // in desc order + assert_eq!(all.messages[4].content, "hello world 5"); + assert_eq!(all.messages[5].content, "hello world 4"); + + let list = test + .load_next_message(&chat_id, 5, Some(all.messages[4].message_id)) + .await; + assert_eq!(list.messages.len(), 4); + assert_eq!(list.messages[0].content, "hello world 9"); + assert_eq!(list.messages[1].content, "hello world 8"); + assert_eq!(list.messages[2].content, "hello world 7"); + assert_eq!(list.messages[3].content, "hello world 6"); + + assert_eq!(all.messages[6].content, "hello world 3"); + + // Load from local + let list = test + .load_prev_message(&chat_id, 5, Some(all.messages[6].message_id)) + .await; + assert_eq!(list.messages.len(), 3); + assert_eq!(list.messages[0].content, "hello world 2"); + assert_eq!(list.messages[1].content, "hello world 1"); + assert_eq!(list.messages[2].content, "hello world 0"); +} + +#[tokio::test] +async fn af_cloud_load_remote_system_message_test() { + user_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + + let current_workspace = test.get_current_workspace().await; + let view = test.create_chat(¤t_workspace.id).await; + let chat_id = view.id.clone(); + + let chat_service = test.server_provider.get_server().unwrap().chat_service(); + for i in 0..10 { + let mut stream = chat_service + .send_chat_message( + ¤t_workspace.id, + &chat_id, + &format!("hello server {}", i), + ChatMessageType::System, + ) + .await + .unwrap(); + while let Some(message) = stream.next().await { + message.unwrap(); + } + } + + let rx = test + .notification_sender + .subscribe::(&chat_id, ChatNotification::DidLoadLatestChatMessage); + + // Previous messages were created by the server, so there are no messages in the local cache. + // It will try to load messages in the background. + let all = test.load_next_message(&chat_id, 5, None).await; + assert!(all.messages.is_empty()); + + // Wait for the messages to be loaded. + let next_back_five = receive_with_timeout(rx, Duration::from_secs(60)) + .await + .unwrap(); + assert_eq!(next_back_five.messages.len(), 5); + assert!(next_back_five.has_more); + assert_eq!(next_back_five.total, 10); + assert_eq!(next_back_five.messages[0].content, "hello server 9"); + assert_eq!(next_back_five.messages[1].content, "hello server 8"); + assert_eq!(next_back_five.messages[2].content, "hello server 7"); + assert_eq!(next_back_five.messages[3].content, "hello server 6"); + assert_eq!(next_back_five.messages[4].content, "hello server 5"); + + // Load first five messages + let rx = test + .notification_sender + .subscribe::(&chat_id, ChatNotification::DidLoadPrevChatMessage); + test + .load_prev_message(&chat_id, 5, Some(next_back_five.messages[4].message_id)) + .await; + let first_five_messages = receive_with_timeout(rx, Duration::from_secs(60)) + .await + .unwrap(); + assert!(!first_five_messages.has_more); + assert_eq!(first_five_messages.messages[0].content, "hello server 4"); + assert_eq!(first_five_messages.messages[1].content, "hello server 3"); + assert_eq!(first_five_messages.messages[2].content, "hello server 2"); + assert_eq!(first_five_messages.messages[3].content, "hello server 1"); + assert_eq!(first_five_messages.messages[4].content, "hello server 0"); +} + +#[tokio::test] +async fn af_cloud_load_remote_user_message_test() { + user_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + + let current_workspace = test.get_current_workspace().await; + let view = test.create_chat(¤t_workspace.id).await; + let chat_id = view.id.clone(); + let rx = test + .notification_sender + .subscribe_without_payload(&chat_id, ChatNotification::FinishAnswerQuestion); + test + .send_message(&chat_id, "hello world", ChatMessageTypePB::User) + .await; + let _ = receive_with_timeout(rx, Duration::from_secs(60)) + .await + .unwrap(); + + let all = test.load_next_message(&chat_id, 5, None).await; + assert_eq!(all.messages.len(), 2); + // 3 means AI + assert_eq!(all.messages[0].author_type, 3); + // 2 means User + assert_eq!(all.messages[1].author_type, 1); + // The message ID is incremented by 1. + assert_eq!(all.messages[1].message_id + 1, all.messages[0].message_id); +} diff --git a/frontend/rust-lib/event-integration-test/tests/chat/mod.rs b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs new file mode 100644 index 0000000000000..773bdab81fef0 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs @@ -0,0 +1 @@ +mod chat_message_test; diff --git a/frontend/rust-lib/event-integration-test/tests/main.rs b/frontend/rust-lib/event-integration-test/tests/main.rs index 05d07974732b5..05f19e9b75a9d 100644 --- a/frontend/rust-lib/event-integration-test/tests/main.rs +++ b/frontend/rust-lib/event-integration-test/tests/main.rs @@ -6,3 +6,5 @@ mod folder; // mod search; mod user; pub mod util; + +mod chat; diff --git a/frontend/rust-lib/flowy-chat-pub/Cargo.toml b/frontend/rust-lib/flowy-chat-pub/Cargo.toml new file mode 100644 index 0000000000000..0cfdae0ec9dae --- /dev/null +++ b/frontend/rust-lib/flowy-chat-pub/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "flowy-chat-pub" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lib-infra = { workspace = true } +flowy-error = { workspace = true } +client-api = { workspace = true } +futures.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-chat-pub/src/cloud.rs b/frontend/rust-lib/flowy-chat-pub/src/cloud.rs new file mode 100644 index 0000000000000..51de79422a3fc --- /dev/null +++ b/frontend/rust-lib/flowy-chat-pub/src/cloud.rs @@ -0,0 +1,50 @@ +pub use client_api::entity::ai_dto::{RelatedQuestion, RepeatedRelatedQuestion}; +pub use client_api::entity::{ + ChatAuthorType, ChatMessage, ChatMessageType, MessageCursor, QAChatMessage, RepeatedChatMessage, +}; +use client_api::error::AppResponseError; +use flowy_error::FlowyError; +use futures::stream::BoxStream; +use lib_infra::async_trait::async_trait; +use lib_infra::future::FutureResult; + +pub type ChatMessageStream = BoxStream<'static, Result>; +#[async_trait] +pub trait ChatCloudService: Send + Sync + 'static { + fn create_chat( + &self, + uid: &i64, + workspace_id: &str, + chat_id: &str, + ) -> FutureResult<(), FlowyError>; + + async fn send_chat_message( + &self, + workspace_id: &str, + chat_id: &str, + message: &str, + message_type: ChatMessageType, + ) -> Result; + + fn get_chat_messages( + &self, + workspace_id: &str, + chat_id: &str, + offset: MessageCursor, + limit: u64, + ) -> FutureResult; + + fn get_related_message( + &self, + workspace_id: &str, + chat_id: &str, + message_id: i64, + ) -> FutureResult; + + fn generate_answer( + &self, + workspace_id: &str, + chat_id: &str, + question_message_id: i64, + ) -> FutureResult; +} diff --git a/frontend/rust-lib/flowy-chat-pub/src/lib.rs b/frontend/rust-lib/flowy-chat-pub/src/lib.rs new file mode 100644 index 0000000000000..1ede32218e76e --- /dev/null +++ b/frontend/rust-lib/flowy-chat-pub/src/lib.rs @@ -0,0 +1 @@ +pub mod cloud; diff --git a/frontend/rust-lib/flowy-chat/Cargo.toml b/frontend/rust-lib/flowy-chat/Cargo.toml new file mode 100644 index 0000000000000..e1a7175442d0a --- /dev/null +++ b/frontend/rust-lib/flowy-chat/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "flowy-chat" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +flowy-derive.workspace = true +flowy-notification = { workspace = true } +flowy-error = { path = "../flowy-error", features = [ + "impl_from_dispatch_error", + "impl_from_collab_folder", +] } +lib-dispatch = { workspace = true } +tracing.workspace = true +uuid.workspace = true +strum_macros = "0.21" +protobuf.workspace = true +bytes.workspace = true +validator = { version = "0.16.0", features = ["derive"] } +lib-infra = { workspace = true } +flowy-chat-pub.workspace = true +dashmap = "5.5" +flowy-sqlite = { workspace = true } +tokio.workspace = true +futures.workspace = true + +[build-dependencies] +flowy-codegen.workspace = true + +[features] +dart = ["flowy-codegen/dart", "flowy-notification/dart"] +tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] +web_ts = ["flowy-codegen/ts", "flowy-notification/web_ts"] diff --git a/frontend/rust-lib/flowy-chat/Flowy.toml b/frontend/rust-lib/flowy-chat/Flowy.toml new file mode 100644 index 0000000000000..1410c5951e861 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/Flowy.toml @@ -0,0 +1,3 @@ +# Check out the FlowyConfig (located in flowy_toml.rs) for more details. +proto_input = ["src/entities.rs", "src/event_map.rs", "src/notification.rs"] +event_files = ["src/event_map.rs"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-chat/build.rs b/frontend/rust-lib/flowy-chat/build.rs new file mode 100644 index 0000000000000..fac4cc65ae836 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/build.rs @@ -0,0 +1,40 @@ +fn main() { + #[cfg(feature = "dart")] + { + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); + } + + #[cfg(feature = "tauri_ts")] + { + flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); + flowy_codegen::protobuf_file::ts_gen( + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + flowy_codegen::Project::Tauri, + ); + flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); + flowy_codegen::protobuf_file::ts_gen( + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + flowy_codegen::Project::TauriApp, + ); + } + + #[cfg(feature = "web_ts")] + { + flowy_codegen::ts_event::gen( + "folder", + flowy_codegen::Project::Web { + relative_path: "../../".to_string(), + }, + ); + flowy_codegen::protobuf_file::ts_gen( + env!("CARGO_PKG_NAME"), + "folder", + flowy_codegen::Project::Web { + relative_path: "../../".to_string(), + }, + ); + } +} diff --git a/frontend/rust-lib/flowy-chat/src/chat.rs b/frontend/rust-lib/flowy-chat/src/chat.rs new file mode 100644 index 0000000000000..712caad91a7b6 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/chat.rs @@ -0,0 +1,471 @@ +use crate::entities::{ + ChatMessageErrorPB, ChatMessageListPB, ChatMessagePB, RepeatedRelatedQuestionPB, +}; +use crate::manager::ChatUserService; +use crate::notification::{send_notification, ChatNotification}; +use crate::persistence::{ + insert_answer_message, insert_chat_messages, select_chat_messages, ChatMessageTable, +}; +use flowy_chat_pub::cloud::{ + ChatAuthorType, ChatCloudService, ChatMessage, ChatMessageType, MessageCursor, +}; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::DBConnection; +use futures::StreamExt; +use std::sync::atomic::AtomicI64; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{error, instrument, trace}; + +enum PrevMessageState { + HasMore, + NoMore, + Loading, +} + +pub struct Chat { + chat_id: String, + uid: i64, + user_service: Arc, + cloud_service: Arc, + prev_message_state: Arc>, + latest_message_id: Arc, +} + +impl Chat { + pub fn new( + uid: i64, + chat_id: String, + user_service: Arc, + cloud_service: Arc, + ) -> Chat { + Chat { + uid, + chat_id, + cloud_service, + user_service, + prev_message_state: Arc::new(RwLock::new(PrevMessageState::HasMore)), + latest_message_id: Default::default(), + } + } + + pub fn close(&self) {} + + #[allow(dead_code)] + pub async fn pull_latest_message(&self, limit: i64) { + let latest_message_id = self + .latest_message_id + .load(std::sync::atomic::Ordering::Relaxed); + if latest_message_id > 0 { + let _ = self + .load_remote_chat_messages(limit, None, Some(latest_message_id)) + .await; + } + } + + #[instrument(level = "info", skip_all, err)] + pub async fn send_chat_message( + &self, + message: &str, + message_type: ChatMessageType, + ) -> Result<(), FlowyError> { + if message.len() > 2000 { + return Err(FlowyError::text_too_long().with_context("Exceeds maximum message 2000 length")); + } + + let uid = self.user_service.user_id()?; + let workspace_id = self.user_service.workspace_id()?; + stream_send_chat_messages( + uid, + workspace_id, + self.chat_id.clone(), + message.to_string(), + message_type, + self.cloud_service.clone(), + self.user_service.clone(), + ); + + Ok(()) + } + + /// Load chat messages for a given `chat_id`. + /// + /// 1. When opening a chat: + /// - Loads local chat messages. + /// - `after_message_id` and `before_message_id` are `None`. + /// - Spawns a task to load messages from the remote server, notifying the user when the remote messages are loaded. + /// + /// 2. Loading more messages in an existing chat with `after_message_id`: + /// - `after_message_id` is the last message ID in the current chat messages. + /// + /// 3. Loading more messages in an existing chat with `before_message_id`: + /// - `before_message_id` is the first message ID in the current chat messages. + pub async fn load_prev_chat_messages( + &self, + limit: i64, + before_message_id: Option, + ) -> Result { + trace!( + "Loading old messages: chat_id={}, limit={}, before_message_id={:?}", + self.chat_id, + limit, + before_message_id + ); + let messages = self + .load_local_chat_messages(limit, None, before_message_id) + .await?; + + // If the number of messages equals the limit, then no need to load more messages from remote + let has_more = !messages.is_empty(); + if messages.len() == limit as usize { + return Ok(ChatMessageListPB { + messages, + has_more, + total: 0, + }); + } + + if matches!( + *self.prev_message_state.read().await, + PrevMessageState::HasMore + ) { + *self.prev_message_state.write().await = PrevMessageState::Loading; + if let Err(err) = self + .load_remote_chat_messages(limit, before_message_id, None) + .await + { + error!("Failed to load previous chat messages: {}", err); + } + } + + Ok(ChatMessageListPB { + messages, + has_more, + total: 0, + }) + } + + pub async fn load_latest_chat_messages( + &self, + limit: i64, + after_message_id: Option, + ) -> Result { + trace!( + "Loading new messages: chat_id={}, limit={}, after_message_id={:?}", + self.chat_id, + limit, + after_message_id, + ); + let messages = self + .load_local_chat_messages(limit, after_message_id, None) + .await?; + + trace!( + "Loaded local chat messages: chat_id={}, messages={}", + self.chat_id, + messages.len() + ); + + // If the number of messages equals the limit, then no need to load more messages from remote + let has_more = !messages.is_empty(); + let _ = self + .load_remote_chat_messages(limit, None, after_message_id) + .await; + Ok(ChatMessageListPB { + messages, + has_more, + total: 0, + }) + } + + async fn load_remote_chat_messages( + &self, + limit: i64, + before_message_id: Option, + after_message_id: Option, + ) -> FlowyResult<()> { + trace!( + "Loading chat messages from remote: chat_id={}, limit={}, before_message_id={:?}, after_message_id={:?}", + self.chat_id, + limit, + before_message_id, + after_message_id + ); + let workspace_id = self.user_service.workspace_id()?; + let chat_id = self.chat_id.clone(); + let cloud_service = self.cloud_service.clone(); + let user_service = self.user_service.clone(); + let uid = self.uid; + let prev_message_state = self.prev_message_state.clone(); + let latest_message_id = self.latest_message_id.clone(); + tokio::spawn(async move { + let cursor = match (before_message_id, after_message_id) { + (Some(bid), _) => MessageCursor::BeforeMessageId(bid), + (_, Some(aid)) => MessageCursor::AfterMessageId(aid), + _ => MessageCursor::NextBack, + }; + match cloud_service + .get_chat_messages(&workspace_id, &chat_id, cursor.clone(), limit as u64) + .await + { + Ok(resp) => { + // Save chat messages to local disk + if let Err(err) = save_chat_message( + user_service.sqlite_connection(uid)?, + &chat_id, + resp.messages.clone(), + ) { + error!("Failed to save chat:{} messages: {}", chat_id, err); + } + + // Update latest message ID + if !resp.messages.is_empty() { + latest_message_id.store( + resp.messages[0].message_id, + std::sync::atomic::Ordering::Relaxed, + ); + } + + let pb = ChatMessageListPB::from(resp); + trace!( + "Loaded chat messages from remote: chat_id={}, messages={}", + chat_id, + pb.messages.len() + ); + if matches!(cursor, MessageCursor::BeforeMessageId(_)) { + if pb.has_more { + *prev_message_state.write().await = PrevMessageState::HasMore; + } else { + *prev_message_state.write().await = PrevMessageState::NoMore; + } + send_notification(&chat_id, ChatNotification::DidLoadPrevChatMessage) + .payload(pb) + .send(); + } else { + send_notification(&chat_id, ChatNotification::DidLoadLatestChatMessage) + .payload(pb) + .send(); + } + }, + Err(err) => error!("Failed to load chat messages: {}", err), + } + Ok::<(), FlowyError>(()) + }); + Ok(()) + } + + pub async fn get_related_question( + &self, + message_id: i64, + ) -> Result { + let workspace_id = self.user_service.workspace_id()?; + let resp = self + .cloud_service + .get_related_message(&workspace_id, &self.chat_id, message_id) + .await?; + + trace!( + "Related messages: chat_id={}, message_id={}, messages:{:?}", + self.chat_id, + message_id, + resp.items + ); + Ok(RepeatedRelatedQuestionPB::from(resp)) + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn generate_answer(&self, question_message_id: i64) -> FlowyResult { + let workspace_id = self.user_service.workspace_id()?; + let resp = self + .cloud_service + .generate_answer(&workspace_id, &self.chat_id, question_message_id) + .await?; + + save_answer( + self.user_service.sqlite_connection(self.uid)?, + &self.chat_id, + resp.clone(), + question_message_id, + )?; + + let pb = ChatMessagePB::from(resp); + Ok(pb) + } + + async fn load_local_chat_messages( + &self, + limit: i64, + after_message_id: Option, + before_message_id: Option, + ) -> Result, FlowyError> { + let conn = self.user_service.sqlite_connection(self.uid)?; + let records = select_chat_messages( + conn, + &self.chat_id, + limit, + after_message_id, + before_message_id, + )?; + let messages = records + .into_iter() + .map(|record| ChatMessagePB { + message_id: record.message_id, + content: record.content, + created_at: record.created_at, + author_type: record.author_type, + author_id: record.author_id, + has_following: false, + reply_message_id: record.reply_message_id, + }) + .collect::>(); + + Ok(messages) + } +} + +fn stream_send_chat_messages( + uid: i64, + workspace_id: String, + chat_id: String, + message_content: String, + message_type: ChatMessageType, + cloud_service: Arc, + user_service: Arc, +) { + tokio::spawn(async move { + trace!( + "Sending chat message: chat_id={}, message={}, type={:?}", + chat_id, + message_content, + message_type + ); + + let mut messages = Vec::with_capacity(2); + let stream_result = cloud_service + .send_chat_message(&workspace_id, &chat_id, &message_content, message_type) + .await; + + // By default, stream only returns two messages: + // 1. user message + // 2. ai response message + match stream_result { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(message) => { + let mut pb = ChatMessagePB::from(message.clone()); + if matches!(message.author.author_type, ChatAuthorType::Human) { + pb.has_following = true; + send_notification(&chat_id, ChatNotification::LastUserSentMessage) + .payload(pb.clone()) + .send(); + } + + // + send_notification(&chat_id, ChatNotification::DidReceiveChatMessage) + .payload(pb) + .send(); + messages.push(message); + }, + Err(err) => { + error!("Failed to send chat message: {}", err); + let pb = ChatMessageErrorPB { + chat_id: chat_id.clone(), + content: message_content.clone(), + error_message: "Service Temporarily Unavailable".to_string(), + }; + send_notification(&chat_id, ChatNotification::StreamChatMessageError) + .payload(pb) + .send(); + break; + }, + } + } + }, + Err(err) => { + error!("Failed to send chat message: {}", err); + let pb = ChatMessageErrorPB { + chat_id: chat_id.clone(), + content: message_content.clone(), + error_message: err.to_string(), + }; + send_notification(&chat_id, ChatNotification::StreamChatMessageError) + .payload(pb) + .send(); + return; + }, + } + + if messages.is_empty() { + return; + } + + trace!( + "Saving chat messages to local disk: chat_id={}, messages:{:?}", + chat_id, + messages + ); + + // Insert chat messages to local disk + if let Err(err) = user_service.sqlite_connection(uid).and_then(|conn| { + let records = messages + .into_iter() + .map(|message| ChatMessageTable { + message_id: message.message_id, + chat_id: chat_id.clone(), + content: message.content, + created_at: message.created_at.timestamp(), + author_type: message.author.author_type as i64, + author_id: message.author.author_id.to_string(), + reply_message_id: message.reply_message_id, + }) + .collect::>(); + insert_chat_messages(conn, &records)?; + + // Mark chat as finished + send_notification(&chat_id, ChatNotification::FinishAnswerQuestion).send(); + Ok(()) + }) { + error!("Failed to save chat messages: {}", err); + } + }); +} + +fn save_chat_message( + conn: DBConnection, + chat_id: &str, + messages: Vec, +) -> FlowyResult<()> { + let records = messages + .into_iter() + .map(|message| ChatMessageTable { + message_id: message.message_id, + chat_id: chat_id.to_string(), + content: message.content, + created_at: message.created_at.timestamp(), + author_type: message.author.author_type as i64, + author_id: message.author.author_id.to_string(), + reply_message_id: message.reply_message_id, + }) + .collect::>(); + insert_chat_messages(conn, &records)?; + Ok(()) +} +fn save_answer( + conn: DBConnection, + chat_id: &str, + message: ChatMessage, + question_message_id: i64, +) -> FlowyResult<()> { + let record = ChatMessageTable { + message_id: message.message_id, + chat_id: chat_id.to_string(), + content: message.content, + created_at: message.created_at.timestamp(), + author_type: message.author.author_type as i64, + author_id: message.author.author_id.to_string(), + reply_message_id: message.reply_message_id, + }; + insert_answer_message(conn, question_message_id, record)?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-chat/src/entities.rs b/frontend/rust-lib/flowy-chat/src/entities.rs new file mode 100644 index 0000000000000..e01c2aaad1bc4 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/entities.rs @@ -0,0 +1,190 @@ +use flowy_chat_pub::cloud::{ + ChatMessage, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, +}; +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use lib_infra::validator_fn::required_not_empty_str; +use validator::Validate; + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct SendChatPayloadPB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub chat_id: String, + + #[pb(index = 2)] + #[validate(custom = "required_not_empty_str")] + pub message: String, + + #[pb(index = 3)] + pub message_type: ChatMessageTypePB, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)] +pub enum ChatMessageTypePB { + #[default] + System = 0, + User = 1, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct LoadPrevChatMessagePB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub chat_id: String, + + #[pb(index = 2)] + pub limit: i64, + + #[pb(index = 4, one_of)] + pub before_message_id: Option, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct LoadNextChatMessagePB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub chat_id: String, + + #[pb(index = 2)] + pub limit: i64, + + #[pb(index = 4, one_of)] + pub after_message_id: Option, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct ChatMessageListPB { + #[pb(index = 1)] + pub has_more: bool, + + #[pb(index = 2)] + pub messages: Vec, + + /// If the total number of messages is 0, then the total number of messages is unknown. + #[pb(index = 3)] + pub total: i64, +} + +impl From for ChatMessageListPB { + fn from(repeated_chat_message: RepeatedChatMessage) -> Self { + let messages = repeated_chat_message + .messages + .into_iter() + .map(ChatMessagePB::from) + .collect(); + ChatMessageListPB { + has_more: repeated_chat_message.has_more, + messages, + total: repeated_chat_message.total, + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct ChatMessagePB { + #[pb(index = 1)] + pub message_id: i64, + + #[pb(index = 2)] + pub content: String, + + #[pb(index = 3)] + pub created_at: i64, + + #[pb(index = 4)] + pub author_type: i64, + + #[pb(index = 5)] + pub author_id: String, + + #[pb(index = 6)] + pub has_following: bool, + + #[pb(index = 7, one_of)] + pub reply_message_id: Option, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct ChatMessageErrorPB { + #[pb(index = 1)] + pub chat_id: String, + + #[pb(index = 2)] + pub content: String, + + #[pb(index = 3)] + pub error_message: String, +} + +impl From for ChatMessagePB { + fn from(chat_message: ChatMessage) -> Self { + ChatMessagePB { + message_id: chat_message.message_id, + content: chat_message.content, + created_at: chat_message.created_at.timestamp(), + author_type: chat_message.author.author_type as i64, + author_id: chat_message.author.author_id.to_string(), + has_following: false, + reply_message_id: None, + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RepeatedChatMessagePB { + #[pb(index = 1)] + items: Vec, +} + +impl From> for RepeatedChatMessagePB { + fn from(messages: Vec) -> Self { + RepeatedChatMessagePB { + items: messages.into_iter().map(ChatMessagePB::from).collect(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct ChatMessageIdPB { + #[pb(index = 1)] + pub chat_id: String, + + #[pb(index = 2)] + pub message_id: i64, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RelatedQuestionPB { + #[pb(index = 1)] + pub content: String, +} + +impl From for RelatedQuestionPB { + fn from(value: RelatedQuestion) -> Self { + RelatedQuestionPB { + content: value.content, + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RepeatedRelatedQuestionPB { + #[pb(index = 1)] + pub message_id: i64, + + #[pb(index = 2)] + pub items: Vec, +} + +impl From for RepeatedRelatedQuestionPB { + fn from(value: RepeatedRelatedQuestion) -> Self { + RepeatedRelatedQuestionPB { + message_id: value.message_id, + items: value + .items + .into_iter() + .map(RelatedQuestionPB::from) + .collect(), + } + } +} diff --git a/frontend/rust-lib/flowy-chat/src/event_handler.rs b/frontend/rust-lib/flowy-chat/src/event_handler.rs new file mode 100644 index 0000000000000..959c0398b4929 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/event_handler.rs @@ -0,0 +1,93 @@ +use flowy_chat_pub::cloud::ChatMessageType; +use std::sync::{Arc, Weak}; +use validator::Validate; + +use flowy_error::{FlowyError, FlowyResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; + +use crate::entities::*; +use crate::manager::ChatManager; + +fn upgrade_chat_manager( + chat_manager: AFPluginState>, +) -> FlowyResult> { + let chat_manager = chat_manager + .upgrade() + .ok_or(FlowyError::internal().with_context("The chat manager is already dropped"))?; + Ok(chat_manager) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn send_chat_message_handler( + data: AFPluginData, + chat_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let chat_manager = upgrade_chat_manager(chat_manager)?; + let data = data.into_inner(); + data.validate()?; + + let message_type = match data.message_type { + ChatMessageTypePB::System => ChatMessageType::System, + ChatMessageTypePB::User => ChatMessageType::User, + }; + chat_manager + .send_chat_message(&data.chat_id, &data.message, message_type) + .await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn load_prev_message_handler( + data: AFPluginData, + chat_manager: AFPluginState>, +) -> DataResult { + let chat_manager = upgrade_chat_manager(chat_manager)?; + let data = data.into_inner(); + data.validate()?; + + let messages = chat_manager + .load_prev_chat_messages(&data.chat_id, data.limit, data.before_message_id) + .await?; + data_result_ok(messages) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn load_next_message_handler( + data: AFPluginData, + chat_manager: AFPluginState>, +) -> DataResult { + let chat_manager = upgrade_chat_manager(chat_manager)?; + let data = data.into_inner(); + data.validate()?; + + let messages = chat_manager + .load_latest_chat_messages(&data.chat_id, data.limit, data.after_message_id) + .await?; + data_result_ok(messages) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_related_question_handler( + data: AFPluginData, + chat_manager: AFPluginState>, +) -> DataResult { + let chat_manager = upgrade_chat_manager(chat_manager)?; + let data = data.into_inner(); + let messages = chat_manager + .get_related_questions(&data.chat_id, data.message_id) + .await?; + data_result_ok(messages) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_answer_handler( + data: AFPluginData, + chat_manager: AFPluginState>, +) -> DataResult { + let chat_manager = upgrade_chat_manager(chat_manager)?; + let data = data.into_inner(); + let message = chat_manager + .generate_answer(&data.chat_id, data.message_id) + .await?; + data_result_ok(message) +} diff --git a/frontend/rust-lib/flowy-chat/src/event_map.rs b/frontend/rust-lib/flowy-chat/src/event_map.rs new file mode 100644 index 0000000000000..9fae853459452 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/event_map.rs @@ -0,0 +1,40 @@ +use std::sync::Weak; + +use strum_macros::Display; + +use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; +use lib_dispatch::prelude::*; + +use crate::event_handler::*; +use crate::manager::ChatManager; + +pub fn init(chat_manager: Weak) -> AFPlugin { + AFPlugin::new() + .name("Flowy-Chat") + .state(chat_manager) + .event(ChatEvent::SendMessage, send_chat_message_handler) + .event(ChatEvent::LoadPrevMessage, load_prev_message_handler) + .event(ChatEvent::LoadNextMessage, load_next_message_handler) + .event(ChatEvent::GetRelatedQuestion, get_related_question_handler) + .event(ChatEvent::GetAnswerForQuestion, get_answer_handler) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] +#[event_err = "FlowyError"] +pub enum ChatEvent { + /// Create a new workspace + #[event(input = "LoadPrevChatMessagePB", output = "ChatMessageListPB")] + LoadPrevMessage = 0, + + #[event(input = "LoadNextChatMessagePB", output = "ChatMessageListPB")] + LoadNextMessage = 1, + + #[event(input = "SendChatPayloadPB")] + SendMessage = 2, + + #[event(input = "ChatMessageIdPB", output = "RepeatedRelatedQuestionPB")] + GetRelatedQuestion = 3, + + #[event(input = "ChatMessageIdPB", output = "ChatMessagePB")] + GetAnswerForQuestion = 4, +} diff --git a/frontend/rust-lib/flowy-chat/src/lib.rs b/frontend/rust-lib/flowy-chat/src/lib.rs new file mode 100644 index 0000000000000..2244af580239f --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/lib.rs @@ -0,0 +1,9 @@ +mod event_handler; +pub mod event_map; + +mod chat; +pub mod entities; +pub mod manager; +pub mod notification; +mod persistence; +mod protobuf; diff --git a/frontend/rust-lib/flowy-chat/src/manager.rs b/frontend/rust-lib/flowy-chat/src/manager.rs new file mode 100644 index 0000000000000..b2cc679eb2779 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/manager.rs @@ -0,0 +1,182 @@ +use crate::chat::Chat; +use crate::entities::{ChatMessageListPB, ChatMessagePB, RepeatedRelatedQuestionPB}; +use crate::persistence::{insert_chat, ChatTable}; +use dashmap::DashMap; +use flowy_chat_pub::cloud::{ChatCloudService, ChatMessageType}; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::DBConnection; +use lib_infra::util::timestamp; +use std::sync::Arc; +use tracing::{instrument, trace}; + +pub trait ChatUserService: Send + Sync + 'static { + fn user_id(&self) -> Result; + fn device_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn sqlite_connection(&self, uid: i64) -> Result; +} + +pub struct ChatManager { + cloud_service: Arc, + user_service: Arc, + chats: Arc>>, +} + +impl ChatManager { + pub fn new( + cloud_service: Arc, + user_service: impl ChatUserService, + ) -> ChatManager { + let user_service = Arc::new(user_service); + + Self { + cloud_service, + user_service, + chats: Arc::new(DashMap::new()), + } + } + + pub async fn open_chat(&self, chat_id: &str) -> Result<(), FlowyError> { + trace!("open chat: {}", chat_id); + self.chats.entry(chat_id.to_string()).or_insert_with(|| { + Arc::new(Chat::new( + self.user_service.user_id().unwrap(), + chat_id.to_string(), + self.user_service.clone(), + self.cloud_service.clone(), + )) + }); + + Ok(()) + } + + pub async fn close_chat(&self, _chat_id: &str) -> Result<(), FlowyError> { + Ok(()) + } + + pub async fn delete_chat(&self, chat_id: &str) -> Result<(), FlowyError> { + if let Some((_, chat)) = self.chats.remove(chat_id) { + chat.close(); + } + Ok(()) + } + + pub async fn create_chat(&self, uid: &i64, chat_id: &str) -> Result, FlowyError> { + let workspace_id = self.user_service.workspace_id()?; + self + .cloud_service + .create_chat(uid, &workspace_id, chat_id) + .await?; + save_chat(self.user_service.sqlite_connection(*uid)?, chat_id)?; + + let chat = Arc::new(Chat::new( + self.user_service.user_id().unwrap(), + chat_id.to_string(), + self.user_service.clone(), + self.cloud_service.clone(), + )); + self.chats.insert(chat_id.to_string(), chat.clone()); + Ok(chat) + } + + #[instrument(level = "info", skip_all, err)] + pub async fn send_chat_message( + &self, + chat_id: &str, + message: &str, + message_type: ChatMessageType, + ) -> Result<(), FlowyError> { + let chat = self.get_or_create_chat_instance(chat_id).await?; + chat.send_chat_message(message, message_type).await?; + Ok(()) + } + + pub async fn get_or_create_chat_instance(&self, chat_id: &str) -> Result, FlowyError> { + let chat = self.chats.get(chat_id).as_deref().cloned(); + match chat { + None => { + let chat = Arc::new(Chat::new( + self.user_service.user_id().unwrap(), + chat_id.to_string(), + self.user_service.clone(), + self.cloud_service.clone(), + )); + self.chats.insert(chat_id.to_string(), chat.clone()); + Ok(chat) + }, + Some(chat) => Ok(chat), + } + } + + /// Load chat messages for a given `chat_id`. + /// + /// 1. When opening a chat: + /// - Loads local chat messages. + /// - `after_message_id` and `before_message_id` are `None`. + /// - Spawns a task to load messages from the remote server, notifying the user when the remote messages are loaded. + /// + /// 2. Loading more messages in an existing chat with `after_message_id`: + /// - `after_message_id` is the last message ID in the current chat messages. + /// + /// 3. Loading more messages in an existing chat with `before_message_id`: + /// - `before_message_id` is the first message ID in the current chat messages. + /// + /// 4. `after_message_id` and `before_message_id` cannot be specified at the same time. + + pub async fn load_prev_chat_messages( + &self, + chat_id: &str, + limit: i64, + before_message_id: Option, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let list = chat + .load_prev_chat_messages(limit, before_message_id) + .await?; + Ok(list) + } + + pub async fn load_latest_chat_messages( + &self, + chat_id: &str, + limit: i64, + after_message_id: Option, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let list = chat + .load_latest_chat_messages(limit, after_message_id) + .await?; + Ok(list) + } + + pub async fn get_related_questions( + &self, + chat_id: &str, + message_id: i64, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let resp = chat.get_related_question(message_id).await?; + Ok(resp) + } + + pub async fn generate_answer( + &self, + chat_id: &str, + question_message_id: i64, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let resp = chat.generate_answer(question_message_id).await?; + Ok(resp) + } +} + +fn save_chat(conn: DBConnection, chat_id: &str) -> FlowyResult<()> { + let row = ChatTable { + chat_id: chat_id.to_string(), + created_at: timestamp(), + name: "".to_string(), + }; + + insert_chat(conn, &row)?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-chat/src/notification.rs b/frontend/rust-lib/flowy-chat/src/notification.rs new file mode 100644 index 0000000000000..830c464a72390 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/notification.rs @@ -0,0 +1,40 @@ +use flowy_derive::ProtoBuf_Enum; +use flowy_notification::NotificationBuilder; + +const CHAT_OBSERVABLE_SOURCE: &str = "Chat"; + +#[derive(ProtoBuf_Enum, Debug, Default)] +pub enum ChatNotification { + #[default] + Unknown = 0, + DidLoadLatestChatMessage = 1, + DidLoadPrevChatMessage = 2, + DidReceiveChatMessage = 3, + StreamChatMessageError = 4, + FinishAnswerQuestion = 5, + LastUserSentMessage = 6, +} + +impl std::convert::From for i32 { + fn from(notification: ChatNotification) -> Self { + notification as i32 + } +} +impl std::convert::From for ChatNotification { + fn from(notification: i32) -> Self { + match notification { + 1 => ChatNotification::DidLoadLatestChatMessage, + 2 => ChatNotification::DidLoadPrevChatMessage, + 3 => ChatNotification::DidReceiveChatMessage, + 4 => ChatNotification::StreamChatMessageError, + 5 => ChatNotification::FinishAnswerQuestion, + 6 => ChatNotification::LastUserSentMessage, + _ => ChatNotification::Unknown, + } + } +} + +#[tracing::instrument(level = "trace")] +pub(crate) fn send_notification(id: &str, ty: ChatNotification) -> NotificationBuilder { + NotificationBuilder::new(id, ty, CHAT_OBSERVABLE_SOURCE) +} diff --git a/frontend/rust-lib/flowy-chat/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-chat/src/persistence/chat_message_sql.rs new file mode 100644 index 0000000000000..3e65123c27887 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/persistence/chat_message_sql.rs @@ -0,0 +1,106 @@ +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::upsert::excluded; +use flowy_sqlite::{ + diesel, insert_into, + query_dsl::*, + schema::{chat_message_table, chat_message_table::dsl}, + DBConnection, ExpressionMethods, Identifiable, Insertable, QueryResult, Queryable, +}; + +#[derive(Queryable, Insertable, Identifiable)] +#[diesel(table_name = chat_message_table)] +#[diesel(primary_key(message_id))] +pub struct ChatMessageTable { + pub message_id: i64, + pub chat_id: String, + pub content: String, + pub created_at: i64, + pub author_type: i64, + pub author_id: String, + pub reply_message_id: Option, +} + +pub fn insert_chat_messages( + mut conn: DBConnection, + new_messages: &[ChatMessageTable], +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + for message in new_messages { + let _ = insert_into(chat_message_table::table) + .values(message) + .on_conflict(chat_message_table::message_id) + .do_update() + .set(( + chat_message_table::content.eq(excluded(chat_message_table::content)), + chat_message_table::created_at.eq(excluded(chat_message_table::created_at)), + chat_message_table::author_type.eq(excluded(chat_message_table::author_type)), + chat_message_table::author_id.eq(excluded(chat_message_table::author_id)), + chat_message_table::reply_message_id.eq(excluded(chat_message_table::reply_message_id)), + )) + .execute(conn)?; + } + Ok::<(), FlowyError>(()) + })?; + + Ok(()) +} + +pub fn insert_answer_message( + mut conn: DBConnection, + question_message_id: i64, + message: ChatMessageTable, +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + // Step 1: Get the message with the given question_message_id + let question_message = dsl::chat_message_table + .filter(chat_message_table::message_id.eq(question_message_id)) + .first::(conn)?; + + // Step 2: Use reply_message_id from the retrieved message to delete the existing message + if let Some(reply_id) = question_message.reply_message_id { + diesel::delete(dsl::chat_message_table.filter(chat_message_table::message_id.eq(reply_id))) + .execute(conn)?; + } + + // Step 3: Insert the new message + let _ = insert_into(chat_message_table::table) + .values(message) + .on_conflict(chat_message_table::message_id) + .do_update() + .set(( + chat_message_table::content.eq(excluded(chat_message_table::content)), + chat_message_table::created_at.eq(excluded(chat_message_table::created_at)), + chat_message_table::author_type.eq(excluded(chat_message_table::author_type)), + chat_message_table::author_id.eq(excluded(chat_message_table::author_id)), + chat_message_table::reply_message_id.eq(excluded(chat_message_table::reply_message_id)), + )) + .execute(conn)?; + Ok::<(), FlowyError>(()) + })?; + + Ok(()) +} +pub fn select_chat_messages( + mut conn: DBConnection, + chat_id_val: &str, + limit_val: i64, + after_message_id: Option, + before_message_id: Option, +) -> QueryResult> { + let mut query = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .into_boxed(); + if let Some(after_message_id) = after_message_id { + query = query.filter(chat_message_table::message_id.gt(after_message_id)); + } + + if let Some(before_message_id) = before_message_id { + query = query.filter(chat_message_table::message_id.lt(before_message_id)); + } + query = query + .order((chat_message_table::message_id.desc(),)) + .limit(limit_val); + + let messages: Vec = query.load::(&mut *conn)?; + Ok(messages) +} diff --git a/frontend/rust-lib/flowy-chat/src/persistence/chat_sql.rs b/frontend/rust-lib/flowy-chat/src/persistence/chat_sql.rs new file mode 100644 index 0000000000000..1fd0480c54810 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/persistence/chat_sql.rs @@ -0,0 +1,52 @@ +use flowy_sqlite::upsert::excluded; +use flowy_sqlite::{ + diesel, + query_dsl::*, + schema::{chat_table, chat_table::dsl}, + DBConnection, ExpressionMethods, Identifiable, Insertable, QueryResult, Queryable, +}; + +#[derive(Clone, Default, Queryable, Insertable, Identifiable)] +#[diesel(table_name = chat_table)] +#[diesel(primary_key(chat_id))] +pub struct ChatTable { + pub chat_id: String, + pub created_at: i64, + pub name: String, +} + +pub fn insert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult { + diesel::insert_into(chat_table::table) + .values(new_chat) + .on_conflict(chat_table::chat_id) + .do_update() + .set(( + chat_table::created_at.eq(excluded(chat_table::created_at)), + chat_table::name.eq(excluded(chat_table::name)), + )) + .execute(&mut *conn) +} + +#[allow(dead_code)] +pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { + let row = dsl::chat_table + .filter(chat_table::chat_id.eq(chat_id_val)) + .first::(&mut *conn)?; + Ok(row) +} + +#[allow(dead_code)] +pub fn update_chat_name( + mut conn: DBConnection, + chat_id_val: &str, + new_name: &str, +) -> QueryResult { + diesel::update(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))) + .set(chat_table::name.eq(new_name)) + .execute(&mut *conn) +} + +#[allow(dead_code)] +pub fn delete_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { + diesel::delete(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))).execute(&mut *conn) +} diff --git a/frontend/rust-lib/flowy-chat/src/persistence/mod.rs b/frontend/rust-lib/flowy-chat/src/persistence/mod.rs new file mode 100644 index 0000000000000..b21eb507ae0d3 --- /dev/null +++ b/frontend/rust-lib/flowy-chat/src/persistence/mod.rs @@ -0,0 +1,5 @@ +mod chat_message_sql; +mod chat_sql; + +pub use chat_message_sql::*; +pub use chat_sql::*; diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 289b4751b02a8..0e5687ce2e59b 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -31,6 +31,8 @@ diesel.workspace = true uuid.workspace = true flowy-storage = { workspace = true } client-api.workspace = true +flowy-chat = { workspace = true } +flowy-chat-pub = { workspace = true } tracing.workspace = true futures-core = { version = "0.3", default-features = false } @@ -61,6 +63,7 @@ dart = [ "flowy-search/dart", "flowy-folder/dart", "flowy-database2/dart", + "flowy-chat/dart", ] ts = [ "flowy-user/tauri_ts", @@ -68,6 +71,7 @@ ts = [ "flowy-search/tauri_ts", "flowy-database2/ts", "flowy-config/tauri_ts", + "flowy-chat/tauri_ts", ] openssl_vendored = ["flowy-sqlite/openssl_vendored"] diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs new file mode 100644 index 0000000000000..9ba1604182549 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs @@ -0,0 +1,47 @@ +use flowy_chat::manager::{ChatManager, ChatUserService}; +use flowy_chat_pub::cloud::ChatCloudService; +use flowy_error::FlowyError; +use flowy_sqlite::DBConnection; +use flowy_user::services::authenticate_user::AuthenticateUser; +use std::sync::{Arc, Weak}; + +pub struct ChatDepsResolver; + +impl ChatDepsResolver { + pub fn resolve( + authenticate_user: Weak, + cloud_service: Arc, + ) -> Arc { + let user_service = ChatUserServiceImpl(authenticate_user); + Arc::new(ChatManager::new(cloud_service, user_service)) + } +} + +struct ChatUserServiceImpl(Weak); +impl ChatUserServiceImpl { + fn upgrade_user(&self) -> Result, FlowyError> { + let user = self + .0 + .upgrade() + .ok_or(FlowyError::internal().with_context("Unexpected error: UserSession is None"))?; + Ok(user) + } +} + +impl ChatUserService for ChatUserServiceImpl { + fn user_id(&self) -> Result { + self.upgrade_user()?.user_id() + } + + fn device_id(&self) -> Result { + self.upgrade_user()?.device_id() + } + + fn workspace_id(&self) -> Result { + self.upgrade_user()?.workspace_id() + } + + fn sqlite_connection(&self, uid: i64) -> Result { + self.upgrade_user()?.get_sqlite_connection(uid) + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs index 1a9fd4160ec64..92e09996d5e3d 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs @@ -1,6 +1,7 @@ use bytes::Bytes; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; +use flowy_chat::manager::ChatManager; use flowy_database2::entities::DatabaseLayoutPB; use flowy_database2::services::share::csv::CSVFormat; use flowy_database2::template::{make_default_board, make_default_calendar, make_default_grid}; @@ -9,10 +10,11 @@ use flowy_document::entities::DocumentDataPB; use flowy_document::manager::DocumentManager; use flowy_document::parser::json::parser::JsonToDocumentParser; use flowy_error::FlowyError; -use flowy_folder::entities::ViewLayoutPB; use flowy_folder::manager::{FolderManager, FolderUser}; use flowy_folder::share::ImportType; -use flowy_folder::view_operation::{FolderOperationHandler, FolderOperationHandlers, View}; +use flowy_folder::view_operation::{ + FolderOperationHandler, FolderOperationHandlers, View, ViewData, +}; use flowy_folder::ViewLayout; use flowy_folder_pub::folder_builder::NestedViewBuilder; use flowy_search::folder::indexer::FolderIndexManagerImpl; @@ -35,12 +37,17 @@ impl FolderDepsResolver { collab_builder: Arc, server_provider: Arc, folder_indexer: Arc, + chat_manager: &Arc, ) -> Arc { let user: Arc = Arc::new(FolderUserImpl { authenticate_user: authenticate_user.clone(), }); - let handlers = folder_operation_handlers(document_manager.clone(), database_manager.clone()); + let handlers = folder_operation_handlers( + document_manager.clone(), + database_manager.clone(), + chat_manager.clone(), + ); Arc::new( FolderManager::new( user.clone(), @@ -58,6 +65,7 @@ impl FolderDepsResolver { fn folder_operation_handlers( document_manager: Arc, database_manager: Arc, + chat_manager: Arc, ) -> FolderOperationHandlers { let mut map: HashMap> = HashMap::new(); @@ -65,9 +73,11 @@ fn folder_operation_handlers( map.insert(ViewLayout::Document, document_folder_operation); let database_folder_operation = Arc::new(DatabaseFolderOperation(database_manager)); + let chat_folder_operation = Arc::new(ChatFolderOperation(chat_manager)); map.insert(ViewLayout::Board, database_folder_operation.clone()); map.insert(ViewLayout::Grid, database_folder_operation.clone()); map.insert(ViewLayout::Calendar, database_folder_operation); + map.insert(ViewLayout::Chat, chat_folder_operation); Arc::new(map) } @@ -315,7 +325,15 @@ impl FolderOperationHandler for DatabaseFolderOperation { }, Some(params) => { let database_manager = self.0.clone(); - let layout = layout_type_from_view_layout(layout.into()); + + let layout = match layout { + ViewLayout::Board => DatabaseLayoutPB::Board, + ViewLayout::Calendar => DatabaseLayoutPB::Calendar, + ViewLayout::Grid => DatabaseLayoutPB::Grid, + ViewLayout::Document | ViewLayout::Chat => { + return FutureResult::new(async move { Err(FlowyError::not_support()) }); + }, + }; let name = name.to_string(); let database_view_id = view_id.to_string(); @@ -351,6 +369,10 @@ impl FolderOperationHandler for DatabaseFolderOperation { Err(FlowyError::internal().with_context(format!("Can't handle {:?} layout type", layout))) }); }, + ViewLayout::Chat => { + // TODO(nathan): AI + todo!("AI") + }, }; FutureResult::new(async move { let result = database_manager.create_database_with_params(data).await; @@ -413,7 +435,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { fn did_update_view(&self, old: &View, new: &View) -> FutureResult<(), FlowyError> { let database_layout = match new.layout { - ViewLayout::Document => { + ViewLayout::Document | ViewLayout::Chat => { return FutureResult::new(async { Err(FlowyError::internal().with_context("Can't handle document layout type")) }); @@ -450,11 +472,83 @@ impl CreateDatabaseExtParams { } } -pub fn layout_type_from_view_layout(layout: ViewLayoutPB) -> DatabaseLayoutPB { - match layout { - ViewLayoutPB::Grid => DatabaseLayoutPB::Grid, - ViewLayoutPB::Board => DatabaseLayoutPB::Board, - ViewLayoutPB::Calendar => DatabaseLayoutPB::Calendar, - ViewLayoutPB::Document => DatabaseLayoutPB::Grid, +struct ChatFolderOperation(Arc); +impl FolderOperationHandler for ChatFolderOperation { + fn open_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { + let manager = self.0.clone(); + let view_id = view_id.to_string(); + FutureResult::new(async move { + manager.open_chat(&view_id).await?; + Ok(()) + }) + } + + fn close_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { + let manager = self.0.clone(); + let view_id = view_id.to_string(); + FutureResult::new(async move { + manager.close_chat(&view_id).await?; + Ok(()) + }) + } + + fn delete_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { + let manager = self.0.clone(); + let view_id = view_id.to_string(); + FutureResult::new(async move { + manager.delete_chat(&view_id).await?; + Ok(()) + }) + } + + fn duplicate_view(&self, _view_id: &str) -> FutureResult { + FutureResult::new(async move { Err(FlowyError::not_support()) }) + } + + fn create_view_with_view_data( + &self, + _user_id: i64, + _view_id: &str, + _name: &str, + _data: Vec, + _layout: ViewLayout, + _meta: HashMap, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async move { Err(FlowyError::not_support()) }) + } + + fn create_built_in_view( + &self, + user_id: i64, + view_id: &str, + _name: &str, + _layout: ViewLayout, + ) -> FutureResult<(), FlowyError> { + let manager = self.0.clone(); + let view_id = view_id.to_string(); + FutureResult::new(async move { + manager.create_chat(&user_id, &view_id).await?; + Ok(()) + }) + } + + fn import_from_bytes( + &self, + _uid: i64, + _view_id: &str, + _name: &str, + _import_type: ImportType, + _bytes: Vec, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async move { Err(FlowyError::not_support()) }) + } + + fn import_from_file_path( + &self, + _view_id: &str, + _name: &str, + _path: String, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async move { Err(FlowyError::not_support()) }) } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs index a93530e519cda..a75589e89e23e 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs @@ -1,3 +1,4 @@ +pub use chat_deps::*; pub use collab_deps::*; pub use database_deps::*; pub use document_deps::*; @@ -9,6 +10,7 @@ mod collab_deps; mod document_deps; mod folder_deps; +mod chat_deps; mod database_deps; mod search_deps; mod user_deps; diff --git a/frontend/rust-lib/flowy-core/src/integrate/log.rs b/frontend/rust-lib/flowy-core/src/integrate/log.rs index 66c37837d1e27..c855a557ac8cf 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/log.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/log.rs @@ -52,6 +52,7 @@ pub fn create_log_filter(level: String, with_crates: Vec, platform: Plat filters.push(format!("flowy_notification={}", "info")); filters.push(format!("lib_infra={}", level)); filters.push(format!("flowy_search={}", level)); + filters.push(format!("flowy_chat={}", level)); // Enable the frontend logs. DO NOT DISABLE. // These logs are essential for debugging and verifying frontend behavior. filters.push(format!("dart_ffi={}", level)); diff --git a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs index a4c3638d4155a..2e672dec6cd94 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs @@ -3,8 +3,10 @@ use std::sync::Arc; use anyhow::Error; use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; - +use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use client_api::entity::ChatMessageType; use collab::core::origin::{CollabClient, CollabOrigin}; + use collab::preclude::CollabPlugin; use collab_entity::CollabType; use collab_plugins::cloud_storage::postgres::SupabaseDBPlugin; @@ -14,6 +16,9 @@ use tracing::debug; use collab_integrate::collab_builder::{ CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, }; +use flowy_chat_pub::cloud::{ + ChatCloudService, ChatMessage, ChatMessageStream, MessageCursor, RepeatedChatMessage, +}; use flowy_database_pub::cloud::{ CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, }; @@ -28,6 +33,7 @@ use flowy_server_pub::supabase_config::SupabaseConfiguration; use flowy_storage::ObjectValue; use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; use flowy_user_pub::entities::{Authenticator, UserTokenState}; +use lib_infra::async_trait::async_trait; use lib_infra::future::FutureResult; use crate::integrate::server::{Server, ServerProvider}; @@ -372,7 +378,12 @@ impl CollabCloudPluginProvider for ServerProvider { collab_object.uid, collab_object.device_id.clone(), )); - let sync_object = SyncObject::from(collab_object); + let sync_object = SyncObject::new( + &collab_object.object_id, + &collab_object.workspace_id, + collab_object.collab_type, + &collab_object.device_id, + ); let (sink, stream) = (channel.sink(), channel.stream()); let sink_config = SinkConfig::new().send_timeout(8); let sync_plugin = SyncPlugin::new( @@ -427,3 +438,93 @@ impl CollabCloudPluginProvider for ServerProvider { *self.user_enable_sync.read() } } + +#[async_trait] +impl ChatCloudService for ServerProvider { + fn create_chat( + &self, + uid: &i64, + workspace_id: &str, + chat_id: &str, + ) -> FutureResult<(), FlowyError> { + let workspace_id = workspace_id.to_string(); + let server = self.get_server(); + let chat_id = chat_id.to_string(); + let uid = *uid; + FutureResult::new(async move { + server? + .chat_service() + .create_chat(&uid, &workspace_id, &chat_id) + .await + }) + } + + async fn send_chat_message( + &self, + workspace_id: &str, + chat_id: &str, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let message = message.to_string(); + let server = self.get_server()?; + server + .chat_service() + .send_chat_message(&workspace_id, &chat_id, &message, message_type) + .await + } + + fn get_chat_messages( + &self, + workspace_id: &str, + chat_id: &str, + offset: MessageCursor, + limit: u64, + ) -> FutureResult { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let server = self.get_server(); + FutureResult::new(async move { + server? + .chat_service() + .get_chat_messages(&workspace_id, &chat_id, offset, limit) + .await + }) + } + + fn get_related_message( + &self, + workspace_id: &str, + chat_id: &str, + message_id: i64, + ) -> FutureResult { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let server = self.get_server(); + FutureResult::new(async move { + server? + .chat_service() + .get_related_message(&workspace_id, &chat_id, message_id) + .await + }) + } + + fn generate_answer( + &self, + workspace_id: &str, + chat_id: &str, + question_message_id: i64, + ) -> FutureResult { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let server = self.get_server(); + FutureResult::new(async move { + server? + .chat_service() + .generate_answer(&workspace_id, &chat_id, question_message_id) + .await + }) + } +} diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 2ba29b83fdd55..80bb1213f5869 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -10,6 +10,7 @@ use tokio::sync::RwLock; use tracing::{debug, error, event, info, instrument}; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabPluginProviderType}; +use flowy_chat::manager::ChatManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; use flowy_error::{FlowyError, FlowyResult}; @@ -57,6 +58,7 @@ pub struct AppFlowyCore { pub task_dispatcher: Arc>, pub store_preference: Arc, pub search_manager: Arc, + pub chat_manager: Arc, } impl AppFlowyCore { @@ -137,6 +139,7 @@ impl AppFlowyCore { document_manager, collab_builder, search_manager, + chat_manager, ) = async { /// The shared collab builder is used to build the [Collab] instance. The plugins will be loaded /// on demand based on the [CollabPluginConfig]. @@ -164,6 +167,8 @@ impl AppFlowyCore { Arc::downgrade(&(server_provider.clone() as Arc)), ); + let chat_manager = + ChatDepsResolver::resolve(Arc::downgrade(&authenticate_user), server_provider.clone()); let folder_indexer = Arc::new(FolderIndexManagerImpl::new(None)); let folder_manager = FolderDepsResolver::resolve( Arc::downgrade(&authenticate_user), @@ -172,6 +177,7 @@ impl AppFlowyCore { collab_builder.clone(), server_provider.clone(), folder_indexer.clone(), + &chat_manager, ) .await; @@ -195,6 +201,7 @@ impl AppFlowyCore { document_manager, collab_builder, search_manager, + chat_manager, ) } .await; @@ -230,6 +237,7 @@ impl AppFlowyCore { Arc::downgrade(&user_manager), Arc::downgrade(&document_manager), Arc::downgrade(&search_manager), + Arc::downgrade(&chat_manager), ), )); @@ -244,6 +252,7 @@ impl AppFlowyCore { task_dispatcher, store_preference, search_manager, + chat_manager, } } diff --git a/frontend/rust-lib/flowy-core/src/module.rs b/frontend/rust-lib/flowy-core/src/module.rs index 8d021955efecc..7077007915f9c 100644 --- a/frontend/rust-lib/flowy-core/src/module.rs +++ b/frontend/rust-lib/flowy-core/src/module.rs @@ -1,3 +1,4 @@ +use flowy_chat::manager::ChatManager; use std::sync::Weak; use flowy_database2::DatabaseManager; @@ -13,6 +14,7 @@ pub fn make_plugins( user_session: Weak, document_manager2: Weak, search_manager: Weak, + chat_manager: Weak, ) -> Vec { let store_preferences = user_session .upgrade() @@ -25,6 +27,7 @@ pub fn make_plugins( let config_plugin = flowy_config::event_map::init(store_preferences); let date_plugin = flowy_date::event_map::init(); let search_plugin = flowy_search::event_map::init(search_manager); + let chat_plugin = flowy_chat::event_map::init(chat_manager); vec![ user_plugin, folder_plugin, @@ -33,5 +36,6 @@ pub fn make_plugins( config_plugin, date_plugin, search_plugin, + chat_plugin, ] } diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 876b8a69f338a..d5945ccaa3291 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -136,6 +136,7 @@ pub enum ViewLayoutPB { Grid = 1, Board = 2, Calendar = 3, + Chat = 4, } impl ViewLayoutPB { @@ -154,6 +155,7 @@ impl std::convert::From for ViewLayoutPB { ViewLayout::Board => ViewLayoutPB::Board, ViewLayout::Document => ViewLayoutPB::Document, ViewLayout::Calendar => ViewLayoutPB::Calendar, + ViewLayout::Chat => ViewLayoutPB::Chat, } } } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 35d2ffc72b6a6..b3a8f6a1f3aa2 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -796,7 +796,10 @@ impl FolderManager { if let Some(view) = &view { let view_layout: ViewLayout = view.layout.clone().into(); if let Some(handle) = self.operation_handlers.get(&view_layout) { - let _ = handle.open_view(view_id).await; + info!("Open view: {}", view.id); + if let Err(err) = handle.open_view(view_id).await { + error!("Open view error: {:?}", err); + } } } diff --git a/frontend/rust-lib/flowy-folder/src/view_operation.rs b/frontend/rust-lib/flowy-folder/src/view_operation.rs index c5dfcf6007be8..10d173394c0a4 100644 --- a/frontend/rust-lib/flowy-folder/src/view_operation.rs +++ b/frontend/rust-lib/flowy-folder/src/view_operation.rs @@ -115,6 +115,7 @@ impl From for ViewLayout { ViewLayoutPB::Grid => ViewLayout::Grid, ViewLayoutPB::Board => ViewLayout::Board, ViewLayoutPB::Calendar => ViewLayout::Calendar, + ViewLayoutPB::Chat => ViewLayout::Chat, } } } diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index d11cb2b594a46..a92b0730ee6b5 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -41,6 +41,7 @@ flowy-error = { workspace = true, features = ["impl_from_serde", "impl_from_reqw flowy-server-pub = { workspace = true } flowy-encrypt = { workspace = true } flowy-storage = { workspace = true } +flowy-chat-pub = { workspace = true } mime_guess = "2.0" url = "2.4" tokio-util = "0.7" diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs new file mode 100644 index 0000000000000..cc484a9346b25 --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -0,0 +1,128 @@ +use crate::af_cloud::AFServer; +use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use client_api::entity::{ + CreateChatMessageParams, CreateChatParams, MessageCursor, RepeatedChatMessage, +}; +use flowy_chat_pub::cloud::{ChatCloudService, ChatMessage, ChatMessageStream, ChatMessageType}; +use flowy_error::FlowyError; +use futures_util::StreamExt; +use lib_infra::async_trait::async_trait; +use lib_infra::future::FutureResult; + +pub(crate) struct AFCloudChatCloudServiceImpl { + pub inner: T, +} + +#[async_trait] +impl ChatCloudService for AFCloudChatCloudServiceImpl +where + T: AFServer, +{ + fn create_chat( + &self, + _uid: &i64, + workspace_id: &str, + chat_id: &str, + ) -> FutureResult<(), FlowyError> { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let try_get_client = self.inner.try_get_client(); + + FutureResult::new(async move { + let params = CreateChatParams { + chat_id, + name: "".to_string(), + rag_ids: vec![], + }; + try_get_client? + .create_chat(&workspace_id, params) + .await + .map_err(FlowyError::from)?; + + Ok(()) + }) + } + + async fn send_chat_message( + &self, + workspace_id: &str, + chat_id: &str, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let message = message.to_string(); + let try_get_client = self.inner.try_get_client(); + let params = CreateChatMessageParams { + content: message, + message_type, + }; + let stream = try_get_client? + .create_chat_message(&workspace_id, &chat_id, params) + .await + .map_err(FlowyError::from)?; + + Ok(stream.boxed()) + } + + fn get_chat_messages( + &self, + workspace_id: &str, + chat_id: &str, + offset: MessageCursor, + limit: u64, + ) -> FutureResult { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let try_get_client = self.inner.try_get_client(); + + FutureResult::new(async move { + let resp = try_get_client? + .get_chat_messages(&workspace_id, &chat_id, offset, limit) + .await + .map_err(FlowyError::from)?; + + Ok(resp) + }) + } + + fn get_related_message( + &self, + workspace_id: &str, + chat_id: &str, + message_id: i64, + ) -> FutureResult { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let try_get_client = self.inner.try_get_client(); + + FutureResult::new(async move { + let resp = try_get_client? + .get_chat_related_question(&workspace_id, &chat_id, message_id) + .await + .map_err(FlowyError::from)?; + + Ok(resp) + }) + } + + fn generate_answer( + &self, + workspace_id: &str, + chat_id: &str, + question_message_id: i64, + ) -> FutureResult { + let workspace_id = workspace_id.to_string(); + let chat_id = chat_id.to_string(); + let try_get_client = self.inner.try_get_client(); + + FutureResult::new(async move { + let resp = try_get_client? + .generate_question_answer(&workspace_id, &chat_id, question_message_id) + .await + .map_err(FlowyError::from)?; + Ok(resp) + }) + } +} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index 6b2a67c67b224..ea995b7b1e432 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -42,10 +42,7 @@ where FutureResult::new(async move { let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: object_id.clone(), - collab_type: collab_type.clone(), - }, + inner: QueryCollab::new(object_id.clone(), collab_type.clone()), }; match try_get_client?.get_collab(params).await { Ok(data) => { @@ -81,10 +78,7 @@ where let client = try_get_client?; let params = object_ids .into_iter() - .map(|object_id| QueryCollab { - object_id, - collab_type: object_ty.clone(), - }) + .map(|object_id| QueryCollab::new(object_id, object_ty.clone())) .collect(); let results = client.batch_get_collab(&workspace_id, params).await?; check_request_workspace_id_is_match( diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index c5d88ba15c02a..98732aa521cfb 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -37,10 +37,7 @@ where FutureResult::new(async move { let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: document_id.to_string(), - collab_type: CollabType::Document, - }, + inner: QueryCollab::new(document_id.to_string(), CollabType::Document), }; let doc_state = try_get_client? .get_collab(params) @@ -82,10 +79,7 @@ where FutureResult::new(async move { let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: document_id.clone(), - collab_type: CollabType::Document, - }, + inner: QueryCollab::new(document_id.clone(), CollabType::Document), }; let doc_state = try_get_client? .get_collab(params) diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index c33569aea8c03..fe58f3fc16083 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -93,10 +93,7 @@ where FutureResult::new(async move { let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: workspace_id.clone(), - collab_type: CollabType::Folder, - }, + inner: QueryCollab::new(workspace_id.clone(), CollabType::Folder), }; let doc_state = try_get_client? .get_collab(params) @@ -140,10 +137,7 @@ where FutureResult::new(async move { let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id, - collab_type, - }, + inner: QueryCollab::new(object_id, collab_type), }; let doc_state = try_get_client? .get_collab(params) @@ -167,10 +161,12 @@ where FutureResult::new(async move { let params = objects .into_iter() - .map(|object| CollabParams { - object_id: object.object_id, - encoded_collab_v1: object.encoded_collab_v1, - collab_type: object.collab_type, + .map(|object| { + CollabParams::new( + object.object_id, + object.collab_type, + object.encoded_collab_v1, + ) }) .collect::>(); try_get_client? diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs index 3ecd8391095bf..37ad00781c61a 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs @@ -1,9 +1,11 @@ +pub(crate) use chat::*; pub(crate) use database::*; pub(crate) use document::*; pub(crate) use file_storage::*; pub(crate) use folder::*; pub(crate) use user::*; +mod chat; mod database; mod document; mod file_storage; diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index f602b404f8c65..858e74f5012f1 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -348,10 +348,7 @@ where FutureResult::new(async move { let params = QueryCollabParams { workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id, - collab_type: CollabType::UserAwareness, - }, + inner: QueryCollab::new(object_id, CollabType::UserAwareness), }; let resp = try_get_client?.get_collab(params).await?; check_request_workspace_id_is_match( @@ -381,10 +378,10 @@ where FutureResult::new(async move { let client = try_get_client?; let params = CreateCollabParams { - workspace_id: collab_object.workspace_id.clone(), - object_id: collab_object.object_id.clone(), + workspace_id: collab_object.workspace_id, + object_id: collab_object.object_id, + collab_type: collab_object.collab_type, encoded_collab_v1: data, - collab_type: collab_object.collab_type.clone(), }; client.create_collab(params).await?; Ok(()) @@ -401,10 +398,12 @@ where FutureResult::new(async move { let params = objects .into_iter() - .map(|object| CollabParams { - object_id: object.object_id, - encoded_collab_v1: object.encoded_collab, - collab_type: object.collab_type, + .map(|object| { + CollabParams::new( + object.object_id, + u8::from(object.collab_type).into(), + object.encoded_collab, + ) }) .collect::>(); try_get_client? diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs index c24ddbb51eead..a33852dd53c1e 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs @@ -89,6 +89,7 @@ pub fn from_af_workspace_member(member: AFWorkspaceMember) -> WorkspaceMember { email: member.email, role: from_af_role(member.role), name: member.name, + avatar_url: member.avatar_url, } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs index c758be253a869..e5fb8000394b1 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs @@ -10,6 +10,7 @@ use client_api::ws::{ ConnectState, WSClient, WSClientConfig, WSConnectStateReceiver, WebSocketChannel, }; use client_api::{Client, ClientConfiguration}; +use flowy_chat_pub::cloud::ChatCloudService; use flowy_storage::ObjectStorageService; use rand::Rng; use semver::Version; @@ -31,9 +32,10 @@ use flowy_user_pub::entities::UserTokenState; use lib_dispatch::prelude::af_spawn; use crate::af_cloud::impls::{ - AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, AFCloudFileStorageServiceImpl, - AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, + AFCloudChatCloudServiceImpl, AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, + AFCloudFileStorageServiceImpl, AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, }; + use crate::AppFlowyServer; pub(crate) type AFCloudClient = Client; @@ -214,6 +216,13 @@ impl AppFlowyServer for AppFlowyCloudServer { }) } + fn chat_service(&self) -> Arc { + let server = AFServerImpl { + client: self.get_client(), + }; + Arc::new(AFCloudChatCloudServiceImpl { inner: server }) + } + fn subscribe_ws_state(&self) -> Option { Some(self.ws_client.subscribe_connect_state()) } diff --git a/frontend/rust-lib/flowy-server/src/default_impl.rs b/frontend/rust-lib/flowy-server/src/default_impl.rs new file mode 100644 index 0000000000000..654bffcb1ce56 --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/default_impl.rs @@ -0,0 +1,66 @@ +use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use client_api::entity::{ChatMessageType, MessageCursor, RepeatedChatMessage}; +use flowy_chat_pub::cloud::{ChatCloudService, ChatMessage, ChatMessageStream}; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use lib_infra::future::FutureResult; + +pub(crate) struct DefaultChatCloudServiceImpl; + +#[async_trait] +impl ChatCloudService for DefaultChatCloudServiceImpl { + fn create_chat( + &self, + _uid: &i64, + _workspace_id: &str, + _chat_id: &str, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async move { + Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + }) + } + + async fn send_chat_message( + &self, + _workspace_id: &str, + _chat_id: &str, + _message: &str, + _message_type: ChatMessageType, + ) -> Result { + Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + } + + fn get_chat_messages( + &self, + _workspace_id: &str, + _chat_id: &str, + _offset: MessageCursor, + _limit: u64, + ) -> FutureResult { + FutureResult::new(async move { + Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + }) + } + + fn get_related_message( + &self, + _workspace_id: &str, + _chat_id: &str, + _message_id: i64, + ) -> FutureResult { + FutureResult::new(async move { + Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + }) + } + + fn generate_answer( + &self, + _workspace_id: &str, + _chat_id: &str, + _question_message_id: i64, + ) -> FutureResult { + FutureResult::new(async move { + Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) + }) + } +} diff --git a/frontend/rust-lib/flowy-server/src/lib.rs b/frontend/rust-lib/flowy-server/src/lib.rs index 4e647f4210e29..704e9e0e49436 100644 --- a/frontend/rust-lib/flowy-server/src/lib.rs +++ b/frontend/rust-lib/flowy-server/src/lib.rs @@ -8,4 +8,5 @@ mod server; #[cfg(feature = "enable_supabase")] pub mod supabase; +mod default_impl; pub mod util; diff --git a/frontend/rust-lib/flowy-server/src/server.rs b/frontend/rust-lib/flowy-server/src/server.rs index 679771d162462..70a8e4b9a8a97 100644 --- a/frontend/rust-lib/flowy-server/src/server.rs +++ b/frontend/rust-lib/flowy-server/src/server.rs @@ -6,11 +6,13 @@ use std::sync::Arc; use anyhow::Error; use client_api::collab_sync::ServerCollabMessage; +use flowy_chat_pub::cloud::ChatCloudService; use parking_lot::RwLock; use tokio_stream::wrappers::WatchStream; #[cfg(feature = "enable_supabase")] use {collab_entity::CollabObject, collab_plugins::cloud_storage::RemoteCollabStorage}; +use crate::default_impl::DefaultChatCloudServiceImpl; use flowy_database_pub::cloud::DatabaseCloudService; use flowy_document_pub::cloud::DocumentCloudService; use flowy_folder_pub::cloud::FolderCloudService; @@ -94,6 +96,10 @@ pub trait AppFlowyServer: Send + Sync + 'static { /// An `Arc` wrapping the `DocumentCloudService` interface. fn document_service(&self) -> Arc; + fn chat_service(&self) -> Arc { + Arc::new(DefaultChatCloudServiceImpl) + } + /// Manages collaborative objects within a remote storage system. This includes operations such as /// checking storage status, retrieving updates and snapshots, and dispatching updates. The service /// also provides subscription capabilities for real-time updates. diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/down.sql new file mode 100644 index 0000000000000..943e78adf2df8 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +drop table chat_table; +drop table chat_message_table; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/up.sql new file mode 100644 index 0000000000000..192e7cf763305 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/up.sql @@ -0,0 +1,20 @@ +-- Create table for chat documents +CREATE TABLE chat_table +( + chat_id TEXT PRIMARY KEY NOT NULL, + created_at BIGINT NOT NULL, + name TEXT NOT NULL DEFAULT '' +); + +-- Create table for chat messages +CREATE TABLE chat_message_table +( + message_id BIGINT PRIMARY KEY NOT NULL, + chat_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at BIGINT NOT NULL, + author_type BIGINT NOT NULL, + author_id TEXT NOT NULL, + reply_message_id BIGINT +); +CREATE INDEX idx_chat_messages_chat_id_message_id ON chat_message_table (chat_id, message_id); diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 37c2ff8bbd01a..24290ea5d85df 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -1,5 +1,25 @@ // @generated automatically by Diesel CLI. +diesel::table! { + chat_message_table (message_id) { + message_id -> BigInt, + chat_id -> Text, + content -> Text, + created_at -> BigInt, + author_type -> BigInt, + author_id -> Text, + reply_message_id -> Nullable, + } +} + +diesel::table! { + chat_table (chat_id) { + chat_id -> Text, + created_at -> BigInt, + name -> Text, + } +} + diesel::table! { collab_snapshot (id) { id -> Text, @@ -48,8 +68,10 @@ diesel::table! { } diesel::allow_tables_to_appear_in_same_query!( - collab_snapshot, - user_data_migration_records, - user_table, - user_workspace_table, + chat_message_table, + chat_table, + collab_snapshot, + user_data_migration_records, + user_table, + user_workspace_table, ); diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index db9a81e8d81d8..6d579c70dc0f6 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -395,6 +395,7 @@ pub struct WorkspaceMember { pub email: String, pub role: Role, pub name: String, + pub avatar_url: Option, } /// represent the user awareness object id for the workspace. diff --git a/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs b/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs index c6435531f329e..cee388e77b712 100644 --- a/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs +++ b/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs @@ -170,6 +170,7 @@ fn sync_view( } } }, + ViewLayout::Chat => {}, } tokio::task::yield_now().await; @@ -357,6 +358,7 @@ fn collab_type_from_view_layout(view_layout: &ViewLayout) -> CollabType { match view_layout { ViewLayout::Document => CollabType::Document, ViewLayout::Grid | ViewLayout::Board | ViewLayout::Calendar => CollabType::Database, + ViewLayout::Chat => CollabType::Unknown, } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index cdbd928fe09c6..bef8151d527ab 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -14,6 +14,9 @@ pub struct WorkspaceMemberPB { #[pb(index = 3)] pub role: AFRolePB, + + #[pb(index = 4, one_of)] + pub avatar_url: Option, } impl From for WorkspaceMemberPB { @@ -22,6 +25,7 @@ impl From for WorkspaceMemberPB { email: value.email, name: value.name, role: value.role.into(), + avatar_url: value.avatar_url, } } }