From 39b1ff09106389e1e98bd944aff06647842c79fc Mon Sep 17 00:00:00 2001 From: Mihir <84044317+squidrye@users.noreply.github.com> Date: Wed, 3 May 2023 07:43:11 +0000 Subject: [PATCH] feat: insert below and replace in smart-edit highlights text (#2107) * feat: insert below and replace in smart-edit highlights text * test: added integration tests to validate insert below and replace in smart-edit highlights text * refactor: using get_it to inject OpenAiRepository to inject mock repo in test * fix: delete node does not propagate non null selection * refactor: suggested changes and fixed bugs causing warning in github-ci * fix: integration tests causing error in github-ci * refactor: reverting redundant changes due to recent changes in repo * refactor: reverting redundant changes due to recent changes in repo * refactor: refactoring to workspace based integration testing. * refactor: reverting redundant changes due to recent changes in repo * chore: fix analysis issues * chore: fix analysis issues * chore: remove the unnecessary conversion --------- Co-authored-by: Lucas.Xu --- .../assets/test/workspaces/ai_workspace.zip | Bin 0 -> 9501 bytes .../open_ai_smart_menu_test.dart | 108 +++++++++++++++ .../integration_test/runner.dart | 2 + .../integration_test/util/data.dart | 1 + .../util/mock/mock_openai_repository.dart | 76 +++++++++++ .../plugins/openai/service/openai_client.dart | 1 + .../widgets/smart_edit_node_widget.dart | 124 ++++++++++-------- .../lib/startup/deps_resolver.dart | 23 +++- frontend/appflowy_flutter/pubspec.lock | 2 +- frontend/appflowy_flutter/pubspec.yaml | 3 +- 10 files changed, 283 insertions(+), 57 deletions(-) create mode 100644 frontend/appflowy_flutter/assets/test/workspaces/ai_workspace.zip create mode 100644 frontend/appflowy_flutter/integration_test/open_ai_smart_menu_test.dart create mode 100644 frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart diff --git a/frontend/appflowy_flutter/assets/test/workspaces/ai_workspace.zip b/frontend/appflowy_flutter/assets/test/workspaces/ai_workspace.zip new file mode 100644 index 0000000000000000000000000000000000000000..b523b64ed045fc0fddf32a3f8b2fdc73abd5314b GIT binary patch literal 9501 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 NgSfN!G5ycc{{vK`=SBbk literal 0 HcmV?d00001 diff --git a/frontend/appflowy_flutter/integration_test/open_ai_smart_menu_test.dart b/frontend/appflowy_flutter/integration_test/open_ai_smart_menu_test.dart new file mode 100644 index 0000000000..f63197a039 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/open_ai_smart_menu_test.dart @@ -0,0 +1,108 @@ +import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'util/mock/mock_openai_repository.dart'; +import 'util/util.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; +import 'package:appflowy/startup/startup.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const service = TestWorkspaceService(TestWorkspace.aiWorkSpace); + + group('integration tests for open-ai smart menu', () { + setUpAll(() async => await service.setUpAll()); + setUp(() async => await service.setUp()); + + testWidgets('testing selection on open-ai smart menu replace', (tester) async { + final appFlowyEditor = await setUpOpenAITesting(tester); + final editorState = appFlowyEditor.editorState; + + editorState.service.selectionService.updateSelection( + Selection( + start: Position(path: [1], offset: 4), + end: Position(path: [1], offset: 10), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1)); + + await tester.tap(find.byTooltip('AI Assistants')); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + await tester.tap(find.text('Summarize')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FlowyRichTextButton, skipOffstage: false).first); + await tester.pumpAndSettle(); + + expect( + editorState.service.selectionService.currentSelection.value, + Selection( + start: Position(path: [1], offset: 4), + end: Position(path: [1], offset: 84), + ), + ); + }); + testWidgets('testing selection on open-ai smart menu insert', (tester) async { + final appFlowyEditor = await setUpOpenAITesting(tester); + final editorState = appFlowyEditor.editorState; + + editorState.service.selectionService.updateSelection( + Selection( + start: Position(path: [1], offset: 0), + end: Position(path: [1], offset: 5), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1)); + + await tester.tap(find.byTooltip('AI Assistants')); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + await tester.tap(find.text('Summarize')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FlowyRichTextButton, skipOffstage: false).at(1)); + await tester.pumpAndSettle(); + + expect( + editorState.service.selectionService.currentSelection.value, + Selection( + start: Position(path: [2], offset: 0), + end: Position(path: [3], offset: 0), + ), + ); + }); + }); +} + +Future setUpOpenAITesting(WidgetTester tester) async { + await tester.initializeAppFlowy(); + await mockOpenAIRepository(); + + await simulateKeyDownEvent(LogicalKeyboardKey.controlLeft); + await simulateKeyDownEvent(LogicalKeyboardKey.backslash); + await tester.pumpAndSettle(); + + final Finder editor = find.byType(AppFlowyEditor); + await tester.tap(editor); + await tester.pumpAndSettle(); + return (tester.state(editor).widget as AppFlowyEditor); +} + +Future mockOpenAIRepository() async { + await getIt.unregister(); + getIt.registerFactoryAsync( + () => Future.value( + MockOpenAIRepository(), + ), + ); + return; +} diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index 1592cccb93..b848ce1fc3 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -3,6 +3,7 @@ import 'package:integration_test/integration_test.dart'; import 'board_test.dart' as board_test; import 'switch_folder_test.dart' as switch_folder_test; import 'empty_document_test.dart' as empty_document_test; +import 'open_ai_smart_menu_test.dart' as smart_menu_test; /// The main task runner for all integration tests in AppFlowy. /// @@ -16,4 +17,5 @@ void main() { switch_folder_test.main(); board_test.main(); empty_document_test.main(); + smart_menu_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/util/data.dart b/frontend/appflowy_flutter/integration_test/util/data.dart index c6912ddeca..0dd0961ee2 100644 --- a/frontend/appflowy_flutter/integration_test/util/data.dart +++ b/frontend/appflowy_flutter/integration_test/util/data.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; enum TestWorkspace { board("board"), emptyDocument("empty_document"), + aiWorkSpace("ai_workspace"), coverImage("cover_image"); const TestWorkspace(this._name); diff --git a/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart b/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart new file mode 100644 index 0000000000..a9486a8536 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart @@ -0,0 +1,76 @@ +import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart'; +import 'package:mocktail/mocktail.dart'; +import 'dart:convert'; +import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_completion.dart'; +import 'package:appflowy/plugins/document/presentation/plugins/openai/service/error.dart'; +import 'package:http/http.dart' as http; +import 'dart:async'; + +class MyMockClient extends Mock implements http.Client { + @override + Future send(http.BaseRequest request) async { + final requestType = request.method; + final requestUri = request.url; + + if (requestType == 'POST' && requestUri == OpenAIRequestType.textCompletion.uri) { + final responseHeaders = {'content-type': 'text/event-stream'}; + final responseBody = Stream.fromIterable([ + utf8.encode( + '{ "choices": [{"text": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula ", "index": 0, "logprobs": null, "finish_reason": null}]}', + ), + utf8.encode('\n'), + utf8.encode('[DONE]'), + ]); + + // Return a mocked response with the expected data + return http.StreamedResponse(responseBody, 200, headers: responseHeaders); + } + + // Return an error response for any other request + return http.StreamedResponse(const Stream.empty(), 404); + } +} + +class MockOpenAIRepository extends HttpOpenAIRepository { + MockOpenAIRepository() : super(apiKey: 'dummyKey', client: MyMockClient()); + + @override + Future getStreamedCompletions({ + required String prompt, + required Future Function() onStart, + required Future Function(TextCompletionResponse response) onProcess, + required Future Function() onEnd, + required void Function(OpenAIError error) onError, + String? suffix, + int maxTokens = 2048, + double temperature = 0.3, + bool useAction = false, + }) async { + final request = http.Request('POST', OpenAIRequestType.textCompletion.uri); + final response = await client.send(request); + + var previousSyntax = ''; + if (response.statusCode == 200) { + await for (final chunk in response.stream.transform(const Utf8Decoder()).transform(const LineSplitter())) { + await onStart(); + final data = chunk.trim().split('data: '); + if (data[0] != '[DONE]') { + final response = TextCompletionResponse.fromJson( + json.decode(data[0]), + ); + if (response.choices.isNotEmpty) { + final text = response.choices.first.text; + if (text == previousSyntax && text == '\n') { + continue; + } + await onProcess(response); + previousSyntax = response.choices.first.text; + } + } else { + await onEnd(); + } + } + } + return; + } +} 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 ccb4b08866..5daf577564 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 @@ -50,6 +50,7 @@ abstract class OpenAIRepository { String? suffix, int maxTokens = 2048, double temperature = 0.3, + bool useAction = false, }); /// Get edits from GPT-3 diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart index 2c157f6ddc..6f580e7e7a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart @@ -4,7 +4,7 @@ import 'package:appflowy/plugins/document/presentation/plugins/openai/service/op import 'package:appflowy/plugins/document/presentation/plugins/openai/util/learn_more_action.dart'; import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/discard_dialog.dart'; import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart'; -import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -242,7 +242,7 @@ class _SmartEditInputState extends State<_SmartEditInput> { ), onPressed: () async { await _onReplace(); - _onExit(); + await _onExit(); }, ), const Space(10, 0), @@ -257,7 +257,7 @@ class _SmartEditInputState extends State<_SmartEditInput> { ), onPressed: () async { await _onInsertBelow(); - _onExit(); + await _onExit(); }, ), const Space(10, 0), @@ -272,10 +272,13 @@ class _SmartEditInputState extends State<_SmartEditInput> { ), onPressed: () async => await _onExit(), ), - const Spacer(), - FlowyText.regular( - LocaleKeys.document_plugins_warning.tr(), - color: Theme.of(context).hintColor, + const Spacer(flex: 2), + Expanded( + child: FlowyText.regular( + overflow: TextOverflow.ellipsis, + LocaleKeys.document_plugins_warning.tr(), + color: Theme.of(context).hintColor, + ), ), ], ); @@ -298,7 +301,22 @@ class _SmartEditInputState extends State<_SmartEditInput> { selection, texts, ); - return widget.editorState.apply(transaction); + await widget.editorState.apply(transaction); + + int endOffset = texts.last.length; + if (texts.length == 1) { + endOffset += selection.start.offset; + } + + await widget.editorState.updateCursorSelection( + Selection( + start: selection.start, + end: Position( + path: [selection.start.path.first + texts.length - 1], + offset: endOffset, + ), + ), + ); } Future _onInsertBelow() async { @@ -317,7 +335,16 @@ class _SmartEditInputState extends State<_SmartEditInput> { ), ), ); - return widget.editorState.apply(transaction); + await widget.editorState.apply(transaction); + + await widget.editorState.updateCursorSelection( + Selection( + start: Position(path: selection.end.path.next, offset: 0), + end: Position( + path: [selection.end.path.next.first + texts.length], + ), + ), + ); } Future _onExit() async { @@ -333,51 +360,42 @@ class _SmartEditInputState extends State<_SmartEditInput> { } Future _requestCompletions() async { - final result = await UserBackendService.getCurrentUserProfile(); - return result.fold((l) async { - final openAIRepository = HttpOpenAIRepository( - client: client, - apiKey: l.openaiKey, - ); + final openAIRepository = await getIt.getAsync(); - var lines = input.split('\n\n'); - if (action == SmartEditAction.summarize) { - lines = [lines.join('\n')]; - } - for (var i = 0; i < lines.length; i++) { - final element = lines[i]; - await openAIRepository.getStreamedCompletions( - useAction: true, - prompt: action.prompt(element), - onStart: () async { - setState(() { - loading = false; - }); - }, - onProcess: (response) async { - setState(() { - if (response.choices.first.text != '\n') { - this.result += response.choices.first.text; - } - }); - }, - onEnd: () async { - setState(() { - if (i != lines.length - 1) { - this.result += '\n'; - } - }); - }, - onError: (error) async { - await _showError(error.message); - await _onExit(); - }, - ); - } - }, (r) async { - await _showError(r.msg); - await _onExit(); - }); + var lines = input.split('\n\n'); + if (action == SmartEditAction.summarize) { + lines = [lines.join('\n')]; + } + for (var i = 0; i < lines.length; i++) { + final element = lines[i]; + await openAIRepository.getStreamedCompletions( + useAction: true, + prompt: action.prompt(element), + onStart: () async { + setState(() { + loading = false; + }); + }, + onProcess: (response) async { + setState(() { + if (response.choices.first.text != '\n') { + result += response.choices.first.text; + } + }); + }, + onEnd: () async { + setState(() { + if (i != lines.length - 1) { + result += '\n'; + } + }); + }, + onError: (error) async { + await _showError(error.message); + await _onExit(); + }, + ); + } } Future _showError(String message) async { diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 17ae094ca4..713eb7f27c 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -4,6 +4,7 @@ import 'package:appflowy/plugins/database_view/application/field/field_controlle import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; import 'package:appflowy/plugins/database_view/application/setting/property_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/application/grid_header_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/util/file_picker/file_picker_impl.dart'; @@ -27,6 +28,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; class DependencyResolver { static Future resolve(GetIt getIt) async { @@ -44,8 +46,25 @@ class DependencyResolver { } } -void _resolveCommonService(GetIt getIt) { +void _resolveCommonService(GetIt getIt) async { getIt.registerFactory(() => FilePicker()); + + getIt.registerFactoryAsync( + () async { + final result = await UserBackendService.getCurrentUserProfile(); + return result.fold( + (l) { + return HttpOpenAIRepository( + client: http.Client(), + apiKey: l.openaiKey, + ); + }, + (r) { + throw Exception('Failed to get user profile: ${r.msg}'); + }, + ); + }, + ); } void _resolveUserDeps(GetIt getIt) { @@ -160,4 +179,4 @@ void _resolveGridDeps(GetIt getIt) { (viewId, cache) => DatabasePropertyBloc(viewId: viewId, fieldController: cache), ); -} +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 1748789b39..aa31fb15fe 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -809,7 +809,7 @@ packages: source: hosted version: "1.0.4" mocktail: - dependency: transitive + dependency: "direct main" description: name: mocktail sha256: "80a996cd9a69284b3dc521ce185ffe9150cde69767c2d3a0720147d93c0cef53" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index d503b8475c..f12622f15e 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: git: url: https://github.com/AppFlowy-IO/appflowy-board.git ref: a183c57 - appflowy_editor: "^0.1.9" + appflowy_editor: ^0.1.9 appflowy_popover: path: packages/appflowy_popover @@ -98,6 +98,7 @@ dependencies: http: ^0.13.5 json_annotation: ^4.7.0 path: ^1.8.2 + mocktail: ^0.3.0 archive: ^3.3.0 flutter_svg: ^2.0.5