Browse Source

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
Lucas.Xu 2 years ago
parent
commit
7c3a823078
23 changed files with 777 additions and 18 deletions
  1. 14 2
      frontend/app_flowy/assets/translations/en.json
  2. 16 0
      frontend/app_flowy/lib/plugins/document/application/doc_bloc.dart
  3. 14 1
      frontend/app_flowy/lib/plugins/document/document_page.dart
  4. 14 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/error.dart
  5. 85 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart
  6. 26 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart
  7. 44 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart
  8. 344 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart
  9. 20 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart
  10. 34 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/openai/widgets/loading.dart
  11. 4 3
      frontend/app_flowy/lib/user/application/user_service.dart
  12. 6 0
      frontend/app_flowy/lib/util/either_extension.dart
  13. 2 2
      frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart
  14. 24 4
      frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart
  15. 1 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  16. 9 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart
  17. 7 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart
  18. 0 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart
  19. 0 1
      frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart
  20. 90 0
      frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart
  21. 3 1
      frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_field.dart
  22. 17 3
      frontend/app_flowy/pubspec.lock
  23. 3 0
      frontend/app_flowy/pubspec.yaml

+ 14 - 2
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": {

+ 16 - 0
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<DocumentEvent, DocumentState> {
   }
 
   Future<void> _initial(Initial value, Emitter<DocumentState> 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<DocumentEvent, DocumentState> {
         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,
       );
 }
 

+ 14 - 1
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<DocumentPage> {
         if (state.isDeleted) _renderBanner(context),
         // AppFlowy Editor
         _renderAppFlowyEditor(
+          context,
           context.read<DocumentBloc>().editorState,
         ),
       ],
@@ -99,7 +102,11 @@ class _DocumentPageState extends State<DocumentPage> {
     );
   }
 
-  Widget _renderAppFlowyEditor(EditorState editorState) {
+  Widget _renderAppFlowyEditor(BuildContext context, EditorState editorState) {
+    // enable open ai features if needed.
+    final userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
+    final openAIKey = userProfilePB?.openaiKey;
+
     final theme = Theme.of(context);
     final editor = AppFlowyEditor(
       editorState: editorState,
@@ -117,6 +124,8 @@ class _DocumentPageState extends State<DocumentPage> {
         kGridType: GridNodeWidgetBuilder(),
         // Card
         kCalloutType: CalloutNodeWidgetBuilder(),
+        // Auto Generator,
+        kAutoCompletionInputType: AutoCompletionInputBuilder(),
       },
       shortcutEvents: [
         // Divider
@@ -141,6 +150,10 @@ class _DocumentPageState extends State<DocumentPage> {
         gridMenuItem,
         // Callout
         calloutMenuItem,
+        // AI
+        if (openAIKey != null && openAIKey.isNotEmpty) ...[
+          autoGeneratorMenuItem,
+        ]
       ],
       themeData: theme.copyWith(extensions: [
         ...theme.extensions.values,

+ 14 - 0
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<String, Object?> json) =>
+      _$OpenAIErrorFromJson(json);
+}

+ 85 - 0
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<Either<OpenAIError, TextCompletionResponse>> 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<String, String> get headers => {
+        'Authorization': 'Bearer $apiKey',
+        'Content-Type': 'application/json',
+      };
+
+  @override
+  Future<Either<OpenAIError, TextCompletionResponse>> 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']));
+    }
+  }
+}

+ 26 - 0
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<String, Object?> json) =>
+      _$TextCompletionChoiceFromJson(json);
+}
+
+@freezed
+class TextCompletionResponse with _$TextCompletionResponse {
+  const factory TextCompletionResponse({
+    required List<TextCompletionChoice> choices,
+  }) = _TextCompletionResponse;
+
+  factory TextCompletionResponse.fromJson(Map<String, Object?> json) =>
+      _$TextCompletionResponseFromJson(json);
+}

+ 44 - 0
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<void> 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();
+      }
+    }
+  }
+}

+ 344 - 0
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<Node> {
+  @override
+  NodeValidator<Node> get nodeValidator => (node) {
+        return node.attributes[kAutoCompletionInputString] is String;
+      };
+
+  @override
+  Widget build(NodeWidgetContext<Node> 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<void> _onExit() async {
+    final transaction = widget.editorState.transaction;
+    transaction.deleteNode(widget.node);
+    await widget.editorState.apply(
+      transaction,
+      options: const ApplyOptions(
+        recordRedo: false,
+        recordUndo: false,
+      ),
+    );
+  }
+
+  Future<void> _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<void> _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<void> _updateEditingText() async {
+    final transaction = widget.editorState.transaction;
+    transaction.updateNode(
+      widget.node,
+      {
+        kAutoCompletionInputString: controller.text,
+      },
+    );
+    await widget.editorState.apply(transaction);
+  }
+
+  Future<void> _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<void> _showError(String message) async {
+    ScaffoldMessenger.of(context).showSnackBar(
+      SnackBar(
+        action: SnackBarAction(
+          label: LocaleKeys.button_Cancel.tr(),
+          onPressed: () {
+            ScaffoldMessenger.of(context).hideCurrentSnackBar();
+          },
+        ),
+        content: FlowyText(message),
+      ),
+    );
+  }
+}

+ 20 - 0
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,
+);

+ 34 - 0
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<void> start() async {
+    return showDialog<void>(
+      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: <Widget>[
+            Center(
+              child: CircularProgressIndicator(),
+            )
+          ],
+        );
+      },
+    );
+  }
+
+  Future<void> stop() async {
+    return Navigator.of(loadingContext).pop();
+  }
+}

+ 4 - 3
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<Either<UserProfilePB, FlowyError>> getUserProfile(
-      {required String userId}) {
+
+  final String userId;
+
+  static Future<Either<UserProfilePB, FlowyError>> getCurrentUserProfile() {
     return UserEventGetUserProfile().send();
   }
 

+ 6 - 0
frontend/app_flowy/lib/util/either_extension.dart

@@ -0,0 +1,6 @@
+import 'package:dartz/dartz.dart';
+
+extension EitherX<L, R> on Either<L, R> {
+  R asRight() => (this as Right).value;
+  L asLeft() => (this as Left).value;
+}

+ 2 - 2
frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart

@@ -43,7 +43,7 @@ class SettingsUserViewBloc extends Bloc<SettingsUserEvent, SettingsUserState> {
             );
           });
         },
-        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;

+ 24 - 4
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<SettingsUserViewBloc>()
-            .add(SettingsUserEvent.updateUserOpenaiKey(val));
+            .add(SettingsUserEvent.updateUserOpenAIKey(val));
       },
     );
   }

+ 1 - 0
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';

+ 9 - 0
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<String, dynamic> 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;

+ 7 - 0
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<String, dynamic> 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.

+ 0 - 1
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';
 

+ 0 - 1
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 {

+ 90 - 0
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<Widget> 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;
+  }
+}

+ 3 - 1
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<FlowyTextField> {
       },
       onSubmitted: (text) => _onSubmitted(text),
       onEditingComplete: widget.onEditingComplete,
-      maxLines: 1,
+      maxLines: widget.maxLines,
       maxLength: widget.maxLength,
       maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
       style: Theme.of(context).textTheme.bodyMedium,

+ 17 - 3
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:

+ 3 - 0
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