From fc1efeb70bde084b4c699043065b7e7042c90763 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Sun, 8 Jan 2023 13:17:18 +0800 Subject: [PATCH] feat: support prompt and suffix --- .../example/lib/home_page.dart | 2 +- .../example/lib/pages/simple_editor.dart | 11 +- .../auto_completion.dart} | 56 ++------- .../lib/plugin/AI/continue_to_write.dart | 50 ++++++++ .../lib/plugin/AI/getgpt3completions.dart | 16 ++- .../example/lib/plugin/AI/text_robot.dart | 48 ++++++++ .../lib/src/commands/text/text_commands.dart | 111 +++++++++--------- .../lib/src/core/transform/transaction.dart | 19 +++ .../appflowy_editor/lib/src/editor_state.dart | 28 +++-- 9 files changed, 221 insertions(+), 120 deletions(-) rename frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/{text_robot.dart => AI/auto_completion.dart} (54%) create mode 100644 frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart create mode 100644 frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/text_robot.dart diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart index d68ae7e361..a20990f84b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart +++ b/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'; diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart index 84397811da..0a1d551390 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart +++ b/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 { diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/text_robot.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/auto_completion.dart similarity index 54% rename from frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/text_robot.dart rename to frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/auto_completion.dart index 538514fd43..3e80e3d3dd 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/text_robot.dart +++ b/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 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); - } - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart new file mode 100644 index 0000000000..5af22bd4e8 --- /dev/null +++ b/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, + ); + } + }, + ); + }), +); diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart index e86bb71024..c7b8c094fc 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart +++ b/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 getGPT3Completion( String apiKey, String prompt, String suffix, - int maxTokens, - double temperature, - Function(String) onData, // callback function to handle streaming data -) async { + Future 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 getGPT3Completion( } final processedText = text - .replaceAll('\\n', '\n') .replaceAll('\\r', '\r') .replaceAll('\\t', '\t') .replaceAll('\\b', '\b') @@ -62,7 +66,7 @@ Future getGPT3Completion( .replaceAll('\\8', '8') .replaceAll('\\9', '9'); - onData(processedText); + await onData(processedText); } } } diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/text_robot.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/text_robot.dart new file mode 100644 index 0000000000..348caf6eed --- /dev/null +++ b/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 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(); + } + } + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text/text_commands.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text/text_commands.dart index 6ee5778889..f8e0db5916 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text/text_commands.dart +++ b/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 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 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 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 formatTextToCheckbox( @@ -109,19 +101,28 @@ extension TextCommands on EditorState { ); } - Future insertNewLine( - EditorState editorState, { + Future 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 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); } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart index 4df4adb228..544fe8d0ca 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart +++ b/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. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index be49ea19d0..7b756e30c2 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ b/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 updateCursorSelection( + Selection? cursorSelection, [ + CursorUpdateReason reason = CursorUpdateReason.others, + ]) { + final completer = Completer(); + // 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 apply( Transaction transaction, { ApplyOptions options = const ApplyOptions(recordUndo: true), ruleCount = 0, withUpdateCursor = true, - }) { + }) async { + final completer = Completer(); + 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() {