浏览代码

feat: support prompt and suffix

Lucas.Xu 2 年之前
父节点
当前提交
fc1efeb70b

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart

@@ -4,7 +4,7 @@ import 'dart:io';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:example/pages/simple_editor.dart';
-import 'package:example/plugin/text_robot.dart';
+import 'package:example/plugin/AI/text_robot.dart';
 import 'package:file_picker/file_picker.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';

+ 8 - 3
frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart

@@ -2,7 +2,9 @@ import 'dart:convert';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
-import 'package:example/plugin/text_robot.dart';
+import 'package:example/plugin/AI/continue_to_write.dart';
+import 'package:example/plugin/AI/auto_completion.dart';
+import 'package:example/plugin/AI/getgpt3completions.dart';
 import 'package:flutter/material.dart';
 
 class SimpleEditor extends StatelessWidget {
@@ -65,8 +67,11 @@ class SimpleEditor extends StatelessWidget {
               codeBlockMenuItem,
               // Emoji
               emojiMenuItem,
-              // Text Robot
-              textRobotMenuItem,
+              // Open AI
+              if (apiKey.isNotEmpty) ...[
+                autoCompletionMenuItem,
+                continueToWriteMenuItem,
+              ]
             ],
           );
         } else {

+ 8 - 48
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/text_robot.dart → frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/auto_completion.dart

@@ -1,10 +1,11 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:example/plugin/AI/getgpt3completions.dart';
+import 'package:example/plugin/AI/text_robot.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
-SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
-  name: () => 'Open AI',
+SelectionMenuItem autoCompletionMenuItem = SelectionMenuItem(
+  name: () => 'Auto generate content',
   icon: (editorState, onSelected) => Icon(
     Icons.rocket,
     size: 18.0,
@@ -12,7 +13,7 @@ SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
         ? editorState.editorStyle.selectionMenuItemSelectedIconColor
         : editorState.editorStyle.selectionMenuItemIconColor,
   ),
-  keywords: ['open ai', 'gpt3', 'ai'],
+  keywords: ['auto generate content', 'open ai', 'gpt3', 'ai'],
   handler: ((editorState, menuService, context) async {
     showDialog(
       context: context,
@@ -35,11 +36,11 @@ SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
               if (key.logicalKey == LogicalKeyboardKey.enter) {
                 Navigator.of(context).pop();
                 // fetch the result and insert it
-                // Please fill in your own API key
-                getGPT3Completion('', controller.text, '', 200, .3,
-                    (result) async {
-                  await editorState.insertTextAtCurrentSelection(
+                final textRobot = TextRobot(editorState: editorState);
+                getGPT3Completion(apiKey, controller.text, '', (result) async {
+                  await textRobot.insertText(
                     result,
+                    inputType: TextRobotInputType.character,
                   );
                 });
               } else if (key.logicalKey == LogicalKeyboardKey.escape) {
@@ -52,44 +53,3 @@ SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
     );
   }),
 );
-
-enum TextRobotInputType {
-  character,
-  word,
-}
-
-class TextRobot {
-  const TextRobot({
-    required this.editorState,
-    this.delay = const Duration(milliseconds: 30),
-  });
-
-  final EditorState editorState;
-  final Duration delay;
-
-  Future<void> insertText(
-    String text, {
-    TextRobotInputType inputType = TextRobotInputType.character,
-  }) async {
-    final lines = text.split('\n');
-    for (final line in lines) {
-      switch (inputType) {
-        case TextRobotInputType.character:
-          final iterator = line.runes.iterator;
-          while (iterator.moveNext()) {
-            await editorState.insertTextAtCurrentSelection(
-              iterator.currentAsString,
-            );
-            await Future.delayed(delay);
-          }
-          break;
-        default:
-      }
-
-      // insert new line
-      if (lines.length > 1) {
-        await editorState.insertNewLine(editorState);
-      }
-    }
-  }
-}

+ 50 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart

@@ -0,0 +1,50 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:example/plugin/AI/getgpt3completions.dart';
+import 'package:example/plugin/AI/text_robot.dart';
+import 'package:flutter/material.dart';
+
+SelectionMenuItem continueToWriteMenuItem = SelectionMenuItem(
+  name: () => 'Continue To Write',
+  icon: (editorState, onSelected) => Icon(
+    Icons.print,
+    size: 18.0,
+    color: onSelected
+        ? editorState.editorStyle.selectionMenuItemSelectedIconColor
+        : editorState.editorStyle.selectionMenuItemIconColor,
+  ),
+  keywords: ['continue to write'],
+  handler: ((editorState, menuService, context) async {
+    // get the current text
+    final selection =
+        editorState.service.selectionService.currentSelection.value;
+    final textNodes = editorState.service.selectionService.currentSelectedNodes;
+    if (selection == null || !selection.isCollapsed || textNodes.length != 1) {
+      return;
+    }
+    final textNode = textNodes.first as TextNode;
+    final prompt = textNode.delta.slice(0, selection.startIndex).toPlainText();
+    final suffix = textNode.delta
+        .slice(
+          selection.endIndex,
+          textNode.toPlainText().length,
+        )
+        .toPlainText();
+    debugPrint('AI: prompt = $prompt, suffix = $suffix');
+    final textRobot = TextRobot(editorState: editorState);
+    getGPT3Completion(
+      apiKey,
+      prompt,
+      suffix,
+      (result) async {
+        if (result == '\\n') {
+          await editorState.insertNewLineAtCurrentSelection();
+        } else {
+          await textRobot.insertText(
+            result,
+            inputType: TextRobotInputType.word,
+          );
+        }
+      },
+    );
+  }),
+);

+ 10 - 6
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart

@@ -2,14 +2,19 @@ import 'package:http/http.dart' as http;
 import 'dart:async';
 import 'dart:convert';
 
+// Please fill in your own API key
+const apiKey = '';
+
 Future<void> getGPT3Completion(
   String apiKey,
   String prompt,
   String suffix,
-  int maxTokens,
-  double temperature,
-  Function(String) onData, // callback function to handle streaming data
-) async {
+  Future<void> Function(String)
+      onData, // callback function to handle streaming data
+  {
+  int maxTokens = 200,
+  double temperature = .3,
+}) async {
   final data = {
     'prompt': prompt,
     'suffix': suffix,
@@ -43,7 +48,6 @@ Future<void> getGPT3Completion(
       }
 
       final processedText = text
-          .replaceAll('\\n', '\n')
           .replaceAll('\\r', '\r')
           .replaceAll('\\t', '\t')
           .replaceAll('\\b', '\b')
@@ -62,7 +66,7 @@ Future<void> getGPT3Completion(
           .replaceAll('\\8', '8')
           .replaceAll('\\9', '9');
 
-      onData(processedText);
+      await onData(processedText);
     }
   }
 }

+ 48 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/text_robot.dart

@@ -0,0 +1,48 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+enum TextRobotInputType {
+  character,
+  word,
+}
+
+class TextRobot {
+  const TextRobot({
+    required this.editorState,
+    this.delay = const Duration(milliseconds: 30),
+  });
+
+  final EditorState editorState;
+  final Duration delay;
+
+  Future<void> insertText(
+    String text, {
+    TextRobotInputType inputType = TextRobotInputType.character,
+  }) 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 editorState.insertTextAtCurrentSelection(
+              iterator.currentAsString,
+            );
+            await Future.delayed(delay, () {});
+          }
+          break;
+        case TextRobotInputType.word:
+          await editorState.insertTextAtCurrentSelection(
+            line,
+          );
+          await Future.delayed(delay, () {});
+          break;
+      }
+
+      // insert new line
+      if (lines.length > 1) {
+        await editorState.insertNewLineAtCurrentSelection();
+      }
+    }
+  }
+}

+ 56 - 55
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text/text_commands.dart

@@ -12,25 +12,21 @@ extension TextCommands on EditorState {
     Path? path,
     TextNode? textNode,
   }) async {
-    return futureCommand(() {
-      final n = getTextNode(path: path, textNode: textNode);
-      apply(
-        transaction..insertText(n, index, text),
-      );
-    });
+    final n = getTextNode(path: path, textNode: textNode);
+    return apply(
+      transaction..insertText(n, index, text),
+    );
   }
 
   Future<void> insertTextAtCurrentSelection(String text) async {
-    return futureCommand(() async {
-      final selection = getSelection(null);
-      assert(selection.isCollapsed);
-      final textNode = getTextNode(path: selection.start.path);
-      await insertText(
-        textNode.toPlainText().length,
-        text,
-        textNode: textNode,
-      );
-    });
+    final selection = getSelection(null);
+    assert(selection.isCollapsed);
+    final textNode = getTextNode(path: selection.start.path);
+    return insertText(
+      selection.startIndex,
+      text,
+      textNode: textNode,
+    );
   }
 
   Future<void> formatText(
@@ -40,13 +36,11 @@ extension TextCommands on EditorState {
     Path? path,
     TextNode? textNode,
   }) async {
-    return futureCommand(() {
-      final n = getTextNode(path: path, textNode: textNode);
-      final s = getSelection(selection);
-      apply(
-        transaction..formatText(n, s.startIndex, s.length, attributes),
-      );
-    });
+    final n = getTextNode(path: path, textNode: textNode);
+    final s = getSelection(selection);
+    return apply(
+      transaction..formatText(n, s.startIndex, s.length, attributes),
+    );
   }
 
   Future<void> formatTextWithBuiltInAttribute(
@@ -57,26 +51,24 @@ extension TextCommands on EditorState {
     Path? path,
     TextNode? textNode,
   }) async {
-    return futureCommand(() {
-      final n = getTextNode(path: path, textNode: textNode);
-      if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
-        final attr = n.attributes
-          ..removeWhere(
-              (key, _) => BuiltInAttributeKey.globalStyleKeys.contains(key))
-          ..addAll(attributes)
-          ..addAll({
-            BuiltInAttributeKey.subtype: key,
-          });
-        apply(
-          transaction..updateNode(n, attr),
-        );
-      } else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
-        final s = getSelection(selection);
-        apply(
-          transaction..formatText(n, s.startIndex, s.length, attributes),
-        );
-      }
-    });
+    final n = getTextNode(path: path, textNode: textNode);
+    if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
+      final attr = n.attributes
+        ..removeWhere(
+            (key, _) => BuiltInAttributeKey.globalStyleKeys.contains(key))
+        ..addAll(attributes)
+        ..addAll({
+          BuiltInAttributeKey.subtype: key,
+        });
+      return apply(
+        transaction..updateNode(n, attr),
+      );
+    } else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
+      final s = getSelection(selection);
+      return apply(
+        transaction..formatText(n, s.startIndex, s.length, attributes),
+      );
+    }
   }
 
   Future<void> formatTextToCheckbox(
@@ -109,19 +101,28 @@ extension TextCommands on EditorState {
     );
   }
 
-  Future<void> insertNewLine(
-    EditorState editorState, {
+  Future<void> insertNewLine({
     Path? path,
   }) async {
-    return futureCommand(() async {
-      final p = path ?? getSelection(null).start.path.next;
-      final transaction = editorState.transaction;
-      transaction.insertNode(p, TextNode.empty());
-      transaction.afterSelection = Selection.single(
-        path: p,
-        startOffset: 0,
-      );
-      apply(transaction);
-    });
+    final p = path ?? getSelection(null).start.path.next;
+    final transaction = this.transaction;
+    transaction.insertNode(p, TextNode.empty());
+    transaction.afterSelection = Selection.single(
+      path: p,
+      startOffset: 0,
+    );
+    return apply(transaction);
+  }
+
+  Future<void> insertNewLineAtCurrentSelection() async {
+    final selection = getSelection(null);
+    assert(selection.isCollapsed);
+    final textNode = getTextNode(path: selection.start.path);
+    final transaction = this.transaction;
+    transaction.splitText(
+      textNode,
+      selection.startIndex,
+    );
+    return apply(transaction);
   }
 }

+ 19 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart

@@ -169,6 +169,25 @@ extension TextTransaction on Transaction {
     ));
   }
 
+  void splitText(TextNode textNode, int offset) {
+    final delta = textNode.delta;
+    final first = delta.slice(0, offset);
+    final second = delta.slice(offset, delta.length);
+    final path = textNode.path.next;
+    updateText(textNode, first);
+    insertNode(
+      path,
+      TextNode(
+        attributes: textNode.attributes,
+        delta: second,
+      ),
+    );
+    afterSelection = Selection.collapsed(Position(
+      path: path,
+      offset: 0,
+    ));
+  }
+
   /// Inserts the text content at a specified index.
   ///
   /// Optionally, you may specify formatting attributes that are applied to the inserted string.

+ 21 - 7
frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart

@@ -96,13 +96,21 @@ class EditorState {
     return null;
   }
 
-  updateCursorSelection(Selection? cursorSelection,
-      [CursorUpdateReason reason = CursorUpdateReason.others]) {
+  Future<void> updateCursorSelection(
+    Selection? cursorSelection, [
+    CursorUpdateReason reason = CursorUpdateReason.others,
+  ]) {
+    final completer = Completer<void>();
+
     // broadcast to other users here
     if (reason != CursorUpdateReason.uiEvent) {
       service.selectionService.updateSelection(cursorSelection);
     }
     _cursorSelection = cursorSelection;
+    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+      completer.complete();
+    });
+    return completer.future;
   }
 
   Timer? _debouncedSealHistoryItemTimer;
@@ -121,14 +129,17 @@ class EditorState {
   ///
   /// The options can be used to determine whether the editor
   /// should record the transaction in undo/redo stack.
-  void apply(
+  Future<void> apply(
     Transaction transaction, {
     ApplyOptions options = const ApplyOptions(recordUndo: true),
     ruleCount = 0,
     withUpdateCursor = true,
-  }) {
+  }) async {
+    final completer = Completer<void>();
+
     if (!editable) {
-      return;
+      completer.complete();
+      return completer.future;
     }
     // TODO: validate the transation.
     for (final op in transaction.operations) {
@@ -137,10 +148,11 @@ class EditorState {
 
     _observer.add(transaction);
 
-    WidgetsBinding.instance.addPostFrameCallback((_) {
+    WidgetsBinding.instance.addPostFrameCallback((_) async {
       _applyRules(ruleCount);
       if (withUpdateCursor) {
-        updateCursorSelection(transaction.afterSelection);
+        await updateCursorSelection(transaction.afterSelection);
+        completer.complete();
       }
     });
 
@@ -160,6 +172,8 @@ class EditorState {
       redoItem.afterSelection = transaction.afterSelection;
       undoManager.redoStack.push(redoItem);
     }
+
+    return completer.future;
   }
 
   void _debouncedSealHistoryItem() {