feat: save messages as a new page (#7224)

This commit is contained in:
Richard Shiue 2025-01-17 20:38:55 +08:00 committed by GitHub
parent 63c7f7b6fe
commit 1723886f3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 877 additions and 200 deletions

View File

@ -14,6 +14,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
class ChatEditDocumentService {
const ChatEditDocumentService._();
static Future<ViewPB?> saveMessagesToNewPage(
String chatPageName,
String parentViewId,
@ -45,12 +47,13 @@ class ChatEditDocumentService {
).toNullable();
}
static Future<void> addMessageToPage(
static Future<void> addMessagesToPage(
String documentId,
TextMessage message,
List<TextMessage> messages,
) async {
if (message.text.isEmpty) {
Log.error('Message is empty');
// Convert messages to markdown and trim the last empty newline.
final completeMessage = messages.map((m) => m.text).join('\n').trimRight();
if (completeMessage.isEmpty) {
return;
}
@ -69,7 +72,7 @@ class ChatEditDocumentService {
return;
}
final messageDocument = customMarkdownToDocument(message.text);
final messageDocument = customMarkdownToDocument(completeMessage);
if (messageDocument.isEmpty) {
Log.error('Failed to convert message to document');
return;

View File

@ -12,13 +12,13 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
ChatMemberBloc() : super(const ChatMemberState()) {
on<ChatMemberEvent>(
(event, emit) async {
event.when(
await event.when(
receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) {
final members = Map<String, ChatMember>.from(state.members);
members[id] = ChatMember(info: memberInfo);
emit(state.copyWith(members: members));
},
getMemberInfo: (String userId) {
getMemberInfo: (String userId) async {
if (state.members.containsKey(userId)) {
// Member info already exists. Debouncing refresh member info from backend would be better.
return;
@ -27,19 +27,15 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
final payload = WorkspaceMemberIdPB(
uid: Int64.parseInt(userId),
);
UserEventGetMemberInfo(payload).send().then((result) {
if (!isClosed) {
result.fold((member) {
add(
ChatMemberEvent.receiveMemberInfo(
userId,
member,
),
);
}, (err) {
Log.error("Error getting member info: $err");
});
}
await UserEventGetMemberInfo(payload).send().then((result) {
result.fold(
(member) {
if (!isClosed) {
add(ChatMemberEvent.receiveMemberInfo(userId, member));
}
},
(err) => Log.error("Error getting member info: $err"),
);
});
},
);

View File

@ -0,0 +1,97 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_select_message_bloc.freezed.dart';
class ChatSelectMessageBloc
extends Bloc<ChatSelectMessageEvent, ChatSelectMessageState> {
ChatSelectMessageBloc({required this.viewNotifier})
: super(ChatSelectMessageState.initial()) {
_dispatch();
}
final ViewPluginNotifier viewNotifier;
void _dispatch() {
on<ChatSelectMessageEvent>(
(event, emit) {
event.when(
toggleSelectingMessages: () {
if (state.isSelectingMessages) {
emit(ChatSelectMessageState.initial());
} else {
emit(state.copyWith(isSelectingMessages: true));
}
},
toggleSelectMessage: (Message message) {
if (state.selectedMessages.contains(message)) {
emit(
state.copyWith(
selectedMessages: state.selectedMessages
.where((m) => m != message)
.toList(),
),
);
} else {
emit(
state.copyWith(
selectedMessages: [...state.selectedMessages, message],
),
);
}
},
selectAllMessages: (List<Message> messages) {
final filtered = messages.where(isAIMessage).toList();
emit(state.copyWith(selectedMessages: filtered));
},
unselectAllMessages: () {
emit(state.copyWith(selectedMessages: const []));
},
saveAsPage: () {
emit(ChatSelectMessageState.initial());
},
);
},
);
}
bool isMessageSelected(String messageId) =>
state.selectedMessages.any((m) => m.id == messageId);
bool isAIMessage(Message message) {
return message.author.id == aiResponseUserId ||
message.author.id == systemUserId ||
message.author.id.startsWith("streamId:");
}
}
@freezed
class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
const factory ChatSelectMessageEvent.toggleSelectingMessages() =
_ToggleSelectingMessages;
const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) =
_ToggleSelectMessage;
const factory ChatSelectMessageEvent.selectAllMessages(
List<Message> messages,
) = _SelectAllMessages;
const factory ChatSelectMessageEvent.unselectAllMessages() =
_UnselectAllMessages;
const factory ChatSelectMessageEvent.saveAsPage() = _SaveAsPage;
}
@freezed
class ChatSelectMessageState with _$ChatSelectMessageState {
const factory ChatSelectMessageState({
required bool isSelectingMessages,
required List<Message> selectedMessages,
}) = _ChatSelectMessageState;
factory ChatSelectMessageState.initial() => const ChatSelectMessageState(
isSelectingMessages: false,
selectedMessages: [],
);
}

View File

@ -1,14 +1,22 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.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/view_ext.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/home/menu/view/view_action_type.dart';
import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.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/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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_bloc/flutter_bloc.dart';
@ -46,13 +54,16 @@ class AIChatPagePlugin extends Plugin {
}) : notifier = ViewPluginNotifier(view: view);
late final ViewInfoBloc _viewInfoBloc;
late final _chatMessageSelectorBloc =
ChatSelectMessageBloc(viewNotifier: notifier);
@override
final ViewPluginNotifier notifier;
@override
PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder(
bloc: _viewInfoBloc,
viewInfoBloc: _viewInfoBloc,
chatMessageSelectorBloc: _chatMessageSelectorBloc,
notifier: notifier,
);
@ -71,6 +82,7 @@ class AIChatPagePlugin extends Plugin {
@override
void dispose() {
_viewInfoBloc.close();
_chatMessageSelectorBloc.close();
notifier.dispose();
}
}
@ -78,11 +90,13 @@ class AIChatPagePlugin extends Plugin {
class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
with NavigationItem {
AIChatPagePluginWidgetBuilder({
required this.bloc,
required this.viewInfoBloc,
required this.chatMessageSelectorBloc,
required this.notifier,
});
final ViewInfoBloc bloc;
final ViewInfoBloc viewInfoBloc;
final ChatSelectMessageBloc chatMessageSelectorBloc;
final ViewPluginNotifier notifier;
int? deletedViewIndex;
@ -110,8 +124,11 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
return const SizedBox();
}
return BlocProvider.value(
value: bloc,
return MultiBlocProvider(
providers: [
BlocProvider.value(value: chatMessageSelectorBloc),
BlocProvider.value(value: viewInfoBloc),
],
child: AIChatPage(
userProfile: context.userProfile!,
key: ValueKey(notifier.view.id),
@ -134,4 +151,51 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
@override
EdgeInsets get contentPadding => EdgeInsets.zero;
@override
Widget? get rightBarItem => MultiBlocProvider(
providers: [
BlocProvider.value(value: viewInfoBloc),
BlocProvider.value(value: chatMessageSelectorBloc),
],
child: BlocBuilder<ChatSelectMessageBloc, ChatSelectMessageState>(
builder: (context, state) {
if (state.isSelectingMessages) {
return const SizedBox.shrink();
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
ViewFavoriteButton(
key: ValueKey('favorite_button_${notifier.view.id}'),
view: notifier.view,
),
const HSpace(4),
MoreViewActions(
key: ValueKey(notifier.view.id),
view: notifier.view,
customActions: [
CustomViewAction(
view: notifier.view,
leftIcon: FlowySvgs.download_s,
label: LocaleKeys.moreAction_saveAsNewPage.tr(),
onTap: () {
chatMessageSelectorBloc.add(
const ChatSelectMessageEvent
.toggleSelectingMessages(),
);
},
),
ViewAction(
type: ViewMoreActionType.divider,
view: notifier.view,
),
],
),
],
);
},
),
);
}

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.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';
@ -22,6 +23,7 @@ import 'application/ai_prompt_input_bloc.dart';
import 'application/chat_bloc.dart';
import 'application/chat_entity.dart';
import 'application/chat_member_bloc.dart';
import 'application/chat_select_message_bloc.dart';
import 'application/chat_message_stream.dart';
import 'presentation/animated_chat_list.dart';
import 'presentation/chat_input/desktop_chat_input.dart';
@ -106,20 +108,22 @@ class _ChatContentPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => Column(
children: [
Expanded(
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => Column(
children: [
ChatMessageSelectorBanner(
view: view,
allMessages: context.read<ChatBloc>().chatController.messages,
),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: _wrapConstraints(
ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(scrollbars: false),
child: Chat(
chatController:
context.read<ChatBloc>().chatController,
@ -135,15 +139,27 @@ class _ChatContentPage extends StatelessWidget {
),
),
),
_buildInput(context),
],
),
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
),
),
),
),
_wrapConstraints(
_builtInput(context),
),
],
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
}
Widget _wrapConstraints(Widget child) {
return Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: child,
);
}
@ -193,31 +209,38 @@ class _ChatContentPage extends StatelessWidget {
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming: state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format)),
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
isSelectingMessages: isSelectingMessages,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format)),
);
},
);
},
);
@ -265,74 +288,100 @@ class _ChatContentPage extends StatelessWidget {
);
}
return ChatAnimatedListReversed(
scrollController: scrollController,
itemBuilder: itemBuilder,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return ChatAnimatedListReversed(
scrollController: scrollController,
itemBuilder: itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
Widget _buildInput(BuildContext context) {
return Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState == PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
Widget _builtInput(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
);
},
child: isSelectingMessages
? const SizedBox.shrink()
: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? DesktopChatInput(
chatId: view.id,
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
)
: MobileChatInput(
chatId: view.id,
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
},
),
return UniversalPlatform.isDesktop
? DesktopChatInput(
chatId: view.id,
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
)
: MobileChatInput(
chatId: view.id,
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
},
),
),
);
},
);
}

View File

@ -10,9 +10,7 @@ import 'package:string_validator/string_validator.dart';
import 'layout_define.dart';
class ChatAIAvatar extends StatelessWidget {
const ChatAIAvatar({
super.key,
});
const ChatAIAvatar({super.key});
@override
Widget build(BuildContext context) {

View File

@ -118,7 +118,6 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
),

View File

@ -0,0 +1,314 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.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_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'message/ai_message_action_bar.dart';
import 'message/message_util.dart';
class ChatMessageSelectorBanner extends StatelessWidget {
const ChatMessageSelectorBanner({
super.key,
required this.view,
this.allMessages = const [],
});
final ViewPB view;
final List<Message> allMessages;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSelectMessageBloc, ChatSelectMessageState>(
builder: (context, state) {
if (!state.isSelectingMessages) {
return const SizedBox.shrink();
}
final selectedAmount = state.selectedMessages.length;
final totalAmount = allMessages.length;
final allSelected = selectedAmount == totalAmount;
return Container(
height: 48,
color: const Color(0xFF00BCF0),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
GestureDetector(
onTap: () {
if (selectedAmount > 0) {
_unselectAllMessages(context);
} else {
_selectAllMessages(context);
}
},
child: FlowySvg(
allSelected
? FlowySvgs.checkbox_ai_selected_s
: selectedAmount > 0
? FlowySvgs.checkbox_ai_minus_s
: FlowySvgs.checkbox_ai_empty_s,
blendMode: BlendMode.dstIn,
size: const Size.square(18),
),
),
const HSpace(8),
Expanded(
child: FlowyText.semibold(
allSelected
? LocaleKeys.chat_selectBanner_allSelected.tr()
: selectedAmount > 0
? LocaleKeys.chat_selectBanner_nSelected
.tr(args: [selectedAmount.toString()])
: LocaleKeys.chat_selectBanner_selectMessages.tr(),
figmaLineHeight: 16,
color: Colors.white,
),
),
SaveToPageButton(
view: view,
),
const HSpace(8),
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => context.read<ChatSelectMessageBloc>().add(
const ChatSelectMessageEvent.toggleSelectingMessages(),
),
child: const FlowySvg(
FlowySvgs.close_m,
color: Colors.white,
size: Size.square(24),
),
),
),
],
),
);
},
);
}
void _selectAllMessages(BuildContext context) => context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.selectAllMessages(allMessages));
void _unselectAllMessages(BuildContext context) => context
.read<ChatSelectMessageBloc>()
.add(const ChatSelectMessageEvent.unselectAllMessages());
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,
required this.view,
});
final ViewPB view;
@override
State<SaveToPageButton> createState() => _SaveToPageButtonState();
}
class _SaveToPageButtonState extends State<SaveToPageButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider(
create: (context) => ChatSettingsCubit(hideDisabled: true),
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(0, 18),
direction: PopoverDirection.bottomWithRightAligned,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
child: buildButton(context, spaceView),
popupBuilder: (_) => buildPopover(context),
);
},
),
);
}
Widget buildButton(BuildContext context, ViewPB? spaceView) {
return BlocBuilder<ChatSelectMessageBloc, ChatSelectMessageState>(
builder: (context, state) {
final selectedAmount = state.selectedMessages.length;
return Opacity(
opacity: selectedAmount == 0 ? 0.5 : 1,
child: FlowyTextButton(
LocaleKeys.chat_selectBanner_saveButton.tr(),
onPressed: selectedAmount == 0
? null
: () async {
final documentId = getOpenedDocumentId();
if (documentId != null) {
await onAddToExistingPage(context, documentId);
await forceReload(documentId);
await Future.delayed(const Duration(milliseconds: 500));
await updateSelection(documentId);
} else {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources([spaceView], spaceView);
}
popoverController.show();
}
},
fontColor: Colors.white,
borderColor: Colors.white,
fillColor: Colors.transparent,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 6.0,
),
),
);
},
);
}
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: SaveToPagePopoverContent(
onAddToNewPage: (parentViewId) async {
await addMessageToNewPage(context, parentViewId);
popoverController.close();
},
onAddToExistingPage: (documentId) async {
final view = await onAddToExistingPage(context, documentId);
if (context.mounted) {
openPageFromMessage(context, view);
}
await Future.delayed(const Duration(milliseconds: 500));
await updateSelection(documentId);
popoverController.close();
},
),
);
}
Future<ViewPB?> onAddToExistingPage(
BuildContext context,
String documentId,
) async {
final bloc = context.read<ChatSelectMessageBloc>();
final selectedMessages = [
...bloc.state.selectedMessages.whereType<TextMessage>(),
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
await ChatEditDocumentService.addMessagesToPage(
documentId,
selectedMessages,
);
await Future.delayed(const Duration(milliseconds: 500));
final view = await ViewBackendService.getView(documentId).toNullable();
if (context.mounted) {
showSaveMessageSuccessToast(context, view);
}
bloc.add(const ChatSelectMessageEvent.saveAsPage());
return view;
}
Future<void> addMessageToNewPage(
BuildContext context,
String parentViewId,
) async {
final bloc = context.read<ChatSelectMessageBloc>();
final selectedMessages = [
...bloc.state.selectedMessages.whereType<TextMessage>(),
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final newView = await ChatEditDocumentService.saveMessagesToNewPage(
widget.view.nameOrDefault,
parentViewId,
selectedMessages,
);
if (context.mounted) {
showSaveMessageSuccessToast(context, newView);
openPageFromMessage(context, newView);
}
bloc.add(const ChatSelectMessageEvent.saveAsPage());
}
Future<void> forceReload(String documentId) async {
final bloc = DocumentBloc.findOpen(documentId);
if (bloc == null) {
return;
}
await bloc.forceReloadDocumentState();
}
Future<void> updateSelection(String documentId) async {
final bloc = DocumentBloc.findOpen(documentId);
if (bloc == null) {
return;
}
await bloc.forceReloadDocumentState();
final editorState = bloc.state.editorState;
final lastNodePath = editorState?.getLastSelectable()?.$1.path;
if (editorState == null || lastNodePath == null) {
return;
}
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: lastNodePath)),
),
);
}
String? getOpenedDocumentId() {
final pageManager = getIt<TabsBloc>().state.currentPageManager;
if (!pageManager.showSecondaryPluginNotifier.value) {
return null;
}
return pageManager.secondaryNotifier.plugin.id;
}
}

View File

@ -488,7 +488,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: _SaveToPagePopoverContent(
child: SaveToPagePopoverContent(
onAddToNewPage: (parentViewId) {
addMessageToNewPage(context, parentViewId);
popoverController.close();
@ -511,9 +511,9 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
BuildContext context,
String documentId,
) async {
await ChatEditDocumentService.addMessageToPage(
await ChatEditDocumentService.addMessagesToPage(
documentId,
widget.textMessage,
[widget.textMessage],
);
await Future.delayed(const Duration(milliseconds: 500));
final view = await ViewBackendService.getView(documentId).toNullable();
@ -541,35 +541,6 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
}
}
void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
if (view == null) {
return;
}
showToastNotification(
context,
richMessage: TextSpan(
children: [
TextSpan(
text: LocaleKeys.chat_addToNewPageSuccessToast.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFFFFFFFF),
),
),
const TextSpan(
text: ' ',
),
TextSpan(
text: view.nameOrDefault,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFFFFFFFF),
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Future<void> forceReload(String documentId) async {
final bloc = DocumentBloc.findOpen(documentId);
if (bloc == null) {
@ -605,8 +576,9 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
}
}
class _SaveToPagePopoverContent extends StatelessWidget {
const _SaveToPagePopoverContent({
class SaveToPagePopoverContent extends StatelessWidget {
const SaveToPagePopoverContent({
super.key,
required this.onAddToNewPage,
required this.onAddToExistingPage,
});

View File

@ -6,6 +6,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
@ -15,6 +16,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
@ -37,6 +39,7 @@ class ChatAIMessageBubble extends StatelessWidget {
required this.child,
required this.showActions,
this.isLastMessage = false,
this.isSelectingMessages = false,
this.onRegenerate,
this.onChangeFormat,
});
@ -45,27 +48,25 @@ class ChatAIMessageBubble extends StatelessWidget {
final Widget child;
final bool showActions;
final bool isLastMessage;
final bool isSelectingMessages;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
@override
Widget build(BuildContext context) {
final avatarAndMessage = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChatAIAvatar(),
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
Expanded(child: child),
],
final messageWidget = _WrapIsSelectingMessage(
isSelectingMessages: isSelectingMessages,
message: message,
child: child,
);
return showActions
return !isSelectingMessages && showActions
? UniversalPlatform.isMobile
? _wrapPopMenu(avatarAndMessage)
? _wrapPopMenu(messageWidget)
: isLastMessage
? _wrapBottomActions(avatarAndMessage)
: _wrapHover(avatarAndMessage)
: avatarAndMessage;
? _wrapBottomActions(messageWidget)
: _wrapHover(messageWidget)
: messageWidget;
}
Widget _wrapBottomActions(Widget child) {
@ -413,9 +414,9 @@ class ChatAIMessagePopup extends StatelessWidget {
return;
}
await ChatEditDocumentService.addMessageToPage(
await ChatEditDocumentService.addMessagesToPage(
selectedView.id,
message as TextMessage,
[message as TextMessage],
);
if (context.mounted) {
@ -429,3 +430,85 @@ class ChatAIMessagePopup extends StatelessWidget {
);
}
}
class _WrapIsSelectingMessage extends StatelessWidget {
const _WrapIsSelectingMessage({
required this.message,
required this.child,
this.isSelectingMessages = false,
});
final Message message;
final Widget child;
final bool isSelectingMessages;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSelectMessageBloc, ChatSelectMessageState>(
builder: (context, state) {
final isSelected =
context.read<ChatSelectMessageBloc>().isMessageSelected(message.id);
return GestureDetector(
onTap: () {
if (isSelectingMessages) {
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.toggleSelectMessage(message));
}
},
behavior: isSelectingMessages ? HitTestBehavior.opaque : null,
child: DecoratedBox(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.tertiaryContainer
: null,
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isSelectingMessages)
ChatSelectMessageIndicator(isSelected: isSelected)
else
const ChatAIAvatar(),
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
Expanded(
child: IgnorePointer(
ignoring: isSelectingMessages,
child: child,
),
),
],
),
),
);
},
);
}
}
class ChatSelectMessageIndicator extends StatelessWidget {
const ChatSelectMessageIndicator({
super.key,
required this.isSelected,
});
final bool isSelected;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: SizedBox.square(
dimension: DesktopAIChatSizes.avatarSize,
child: Center(
child: FlowySvg(
isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
size: const Size.square(20),
),
),
),
);
}
}

View File

@ -38,6 +38,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.onChangeFormat,
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
});
final User user;
@ -53,6 +54,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final void Function(PredefinedFormat)? onChangeFormat;
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
@override
Widget build(BuildContext context) {
@ -98,6 +100,7 @@ class ChatAIMessageWidget extends StatelessWidget {
showActions: stream == null &&
state.text.isNotEmpty &&
!isStreaming,
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
child: Column(

View File

@ -6,7 +6,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
/// Opens a message in the right hand sidebar on desktop, and push the page
@ -30,3 +30,32 @@ void openPageFromMessage(BuildContext context, ViewPB? view) {
context.pushView(view);
}
}
void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
if (view == null) {
return;
}
showToastNotification(
context,
richMessage: TextSpan(
children: [
TextSpan(
text: LocaleKeys.chat_addToNewPageSuccessToast.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFFFFFFFF),
),
),
const TextSpan(
text: ' ',
),
TextSpan(
text: view.nameOrDefault,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFFFFFFFF),
fontWeight: FontWeight.w700,
),
),
],
),
);
}

View File

@ -288,7 +288,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
const HSpace(10),
ViewFavoriteButton(view: view),
const HSpace(4),
MoreViewActions(view: view, isDocument: false),
MoreViewActions(view: view),
],
),
);

View File

@ -70,6 +70,8 @@ extension ViewExtension on ViewPB {
String get nameOrDefault =>
name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : name;
bool get isDocument => pluginType == PluginType.document;
Widget defaultIcon({Size? size}) => FlowySvg(
switch (layout) {
ViewLayoutPB.Board => FlowySvgs.icon_board_s,

View File

@ -294,7 +294,7 @@ class _SecondaryViewState extends State<SecondaryView>
return CompositedTransformFollower(
link: layerLink,
followerAnchor: Alignment.topRight,
offset: const Offset(0.0, 80.0),
offset: const Offset(0.0, 120.0),
child: Align(
alignment: AlignmentDirectional.topEnd,
child: AnimatedSwitcher(
@ -964,13 +964,16 @@ class NonClippingSizeTransition extends AnimatedWidget {
@override
Widget build(BuildContext context) {
final AlignmentDirectional alignment;
final Edge edge;
if (axis == Axis.vertical) {
alignment = AlignmentDirectional(-1.0, axisAlignment);
edge = switch (axisAlignment) { -1.0 => Edge.bottom, _ => Edge.top };
} else {
alignment = AlignmentDirectional(axisAlignment, -1.0);
edge = switch (axisAlignment) { -1.0 => Edge.right, _ => Edge.left };
}
return ClipRect(
clipper: const EdgeRectClipper(edge: Edge.right, margin: 20),
clipper: EdgeRectClipper(edge: edge, margin: 20),
child: Align(
alignment: alignment,
heightFactor: axis == Axis.vertical

View File

@ -4,6 +4,7 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart';
@ -21,14 +22,16 @@ class MoreViewActions extends StatefulWidget {
const MoreViewActions({
super.key,
required this.view,
this.isDocument = true,
this.customActions = const [],
});
/// The view to show the actions for.
///
final ViewPB view;
/// If false the view is a Database, otherwise it is a Document.
final bool isDocument;
/// Custom actions to show in the popover, will be laid out at the top.
///
final List<Widget> customActions;
@override
State<MoreViewActions> createState() => _MoreViewActionsState();
@ -49,8 +52,9 @@ class _MoreViewActionsState extends State<MoreViewActions> {
builder: (context, state) {
return AppFlowyPopover(
mutex: popoverMutex,
constraints: const BoxConstraints(maxWidth: 220),
offset: const Offset(0, 42),
constraints: const BoxConstraints(maxWidth: 245),
direction: PopoverDirection.bottomWithRightAligned,
offset: const Offset(0, 12),
popupBuilder: (_) => _buildPopup(state),
child: const _ThreeDots(),
);
@ -106,16 +110,21 @@ class _MoreViewActionsState extends State<MoreViewActions> {
final timeFormat = appearanceSettings.timeFormat;
final viewMoreActionTypes = [
if (widget.isDocument) ViewMoreActionType.divider,
ViewMoreActionType.duplicate,
if (widget.view.layout != ViewLayoutPB.Chat) ViewMoreActionType.duplicate,
ViewMoreActionType.moveTo,
ViewMoreActionType.delete,
ViewMoreActionType.divider,
];
final actions = [
if (widget.isDocument) ...[
...widget.customActions,
if (widget.view.isDocument) ...[
const FontSizeAction(),
ViewAction(
type: ViewMoreActionType.divider,
view: widget.view,
mutex: popoverMutex,
),
],
...viewMoreActionTypes.map(
(type) => ViewAction(

View File

@ -1,3 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@ -11,6 +12,8 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -97,3 +100,38 @@ class ViewAction extends StatelessWidget {
}
}
}
class CustomViewAction extends StatelessWidget {
const CustomViewAction({
super.key,
required this.view,
required this.leftIcon,
required this.label,
this.onTap,
this.mutex,
});
final ViewPB view;
final FlowySvgData leftIcon;
final String label;
final VoidCallback? onTap;
final PopoverMutex? mutex;
@override
Widget build(BuildContext context) {
return Container(
height: 34,
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: FlowyIconTextButton(
margin: const EdgeInsets.symmetric(horizontal: 6),
onTap: onTap,
leftIconBuilder: (onHover) => FlowySvg(leftIcon),
iconPadding: 10.0,
textBuilder: (onHover) => FlowyText(
label,
figmaLineHeight: 18.0,
),
),
);
}
}

View File

@ -0,0 +1,3 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.8125" y="3.73499" width="12.375" height="12.375" rx="3.9375" fill="white" stroke="#BDBDBD" stroke-width="1.125"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.8125" y="3.64246" width="12.375" height="12.375" rx="3.9375" fill="white" stroke="#BDBDBD" stroke-width="1.125"/>
<path d="M6 9.82996H12" stroke="#00BCF0" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.815" y="2.815" width="12.37" height="12.37" rx="3.935" fill="white" stroke="#BDBDBD" stroke-width="1.13"/>
<path d="M6.75 9L8.56731 10.6875L11.8125 7.3125" stroke="#00BCF0" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@ -150,7 +150,8 @@
"wordCountLabel": "Word count: ",
"charCountLabel": "Character count: ",
"createdAtLabel": "Created: ",
"syncedAtLabel": "Synced: "
"syncedAtLabel": "Synced: ",
"saveAsNewPage": "Save messages to page"
},
"importPanel": {
"textAndMarkdown": "Text & Markdown",
@ -238,6 +239,12 @@
"numberWithImageDescription": "@:chat.changeFormat.number with image",
"bulletWithImageDescription": "@:chat.changeFormat.bullet with image",
"tableWithImageDescription": "@:chat.changeFormat.table with image"
},
"selectBanner": {
"saveButton": "Add to...",
"selectMessages": "Select messages",
"nSelected": "{} selected",
"allSelected": "All selected"
}
},
"trash": {