diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index c54ae23ed6..72df3b0bd7 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -175,7 +175,7 @@ SPEC CHECKSUMS: file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c + fluttertoast: 723e187574b149e68e63ca4d39b837586b903cfa image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 @@ -197,4 +197,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca -COCOAPODS: 1.15.2 +COCOAPODS: 1.11.3 diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_bloc.dart index a80731d3a8..1f32a045e4 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_bloc.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'chat_input_action_control.dart'; @@ -43,34 +45,28 @@ class ChatInputActionBloc ); }, refreshViews: (List views) { + final List pages = _filterPages( + views, + state.selectedPages, + state.filter, + ); emit( state.copyWith( views: views, - pages: views.map((v) => ViewActionPage(view: v)).toList(), + pages: pages, + indicator: const ChatActionMenuIndicator.ready(), ), ); }, filter: (String filter) { - final List pages = []; - if (filter.isEmpty) { - pages.addAll(state.views.map((v) => ViewActionPage(view: v))); - } else { - pages.addAll( - state.views - .where( - (v) => v.name.toLowerCase().contains( - filter.toLowerCase(), - ), - ) - .map( - (v) => ViewActionPage(view: v), - ), - ); - } - pages.retainWhere((view) { - return !state.selectedPages.contains(view); - }); - emit(state.copyWith(pages: pages)); + Log.debug("Filter chat input pages: $filter"); + final List pages = _filterPages( + state.views, + state.selectedPages, + filter, + ); + + emit(state.copyWith(pages: pages, filter: filter)); }, handleKeyEvent: (PhysicalKeyboardKey physicalKey) { emit( @@ -81,8 +77,14 @@ class ChatInputActionBloc }, addPage: (ChatInputActionPage page) { if (!state.selectedPages.any((p) => p.pageId == page.pageId)) { + final List pages = _filterPages( + state.views, + state.selectedPages, + state.filter, + ); emit( state.copyWith( + pages: pages, selectedPages: [...state.selectedPages, page], ), ); @@ -93,9 +95,11 @@ class ChatInputActionBloc List.from(state.selectedPages); selectedPages.retainWhere((t) => !text.contains(t.title)); - final allPages = - state.views.map((v) => ViewActionPage(view: v)).toList(); - allPages.retainWhere((view) => !selectedPages.contains(view)); + final List allPages = _filterPages( + state.views, + state.selectedPages, + state.filter, + ); emit( state.copyWith( @@ -108,6 +112,32 @@ class ChatInputActionBloc } } +List _filterPages( + List views, + List selectedPages, + String filter, +) { + final pages = views + .map( + (v) => ViewActionPage(view: v), + ) + .toList(); + + pages.retainWhere((page) { + return !selectedPages.contains(page); + }); + + if (filter.isEmpty) { + return pages; + } + + return pages + .where( + (v) => v.title.toLowerCase().contains(filter.toLowerCase()), + ) + .toList(); +} + class ViewActionPage extends ChatInputActionPage { ViewActionPage({required this.view}); @@ -124,6 +154,9 @@ class ViewActionPage extends ChatInputActionPage { @override dynamic get page => view; + + @override + Widget get icon => view.defaultIcon(); } @freezed @@ -146,7 +179,10 @@ class ChatInputActionState with _$ChatInputActionState { @Default([]) List views, @Default([]) List pages, @Default([]) List selectedPages, + @Default("") String filter, ChatInputKeyboardEvent? keyboardKey, + @Default(ChatActionMenuIndicator.loading()) + ChatActionMenuIndicator indicator, }) = _ChatInputActionState; } @@ -159,3 +195,9 @@ class ChatInputKeyboardEvent extends Equatable { @override List get props => [timestamp]; } + +@freezed +class ChatActionMenuIndicator with _$ChatActionMenuIndicator { + const factory ChatActionMenuIndicator.ready() = _Ready; + const factory ChatActionMenuIndicator.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_control.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_control.dart index eb6982bf5b..6569b3a1df 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_control.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_control.dart @@ -9,6 +9,7 @@ abstract class ChatInputActionPage extends Equatable { String get title; String get pageId; dynamic get page; + Widget get icon; } typedef ChatInputMetadata = Map; @@ -26,10 +27,9 @@ class ChatInputActionControl extends ChatActionHandler { final String chatId; // Private attributes - bool _isShowActionMenu = false; String _atText = ""; String _prevText = ""; - bool _didLoadViews = false; + String _showMenuText = ""; // Getter List get tags => @@ -43,12 +43,12 @@ class ChatInputActionControl extends ChatActionHandler { void handleKeyEvent(KeyEvent event) { // ignore: deprecated_member_use if (event is KeyDownEvent || event is RawKeyDownEvent) { - commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey)); + _commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey)); } } bool canHandleKeyEvent(KeyEvent event) { - return _isShowActionMenu && + return _showMenuText.isNotEmpty && { PhysicalKeyboardKey.arrowDown, PhysicalKeyboardKey.arrowUp, @@ -58,35 +58,29 @@ class ChatInputActionControl extends ChatActionHandler { } void dispose() { - commandBloc.close(); + _commandBloc.close(); } @override void onSelected(ChatInputActionPage page) { - _atText = ""; - _isShowActionMenu = false; - _commandBloc.add(ChatInputActionEvent.addPage(page)); - textController.text = - "${textController.text.replaceAll(_atText, '')}${page.title}"; - _prevText = textController.text; + textController.text = "$_showMenuText${page.title}"; + + onExit(); } @override void onExit() { _atText = ""; - _isShowActionMenu = false; - _didLoadViews = false; - commandBloc.add(const ChatInputActionEvent.filter("")); + _showMenuText = ""; + _prevText = ""; + _commandBloc.add(const ChatInputActionEvent.filter("")); } @override void onEnter() { - if (!_didLoadViews) { - _didLoadViews = true; - commandBloc.add(const ChatInputActionEvent.started()); - } - _isShowActionMenu = true; + _commandBloc.add(const ChatInputActionEvent.started()); + _showMenuText = textController.text; } @override @@ -134,16 +128,15 @@ class ChatInputActionControl extends ChatActionHandler { } // If the action menu is shown, filter the views - if (_isShowActionMenu) { - // before filter the views, remove the first character '@' if it exists - if (inputText.startsWith("@")) { - final filter = inputText.substring(1); - commandBloc.add(ChatInputActionEvent.filter(filter)); + if (_showMenuText.isNotEmpty) { + if (text.length >= _showMenuText.length) { + final filterText = inputText.substring(_showMenuText.length); + _commandBloc.add(ChatInputActionEvent.filter(filterText)); } // If the text change from "xxx @"" to "xxx", which means user delete the @, we should hide the action menu if (_atText.isNotEmpty && !inputText.contains(_atText)) { - commandBloc.add( + _commandBloc.add( const ChatInputActionEvent.handleKeyEvent(PhysicalKeyboardKey.escape), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart index a778ed8c13..cbc3327b72 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -489,6 +489,7 @@ class _ChatContentPageState extends State<_ChatContentPage> { child: BlocBuilder( builder: (context, state) { // Show different hint text based on the AI type + final aiType = state.aiType; final hintText = state.aiType.when( appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(), localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(), @@ -500,6 +501,7 @@ class _ChatContentPageState extends State<_ChatContentPage> { selector: (state) => state.streamingStatus, builder: (context, state) { return ChatInput( + aiType: aiType, chatId: widget.view.id, onSendPressed: (message) { context.read().add( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart index c492b9fcc4..faef4f8a80 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart'; import 'package:extended_text_field/extended_text_field.dart'; @@ -26,6 +27,7 @@ class ChatInput extends StatefulWidget { required this.isStreaming, required this.onStopStreaming, required this.hintText, + required this.aiType, }); final bool? isAttachmentUploading; @@ -36,6 +38,7 @@ class ChatInput extends StatefulWidget { final String chatId; final bool isStreaming; final String hintText; + final AIType aiType; @override State createState() => _ChatInputState(); @@ -117,7 +120,9 @@ class _ChatInputState extends State { Expanded(child: _inputTextField(textPadding)), // TODO(lucas): support mobile - if (PlatformExtension.isDesktop) _atButton(buttonPadding), + if (PlatformExtension.isDesktop && + widget.aiType == const AIType.appflowyAI()) + _atButton(buttonPadding), _sendButton(buttonPadding), const HSpace(14), ], @@ -169,6 +174,7 @@ class _ChatInputState extends State { decoration: InputDecoration( border: InputBorder.none, hintText: widget.hintText, + focusedBorder: InputBorder.none, hintStyle: TextStyle( color: AFThemeExtension.of(context).textColor.withOpacity(0.5), ), @@ -190,6 +196,10 @@ class _ChatInputState extends State { } void _handleOnTextChange(BuildContext context, String text) { + if (widget.aiType != const AIType.appflowyAI()) { + return; + } + if (PlatformExtension.isDesktop) { if (_inputActionControl.onTextChanged(text)) { ChatActionsMenu( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart index 0250c4be07..af181c6e46 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart @@ -78,6 +78,8 @@ class ChatActionsMenu { ), maxHeight, ); + final isLoading = + state.indicator == const ChatActionMenuIndicator.loading(); return Stack( children: [ @@ -106,6 +108,7 @@ class ChatActionsMenu { vertical: 2, ), child: ActionList( + isLoading: isLoading, handler: handler, onDismiss: () => dismiss(), pages: state.pages, @@ -149,6 +152,7 @@ class _ActionItem extends StatelessWidget { borderRadius: BorderRadius.circular(4.0), ), child: FlowyButton( + leftIcon: item.icon, margin: const EdgeInsets.symmetric(horizontal: 6), iconPadding: 10.0, text: FlowyText.regular( @@ -166,11 +170,13 @@ class ActionList extends StatefulWidget { required this.handler, required this.onDismiss, required this.pages, + required this.isLoading, }); final ChatActionHandler handler; final VoidCallback? onDismiss; final List pages; + final bool isLoading; @override State createState() => _ActionListState(); @@ -223,13 +229,22 @@ class _ActionListState extends State { child: ListView( shrinkWrap: true, controller: _scrollController, - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(4), children: _buildPages(), ), ); } List _buildPages() { + if (widget.isLoading) { + return [ + SizedBox( + height: _noPageHeight.toDouble(), + child: const Center(child: CircularProgressIndicator.adaptive()), + ), + ]; + } + if (widget.pages.isEmpty) { return [ SizedBox( diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs index 9043870af1..5f411a9130 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs @@ -30,13 +30,13 @@ impl Stream for LocalAIStreamAdaptor { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.project(); - return match ready!(this.stream.as_mut().poll_next(cx)) { + match ready!(this.stream.as_mut().poll_next(cx)) { Some(Ok(bytes)) => match String::from_utf8(bytes.to_vec()) { Ok(s) => Poll::Ready(Some(Ok(QuestionStreamValue::Answer { value: s }))), Err(err) => Poll::Ready(Some(Err(FlowyError::internal().with_context(err)))), }, Some(Err(err)) => Poll::Ready(Some(Err(FlowyError::local_ai().with_context(err)))), None => Poll::Ready(None), - }; + } } } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 1288bda52e..c5cfb2b85c 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -10,7 +10,7 @@ use crate::manager_observer::{ ChildViewChangeReason, }; use crate::notification::{ - send_notification, send_workspace_setting_notification, FolderNotification, + send_current_workspace_notification, send_notification, FolderNotification, }; use crate::publish_util::{generate_publish_name, view_pb_to_publish_view}; use crate::share::{ImportParams, ImportValue}; @@ -978,7 +978,11 @@ impl FolderManager { } let workspace_id = self.user.workspace_id()?; - send_workspace_setting_notification(workspace_id, view); + let setting = WorkspaceSettingPB { + workspace_id, + latest_view: view, + }; + send_current_workspace_notification(FolderNotification::DidUpdateWorkspaceSetting, setting); Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/src/notification.rs b/frontend/rust-lib/flowy-folder/src/notification.rs index 1ddcebcafd..bf72f2097e 100644 --- a/frontend/rust-lib/flowy-folder/src/notification.rs +++ b/frontend/rust-lib/flowy-folder/src/notification.rs @@ -2,8 +2,6 @@ use flowy_derive::ProtoBuf_Enum; use flowy_notification::NotificationBuilder; use lib_dispatch::prelude::ToBytes; -use crate::entities::{ViewPB, WorkspaceSettingPB}; - const FOLDER_OBSERVABLE_SOURCE: &str = "Workspace"; #[derive(ProtoBuf_Enum, Debug, Default)] @@ -78,20 +76,8 @@ pub(crate) fn send_notification(id: &str, ty: FolderNotification) -> Notificatio /// The [CURRENT_WORKSPACE] represents as the current workspace that opened by the /// user. Only one workspace can be opened at a time. const CURRENT_WORKSPACE: &str = "current-workspace"; -pub(crate) fn send_workspace_notification(ty: FolderNotification, payload: T) { +pub(crate) fn send_current_workspace_notification(ty: FolderNotification, payload: T) { send_notification(CURRENT_WORKSPACE, ty) .payload(payload) .send(); } - -pub(crate) fn send_workspace_setting_notification( - workspace_id: String, - latest_view: Option, -) -> Option<()> { - let setting = WorkspaceSettingPB { - workspace_id, - latest_view, - }; - send_workspace_notification(FolderNotification::DidUpdateWorkspaceSetting, setting); - None -}