mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-12 15:42:34 +00:00
feat: save messages as a new page (#7224)
This commit is contained in:
parent
63c7f7b6fe
commit
1723886f3a
@ -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;
|
||||
|
||||
@ -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"),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -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: [],
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
3
frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg
Normal file
3
frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg
Normal 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 |
4
frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg
Normal file
4
frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg
Normal 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 |
@ -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 |
@ -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": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user