feat(flutter): pre-defined response formats (#7128)

* feat: pre-defined response formats

* chore: adjust bottom sheet

* chore: rename and clean up enums

* chore: move all mobile input actions to the bottom

* chore: bump client-api

* chore: connect to API

* chore: apply suggestions from code review

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>

* chore: code cleanup

* chore: code cleanup

* chore: update client-api

* chore: expand page

* chore: simplify logic for not displaying related questions

* chore: remove hover effect view icon in select sources

* chore: regenerate with different format

* chore: remove error messages when sending new one

* chore: code style

* chore: bump client api

* fix: image not displaying and hide editing options

* chore: don't fetch related questions for image only

* chore: fix clippy

* chore: don't add related questions on regenerate

* chore: bump editor

* fix: expand sidebar page

* chore: update client api

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: weidong fu <nathan@appflowy.io>
This commit is contained in:
Richard Shiue 2025-01-08 10:43:03 +08:00 committed by GitHub
parent e25633636b
commit ab8e01bbf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1914 additions and 400 deletions

View File

@ -59,7 +59,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
bool isLoadingPreviousMessages = false;
bool hasMorePreviousMessages = true;
AnswerStream? answerStream;
int numSendMessage = 0;
bool isFetchingRelatedQuestions = false;
bool shouldFetchRelatedQuestions = false;
@override
Future<void> close() async {
@ -165,14 +166,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
sendMessage: (
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
) {
numSendMessage += 1;
_clearErrorMessages();
_clearRelatedQuestions();
_startStreamingMessage(message, metadata);
_startStreamingMessage(message, format, metadata);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions =
format == null || format.imageFormat.hasText;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
@ -231,11 +236,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
regenerateAnswer: (id) {
regenerateAnswer: (id, format) {
_clearRelatedQuestions();
_regenerateAnswer(id);
_regenerateAnswer(id, format);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = false;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
@ -319,7 +327,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// The answer stream will bet set to null after the streaming has
// finished, got cancelled, or errored. In this case, don't retrieve
// related questions.
if (answerStream == null || lastSentMessage == null) {
if (answerStream == null ||
lastSentMessage == null ||
!shouldFetchRelatedQuestions) {
return;
}
@ -328,17 +338,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
messageId: lastSentMessage!.messageId,
);
// when previous numSendMessage is not equal to current numSendMessage, it means that the user
// has sent a new message. So we don't need to get related questions.
final preNumSendMessage = numSendMessage;
isFetchingRelatedQuestions = true;
await AIEventGetRelatedQuestion(payload).send().fold(
(list) {
if (!isClosed && preNumSendMessage == numSendMessage) {
// while fetching related questions, the user might enter a new
// question or regenerate a previous response. In such cases, don't
// display the relatedQuestions
if (!isClosed && isFetchingRelatedQuestions) {
add(
ChatEvent.didReceiveRelatedQuestions(
list.items.map((e) => e.content).toList(),
),
);
isFetchingRelatedQuestions = false;
}
},
(err) => Log.error("Failed to get related questions: $err"),
@ -398,6 +410,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Future<void> _startStreamingMessage(
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
) async {
await answerStream?.dispose();
@ -420,6 +433,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
answerStreamPort: Int64(answerStream!.nativePort),
metadata: await metadataPBFromMetadata(metadata),
);
if (format != null) {
payload.format = format.toPB();
}
// stream the question to the server
await AIEventStreamMessage(payload).send().fold(
@ -460,7 +476,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
void _regenerateAnswer(String answerMessageIdString) async {
void _regenerateAnswer(
String answerMessageIdString,
PredefinedFormat? format,
) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
?.key ??
@ -479,6 +498,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
if (format != null) {
payload.format = format.toPB();
}
await AIEventRegenerateResponse(payload).send().fold(
(success) {
@ -558,6 +580,20 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
void _clearErrorMessages() {
final errorMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.error,
)
.toList();
for (final message in errorMessages) {
chatController.remove(message);
}
}
void _clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
@ -586,13 +622,17 @@ class ChatEvent with _$ChatEvent {
// send message
const factory ChatEvent.sendMessage({
required String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
}) = _SendMessage;
const factory ChatEvent.finishSending() = _FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// regenerate
const factory ChatEvent.regenerateAnswer(String id) = _RegenerateAnswer;
const factory ChatEvent.regenerateAnswer(
String id,
PredefinedFormat? format,
) = _RegenerateAnswer;
// streaming answer
const factory ChatEvent.stopStream() = _StopStream;

View File

@ -1,8 +1,11 @@
import 'dart:io';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:path/path.dart' as path;
@ -137,3 +140,87 @@ enum LoadChatMessageStatus {
loadingRemote,
ready,
}
class PredefinedFormat extends Equatable {
const PredefinedFormat({
required this.imageFormat,
required this.textFormat,
});
const PredefinedFormat.auto()
: imageFormat = ImageFormat.text,
textFormat = TextFormat.auto;
final ImageFormat imageFormat;
final TextFormat? textFormat;
PredefinedFormatPB toPB() {
return PredefinedFormatPB(
imageFormat: switch (imageFormat) {
ImageFormat.text => ResponseImageFormatPB.TextOnly,
ImageFormat.image => ResponseImageFormatPB.ImageOnly,
ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage,
},
textFormat: switch (textFormat) {
TextFormat.auto => ResponseTextFormatPB.Paragraph,
TextFormat.bulletList => ResponseTextFormatPB.BulletedList,
TextFormat.numberedList => ResponseTextFormatPB.NumberedList,
TextFormat.table => ResponseTextFormatPB.Table,
_ => null,
},
);
}
@override
List<Object?> get props => [imageFormat, textFormat];
}
enum ImageFormat {
text,
image,
textAndImage;
bool get hasText => this == text || this == textAndImage;
FlowySvgData get icon {
return switch (this) {
ImageFormat.text => FlowySvgs.ai_text_s,
ImageFormat.image => FlowySvgs.ai_image_s,
ImageFormat.textAndImage => FlowySvgs.ai_text_image_s,
};
}
String get i18n {
return switch (this) {
ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(),
ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(),
ImageFormat.textAndImage =>
LocaleKeys.chat_changeFormat_textAndImage.tr(),
};
}
}
enum TextFormat {
auto,
bulletList,
numberedList,
table;
FlowySvgData get icon {
return switch (this) {
TextFormat.auto => FlowySvgs.ai_paragraph_s,
TextFormat.bulletList => FlowySvgs.ai_list_s,
TextFormat.numberedList => FlowySvgs.ai_number_list_s,
TextFormat.table => FlowySvgs.ai_table_s,
};
}
String get i18n {
return switch (this) {
TextFormat.auto => LocaleKeys.chat_changeFormat_text.tr(),
TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(),
TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(),
TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(),
};
}
}

View File

@ -214,7 +214,10 @@ class _ChatContentPage extends StatelessWidget {
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id)),
.add(ChatEvent.regenerateAnswer(message.id, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format)),
);
},
);
@ -288,10 +291,11 @@ class _ChatContentPage extends StatelessWidget {
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, metadata) {
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
@ -310,10 +314,11 @@ class _ChatContentPage extends StatelessWidget {
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, metadata) {
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);

View File

@ -310,7 +310,7 @@ class MentionViewIcon extends StatelessWidget {
if (view.icon.value.isNotEmpty) {
return SizedBox(
width: 16.0,
child: EmojiIconWidget(
child: RawEmojiIconWidget(
emoji: view.icon.toEmojiIconData(),
emojiSize: 14,
),

View File

@ -11,11 +11,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/chat_entity.dart';
import '../layout_define.dart';
import 'ai_prompt_buttons.dart';
import 'chat_input_file.dart';
import 'chat_input_span.dart';
import 'chat_mention_page_menu.dart';
import 'predefined_format_buttons.dart';
import 'select_sources_menu.dart';
class DesktopAIPromptInput extends StatefulWidget {
@ -31,7 +33,8 @@ class DesktopAIPromptInput extends StatefulWidget {
final String chatId;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(String, Map<String, dynamic>) onSubmitted;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final void Function(List<String>) onUpdateSelectedSources;
@override
@ -46,6 +49,8 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
final focusNode = FocusNode();
final textController = TextEditingController();
bool showPredefinedFormatSection = false;
PredefinedFormat predefinedFormat = const PredefinedFormat.auto();
late SendButtonState sendButtonState;
@override
@ -115,6 +120,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
),
@ -136,17 +142,35 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
),
),
),
const VSpace(4.0),
Stack(
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight +
DesktopAIPromptSizes.actionBarPadding.vertical,
maxHeight: 300,
),
Container(
constraints: getTextFieldConstraints(),
child: inputTextField(),
),
if (showPredefinedFormatSection)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
child: Padding(
padding:
const EdgeInsetsDirectional.only(start: 8.0),
child: ChangeFormatBar(
predefinedFormat: predefinedFormat,
spacing: DesktopAIPromptSizes
.predefinedFormatBarButtonSpacing,
iconSize: DesktopAIPromptSizes
.predefinedFormatIconHeight,
buttonSize: DesktopAIPromptSizes
.predefinedFormatButtonHeight,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
),
),
),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
@ -154,6 +178,19 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
textController: textController,
overlayController: overlayController,
focusNode: focusNode,
showPredefinedFormats: showPredefinedFormatSection,
predefinedFormat: predefinedFormat.imageFormat,
predefinedTextFormat: predefinedFormat.textFormat,
onTogglePredefinedFormatSection: () {
setState(() {
showPredefinedFormatSection =
!showPredefinedFormatSection;
if (!showPredefinedFormatSection) {
predefinedFormat =
const PredefinedFormat.auto();
}
});
},
sendButtonState: sendButtonState,
onSendPressed: handleSendPressed,
onStopStreaming: widget.onStopStreaming,
@ -172,6 +209,18 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
);
}
BoxConstraints getTextFieldConstraints() {
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight +
DesktopAIPromptSizes.actionBarPadding.vertical;
double maxHeight = 300;
if (showPredefinedFormatSection) {
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
}
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
}
void cancelMentionPage() {
if (overlayController.isShowing) {
inputControlCubit.reset();
@ -204,7 +253,11 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
widget.onSubmitted(trimmedText, metadata);
widget.onSubmitted(
trimmedText,
showPredefinedFormatSection ? predefinedFormat : null,
metadata,
);
}
void handleTextControllerChanged() {
@ -301,6 +354,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
cubit: inputControlCubit,
textController: textController,
textFieldFocusNode: focusNode,
showPredefinedFormatSection: showPredefinedFormatSection,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
@ -366,15 +420,17 @@ class _PromptTextField extends StatefulWidget {
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
// required this.onStartMentioningPage,
this.showPredefinedFormatSection = false,
this.hintText = "",
// this.onStartMentioningPage,
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final FocusNode textFieldFocusNode;
// final void Function() onStartMentioningPage;
final bool showPredefinedFormatSection;
final String hintText;
// final void Function()? onStartMentioningPage;
@override
State<_PromptTextField> createState() => _PromptTextFieldState();
@ -408,11 +464,7 @@ class _PromptTextFieldState extends State<_PromptTextField> {
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: DesktopAIPromptSizes.textFieldContentPadding.add(
const EdgeInsets.only(
bottom: DesktopAIPromptSizes.actionBarHeight,
),
),
contentPadding: calculateContentPadding(),
hintText: widget.hintText,
hintStyle: Theme.of(context)
.textTheme
@ -450,6 +502,16 @@ class _PromptTextFieldState extends State<_PromptTextField> {
);
}
EdgeInsetsGeometry calculateContentPadding() {
final top = widget.showPredefinedFormatSection
? DesktopAIPromptSizes.predefinedFormatButtonHeight
: 0.0;
const bottom = DesktopAIPromptSizes.actionBarHeight;
return DesktopAIPromptSizes.textFieldContentPadding
.add(EdgeInsets.only(top: top, bottom: bottom));
}
Map<ShortcutActivator, Intent> buildShortcuts() {
if (isComposing) {
return const {};
@ -486,6 +548,10 @@ class _PromptBottomActions extends StatelessWidget {
required this.overlayController,
required this.focusNode,
required this.sendButtonState,
required this.predefinedFormat,
required this.predefinedTextFormat,
required this.onTogglePredefinedFormatSection,
required this.showPredefinedFormats,
required this.onSendPressed,
required this.onStopStreaming,
required this.onUpdateSelectedSources,
@ -494,6 +560,10 @@ class _PromptBottomActions extends StatelessWidget {
final TextEditingController textController;
final OverlayPortalController overlayController;
final FocusNode focusNode;
final bool showPredefinedFormats;
final ImageFormat predefinedFormat;
final TextFormat? predefinedTextFormat;
final void Function() onTogglePredefinedFormatSection;
final SendButtonState sendButtonState;
final void Function() onSendPressed;
final void Function() onStopStreaming;
@ -514,7 +584,7 @@ class _PromptBottomActions extends StatelessWidget {
}
return Row(
children: [
// predefinedFormatButton(),
_predefinedFormatButton(),
const Spacer(),
if (state.aiType == AIType.appflowyAI) ...[
_selectSourcesButton(context),
@ -540,6 +610,15 @@ class _PromptBottomActions extends StatelessWidget {
);
}
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormats,
predefinedFormat: predefinedFormat,
predefinedTextFormat: predefinedTextFormat,
onTap: onTogglePredefinedFormatSection,
);
}
Widget _selectSourcesButton(BuildContext context) {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,

View File

@ -1,5 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:easy_localization/easy_localization.dart';
@ -13,6 +14,7 @@ import 'ai_prompt_buttons.dart';
import 'chat_input_file.dart';
import 'chat_input_span.dart';
import 'chat_mention_page_bottom_sheet.dart';
import 'predefined_format_buttons.dart';
import 'select_sources_bottom_sheet.dart';
class MobileAIPromptInput extends StatefulWidget {
@ -28,7 +30,8 @@ class MobileAIPromptInput extends StatefulWidget {
final String chatId;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(String, Map<String, dynamic>) onSubmitted;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final void Function(List<String>) onUpdateSelectedSources;
@override
@ -40,6 +43,8 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
final focusNode = FocusNode();
final textController = TextEditingController();
bool showPredefinedFormatSection = false;
PredefinedFormat predefinedFormat = const PredefinedFormat.auto();
late SendButtonState sendButtonState;
@override
@ -103,6 +108,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
borderRadius: MobileAIPromptSizes.promptFrameRadius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: BoxConstraints(
@ -117,24 +123,33 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
.add(AIPromptInputEvent.removeFile(file)),
),
),
Container(
constraints: const BoxConstraints(
minHeight: MobileAIPromptSizes.textFieldMinHeight,
maxHeight: 220,
),
child: IntrinsicHeight(
child: Row(
children: [
const HSpace(8.0),
leadingButtons(context),
Expanded(
child: inputTextField(context),
),
sendButton(),
const HSpace(12.0),
],
if (showPredefinedFormatSection)
TextFieldTapRegion(
child: Container(
padding: MobileAIPromptSizes.predefinedFormatBarPadding,
child: ChangeFormatBar(
predefinedFormat: predefinedFormat,
spacing: MobileAIPromptSizes
.predefinedFormatBarButtonSpacing,
iconSize:
MobileAIPromptSizes.predefinedFormatIconHeight,
buttonSize:
MobileAIPromptSizes.predefinedFormatButtonHeight,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
),
),
),
inputTextField(context),
Row(
children: [
const HSpace(8.0),
leadingButtons(context),
const Spacer(),
sendButton(),
const HSpace(12.0),
],
),
],
),
@ -169,7 +184,11 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
widget.onSubmitted(trimmedText, metadata);
widget.onSubmitted(
trimmedText,
showPredefinedFormatSection ? predefinedFormat : null,
metadata,
);
}
void handleTextControllerChange() {
@ -236,6 +255,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
return ExtendedTextField(
controller: textController,
focusNode: focusNode,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
@ -252,7 +272,8 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(height: 20 / 14),
specialTextSpanBuilder: ChatInputTextSpanBuilder(
inputControlCubit: inputControlCubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
@ -267,10 +288,8 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
Widget leadingButtons(BuildContext context) {
return Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.only(bottom: 8.0),
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: _LeadingActions(
textController: textController,
// onMention: () {
// textController.text += '@';
// if (!focusNode.hasFocus) {
@ -280,6 +299,16 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
// mentionPage(context);
// });
// },
showPredefinedFormatSection: showPredefinedFormatSection,
predefinedFormat: predefinedFormat,
onTogglePredefinedFormatSection: () {
setState(() {
showPredefinedFormatSection = !showPredefinedFormatSection;
if (!showPredefinedFormatSection) {
predefinedFormat = const PredefinedFormat.auto();
}
});
},
onUpdateSelectedSources: widget.onUpdateSelectedSources,
),
);
@ -288,7 +317,6 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
Widget sendButton() {
return Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.only(bottom: 8.0),
child: PromptInputSendButton(
buttonSize: MobileAIPromptSizes.sendButtonSize,
iconSize: MobileAIPromptSizes.sendButtonSize,
@ -302,19 +330,33 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
class _LeadingActions extends StatelessWidget {
const _LeadingActions({
required this.textController,
required this.showPredefinedFormatSection,
required this.predefinedFormat,
required this.onTogglePredefinedFormatSection,
required this.onUpdateSelectedSources,
});
final TextEditingController textController;
final bool showPredefinedFormatSection;
final PredefinedFormat predefinedFormat;
final void Function() onTogglePredefinedFormatSection;
final void Function(List<String>) onUpdateSelectedSources;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: PromptInputMobileSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
child: SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const HSpace(4.0),
children: [
PromptInputMobileSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
),
PromptInputMobileToggleFormatButton(
showFormatBar: showPredefinedFormatSection,
onTap: onTogglePredefinedFormatSection,
),
],
),
);
}

View File

@ -0,0 +1,225 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import '../layout_define.dart';
class PromptInputDesktopToggleFormatButton extends StatelessWidget {
const PromptInputDesktopToggleFormatButton({
super.key,
required this.showFormatBar,
required this.predefinedFormat,
required this.predefinedTextFormat,
required this.onTap,
});
final bool showFormatBar;
final ImageFormat predefinedFormat;
final TextFormat? predefinedTextFormat;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(6.0),
child: FlowyText(
_getDescription(),
fontSize: 12.0,
figmaLineHeight: 16.0,
),
),
),
),
);
}
String _getDescription() {
if (!showFormatBar) {
return LocaleKeys.chat_changeFormat_blankDescription.tr();
}
return switch ((predefinedFormat, predefinedTextFormat)) {
(ImageFormat.image, _) => predefinedFormat.i18n,
(ImageFormat.text, TextFormat.auto) =>
LocaleKeys.chat_changeFormat_defaultDescription.tr(),
(ImageFormat.text, _) when predefinedTextFormat != null =>
predefinedTextFormat!.i18n,
(ImageFormat.textAndImage, TextFormat.auto) =>
LocaleKeys.chat_changeFormat_textWithImageDescription.tr(),
(ImageFormat.textAndImage, TextFormat.bulletList) =>
LocaleKeys.chat_changeFormat_bulletWithImageDescription.tr(),
(ImageFormat.textAndImage, TextFormat.numberedList) =>
LocaleKeys.chat_changeFormat_numberWithImageDescription.tr(),
(ImageFormat.textAndImage, TextFormat.table) =>
LocaleKeys.chat_changeFormat_tableWithImageDescription.tr(),
_ => throw UnimplementedError(),
};
}
}
class ChangeFormatBar extends StatelessWidget {
const ChangeFormatBar({
super.key,
required this.predefinedFormat,
required this.buttonSize,
required this.iconSize,
required this.spacing,
required this.onSelectPredefinedFormat,
});
final PredefinedFormat predefinedFormat;
final double buttonSize;
final double iconSize;
final double spacing;
final void Function(PredefinedFormat) onSelectPredefinedFormat;
@override
Widget build(BuildContext context) {
return SizedBox(
height: DesktopAIPromptSizes.predefinedFormatButtonHeight,
child: SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => HSpace(spacing),
children: [
_buildFormatButton(context, ImageFormat.text),
_buildFormatButton(context, ImageFormat.textAndImage),
_buildFormatButton(context, ImageFormat.image),
if (predefinedFormat.imageFormat.hasText) ...[
_buildDivider(),
_buildTextFormatButton(context, TextFormat.auto),
_buildTextFormatButton(context, TextFormat.bulletList),
_buildTextFormatButton(context, TextFormat.numberedList),
_buildTextFormatButton(context, TextFormat.table),
],
],
),
);
}
Widget _buildFormatButton(BuildContext context, ImageFormat format) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (format == predefinedFormat.imageFormat) {
return;
}
if (format.hasText) {
final textFormat = predefinedFormat.textFormat ?? TextFormat.auto;
onSelectPredefinedFormat(
PredefinedFormat(imageFormat: format, textFormat: textFormat),
);
} else {
onSelectPredefinedFormat(
PredefinedFormat(imageFormat: format, textFormat: null),
);
}
},
child: FlowyTooltip(
message: format.i18n,
child: SizedBox.square(
dimension: buttonSize,
child: FlowyHover(
isSelected: () => format == predefinedFormat.imageFormat,
child: Center(
child: FlowySvg(
format.icon,
size: format == ImageFormat.textAndImage
? Size(21.0 / 16.0 * iconSize, iconSize)
: Size.square(iconSize),
),
),
),
),
),
);
}
Widget _buildDivider() {
return VerticalDivider(
indent: 6.0,
endIndent: 6.0,
width: 1.0 + spacing * 2,
);
}
Widget _buildTextFormatButton(
BuildContext context,
TextFormat format,
) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (format == predefinedFormat.textFormat) {
return;
}
onSelectPredefinedFormat(
PredefinedFormat(
imageFormat: predefinedFormat.imageFormat,
textFormat: format,
),
);
},
child: FlowyTooltip(
message: format.i18n,
child: SizedBox.square(
dimension: buttonSize,
child: FlowyHover(
isSelected: () => format == predefinedFormat.textFormat,
child: Center(
child: FlowySvg(
format.icon,
size: Size.square(iconSize),
),
),
),
),
),
);
}
}
class PromptInputMobileToggleFormatButton extends StatelessWidget {
const PromptInputMobileToggleFormatButton({
super.key,
required this.showFormatBar,
required this.onTap,
});
final bool showFormatBar;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 32.0,
child: FlowyButton(
radius: const BorderRadius.all(Radius.circular(8.0)),
margin: EdgeInsets.zero,
expandText: false,
text: showFormatBar
? const FlowySvg(
FlowySvgs.ai_text_auto_s,
size: Size.square(24.0),
)
: const FlowySvg(
FlowySvgs.ai_text_image_s,
size: Size(26.25, 20.0),
),
onTap: onTap,
),
);
}
}

View File

@ -16,7 +16,7 @@ class RelatedQuestionList extends StatelessWidget {
required this.relatedQuestions,
});
final Function(String) onQuestionSelected;
final void Function(String) onQuestionSelected;
final List<String> relatedQuestions;
@override

View File

@ -23,13 +23,17 @@ class DesktopAIPromptSizes {
static const promptFrameRadius = BorderRadius.all(Radius.circular(12.0));
static const attachedFilesBarPadding =
EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0);
EdgeInsets.only(left: 8.0, top: 8.0, right: 8.0);
static const attachedFilesPreviewHeight = 48.0;
static const attachedFilesPreviewSpacing = 12.0;
static const textFieldMinHeight = 40.0;
static const predefinedFormatButtonHeight = 28.0;
static const predefinedFormatIconHeight = 16.0;
static const predefinedFormatBarButtonSpacing = 4.0;
static const textFieldMinHeight = 36.0;
static const textFieldContentPadding =
EdgeInsetsDirectional.fromSTEB(14.0, 12.0, 14.0, 8.0);
EdgeInsetsDirectional.fromSTEB(14.0, 8.0, 14.0, 8.0);
static const actionBarHeight = 32.0;
static const actionBarPadding = EdgeInsetsDirectional.fromSTEB(8, 0, 8, 4);
@ -49,8 +53,14 @@ class MobileAIPromptSizes {
static const attachedFilesPreviewHeight = 56.0;
static const attachedFilesPreviewSpacing = 8.0;
static const textFieldMinHeight = 48.0;
static const textFieldContentPadding = EdgeInsets.all(8.0);
static const predefinedFormatButtonHeight = 32.0;
static const predefinedFormatIconHeight = 20.0;
static const predefinedFormatBarButtonSpacing = 8.0;
static const predefinedFormatBarPadding = EdgeInsets.all(8.0);
static const textFieldMinHeight = 32.0;
static const textFieldContentPadding =
EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0);
static const mentionIconSize = 20.0;
static const sendButtonSize = 32.0;

View File

@ -0,0 +1,193 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
Future<PredefinedFormat?> showChangeFormatBottomSheet(
BuildContext context,
) {
return showMobileBottomSheet<PredefinedFormat?>(
context,
showDragHandle: true,
builder: (context) => const _ChangeFormatBottomSheetContent(),
);
}
class _ChangeFormatBottomSheetContent extends StatefulWidget {
const _ChangeFormatBottomSheetContent();
@override
State<_ChangeFormatBottomSheetContent> createState() =>
_ChangeFormatBottomSheetContentState();
}
class _ChangeFormatBottomSheetContentState
extends State<_ChangeFormatBottomSheetContent> {
PredefinedFormat predefinedFormat = const PredefinedFormat.auto();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(
onCancel: () => Navigator.of(context).pop(),
onDone: () => Navigator.of(context).pop(predefinedFormat),
),
const VSpace(4.0),
_Body(
predefinedFormat: predefinedFormat,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
),
const VSpace(16.0),
],
);
}
}
class _Header extends StatelessWidget {
const _Header({
required this.onCancel,
required this.onDone,
});
final VoidCallback onCancel;
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44.0,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: AppBarBackButton(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
onTap: onCancel,
),
),
Align(
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: FlowyText(
LocaleKeys.chat_changeFormat_actionButton.tr(),
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),
),
Align(
alignment: Alignment.centerRight,
child: AppBarDoneButton(
onTap: onDone,
),
),
],
),
);
}
}
class _Body extends StatelessWidget {
const _Body({
required this.predefinedFormat,
required this.onSelectPredefinedFormat,
});
final PredefinedFormat predefinedFormat;
final void Function(PredefinedFormat) onSelectPredefinedFormat;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFormatButton(ImageFormat.text, true),
_buildFormatButton(ImageFormat.textAndImage),
_buildFormatButton(ImageFormat.image),
const VSpace(32.0),
Opacity(
opacity: predefinedFormat.imageFormat.hasText ? 1 : 0,
child: Column(
children: [
_buildTextFormatButton(TextFormat.auto, true),
_buildTextFormatButton(TextFormat.bulletList),
_buildTextFormatButton(TextFormat.numberedList),
_buildTextFormatButton(TextFormat.table),
],
),
),
],
);
}
Widget _buildFormatButton(
ImageFormat format, [
bool isFirst = false,
]) {
return FlowyOptionTile.checkbox(
text: format.i18n,
isSelected: format == predefinedFormat.imageFormat,
showTopBorder: isFirst,
leftIcon: FlowySvg(
format.icon,
size: format == ImageFormat.textAndImage
? const Size(21.0 / 16.0 * 20, 20)
: const Size.square(20),
),
onTap: () {
if (format == predefinedFormat.imageFormat) {
return;
}
if (format.hasText) {
final textFormat = predefinedFormat.textFormat ?? TextFormat.auto;
onSelectPredefinedFormat(
PredefinedFormat(imageFormat: format, textFormat: textFormat),
);
} else {
onSelectPredefinedFormat(
PredefinedFormat(imageFormat: format, textFormat: null),
);
}
},
);
}
Widget _buildTextFormatButton(
TextFormat format, [
bool isFirst = false,
]) {
return FlowyOptionTile.checkbox(
text: format.i18n,
isSelected: format == predefinedFormat.textFormat,
showTopBorder: isFirst,
leftIcon: FlowySvg(
format.icon,
size: const Size.square(20),
),
onTap: () {
if (format == predefinedFormat.textFormat) {
return;
}
onSelectPredefinedFormat(
PredefinedFormat(
imageFormat: predefinedFormat.imageFormat,
textFormat: format,
),
);
},
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
@ -29,24 +30,34 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import '../chat_input/predefined_format_buttons.dart';
import '../chat_input/select_sources_menu.dart';
import '../layout_define.dart';
import 'message_util.dart';
class AIMessageActionBar extends StatelessWidget {
class AIMessageActionBar extends StatefulWidget {
const AIMessageActionBar({
super.key,
required this.message,
required this.showDecoration,
this.onRegenerate,
this.onChangeFormat,
this.onOverrideVisibility,
});
final Message message;
final bool showDecoration;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(bool)? onOverrideVisibility;
@override
State<AIMessageActionBar> createState() => _AIMessageActionBarState();
}
class _AIMessageActionBarState extends State<AIMessageActionBar> {
final popoverMutex = PopoverMutex();
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
@ -58,7 +69,7 @@ class AIMessageActionBar extends StatelessWidget {
children: _buildChildren(),
);
return showDecoration
return widget.showDecoration
? Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
@ -67,6 +78,7 @@ class AIMessageActionBar extends StatelessWidget {
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
strokeAlign: BorderSide.strokeAlignOutside,
),
color: Theme.of(context).cardColor,
boxShadow: [
@ -103,17 +115,24 @@ class AIMessageActionBar extends StatelessWidget {
List<Widget> _buildChildren() {
return [
CopyButton(
isInHoverBar: showDecoration,
textMessage: message as TextMessage,
isInHoverBar: widget.showDecoration,
textMessage: widget.message as TextMessage,
),
RegenerateButton(
isInHoverBar: showDecoration,
onTap: () => onRegenerate?.call(),
isInHoverBar: widget.showDecoration,
onTap: () => widget.onRegenerate?.call(),
),
ChangeFormatButton(
isInHoverBar: widget.showDecoration,
onRegenerate: widget.onChangeFormat,
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
SaveToPageButton(
textMessage: message as TextMessage,
isInHoverBar: showDecoration,
onOverrideVisibility: onOverrideVisibility,
textMessage: widget.message as TextMessage,
isInHoverBar: widget.showDecoration,
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
];
}
@ -195,16 +214,186 @@ class RegenerateButton extends StatelessWidget {
}
}
class ChangeFormatButton extends StatefulWidget {
const ChangeFormatButton({
super.key,
required this.isInHoverBar,
this.popoverMutex,
this.onRegenerate,
this.onOverrideVisibility,
});
final bool isInHoverBar;
final PopoverMutex? popoverMutex;
final void Function(PredefinedFormat)? onRegenerate;
final void Function(bool)? onOverrideVisibility;
@override
State<ChangeFormatButton> createState() => _ChangeFormatButtonState();
}
class _ChangeFormatButtonState extends State<ChangeFormatButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
controller: popoverController,
mutex: widget.popoverMutex,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: Offset(0, widget.isInHoverBar ? 8 : 4),
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(),
onClose: () => widget.onOverrideVisibility?.call(false),
child: buildButton(context),
popupBuilder: (_) => _ChangeFormatPopoverContent(
onRegenerate: widget.onRegenerate,
),
);
}
Widget buildButton(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_changeFormat_actionButton.tr(),
child: FlowyIconButton(
width: 32.0,
height: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: widget.isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_retry_font_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
onPressed: () {
widget.onOverrideVisibility?.call(true);
popoverController.show();
},
),
);
}
}
class _ChangeFormatPopoverContent extends StatefulWidget {
const _ChangeFormatPopoverContent({
this.onRegenerate,
});
final void Function(PredefinedFormat)? onRegenerate;
@override
State<_ChangeFormatPopoverContent> createState() =>
_ChangeFormatPopoverContentState();
}
class _ChangeFormatPopoverContentState
extends State<_ChangeFormatPopoverContent> {
PredefinedFormat predefinedFormat = const PredefinedFormat.auto();
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
return Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.hoverActionBarRadius,
border: Border.all(
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
strokeAlign: BorderSide.strokeAlignOutside,
),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 4,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 8,
spreadRadius: 2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ChangeFormatBar(
spacing: 2.0,
iconSize: 16.0,
buttonSize: DesktopAIPromptSizes.predefinedFormatButtonHeight,
predefinedFormat: predefinedFormat,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
),
const HSpace(4.0),
FlowyTooltip(
message: LocaleKeys.chat_changeFormat_confirmButton.tr(),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onRegenerate?.call(predefinedFormat),
child: SizedBox.square(
dimension: DesktopAIPromptSizes.predefinedFormatButtonHeight,
child: Center(
child: FlowySvg(
FlowySvgs.ai_retry_filled_s,
color: Theme.of(context).colorScheme.primary,
size: const Size.square(20),
),
),
),
),
),
),
],
),
);
}
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,
required this.textMessage,
required this.isInHoverBar,
this.popoverMutex,
this.onOverrideVisibility,
});
final TextMessage textMessage;
final bool isInHoverBar;
final PopoverMutex? popoverMutex;
final void Function(bool)? onOverrideVisibility;
@override
@ -240,6 +429,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
mutex: widget.popoverMutex,
offset: const Offset(8, 0),
direction: PopoverDirection.rightWithBottomAligned,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),

View File

@ -5,7 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.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';
@ -20,8 +20,10 @@ import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../chat_input/chat_mention_page_bottom_sheet.dart';
import '../layout_define.dart';
import 'ai_message_action_bar.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'message_util.dart';
/// Wraps an AI response message with the avatar and actions. On desktop,
@ -36,6 +38,7 @@ class ChatAIMessageBubble extends StatelessWidget {
required this.showActions,
this.isLastMessage = false,
this.onRegenerate,
this.onChangeFormat,
});
final Message message;
@ -43,6 +46,7 @@ class ChatAIMessageBubble extends StatelessWidget {
final bool showActions;
final bool isLastMessage;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
@override
Widget build(BuildContext context) {
@ -68,6 +72,7 @@ class ChatAIMessageBubble extends StatelessWidget {
return ChatAIBottomInlineActions(
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
child: child,
);
}
@ -76,6 +81,7 @@ class ChatAIMessageBubble extends StatelessWidget {
return ChatAIMessageHover(
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
child: child,
);
}
@ -84,6 +90,7 @@ class ChatAIMessageBubble extends StatelessWidget {
return ChatAIMessagePopup(
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
child: child,
);
}
@ -95,11 +102,13 @@ class ChatAIBottomInlineActions extends StatelessWidget {
required this.child,
required this.message,
this.onRegenerate,
this.onChangeFormat,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
@override
Widget build(BuildContext context) {
@ -117,6 +126,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
message: message,
showDecoration: false,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
),
),
const VSpace(32.0),
@ -131,11 +141,13 @@ class ChatAIMessageHover extends StatefulWidget {
required this.child,
required this.message,
this.onRegenerate,
this.onChangeFormat,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
@ -217,6 +229,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
message: widget.message,
showDecoration: true,
onRegenerate: widget.onRegenerate,
onChangeFormat: widget.onChangeFormat,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
@ -288,11 +301,13 @@ class ChatAIMessagePopup extends StatelessWidget {
required this.child,
required this.message,
this.onRegenerate,
this.onChangeFormat,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
@override
Widget build(BuildContext context) {
@ -307,11 +322,12 @@ class ChatAIMessagePopup extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(16.0),
_copyButton(context, bottomSheetContext),
_divider(),
_regenerateButton(context),
_divider(),
_changeFormatButton(context),
_divider(),
_saveToPageButton(context),
],
);
@ -366,6 +382,23 @@ class ChatAIMessagePopup extends StatelessWidget {
);
}
Widget _changeFormatButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {
final result = await showChangeFormatBottomSheet(context);
if (result != null) {
onChangeFormat?.call(result);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
icon: FlowySvgs.ai_retry_font_s,
iconSize: const Size.square(20),
text: LocaleKeys.chat_changeFormat_actionButton.tr(),
);
}
Widget _saveToPageButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {

View File

@ -34,6 +34,7 @@ class ChatAIMessageWidget extends StatelessWidget {
required this.refSourceJsonString,
this.onSelectedMetadata,
this.onRegenerate,
this.onChangeFormat,
this.isLastMessage = false,
this.isStreaming = false,
});
@ -48,6 +49,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final String? refSourceJsonString;
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final bool isStreaming;
final bool isLastMessage;
@ -87,6 +89,7 @@ class ChatAIMessageWidget extends StatelessWidget {
state.text.isNotEmpty &&
!isStreaming,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@ -2,12 +2,12 @@ import 'dart:ui';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
@ -68,14 +68,16 @@ class _ImageMenuState extends State<ImageMenu> {
onTap: copyImageLink,
),
const HSpace(4),
_ImageAlignButton(node: widget.node, state: widget.state),
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.trash_s,
onTap: deleteImage,
),
const HSpace(4),
if (widget.state.editorState.editable) ...[
_ImageAlignButton(node: widget.node, state: widget.state),
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.trash_s,
onTap: deleteImage,
),
const HSpace(4),
],
],
),
);
@ -126,7 +128,7 @@ class _ImageMenuState extends State<ImageMenu> {
showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().state.userProfilePB,
userProfile: context.read<UserWorkspaceBloc>().userProfile,
imageProvider: AFBlockImageProvider(
images: [
ImageBlockData(
@ -136,11 +138,13 @@ class _ImageMenuState extends State<ImageMenu> {
),
),
],
onDeleteImage: (_) async {
final transaction = widget.state.editorState.transaction;
transaction.deleteNode(widget.node);
await widget.state.editorState.apply(transaction);
},
onDeleteImage: widget.state.editorState.editable
? (_) async {
final transaction = widget.state.editorState.transaction;
transaction.deleteNode(widget.node);
await widget.state.editorState.apply(transaction);
}
: null,
),
),
);

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@ -53,7 +54,7 @@ class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
@override
void initState() {
super.initState();
_userProfile = context.read<DocumentBloc>().state.userProfilePB;
_userProfile = context.read<UserWorkspaceBloc?>()?.userProfile;
}
@override

View File

@ -5,6 +5,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -60,7 +61,7 @@ class _ResizableImageState extends State<ResizableImage> {
void initState() {
super.initState();
imageWidth = widget.width;
_userProfilePB = context.read<DocumentBloc?>()?.state.userProfilePB;
_userProfilePB = context.read<UserWorkspaceBloc?>()?.userProfile;
}
@override

View File

@ -173,12 +173,9 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
},
expandSecondaryPlugin: () {
final pageManager = state.currentPageManager;
pageManager.setPlugin(
pageManager.secondaryNotifier.plugin,
true,
false,
);
pageManager.hideSecondaryPlugin();
pageManager
..hideSecondaryPlugin()
..expandSecondaryPlugin();
_setLatestOpenView();
},
switchWorkspace: (workspaceId) {

View File

@ -621,10 +621,12 @@ class PageNotifier extends ChangeNotifier {
]) =>
_plugin.widgetBuilder.tabBarItem(pluginId, shortForm);
/// This is the only place where the plugin is set.
/// No need compare the old plugin with the new plugin. Just set it.
void setPlugin(Plugin newPlugin, bool setLatest) {
if (newPlugin.id != plugin.id) {
void setPlugin(
Plugin newPlugin, {
required bool setLatest,
bool disposeExisting = true,
}) {
if (newPlugin.id != plugin.id && disposeExisting) {
_plugin.dispose();
}
@ -660,12 +662,21 @@ class PageManager {
if (init) {
newPlugin.init();
}
_notifier.setPlugin(newPlugin, setLatest);
_notifier.setPlugin(newPlugin, setLatest: setLatest);
}
void setSecondaryPlugin(Plugin newPlugin) {
newPlugin.init();
_secondaryNotifier.setPlugin(newPlugin, false);
_secondaryNotifier.setPlugin(newPlugin, setLatest: false);
}
void expandSecondaryPlugin() {
_notifier.setPlugin(_secondaryNotifier.plugin, setLatest: true);
_secondaryNotifier.setPlugin(
BlankPagePlugin(),
setLatest: false,
disposeExisting: false,
);
}
void showSecondaryPlugin() {

View File

@ -29,13 +29,13 @@ class AFBlockImageProvider implements AFImageProvider {
const AFBlockImageProvider({
required this.images,
this.initialIndex = 0,
required this.onDeleteImage,
this.onDeleteImage,
});
final List<ImageBlockData> images;
@override
final Function(int) onDeleteImage;
final Function(int)? onDeleteImage;
@override
final int initialIndex;

View File

@ -140,8 +140,9 @@ class _InteractiveImageViewerState extends State<InteractiveImageViewer> {
final scaleStep = scale / currentScale;
_zoom(scaleStep, size);
},
onDelete: () =>
widget.imageProvider.onDeleteImage?.call(currentIndex),
onDelete: widget.imageProvider.onDeleteImage == null
? null
: () => widget.imageProvider.onDeleteImage?.call(currentIndex),
),
],
),

View File

@ -61,8 +61,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "2490f69"
resolved-ref: "2490f698c39d59ef81a03bbe192088f36b75e968"
ref: "448174b"
resolved-ref: "448174bb11ae4cfb3bb093522ef02f10f856abdf"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "4.0.0"

View File

@ -174,7 +174,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "2490f69"
ref: "448174b"
appflowy_editor_plugins:
git:

View File

@ -1,11 +1,50 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3532_93841)">
<path fill="black" d="M8 0.65A7.35 7.35 0 1 1 0.65 8a7.35 7.35 0 0 1 14.7 0z"/>
<path d="m11.182 5.696-0.354-0.354a4 4 0 1 0 1.14 2.329m-0.787-1.975h-2.121m2.121 0V3.574" stroke="white" stroke-width="1.12" stroke-linecap="round" stroke-linejoin="round"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
version="1.1"
id="svg3"
sodipodi:docname="ai_retry_filled.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="24.32251"
inkscape:cx="-1.8090238"
inkscape:cy="8.6545345"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g2" />
<g
clip-path="url(#clip0_3532_93841)"
id="g2">
<path
d="M 7.9960937,0.6171875 C 3.8984912,0.61784666 0.57687774,3.9578212 0.57617187,8.078125 0.57580518,12.199191 3.8977327,15.540356 7.9960937,15.541016 12.095218,15.541441 15.418335,12.199958 15.417969,8.078125 15.417263,3.9570543 12.094459,0.61676218 7.9960937,0.6171875 Z m 3.1855473,2.3964844 c 0.308943,7.756e-4 0.558896,0.2516023 0.558593,0.5605469 v 2.1210937 c -0.0053,0.01456 -0.01113,0.028895 -0.01758,0.042969 -0.0053,0.131134 -0.05642,0.2562536 -0.144531,0.3535157 0,0 -0.002,0 -0.002,0 -0.104658,0.1046817 -0.246506,0.1636679 -0.394531,0.1640625 H 9.0605469 C 8.7508401,6.256162 8.4996974,6.0050192 8.5,5.6953125 8.5007757,5.3863688 8.7516023,5.1364161 9.0605469,5.1367187 h 0.515625 C 8.3534414,4.4547337 7.0207494,4.6410706 6,5.3710937 4.7359739,6.2751043 4.0326162,7.8888416 4.9101562,9.6816406 5.7876964,11.47444 7.495039,11.910503 8.984375,11.466797 c 1.489336,-0.443706 2.677355,-1.7416934 2.427734,-3.7265626 -0.03828,-0.3068806 0.179452,-0.5866929 0.486329,-0.625 0.30688,-0.038278 0.586692,0.1794512 0.625,0.4863281 C 12.838147,10.103997 11.240268,11.96241 9.3046875,12.539063 7.3691071,13.115715 5.0114879,12.4358 3.9042969,10.173828 2.7971059,7.9118565 3.7068477,5.6338604 5.3496094,4.4589844 6.8201788,3.4072574 8.9142819,3.2819034 10.621094,4.5292969 V 3.5742188 c -3.03e-4,-0.3097068 0.25084,-0.5608495 0.560547,-0.5605469 z"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#000000;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
id="path13" />
</g>
<defs>
<clipPath id="clip0_3532_93841">
<path d="M0 0H16V16H0V0z"/>
<defs
id="defs3">
<clipPath
id="clip0_3532_93841">
<path
d="M0 0H16V16H0V0z"
id="path3" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,6 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0938 7.37305H3.09375" stroke="#1F2329" stroke-width="1.25" stroke-linecap="round"/>
<path d="M10.0938 12.373H3.09375" stroke="#1F2329" stroke-width="1.25" stroke-linecap="round"/>
<path d="M8.09375 17.373H3.09375" stroke="#1F2329" stroke-width="1.25" stroke-linecap="round"/>
<path d="M11.41 17.0652C11.24 17.443 11.4084 17.887 11.7861 18.0569C12.1639 18.2269 12.6079 18.0585 12.7778 17.6808L11.41 17.0652ZM16.5939 7.37305L17.2778 7.06528C17.1567 6.79614 16.889 6.62305 16.5939 6.62305C16.2988 6.62305 16.0311 6.79614 15.91 7.06528L16.5939 7.37305ZM20.41 17.6808C20.5799 18.0585 21.0239 18.2269 21.4017 18.0569C21.7794 17.887 21.9478 17.443 21.7778 17.0652L20.41 17.6808ZM13.7303 12.9866C13.3161 12.9866 12.9803 13.3224 12.9803 13.7366C12.9803 14.1509 13.3161 14.4866 13.7303 14.4866V12.9866ZM12.7778 17.6808L17.2778 7.68082L15.91 7.06528L11.41 17.0652L12.7778 17.6808ZM21.7778 17.0652L20.1415 13.4289L18.7736 14.0444L20.41 17.6808L21.7778 17.0652ZM20.1415 13.4289L17.2778 7.06528L15.91 7.68082L18.7736 14.0444L20.1415 13.4289ZM19.4575 12.9866H13.7303V14.4866H19.4575V12.9866Z" fill="#1F2329"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -221,7 +221,24 @@
"addToNewPage": "Create new page",
"addToNewPageName": "Messages extracted from \"{}\"",
"addToNewPageSuccessToast": "Message added to",
"openPagePreviewFailedToast": "Failed to open page"
"openPagePreviewFailedToast": "Failed to open page",
"changeFormat": {
"actionButton": "Change format",
"confirmButton": "Regenerate with this format",
"textOnly": "Text",
"imageOnly": "Image only",
"textAndImage": "Text and Image",
"text": "Paragraph",
"bullet": "Bullet list",
"number": "Numbered list",
"table": "Table",
"blankDescription": "Format response",
"defaultDescription": "Auto",
"textWithImageDescription": "@:chat.changeFormat.text with image",
"numberWithImageDescription": "@:chat.changeFormat.number with image",
"bulletWithImageDescription": "@:chat.changeFormat.bullet with image",
"tableWithImageDescription": "@:chat.changeFormat.table with image"
}
},
"trash": {
"text": "Trash",

File diff suppressed because it is too large Load Diff

View File

@ -103,8 +103,8 @@ dashmap = "6.0.1"
# Run the script.add_workspace_members:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ea131f0baab67defe7591067357eced490072372" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ea131f0baab67defe7591067357eced490072372" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2bd6da228d3e3f0f258c982b7a2a3571718d3688" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2bd6da228d3e3f0f258c982b7a2a3571718d3688" }
[profile.dev]
opt-level = 0

View File

@ -1,7 +1,8 @@
use bytes::Bytes;
pub use client_api::entity::ai_dto::{
AppFlowyOfflineAI, CompletionType, CreateChatContext, LLMModel, LocalAIConfig, ModelInfo,
RelatedQuestion, RepeatedRelatedQuestion, StringOrMessage,
OutputContent, OutputLayout, RelatedQuestion, RepeatedRelatedQuestion, ResponseFormat,
StringOrMessage,
};
pub use client_api::entity::billing_dto::SubscriptionPlan;
pub use client_api::entity::chat_dto::{
@ -53,6 +54,7 @@ pub trait ChatCloudService: Send + Sync + 'static {
workspace_id: &str,
chat_id: &str,
message_id: i64,
format: ResponseFormat,
) -> Result<StreamAnswer, FlowyError>;
async fn get_answer(

View File

@ -1,6 +1,7 @@
use crate::chat::Chat;
use crate::entities::{
ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, FilePB, RepeatedRelatedQuestionPB,
ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, FilePB, PredefinedFormatPB,
RepeatedRelatedQuestionPB, StreamMessageParams,
};
use crate::local_ai::local_llm_chat::LocalAIController;
use crate::middleware::chat_service_mw::AICloudServiceMiddleware;
@ -9,9 +10,7 @@ use std::collections::HashMap;
use appflowy_plugin::manager::PluginManager;
use dashmap::DashMap;
use flowy_ai_pub::cloud::{
ChatCloudService, ChatMessageMetadata, ChatMessageType, ChatSettings, UpdateChatParams,
};
use flowy_ai_pub::cloud::{ChatCloudService, ChatSettings, UpdateChatParams};
use flowy_error::{FlowyError, FlowyResult};
use flowy_sqlite::kv::KVStorePreferences;
use flowy_sqlite::DBConnection;
@ -212,28 +211,15 @@ impl AIManager {
Ok(chat)
}
pub async fn stream_chat_message(
&self,
chat_id: &str,
message: &str,
message_type: ChatMessageType,
answer_stream_port: i64,
question_stream_port: i64,
metadata: Vec<ChatMessageMetadata>,
pub async fn stream_chat_message<'a>(
&'a self,
params: &'a StreamMessageParams<'a>,
) -> Result<ChatMessagePB, FlowyError> {
let chat = self.get_or_create_chat_instance(chat_id).await?;
let question = chat
.stream_chat_message(
message,
message_type,
answer_stream_port,
question_stream_port,
metadata,
)
.await?;
let chat = self.get_or_create_chat_instance(params.chat_id).await?;
let question = chat.stream_chat_message(params).await?;
let _ = self
.external_service
.notify_did_send_message(chat_id, message)
.notify_did_send_message(params.chat_id, params.message)
.await;
Ok(question)
}
@ -243,13 +229,14 @@ impl AIManager {
chat_id: &str,
answer_message_id: i64,
answer_stream_port: i64,
format: Option<PredefinedFormatPB>,
) -> FlowyResult<()> {
let chat = self.get_or_create_chat_instance(chat_id).await?;
let question_message_id = chat
.get_question_id_from_answer_id(answer_message_id)
.await?;
chat
.stream_regenerate_response(question_message_id, answer_stream_port)
.stream_regenerate_response(question_message_id, answer_stream_port, format)
.await?;
Ok(())
}

View File

@ -1,6 +1,7 @@
use crate::ai_manager::AIUserService;
use crate::entities::{
ChatMessageErrorPB, ChatMessageListPB, ChatMessagePB, RepeatedRelatedQuestionPB,
ChatMessageErrorPB, ChatMessageListPB, ChatMessagePB, PredefinedFormatPB,
RepeatedRelatedQuestionPB, StreamMessageParams,
};
use crate::middleware::chat_service_mw::AICloudServiceMiddleware;
use crate::notification::{chat_notification_builder, ChatNotification};
@ -11,8 +12,7 @@ use crate::persistence::{
use crate::stream_message::StreamMessage;
use allo_isolate::Isolate;
use flowy_ai_pub::cloud::{
ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, MessageCursor,
QuestionStreamValue,
ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat,
};
use flowy_error::{FlowyError, FlowyResult};
use flowy_sqlite::DBConnection;
@ -81,20 +81,17 @@ impl Chat {
}
#[instrument(level = "info", skip_all, err)]
pub async fn stream_chat_message(
&self,
message: &str,
message_type: ChatMessageType,
answer_stream_port: i64,
question_stream_port: i64,
metadata: Vec<ChatMessageMetadata>,
pub async fn stream_chat_message<'a>(
&'a self,
params: &'a StreamMessageParams<'a>,
) -> Result<ChatMessagePB, FlowyError> {
trace!(
"[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, metadata={:?}",
"[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, metadata={:?}, format={:?}",
self.chat_id,
message,
message_type,
metadata
params.message,
params.message_type,
params.metadata,
params.format,
);
// clear
@ -103,22 +100,22 @@ impl Chat {
.store(false, std::sync::atomic::Ordering::SeqCst);
self.stream_buffer.lock().await.clear();
let mut question_sink = IsolateSink::new(Isolate::new(question_stream_port));
let mut question_sink = IsolateSink::new(Isolate::new(params.question_stream_port));
let answer_stream_buffer = self.stream_buffer.clone();
let uid = self.user_service.user_id()?;
let workspace_id = self.user_service.workspace_id()?;
let _ = question_sink
.send(StreamMessage::Text(message.to_string()).to_string())
.send(StreamMessage::Text(params.message.to_string()).to_string())
.await;
let question = self
.chat_service
.create_question(
&workspace_id,
&self.chat_id,
message,
message_type,
&metadata,
params.message,
params.message_type.clone(),
&params.metadata,
)
.await
.map_err(|err| {
@ -131,7 +128,7 @@ impl Chat {
.await;
if let Err(err) = self
.chat_service
.index_message_metadata(&self.chat_id, &metadata, &mut question_sink)
.index_message_metadata(&self.chat_id, &params.metadata, &mut question_sink)
.await
{
error!("Failed to index file: {}", err);
@ -141,12 +138,15 @@ impl Chat {
// Save message to disk
save_and_notify_message(uid, &self.chat_id, &self.user_service, question.clone())?;
let format = params.format.clone().unwrap_or_default().into();
self.stream_response(
answer_stream_port,
params.answer_stream_port,
answer_stream_buffer,
uid,
workspace_id,
question.message_id,
format,
);
let question_pb = ChatMessagePB::from(question);
@ -158,6 +158,7 @@ impl Chat {
&self,
question_id: i64,
answer_stream_port: i64,
format: Option<PredefinedFormatPB>,
) -> FlowyResult<()> {
trace!(
"[Chat] regenerate and stream chat message: chat_id={}",
@ -170,6 +171,8 @@ impl Chat {
.store(false, std::sync::atomic::Ordering::SeqCst);
self.stream_buffer.lock().await.clear();
let format = format.unwrap_or_default().into();
let answer_stream_buffer = self.stream_buffer.clone();
let uid = self.user_service.user_id()?;
let workspace_id = self.user_service.workspace_id()?;
@ -180,6 +183,7 @@ impl Chat {
uid,
workspace_id,
question_id,
format,
);
Ok(())
@ -192,6 +196,7 @@ impl Chat {
uid: i64,
workspace_id: String,
question_id: i64,
format: ResponseFormat,
) {
let stop_stream = self.stop_stream.clone();
let chat_id = self.chat_id.clone();
@ -200,7 +205,7 @@ impl Chat {
tokio::spawn(async move {
let mut answer_sink = IsolateSink::new(Isolate::new(answer_stream_port));
match cloud_service
.stream_answer(&workspace_id, &chat_id, question_id)
.stream_answer(&workspace_id, &chat_id, question_id, format)
.await
{
Ok(mut stream) => {
@ -214,14 +219,21 @@ impl Chat {
match message {
QuestionStreamValue::Answer { value } => {
answer_stream_buffer.lock().await.push_str(&value);
let _ = answer_sink.send(format!("data:{}", value)).await;
// trace!("[Chat] stream answer: {}", value);
if let Err(err) = answer_sink.send(format!("data:{}", value)).await {
error!("Failed to stream answer: {}", err);
}
},
QuestionStreamValue::Metadata { value } => {
if let Ok(s) = serde_json::to_string(&value) {
// trace!("[Chat] stream metadata: {}", s);
answer_stream_buffer.lock().await.set_metadata(value);
let _ = answer_sink.send(format!("metadata:{}", s)).await;
}
},
QuestionStreamValue::KeepAlive => {
// trace!("[Chat] stream keep alive");
},
}
},
Err(err) => {

View File

@ -4,7 +4,8 @@ use std::collections::HashMap;
use crate::local_ai::local_llm_resource::PendingResource;
use flowy_ai_pub::cloud::{
ChatMessage, LLMModel, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion,
ChatMessage, ChatMessageMetadata, ChatMessageType, LLMModel, OutputContent, OutputLayout,
RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat,
};
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use lib_infra::validator_fn::required_not_empty_str;
@ -67,10 +68,24 @@ pub struct StreamChatPayloadPB {
#[pb(index = 5)]
pub question_stream_port: i64,
#[pb(index = 6)]
#[pb(index = 6, one_of)]
pub format: Option<PredefinedFormatPB>,
#[pb(index = 7)]
pub metadata: Vec<ChatMessageMetaPB>,
}
#[derive(Default, Debug)]
pub struct StreamMessageParams<'a> {
pub chat_id: &'a str,
pub message: &'a str,
pub message_type: ChatMessageType,
pub answer_stream_port: i64,
pub question_stream_port: i64,
pub format: Option<PredefinedFormatPB>,
pub metadata: Vec<ChatMessageMetadata>,
}
#[derive(Default, ProtoBuf, Validate, Clone, Debug)]
pub struct RegenerateResponsePB {
#[pb(index = 1)]
@ -82,6 +97,9 @@ pub struct RegenerateResponsePB {
#[pb(index = 3)]
pub answer_stream_port: i64,
#[pb(index = 4, one_of)]
pub format: Option<PredefinedFormatPB>,
}
#[derive(Default, ProtoBuf, Validate, Clone, Debug)]
@ -554,3 +572,51 @@ pub struct UpdateChatSettingsPB {
#[pb(index = 2)]
pub rag_ids: Vec<String>,
}
#[derive(Debug, Default, Clone, ProtoBuf)]
pub struct PredefinedFormatPB {
#[pb(index = 1)]
pub image_format: ResponseImageFormatPB,
#[pb(index = 2, one_of)]
pub text_format: Option<ResponseTextFormatPB>,
}
#[derive(Debug, Default, Clone, ProtoBuf_Enum)]
pub enum ResponseImageFormatPB {
#[default]
TextOnly = 0,
ImageOnly = 1,
TextAndImage = 2,
}
#[derive(Debug, Default, Clone, ProtoBuf_Enum)]
pub enum ResponseTextFormatPB {
#[default]
Paragraph = 0,
BulletedList = 1,
NumberedList = 2,
Table = 3,
}
impl From<PredefinedFormatPB> for ResponseFormat {
fn from(value: PredefinedFormatPB) -> Self {
Self {
output_layout: match value.text_format {
Some(format) => match format {
ResponseTextFormatPB::Paragraph => OutputLayout::Paragraph,
ResponseTextFormatPB::BulletedList => OutputLayout::BulletList,
ResponseTextFormatPB::NumberedList => OutputLayout::NumberedList,
ResponseTextFormatPB::Table => OutputLayout::SimpleTable,
},
None => OutputLayout::Paragraph,
},
output_content: match value.image_format {
ResponseImageFormatPB::TextOnly => OutputContent::TEXT,
ResponseImageFormatPB::ImageOnly => OutputContent::IMAGE,
ResponseImageFormatPB::TextAndImage => OutputContent::RichTextImage,
},
output_content_metadata: None,
}
}
}

View File

@ -32,13 +32,22 @@ pub(crate) async fn stream_chat_message_handler(
let data = data.into_inner();
data.validate()?;
let message_type = match data.message_type {
let StreamChatPayloadPB {
chat_id,
message,
message_type,
answer_stream_port,
question_stream_port,
format,
metadata,
} = data;
let message_type = match message_type {
ChatMessageTypePB::System => ChatMessageType::System,
ChatMessageTypePB::User => ChatMessageType::User,
};
let metadata = data
.metadata
let metadata = metadata
.into_iter()
.map(|metadata| {
let (content_type, content_len) = match metadata.loader_type {
@ -63,17 +72,19 @@ pub(crate) async fn stream_chat_message_handler(
.collect::<Vec<_>>();
trace!("Stream chat message with metadata: {:?}", metadata);
let params = StreamMessageParams {
chat_id: &chat_id,
message: &message,
message_type,
answer_stream_port,
question_stream_port,
format,
metadata,
};
let ai_manager = upgrade_ai_manager(ai_manager)?;
let result = ai_manager
.stream_chat_message(
&data.chat_id,
&data.message,
message_type,
data.answer_stream_port,
data.question_stream_port,
metadata,
)
.await?;
let result = ai_manager.stream_chat_message(&params).await?;
data_result_ok(result)
}
@ -90,6 +101,7 @@ pub(crate) async fn regenerate_response_handler(
&data.chat_id,
data.answer_message_id,
data.answer_stream_port,
data.format,
)
.await?;
Ok(())

View File

@ -11,7 +11,8 @@ use std::collections::HashMap;
use flowy_ai_pub::cloud::{
ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings,
CompletionType, LocalAIConfig, MessageCursor, RelatedQuestion, RepeatedChatMessage,
RepeatedRelatedQuestion, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams,
RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan,
UpdateChatParams,
};
use flowy_error::{FlowyError, FlowyResult};
use futures::{stream, Sink, StreamExt, TryStreamExt};
@ -155,6 +156,7 @@ impl ChatCloudService for AICloudServiceMiddleware {
workspace_id: &str,
chat_id: &str,
question_id: i64,
format: ResponseFormat,
) -> Result<StreamAnswer, FlowyError> {
if self.local_llm_controller.is_running() {
let row = self.get_message_record(question_id)?;
@ -172,7 +174,7 @@ impl ChatCloudService for AICloudServiceMiddleware {
} else {
self
.cloud_service
.stream_answer(workspace_id, chat_id, question_id)
.stream_answer(workspace_id, chat_id, question_id, format)
.await
}
}

View File

@ -22,8 +22,8 @@ use collab_integrate::collab_builder::{
};
use flowy_ai_pub::cloud::{
ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, LocalAIConfig,
MessageCursor, RepeatedChatMessage, StreamAnswer, StreamComplete, SubscriptionPlan,
UpdateChatParams,
MessageCursor, RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete,
SubscriptionPlan, UpdateChatParams,
};
use flowy_database_pub::cloud::{
DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent,
@ -704,13 +704,14 @@ impl ChatCloudService for ServerProvider {
workspace_id: &str,
chat_id: &str,
message_id: i64,
format: ResponseFormat,
) -> Result<StreamAnswer, FlowyError> {
let workspace_id = workspace_id.to_string();
let chat_id = chat_id.to_string();
let server = self.get_server()?;
server
.chat_service()
.stream_answer(&workspace_id, &chat_id, message_id)
.stream_answer(&workspace_id, &chat_id, message_id, format)
.await
}

View File

@ -1,5 +1,7 @@
use crate::af_cloud::AFServer;
use client_api::entity::ai_dto::{CompleteTextParams, CompletionType, RepeatedRelatedQuestion};
use client_api::entity::ai_dto::{
ChatQuestionQuery, CompleteTextParams, CompletionType, RepeatedRelatedQuestion, ResponseFormat,
};
use client_api::entity::chat_dto::{
CreateAnswerMessageParams, CreateChatMessageParams, CreateChatParams, MessageCursor,
RepeatedChatMessage,
@ -15,6 +17,7 @@ use lib_infra::util::{get_operating_system, OperatingSystem};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use tracing::trace;
pub(crate) struct AFCloudChatCloudServiceImpl<T> {
pub inner: T,
@ -97,10 +100,24 @@ where
workspace_id: &str,
chat_id: &str,
message_id: i64,
format: ResponseFormat,
) -> Result<StreamAnswer, FlowyError> {
trace!(
"stream_answer: workspace_id={}, chat_id={}, format={:?}",
workspace_id,
chat_id,
format
);
let try_get_client = self.inner.try_get_client();
let result = try_get_client?
.stream_answer_v2(workspace_id, chat_id, message_id)
.stream_answer_v3(
workspace_id,
ChatQuestionQuery {
chat_id: chat_id.to_string(),
question_id: message_id,
format,
},
)
.await;
let stream = result.map_err(FlowyError::from)?.map_err(FlowyError::from);

View File

@ -1,7 +1,8 @@
use client_api::entity::ai_dto::{CompletionType, LocalAIConfig, RepeatedRelatedQuestion};
use flowy_ai_pub::cloud::{
ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, MessageCursor,
RepeatedChatMessage, StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams,
RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan,
UpdateChatParams,
};
use flowy_error::FlowyError;
use lib_infra::async_trait::async_trait;
@ -50,6 +51,7 @@ impl ChatCloudService for DefaultChatCloudServiceImpl {
_workspace_id: &str,
_chat_id: &str,
_message_id: i64,
_format: ResponseFormat,
) -> Result<StreamAnswer, FlowyError> {
Err(FlowyError::not_support().with_context("Chat is not supported in local server."))
}