feat: add sample for open AI editing

This commit is contained in:
Lucas.Xu 2023-01-08 15:48:28 +08:00
parent fc1efeb70b
commit 310236dca0
8 changed files with 264 additions and 4 deletions

View File

@ -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(

View File

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

View File

@ -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();
}
}

View File

@ -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);
},
);
}
}

View File

@ -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';

View File

@ -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();

View File

@ -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();
}

View File

@ -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;