mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-10-19 12:03:03 +00:00
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:
parent
e25633636b
commit
ab8e01bbf7
@ -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;
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
@ -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 {
|
||||
|
@ -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: [
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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 |
6
frontend/resources/flowy_icons/16x/ai_text_auto.svg
Normal file
6
frontend/resources/flowy_icons/16x/ai_text_auto.svg
Normal 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 |
@ -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",
|
||||
|
837
frontend/rust-lib/Cargo.lock
generated
837
frontend/rust-lib/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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(),
|
||||
¶ms.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, ¶ms.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) => {
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(¶ms).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(())
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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."))
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user