diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart index 37a5279662..9c6f3339fc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; import 'text_completion.dart'; import 'package:dartz/dartz.dart'; @@ -41,6 +43,17 @@ abstract class OpenAIRepository { double temperature = .3, }); + Future getStreamedCompletions({ + required String prompt, + required Future Function() onStart, + required Future Function(TextCompletionResponse response) onProcess, + required VoidCallback onEnd, + required void Function(OpenAIError error) onError, + String? suffix, + int maxTokens = 500, + double temperature = 0.3, + }); + /// Get edits from GPT-3 /// /// [input] is the input text @@ -103,6 +116,74 @@ class HttpOpenAIRepository implements OpenAIRepository { } } + @override + Future getStreamedCompletions({ + required String prompt, + required Future Function() onStart, + required Future Function(TextCompletionResponse response) onProcess, + required VoidCallback onEnd, + required void Function(OpenAIError error) onError, + String? suffix, + int maxTokens = 500, + double temperature = 0.3, + }) async { + final parameters = { + 'model': 'text-davinci-003', + 'prompt': prompt, + 'suffix': suffix, + 'max_tokens': maxTokens, + 'temperature': temperature, + 'stream': true, + }; + + final request = http.Request('POST', OpenAIRequestType.textCompletion.uri); + request.headers.addAll(headers); + request.body = jsonEncode(parameters); + + final response = await client.send(request); + + // NEED TO REFACTOR. + // WHY OPENAI USE TWO LINES TO INDICATE THE START OF THE STREAMING RESPONSE? + // AND WHY OPENAI USE [DONE] TO INDICATE THE END OF THE STREAMING RESPONSE? + int syntax = 0; + var previousSyntax = ''; + if (response.statusCode == 200) { + await for (final chunk in response.stream + .transform(const Utf8Decoder()) + .transform(const LineSplitter())) { + syntax += 1; + if (syntax == 3) { + await onStart(); + continue; + } else if (syntax < 3) { + continue; + } + final data = chunk.trim().split('data: '); + if (data.length > 1 && data[1] != '[DONE]') { + final response = TextCompletionResponse.fromJson( + json.decode(data[1]), + ); + if (response.choices.isNotEmpty) { + final text = response.choices.first.text; + if (text == previousSyntax && text == '\n') { + continue; + } + await onProcess(response); + previousSyntax = response.choices.first.text; + Log.editor.info(response.choices.first.text); + } + } else { + onEnd(); + } + } + } else { + final body = await response.stream.bytesToString(); + onError( + OpenAIError.fromJson(json.decode(body)['error']), + ); + } + } + @override Future> getEdits({ required String input, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart index b2c2a55cc6..067049adbf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_completion.dart @@ -8,7 +8,7 @@ class TextCompletionChoice with _$TextCompletionChoice { required String text, required int index, // ignore: invalid_annotation_target - @JsonKey(name: 'finish_reason') required String finishReason, + @JsonKey(name: 'finish_reason') String? finishReason, }) = _TextCompletionChoice; factory TextCompletionChoice.fromJson(Map json) => diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart index fd27c65cef..09238cabac 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/util/editor_extension.dart @@ -11,6 +11,10 @@ extension TextRobot on EditorState { TextRobotInputType inputType = TextRobotInputType.word, Duration delay = const Duration(milliseconds: 10), }) async { + if (text == '\n') { + await insertNewLineAtCurrentSelection(); + return; + } final lines = text.split('\n'); for (final line in lines) { if (line.isEmpty) { @@ -28,13 +32,21 @@ extension TextRobot on EditorState { } break; case TextRobotInputType.word: - final words = line.split(' ').map((e) => '$e '); - for (final word in words) { + final words = line.split(' '); + if (words.length == 1 || + (words.length == 2 && + (words.first.isEmpty || words.last.isEmpty))) { await insertTextAtCurrentSelection( - word, + line, ); - await Future.delayed(delay, () {}); + } else { + for (final word in words.map((e) => '$e ')) { + await insertTextAtCurrentSelection( + word, + ); + } } + await Future.delayed(delay, () {}); break; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart index acbad03ea4..616192524b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart @@ -61,19 +61,14 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> { void initState() { super.initState(); - focusNode.addListener(() { - if (focusNode.hasFocus) { - widget.editorState.service.selectionService.clearSelection(); - } else { - widget.editorState.service.keyboardService?.enable(); - } - }); + textFieldFocusNode.addListener(_onFocusChanged); textFieldFocusNode.requestFocus(); } @override void dispose() { controller.dispose(); + textFieldFocusNode.removeListener(_onFocusChanged); super.dispose(); } @@ -242,30 +237,33 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> { loading.start(); await _updateEditingText(); final result = await UserBackendService.getCurrentUserProfile(); + result.fold((userProfile) async { final openAIRepository = HttpOpenAIRepository( client: http.Client(), apiKey: userProfile.openaiKey, ); - final completions = await openAIRepository.getCompletions( + await openAIRepository.getStreamedCompletions( prompt: controller.text, + onStart: () async { + loading.stop(); + await _makeSurePreviousNodeIsEmptyTextNode(); + }, + onProcess: (response) async { + if (response.choices.isNotEmpty) { + final text = response.choices.first.text; + await widget.editorState.autoInsertText( + text, + inputType: TextRobotInputType.word, + ); + } + }, + onEnd: () {}, + onError: (error) async { + loading.stop(); + await _showError(error.message); + }, ); - completions.fold((error) async { - loading.stop(); - await _showError(error.message); - }, (textCompletion) async { - loading.stop(); - await _makeSurePreviousNodeIsEmptyTextNode(); - // Open AI result uses two '\n' as the begin syntax. - var texts = textCompletion.choices.first.text.split('\n'); - if (texts.length > 2) { - texts.removeRange(0, 2); - await widget.editorState.autoInsertText( - texts.join('\n'), - ); - } - focusNode.requestFocus(); - }); }, (error) async { loading.stop(); await _showError( @@ -345,4 +343,14 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> { ), ); } + + void _onFocusChanged() { + if (textFieldFocusNode.hasFocus) { + widget.editorState.service.keyboardService?.disable( + disposition: UnfocusDisposition.previouslyFocusedChild, + ); + } else { + widget.editorState.service.keyboardService?.enable(); + } + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart index fad31c711e..a25b7a6f1e 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart @@ -35,7 +35,10 @@ abstract class AppFlowyKeyboardService { /// you can disable the keyboard service of flowy_editor. /// But you need to call the `enable` function to restore after exiting /// your custom component, otherwise the keyboard service will fails. - void disable({bool showCursor = false}); + void disable({ + bool showCursor = false, + UnfocusDisposition disposition = UnfocusDisposition.scope, + }); } /// Process keyboard events @@ -102,10 +105,13 @@ class _AppFlowyKeyboardState extends State } @override - void disable({bool showCursor = false}) { + void disable({ + bool showCursor = false, + UnfocusDisposition disposition = UnfocusDisposition.scope, + }) { isFocus = false; this.showCursor = showCursor; - _focusNode.unfocus(); + _focusNode.unfocus(disposition: disposition); } @override