From 7c3a823078099075a00d8d80f9b4ce66bc7dfe25 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 16 Feb 2023 10:17:08 +0800 Subject: [PATCH] feat: add openai service (#1858) * feat: add openai service * feat: add openai auto completion plugin * feat: add visible icon for open ai input field * chore: optimize user experience * feat: add auto completion node plugin * feat: support keep and discard the auto generated text * fix: can't delete the auto completion node * feat: disable ai plugins if open ai key is null * fix: wrong auto completion node card color * fix: make sure the previous text node is pure when using auto generator --- .../app_flowy/assets/translations/en.json | 16 +- .../document/application/doc_bloc.dart | 16 + .../lib/plugins/document/document_page.dart | 15 +- .../plugins/openai/service/error.dart | 14 + .../plugins/openai/service/openai_client.dart | 85 +++++ .../openai/service/text_completion.dart | 26 ++ .../plugins/openai/util/editor_extension.dart | 44 +++ .../widgets/auto_completion_node_widget.dart | 344 ++++++++++++++++++ .../widgets/auto_completion_plugins.dart | 20 + .../plugins/openai/widgets/loading.dart | 34 ++ .../lib/user/application/user_service.dart | 7 +- .../app_flowy/lib/util/either_extension.dart | 6 + .../application/user/settings_user_bloc.dart | 4 +- .../settings/widgets/settings_user_view.dart | 28 +- .../appflowy_editor/lib/appflowy_editor.dart | 1 + .../lib/src/core/location/position.dart | 9 + .../lib/src/core/location/selection.dart | 7 + .../copy_paste_handler.dart | 1 - .../core/document/node_iterator_test.dart | 1 - .../lib/style_widget/button.dart | 90 +++++ .../lib/style_widget/text_field.dart | 4 +- frontend/app_flowy/pubspec.lock | 20 +- frontend/app_flowy/pubspec.yaml | 3 + 23 files changed, 777 insertions(+), 18 deletions(-) create mode 100644 frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/error.dart create mode 100644 frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart create mode 100644 frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart create mode 100644 frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart create mode 100644 frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart create mode 100644 frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart create mode 100644 frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/loading.dart create mode 100644 frontend/app_flowy/lib/util/either_extension.dart diff --git a/frontend/app_flowy/assets/translations/en.json b/frontend/app_flowy/assets/translations/en.json index a5525873ecb9..50f04014f7e3 100644 --- a/frontend/app_flowy/assets/translations/en.json +++ b/frontend/app_flowy/assets/translations/en.json @@ -131,7 +131,12 @@ "signIn": "Sign In", "signOut": "Sign Out", "complete": "Complete", - "save": "Save" + "save": "Save", + "generate": "Generate", + "esc": "ESC", + "keep": "Keep", + "tryAgain": "Try again", + "discard": "Discard" }, "label": { "welcome": "Welcome!", @@ -334,7 +339,14 @@ }, "plugins": { "referencedBoard": "Referenced Board", - "referencedGrid": "Referenced Grid" + "referencedGrid": "Referenced Grid", + "autoCompletionMenuItemName": "Auto Completion", + "autoGeneratorMenuItemName": "Auto Generator", + "autoGeneratorTitleName": "Open AI: Auto Generator", + "autoGeneratorLearnMore": "Learn more", + "autoGeneratorGenerate": "Generate", + "autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...", + "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key" } }, "board": { diff --git a/frontend/app_flowy/lib/plugins/document/application/doc_bloc.dart b/frontend/app_flowy/lib/plugins/document/application/doc_bloc.dart index b8be9c1493ab..abc407847696 100644 --- a/frontend/app_flowy/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/app_flowy/lib/plugins/document/application/doc_bloc.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'package:app_flowy/plugins/trash/application/trash_service.dart'; +import 'package:app_flowy/user/application/user_service.dart'; import 'package:app_flowy/workspace/application/view/view_listener.dart'; import 'package:app_flowy/plugins/document/application/doc_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; import 'package:appflowy_editor/appflowy_editor.dart' show EditorState, Document, Transaction; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; @@ -12,6 +14,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:dartz/dartz.dart'; import 'dart:async'; +import 'package:app_flowy/util/either_extension.dart'; part 'doc_bloc.freezed.dart'; @@ -73,6 +76,16 @@ class DocumentBloc extends Bloc { } Future _initial(Initial value, Emitter emit) async { + final userProfile = await UserService.getCurrentUserProfile(); + if (userProfile.isRight()) { + emit( + state.copyWith( + loadingState: + DocumentLoadingState.finish(right(userProfile.asRight())), + ), + ); + return; + } final result = await _documentService.openDocument(view: view); result.fold( (documentData) { @@ -82,6 +95,7 @@ class DocumentBloc extends Bloc { emit( state.copyWith( loadingState: DocumentLoadingState.finish(left(unit)), + userProfilePB: userProfile.asLeft(), ), ); }, @@ -142,12 +156,14 @@ class DocumentState with _$DocumentState { required DocumentLoadingState loadingState, required bool isDeleted, required bool forceClose, + UserProfilePB? userProfilePB, }) = _DocumentState; factory DocumentState.initial() => const DocumentState( loadingState: _Loading(), isDeleted: false, forceClose: false, + userProfilePB: null, ); } diff --git a/frontend/app_flowy/lib/plugins/document/document_page.dart b/frontend/app_flowy/lib/plugins/document/document_page.dart index 7d76bd06dc12..cd0e08f5a049 100644 --- a/frontend/app_flowy/lib/plugins/document/document_page.dart +++ b/frontend/app_flowy/lib/plugins/document/document_page.dart @@ -2,6 +2,8 @@ import 'package:app_flowy/plugins/document/presentation/plugins/board/board_menu import 'package:app_flowy/plugins/document/presentation/plugins/board/board_node_widget.dart'; import 'package:app_flowy/plugins/document/presentation/plugins/grid/grid_menu_item.dart'; import 'package:app_flowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart'; +import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart'; +import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; @@ -83,6 +85,7 @@ class _DocumentPageState extends State { if (state.isDeleted) _renderBanner(context), // AppFlowy Editor _renderAppFlowyEditor( + context, context.read().editorState, ), ], @@ -99,7 +102,11 @@ class _DocumentPageState extends State { ); } - Widget _renderAppFlowyEditor(EditorState editorState) { + Widget _renderAppFlowyEditor(BuildContext context, EditorState editorState) { + // enable open ai features if needed. + final userProfilePB = context.read().state.userProfilePB; + final openAIKey = userProfilePB?.openaiKey; + final theme = Theme.of(context); final editor = AppFlowyEditor( editorState: editorState, @@ -117,6 +124,8 @@ class _DocumentPageState extends State { kGridType: GridNodeWidgetBuilder(), // Card kCalloutType: CalloutNodeWidgetBuilder(), + // Auto Generator, + kAutoCompletionInputType: AutoCompletionInputBuilder(), }, shortcutEvents: [ // Divider @@ -141,6 +150,10 @@ class _DocumentPageState extends State { gridMenuItem, // Callout calloutMenuItem, + // AI + if (openAIKey != null && openAIKey.isNotEmpty) ...[ + autoGeneratorMenuItem, + ] ], themeData: theme.copyWith(extensions: [ ...theme.extensions.values, diff --git a/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/error.dart b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/error.dart new file mode 100644 index 000000000000..d682a82f0883 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/error.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'error.freezed.dart'; +part 'error.g.dart'; + +@freezed +class OpenAIError with _$OpenAIError { + const factory OpenAIError({ + String? code, + required String message, + }) = _OpenAIError; + + factory OpenAIError.fromJson(Map json) => + _$OpenAIErrorFromJson(json); +} diff --git a/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart new file mode 100644 index 000000000000..772dda9fbf68 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +import 'text_completion.dart'; +import 'package:dartz/dartz.dart'; +import 'dart:async'; + +import 'error.dart'; +import 'package:http/http.dart' as http; + +// Please fill in your own API key +const apiKey = ''; + +enum OpenAIRequestType { + textCompletion, + textEdit; + + Uri get uri { + switch (this) { + case OpenAIRequestType.textCompletion: + return Uri.parse('https://api.openai.com/v1/completions'); + case OpenAIRequestType.textEdit: + return Uri.parse('https://api.openai.com/v1/edits'); + } + } +} + +abstract class OpenAIRepository { + /// Get completions from GPT-3 + /// + /// [prompt] is the prompt text + /// [suffix] is the suffix text + /// [maxTokens] is the maximum number of tokens to generate + /// [temperature] is the temperature of the model + /// + Future> getCompletions({ + required String prompt, + String? suffix, + int maxTokens = 50, + double temperature = .3, + }); +} + +class HttpOpenAIRepository implements OpenAIRepository { + const HttpOpenAIRepository({ + required this.client, + required this.apiKey, + }); + + final http.Client client; + final String apiKey; + + Map get headers => { + 'Authorization': 'Bearer $apiKey', + 'Content-Type': 'application/json', + }; + + @override + Future> getCompletions({ + required String prompt, + String? suffix, + int maxTokens = 50, + double temperature = 0.3, + }) async { + final parameters = { + 'model': 'text-davinci-003', + 'prompt': prompt, + 'suffix': suffix, + 'max_tokens': maxTokens, + 'temperature': temperature, + 'stream': false, + }; + + final response = await http.post( + OpenAIRequestType.textCompletion.uri, + headers: headers, + body: json.encode(parameters), + ); + + if (response.statusCode == 200) { + return Right(TextCompletionResponse.fromJson(json.decode(response.body))); + } else { + return Left(OpenAIError.fromJson(json.decode(response.body)['error'])); + } + } +} diff --git a/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart new file mode 100644 index 000000000000..b2c2a55cc621 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'text_completion.freezed.dart'; +part 'text_completion.g.dart'; + +@freezed +class TextCompletionChoice with _$TextCompletionChoice { + factory TextCompletionChoice({ + required String text, + required int index, + // ignore: invalid_annotation_target + @JsonKey(name: 'finish_reason') required String finishReason, + }) = _TextCompletionChoice; + + factory TextCompletionChoice.fromJson(Map json) => + _$TextCompletionChoiceFromJson(json); +} + +@freezed +class TextCompletionResponse with _$TextCompletionResponse { + const factory TextCompletionResponse({ + required List choices, + }) = _TextCompletionResponse; + + factory TextCompletionResponse.fromJson(Map json) => + _$TextCompletionResponseFromJson(json); +} diff --git a/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart new file mode 100644 index 000000000000..f85669b98978 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart @@ -0,0 +1,44 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +enum TextRobotInputType { + character, + word, +} + +extension TextRobot on EditorState { + Future autoInsertText( + String text, { + TextRobotInputType inputType = TextRobotInputType.word, + Duration delay = const Duration(milliseconds: 10), + }) async { + final lines = text.split('\n'); + for (final line in lines) { + if (line.isEmpty) continue; + switch (inputType) { + case TextRobotInputType.character: + final iterator = line.runes.iterator; + while (iterator.moveNext()) { + await insertTextAtCurrentSelection( + iterator.currentAsString, + ); + await Future.delayed(delay, () {}); + } + break; + case TextRobotInputType.word: + final words = line.split(' ').map((e) => '$e '); + for (final word in words) { + await insertTextAtCurrentSelection( + word, + ); + await Future.delayed(delay, () {}); + } + break; + } + + // insert new line + if (lines.length > 1) { + await insertNewLineAtCurrentSelection(); + } + } + } +} diff --git a/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart new file mode 100644 index 000000000000..97d488f6621f --- /dev/null +++ b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart @@ -0,0 +1,344 @@ +import 'dart:convert'; + +import 'package:app_flowy/plugins/document/presentation/plugins/openai/service/openai_client.dart'; +import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/loading.dart'; +import 'package:app_flowy/user/application/user_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:app_flowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import '../util/editor_extension.dart'; + +const String kAutoCompletionInputType = 'auto_completion_input'; +const String kAutoCompletionInputString = 'auto_completion_input_string'; +const String kAutoCompletionInputStartSelection = + 'auto_completion_input_start_selection'; + +class AutoCompletionInputBuilder extends NodeWidgetBuilder { + @override + NodeValidator get nodeValidator => (node) { + return node.attributes[kAutoCompletionInputString] is String; + }; + + @override + Widget build(NodeWidgetContext context) { + return _AutoCompletionInput( + key: context.node.key, + node: context.node, + editorState: context.editorState, + ); + } +} + +class _AutoCompletionInput extends StatefulWidget { + final Node node; + + final EditorState editorState; + const _AutoCompletionInput({ + Key? key, + required this.node, + required this.editorState, + }); + + @override + State<_AutoCompletionInput> createState() => _AutoCompletionInputState(); +} + +class _AutoCompletionInputState extends State<_AutoCompletionInput> { + String get text => widget.node.attributes[kAutoCompletionInputString]; + + final controller = TextEditingController(); + final focusNode = FocusNode(); + final textFieldFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + focusNode.addListener(() { + if (focusNode.hasFocus) { + widget.editorState.service.selectionService.clearSelection(); + } else { + widget.editorState.service.keyboardService?.enable(); + } + }); + textFieldFocusNode.requestFocus(); + } + + @override + void dispose() { + controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 5, + color: Theme.of(context).colorScheme.surface, + child: Container( + margin: const EdgeInsets.all(10), + child: _buildAutoGeneratorPanel(context), + ), + ); + } + + Widget _buildAutoGeneratorPanel(BuildContext context) { + if (text.isEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeaderWidget(context), + const Space(0, 10), + _buildInputWidget(context), + const Space(0, 10), + _buildInputFooterWidget(context), + ], + ); + } else { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeaderWidget(context), + const Space(0, 10), + _buildFooterWidget(context), + ], + ); + } + } + + Widget _buildHeaderWidget(BuildContext context) { + return Row( + children: [ + FlowyText.medium( + LocaleKeys.document_plugins_autoGeneratorTitleName.tr(), + fontSize: 14, + ), + const Spacer(), + FlowyText.regular( + LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(), + ), + ], + ); + } + + Widget _buildInputWidget(BuildContext context) { + return RawKeyboardListener( + focusNode: focusNode, + onKey: (RawKeyEvent event) async { + if (event is! RawKeyDownEvent) return; + if (event.logicalKey == LogicalKeyboardKey.enter) { + if (controller.text.isNotEmpty) { + textFieldFocusNode.unfocus(); + await _onGenerate(); + } + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + await _onExit(); + } + }, + child: FlowyTextField( + hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(), + controller: controller, + maxLines: 3, + focusNode: textFieldFocusNode, + autoFocus: false, + ), + ); + } + + Widget _buildInputFooterWidget(BuildContext context) { + return Row( + children: [ + FlowyRichTextButton( + TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.button_generate.tr()} ', + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: '↵', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), // FIXME: color + ), + ], + ), + onPressed: () async => await _onGenerate(), + ), + const Space(10, 0), + FlowyRichTextButton( + TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.button_Cancel.tr()} ', + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: LocaleKeys.button_esc.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), // FIXME: color + ), + ], + ), + onPressed: () async => await _onExit(), + ), + ], + ); + } + + Widget _buildFooterWidget(BuildContext context) { + return Row( + children: [ + // FIXME: l10n + FlowyRichTextButton( + TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.button_keep.tr()} ', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + onPressed: () => _onExit(), + ), + const Space(10, 0), + FlowyRichTextButton( + TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.button_discard.tr()} ', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + onPressed: () => _onDiscard(), + ), + ], + ); + } + + Future _onExit() async { + final transaction = widget.editorState.transaction; + transaction.deleteNode(widget.node); + await widget.editorState.apply( + transaction, + options: const ApplyOptions( + recordRedo: false, + recordUndo: false, + ), + ); + } + + Future _onGenerate() async { + final loading = Loading(context); + loading.start(); + await _updateEditingText(); + final result = await UserService.getCurrentUserProfile(); + result.fold((userProfile) async { + final openAIRepository = HttpOpenAIRepository( + client: http.Client(), + apiKey: userProfile.openaiKey, + ); + final completions = await openAIRepository.getCompletions( + prompt: controller.text, + ); + completions.fold((error) async { + loading.stop(); + await _showError(error.message); + }, (textCompletion) async { + loading.stop(); + await _makeSurePreviousNodeIsEmptyTextNode(); + await widget.editorState.autoInsertText( + textCompletion.choices.first.text, + ); + focusNode.requestFocus(); + }); + }, (error) async { + loading.stop(); + await _showError( + LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(), + ); + }); + } + + Future _onDiscard() async { + final selection = + widget.node.attributes[kAutoCompletionInputStartSelection]; + if (selection != null) { + final start = Selection.fromJson(json.decode(selection)).start.path; + final end = widget.node.previous?.path; + if (end != null) { + final transaction = widget.editorState.transaction; + transaction.deleteNodesAtPath( + start, + end.last - start.last, + ); + await widget.editorState.apply(transaction); + } + } + _onExit(); + } + + Future _updateEditingText() async { + final transaction = widget.editorState.transaction; + transaction.updateNode( + widget.node, + { + kAutoCompletionInputString: controller.text, + }, + ); + await widget.editorState.apply(transaction); + } + + Future _makeSurePreviousNodeIsEmptyTextNode() async { + // make sure the previous node is a empty text node without any styles. + final transaction = widget.editorState.transaction; + final Selection selection; + if (widget.node.previous is! TextNode || + (widget.node.previous as TextNode).toPlainText().isNotEmpty || + (widget.node.previous as TextNode).subtype != null) { + transaction.insertNode( + widget.node.path, + TextNode.empty(), + ); + selection = Selection.single( + path: widget.node.path, + startOffset: 0, + ); + transaction.afterSelection = selection; + } else { + selection = Selection.single( + path: widget.node.path.previous, + startOffset: 0, + ); + transaction.afterSelection = selection; + } + transaction.updateNode(widget.node, { + kAutoCompletionInputStartSelection: jsonEncode(selection.toJson()), + }); + await widget.editorState.apply(transaction); + } + + Future _showError(String message) async { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + action: SnackBarAction( + label: LocaleKeys.button_Cancel.tr(), + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + content: FlowyText(message), + ), + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart new file mode 100644 index 000000000000..732db442bfa0 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart @@ -0,0 +1,20 @@ +import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node( + name: 'Auto Generator', + iconData: Icons.generating_tokens, + keywords: ['autogenerator', 'auto generator'], + nodeBuilder: (editorState) { + final node = Node( + type: kAutoCompletionInputType, + attributes: { + kAutoCompletionInputString: '', + }, + ); + return node; + }, + replace: (_, textNode) => textNode.toPlainText().isEmpty, + updateSelection: null, +); diff --git a/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/loading.dart b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/loading.dart new file mode 100644 index 000000000000..31e97bddcfea --- /dev/null +++ b/frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/loading.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class Loading { + Loading( + this.context, + ); + + late BuildContext loadingContext; + final BuildContext context; + + Future start() async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + loadingContext = context; + return const SimpleDialog( + elevation: 0.0, + backgroundColor: + Colors.transparent, // can change this to your prefered color + children: [ + Center( + child: CircularProgressIndicator(), + ) + ], + ); + }, + ); + } + + Future stop() async { + return Navigator.of(loadingContext).pop(); + } +} diff --git a/frontend/app_flowy/lib/user/application/user_service.dart b/frontend/app_flowy/lib/user/application/user_service.dart index cb20191d8a12..50d0713938c6 100644 --- a/frontend/app_flowy/lib/user/application/user_service.dart +++ b/frontend/app_flowy/lib/user/application/user_service.dart @@ -7,12 +7,13 @@ import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; class UserService { - final String userId; UserService({ required this.userId, }); - Future> getUserProfile( - {required String userId}) { + + final String userId; + + static Future> getCurrentUserProfile() { return UserEventGetUserProfile().send(); } diff --git a/frontend/app_flowy/lib/util/either_extension.dart b/frontend/app_flowy/lib/util/either_extension.dart new file mode 100644 index 000000000000..7ec1beeb53d5 --- /dev/null +++ b/frontend/app_flowy/lib/util/either_extension.dart @@ -0,0 +1,6 @@ +import 'package:dartz/dartz.dart'; + +extension EitherX on Either { + R asRight() => (this as Right).value; + L asLeft() => (this as Left).value; +} diff --git a/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart b/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart index f0168f4c0ed2..e56b11d6d84d 100644 --- a/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart @@ -43,7 +43,7 @@ class SettingsUserViewBloc extends Bloc { ); }); }, - updateUserOpenaiKey: (openAIKey) { + updateUserOpenAIKey: (openAIKey) { _userService.updateUserProfile(openAIKey: openAIKey).then((result) { result.fold( (l) => null, @@ -81,7 +81,7 @@ class SettingsUserEvent with _$SettingsUserEvent { const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName; const factory SettingsUserEvent.updateUserIcon(String iconUrl) = _UpdateUserIcon; - const factory SettingsUserEvent.updateUserOpenaiKey(String openAIKey) = + const factory SettingsUserEvent.updateUserOpenAIKey(String openAIKey) = _UpdateUserOpenaiKey; const factory SettingsUserEvent.didReceiveUserProfile( UserProfilePB newUserProfile) = _DidReceiveUserProfile; diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart index 1e4e7ef259b6..fdd66145650c 100644 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart @@ -85,26 +85,46 @@ class UserNameInput extends StatelessWidget { } } -class _OpenaiKeyInput extends StatelessWidget { +class _OpenaiKeyInput extends StatefulWidget { final String openAIKey; const _OpenaiKeyInput( this.openAIKey, { Key? key, }) : super(key: key); + @override + State<_OpenaiKeyInput> createState() => _OpenaiKeyInputState(); +} + +class _OpenaiKeyInputState extends State<_OpenaiKeyInput> { + bool visible = false; + @override Widget build(BuildContext context) { return TextField( - controller: TextEditingController()..text = openAIKey, + controller: TextEditingController()..text = widget.openAIKey, + obscureText: !visible, decoration: InputDecoration( - labelText: 'Openai Key', + labelText: 'OpenAI Key', hintText: LocaleKeys.settings_user_pleaseInputYourOpenAIKey.tr(), + suffixIcon: IconButton( + iconSize: 15.0, + icon: Icon(visible ? Icons.visibility : Icons.visibility_off), + padding: EdgeInsets.zero, + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + onPressed: () { + setState(() { + visible = !visible; + }); + }, + ), ), onSubmitted: (val) { // TODO: validate key context .read() - .add(SettingsUserEvent.updateUserOpenaiKey(val)); + .add(SettingsUserEvent.updateUserOpenAIKey(val)); }, ); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 18142b360c96..2f48b2cfad8f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -47,3 +47,4 @@ export 'src/render/toolbar/toolbar_item.dart'; export 'src/extensions/node_extensions.dart'; export 'src/render/action_menu/action_menu.dart'; export 'src/render/action_menu/action_menu_item.dart'; +export 'src/core/document/node_iterator.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart index e793faa62524..4f3d104d2d06 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart @@ -9,6 +9,15 @@ class Position { this.offset = 0, }); + factory Position.fromJson(Map json) { + final path = Path.from(json['path'] as List); + final offset = json['offset']; + return Position( + path: path, + offset: offset ?? 0, + ); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart index b22d743c7d3d..39410897eb31 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart @@ -15,6 +15,13 @@ class Selection { required this.end, }); + factory Selection.fromJson(Map json) { + return Selection( + start: Position.fromJson(json['start']), + end: Position.fromJson(json['end']), + ); + } + /// Create a selection with [Path], [startOffset] and [endOffset]. /// /// The [endOffset] is optional. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index 47b8c3967c7d..3110a0e55949 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,7 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/infra/clipboard.dart'; import 'package:appflowy_editor/src/infra/html_converter.dart'; -import 'package:appflowy_editor/src/core/document/node_iterator.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart index 05a0090ec3a4..a816efbbc11a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart @@ -1,5 +1,4 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/core/document/node_iterator.dart'; import 'package:flutter_test/flutter_test.dart'; void main() async { diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart index 867d31da57c9..3c958a160a50 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -203,3 +203,93 @@ class FlowyTextButton extends StatelessWidget { return child; } } + +class FlowyRichTextButton extends StatelessWidget { + final InlineSpan text; + final TextOverflow overflow; + + final VoidCallback? onPressed; + final EdgeInsets padding; + final Widget? heading; + final Color? hoverColor; + final Color? fillColor; + final BorderRadius? radius; + final MainAxisAlignment mainAxisAlignment; + final String? tooltip; + final BoxConstraints constraints; + + final TextDecoration? decoration; + + // final HoverDisplayConfig? hoverDisplay; + const FlowyRichTextButton( + this.text, { + Key? key, + this.onPressed, + this.overflow = TextOverflow.ellipsis, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + this.hoverColor, + this.fillColor, + this.heading, + this.radius, + this.mainAxisAlignment = MainAxisAlignment.start, + this.tooltip, + this.constraints = const BoxConstraints(minWidth: 58.0, minHeight: 30.0), + this.decoration, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + List children = []; + if (heading != null) { + children.add(heading!); + children.add(const HSpace(6)); + } + children.add( + RichText( + text: text, + overflow: overflow, + textAlign: TextAlign.center, + ), + ); + + Widget child = Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: mainAxisAlignment, + children: children, + ), + ); + + child = RawMaterialButton( + visualDensity: VisualDensity.compact, + hoverElevation: 0, + highlightElevation: 0, + shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), + fillColor: fillColor ?? Theme.of(context).colorScheme.secondaryContainer, + hoverColor: hoverColor ?? Theme.of(context).colorScheme.secondary, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + elevation: 0, + constraints: constraints, + onPressed: () {}, + child: child, + ); + + child = IgnoreParentGestureWidget( + onPress: onPressed, + child: child, + ); + + if (tooltip != null) { + child = Tooltip( + message: tooltip!, + textStyle: AFThemeExtension.of(context).caption.textColor(Colors.white), + child: child, + ); + } + + return child; + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_field.dart index 067c3cc64ca7..01d3834e8fa2 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_field.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -20,6 +20,7 @@ class FlowyTextField extends StatefulWidget { final bool submitOnLeave; final Duration? debounceDuration; final String? errorText; + final int maxLines; const FlowyTextField({ this.hintText = "", @@ -36,6 +37,7 @@ class FlowyTextField extends StatefulWidget { this.submitOnLeave = false, this.debounceDuration, this.errorText, + this.maxLines = 1, Key? key, }) : super(key: key); @@ -103,7 +105,7 @@ class FlowyTextFieldState extends State { }, onSubmitted: (text) => _onSubmitted(text), onEditingComplete: widget.onEditingComplete, - maxLines: 1, + maxLines: widget.maxLines, maxLength: widget.maxLength, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, style: Theme.of(context).textTheme.bodyMedium, diff --git a/frontend/app_flowy/pubspec.lock b/frontend/app_flowy/pubspec.lock index 2bc8544eff35..5cc640492fa2 100644 --- a/frontend/app_flowy/pubspec.lock +++ b/frontend/app_flowy/pubspec.lock @@ -601,7 +601,7 @@ packages: source: hosted version: "0.15.1" http: - dependency: transitive + dependency: "direct main" description: name: http url: "https://pub.dartlang.org" @@ -662,12 +662,19 @@ packages: source: hosted version: "0.6.4" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "4.8.0" + version: "4.7.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "6.5.4" linked_scroll_controller: dependency: "direct main" description: @@ -1128,6 +1135,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.6" + source_helper: + dependency: transitive + description: + name: source_helper + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.3" source_map_stack_trace: dependency: transitive description: diff --git a/frontend/app_flowy/pubspec.yaml b/frontend/app_flowy/pubspec.yaml index 92cecf49a8e1..33af66d751d1 100644 --- a/frontend/app_flowy/pubspec.yaml +++ b/frontend/app_flowy/pubspec.yaml @@ -93,6 +93,8 @@ dependencies: path: packages/appflowy_editor_plugins calendar_view: ^1.0.1 window_manager: ^0.3.0 + http: ^0.13.5 + json_annotation: ^4.7.0 dev_dependencies: flutter_lints: ^2.0.1 @@ -104,6 +106,7 @@ dev_dependencies: build_runner: ^2.2.0 freezed: ^2.1.0+1 bloc_test: ^9.0.2 + json_serializable: ^6.5.4 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is