Browse Source

feat: add sample for open AI editing

Lucas.Xu 2 years ago
parent
commit
310236dca0

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart

@@ -5,6 +5,7 @@ import 'package:appflowy_editor_plugins/appflowy_editor_plugins.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:example/plugin/AI/smart_edit.dart';
 import 'package:flutter/material.dart';
 
 class SimpleEditor extends StatelessWidget {
@@ -73,6 +74,9 @@ class SimpleEditor extends StatelessWidget {
                 continueToWriteMenuItem,
               ]
             ],
+            toolbarItems: [
+              smartEditItem,
+            ],
           );
         } else {
           return const Center(

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

@@ -29,7 +29,6 @@ SelectionMenuItem continueToWriteMenuItem = SelectionMenuItem(
           textNode.toPlainText().length,
         )
         .toPlainText();
-    debugPrint('AI: prompt = $prompt, suffix = $suffix');
     final textRobot = TextRobot(editorState: editorState);
     getGPT3Completion(
       apiKey,

+ 40 - 1
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart

@@ -14,13 +14,14 @@ Future<void> getGPT3Completion(
   {
   int maxTokens = 200,
   double temperature = .3,
+  bool stream = true,
 }) async {
   final data = {
     'prompt': prompt,
     'suffix': suffix,
     'max_tokens': maxTokens,
     'temperature': temperature,
-    'stream': true, // set stream parameter to true
+    'stream': stream, // set stream parameter to true
   };
 
   final headers = {
@@ -70,3 +71,41 @@ Future<void> getGPT3Completion(
     }
   }
 }
+
+Future<void> getGPT3Edit(
+  String apiKey,
+  String input,
+  String instruction, {
+  required Future<void> Function(List<String> result) onResult,
+  required Future<void> Function() onError,
+  int n = 1,
+  double temperature = .3,
+}) async {
+  final data = {
+    'model': 'text-davinci-edit-001',
+    'input': input,
+    'instruction': instruction,
+    'temperature': temperature,
+    'n': n,
+  };
+
+  final headers = {
+    'Authorization': apiKey,
+    'Content-Type': 'application/json',
+  };
+
+  var response = await http.post(
+    Uri.parse('https://api.openai.com/v1/edits'),
+    headers: headers,
+    body: json.encode(data),
+  );
+  if (response.statusCode == 200) {
+    final result = json.decode(response.body);
+    final choices = result['choices'];
+    if (choices != null && choices is List) {
+      onResult(choices.map((e) => e['text'] as String).toList());
+    }
+  } else {
+    onError();
+  }
+}

+ 198 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/smart_edit.dart

@@ -0,0 +1,198 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:example/plugin/AI/getgpt3completions.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+ToolbarItem smartEditItem = ToolbarItem(
+  id: 'appflowy.toolbar.smart_edit',
+  type: 5,
+  iconBuilder: (isHighlight) {
+    return Icon(
+      Icons.edit,
+      color: isHighlight ? Colors.lightBlue : Colors.white,
+      size: 14,
+    );
+  },
+  validator: (editorState) {
+    final nodes = editorState.service.selectionService.currentSelectedNodes;
+    return nodes.whereType<TextNode>().length == nodes.length &&
+        1 == nodes.length;
+  },
+  highlightCallback: (_) => false,
+  tooltipsMessage: 'Smart Edit',
+  handler: (editorState, context) {
+    showDialog(
+      context: context,
+      builder: (context) {
+        return AlertDialog(
+          content: SmartEditWidget(
+            editorState: editorState,
+          ),
+        );
+      },
+    );
+  },
+);
+
+class SmartEditWidget extends StatefulWidget {
+  const SmartEditWidget({
+    super.key,
+    required this.editorState,
+  });
+
+  final EditorState editorState;
+
+  @override
+  State<SmartEditWidget> createState() => _SmartEditWidgetState();
+}
+
+class _SmartEditWidgetState extends State<SmartEditWidget> {
+  final inputEventController = TextEditingController(text: '');
+  final resultController = TextEditingController(text: '');
+
+  var result = '';
+
+  Iterable<TextNode> get currentSelectedTextNodes =>
+      widget.editorState.service.selectionService.currentSelectedNodes
+          .whereType<TextNode>();
+  Selection? get currentSelection =>
+      widget.editorState.service.selectionService.currentSelection.value;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      constraints: const BoxConstraints(maxWidth: 400),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          RawKeyboardListener(
+            focusNode: FocusNode(),
+            child: TextField(
+              autofocus: true,
+              controller: inputEventController,
+              maxLines: null,
+              decoration: const InputDecoration(
+                border: OutlineInputBorder(),
+                hintText: 'Describe how you\'d like AppFlowy to edit this text',
+              ),
+            ),
+            onKey: (key) {
+              if (key is! RawKeyDownEvent) return;
+              if (key.logicalKey == LogicalKeyboardKey.enter) {
+                _requestGPT3EditResult();
+              } else if (key.logicalKey == LogicalKeyboardKey.escape) {
+                Navigator.of(context).pop();
+              }
+            },
+          ),
+          if (result.isNotEmpty) ...[
+            const SizedBox(height: 20),
+            const Text(
+              'Result: ',
+              style: TextStyle(color: Colors.grey),
+            ),
+            const SizedBox(height: 10),
+            SizedBox(
+              height: 300,
+              child: TextField(
+                controller: resultController..text = result,
+                maxLines: null,
+                decoration: const InputDecoration(
+                  border: OutlineInputBorder(),
+                  hintText:
+                      'Describe how you\'d like AppFlowy to edit this text',
+                ),
+              ),
+            ),
+            const SizedBox(height: 10),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.end,
+              children: [
+                TextButton(
+                  onPressed: () {
+                    Navigator.of(context).pop();
+                  },
+                  child: const Text('Cancel'),
+                ),
+                TextButton(
+                  onPressed: () {
+                    Navigator.of(context).pop();
+
+                    // replace the text
+                    final selection = currentSelection;
+                    if (selection != null) {
+                      assert(selection.isSingle);
+                      final transaction = widget.editorState.transaction;
+                      transaction.replaceText(
+                        currentSelectedTextNodes.first,
+                        selection.startIndex,
+                        selection.length,
+                        resultController.text,
+                      );
+                      widget.editorState.apply(transaction);
+                    }
+                  },
+                  child: const Text('Replace'),
+                ),
+              ],
+            ),
+          ]
+        ],
+      ),
+    );
+  }
+
+  void _requestGPT3EditResult() {
+    final selection =
+        widget.editorState.service.selectionService.currentSelection.value;
+    if (selection == null || !selection.isSingle) {
+      return;
+    }
+    final text =
+        widget.editorState.service.selectionService.currentSelectedNodes
+            .whereType<TextNode>()
+            .first
+            .delta
+            .slice(
+              selection.startIndex,
+              selection.endIndex,
+            )
+            .toPlainText();
+    if (text.isEmpty) {
+      Navigator.of(context).pop();
+      return;
+    }
+
+    showDialog(
+      context: context,
+      builder: (context) {
+        return AlertDialog(
+          content: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: const [
+              CircularProgressIndicator(),
+              SizedBox(height: 10),
+              Text('Loading'),
+            ],
+          ),
+        );
+      },
+    );
+
+    getGPT3Edit(
+      apiKey,
+      text,
+      inputEventController.text,
+      onResult: (result) async {
+        Navigator.of(context).pop(true);
+        setState(() {
+          this.result = result.join('\n').trim();
+        });
+      },
+      onError: () async {
+        Navigator.of(context).pop(true);
+      },
+    );
+  }
+}

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart

@@ -43,3 +43,4 @@ export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart';
 export 'src/plugins/markdown/document_markdown.dart';
 export 'src/plugins/quill_delta/delta_document_encoder.dart';
 export 'src/commands/text/text_commands.dart';
+export 'src/render/toolbar/toolbar_item.dart';

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart

@@ -3,6 +3,7 @@ import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/infra/log.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
 import 'package:appflowy_editor/src/render/style/editor_style.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 import 'package:appflowy_editor/src/service/service.dart';
 import 'package:flutter/material.dart';
 
@@ -60,6 +61,9 @@ class EditorState {
   /// Stores the selection menu items.
   List<SelectionMenuItem> selectionMenuItems = [];
 
+  /// Stores the toolbar items.
+  List<ToolbarItem> toolbarItems = [];
+
   /// Operation stream.
   Stream<Transaction> get transactionStream => _observer.stream;
   final StreamController<Transaction> _observer = StreamController.broadcast();

+ 6 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -1,6 +1,7 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/flutter/overlay.dart';
 import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
 import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
 
@@ -30,6 +31,7 @@ class AppFlowyEditor extends StatefulWidget {
     this.customBuilders = const {},
     this.shortcutEvents = const [],
     this.selectionMenuItems = const [],
+    this.toolbarItems = const [],
     this.editable = true,
     this.autoFocus = false,
     ThemeData? themeData,
@@ -51,6 +53,8 @@ class AppFlowyEditor extends StatefulWidget {
 
   final List<SelectionMenuItem> selectionMenuItems;
 
+  final List<ToolbarItem> toolbarItems;
+
   late final ThemeData themeData;
 
   final bool editable;
@@ -74,6 +78,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     super.initState();
 
     editorState.selectionMenuItems = widget.selectionMenuItems;
+    editorState.toolbarItems = widget.toolbarItems;
     editorState.themeData = widget.themeData;
     editorState.service.renderPluginService = _createRenderPlugin();
     editorState.editable = widget.editable;
@@ -94,6 +99,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
 
     if (editorState.service != oldWidget.editorState.service) {
       editorState.selectionMenuItems = widget.selectionMenuItems;
+      editorState.toolbarItems = widget.toolbarItems;
       editorState.service.renderPluginService = _createRenderPlugin();
     }
 

+ 11 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart

@@ -35,11 +35,20 @@ class _FlowyToolbarState extends State<FlowyToolbar>
     implements AppFlowyToolbarService {
   OverlayEntry? _toolbarOverlay;
   final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget');
+  late final List<ToolbarItem> toolbarItems;
+
+  @override
+  void initState() {
+    super.initState();
+
+    toolbarItems = [...defaultToolbarItems, ...widget.editorState.toolbarItems]
+      ..sort((a, b) => a.type.compareTo(b.type));
+  }
 
   @override
   void showInOffset(Offset offset, Alignment alignment, LayerLink layerLink) {
     hide();
-    final items = _filterItems(defaultToolbarItems);
+    final items = _filterItems(toolbarItems);
     if (items.isEmpty) {
       return;
     }
@@ -65,7 +74,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
 
   @override
   bool triggerHandler(String id) {
-    final items = defaultToolbarItems.where((item) => item.id == id);
+    final items = toolbarItems.where((item) => item.id == id);
     if (items.length != 1) {
       assert(items.length == 1, 'The toolbar item\'s id must be unique');
       return false;