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 <lucas.xu@appflowy.io>
This commit is contained in:
Mihir 2023-05-03 07:43:11 +00:00 committed by GitHub
parent f9095cfc64
commit 39b1ff0910
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 283 additions and 57 deletions

View File

@ -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<AppFlowyEditor> 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<void> mockOpenAIRepository() async {
await getIt.unregister<OpenAIRepository>();
getIt.registerFactoryAsync<OpenAIRepository>(
() => Future.value(
MockOpenAIRepository(),
),
);
return;
}

View File

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

View File

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

View File

@ -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<http.StreamedResponse> send(http.BaseRequest request) async {
final requestType = request.method;
final requestUri = request.url;
if (requestType == 'POST' && requestUri == OpenAIRequestType.textCompletion.uri) {
final responseHeaders = <String, String>{'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<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> 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;
}
}

View File

@ -50,6 +50,7 @@ abstract class OpenAIRepository {
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
});
/// Get edits from GPT-3

View File

@ -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<void> _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<void> _onExit() async {
@ -333,51 +360,42 @@ class _SmartEditInputState extends State<_SmartEditInput> {
}
Future<void> _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<OpenAIRepository>();
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<void> _showError(String message) async {

View File

@ -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<void> resolve(GetIt getIt) async {
@ -44,8 +46,25 @@ class DependencyResolver {
}
}
void _resolveCommonService(GetIt getIt) {
void _resolveCommonService(GetIt getIt) async {
getIt.registerFactory<FilePickerService>(() => FilePicker());
getIt.registerFactoryAsync<OpenAIRepository>(
() 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),
);
}
}

View File

@ -809,7 +809,7 @@ packages:
source: hosted
version: "1.0.4"
mocktail:
dependency: transitive
dependency: "direct main"
description:
name: mocktail
sha256: "80a996cd9a69284b3dc521ce185ffe9150cde69767c2d3a0720147d93c0cef53"

View File

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