From 8c956afabdf88f96d490caf4b5575b7c8f1bee16 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 8 Oct 2024 18:28:07 +0800 Subject: [PATCH] fix: show AI limit error toast if exceeding the AI response (#6505) * fix: show AI limit error toast if exceeding the AI response * test: add ai limit test --- .../openai/widgets/smart_edit_bloc.dart | 29 ++++-- .../widgets/smart_edit_node_widget.dart | 89 ++++++++++++------- .../smart_editor_bloc_test.dart | 87 +++++++++++++++--- 3 files changed, 151 insertions(+), 54 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart index 55e450141bbf..252c427c3a24 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/user/application/ai_service.dart'; @@ -45,11 +46,12 @@ class SmartEditBloc extends Bloc { isCanceled = true; await _exit(); }, - update: (result, isLoading) async { + update: (result, isLoading, aiError) async { emit( state.copyWith( result: result, loading: isLoading, + requestError: aiError, ), ); }, @@ -73,7 +75,7 @@ class SmartEditBloc extends Bloc { await aiRepositoryCompleter.future; if (rewrite) { - add(const SmartEditEvent.update('', true)); + add(const SmartEditEvent.update('', true, null)); } if (enableLogging) { @@ -91,7 +93,7 @@ class SmartEditBloc extends Bloc { if (enableLogging) { Log.info('[smart_edit] start generating'); } - add(const SmartEditEvent.update('', true)); + add(const SmartEditEvent.update('', true, null)); }, onProcess: (text) async { if (isCanceled) { @@ -102,7 +104,7 @@ class SmartEditBloc extends Bloc { Log.debug('[smart_edit] onProcess: $text'); } final newResult = state.result + text; - add(SmartEditEvent.update(newResult, false)); + add(SmartEditEvent.update(newResult, false, null)); }, onEnd: () async { if (isCanceled) { @@ -111,7 +113,7 @@ class SmartEditBloc extends Bloc { if (enableLogging) { Log.info('[smart_edit] end generating'); } - add(SmartEditEvent.update('${state.result}\n', false)); + add(SmartEditEvent.update('${state.result}\n', false, null)); }, onError: (error) async { if (isCanceled) { @@ -120,7 +122,9 @@ class SmartEditBloc extends Bloc { if (enableLogging) { Log.info('[smart_edit] onError: $error'); } + add(SmartEditEvent.update('', false, error)); await _exit(); + await _clearSelection(); }, ); } @@ -207,6 +211,14 @@ class SmartEditBloc extends Bloc { ), ); } + + Future _clearSelection() async { + final selection = editorState.selection; + if (selection == null) { + return; + } + editorState.selection = null; + } } @freezed @@ -219,7 +231,11 @@ class SmartEditEvent with _$SmartEditEvent { const factory SmartEditEvent.replace() = _Replace; const factory SmartEditEvent.insertBelow() = _InsertBelow; const factory SmartEditEvent.cancel() = _Cancel; - const factory SmartEditEvent.update(String result, bool isLoading) = _Update; + const factory SmartEditEvent.update( + String result, + bool isLoading, + AIError? error, + ) = _Update; } @freezed @@ -228,6 +244,7 @@ class SmartEditState with _$SmartEditState { required bool loading, required String result, required SmartEditAction action, + @Default(null) AIError? requestError, }) = _SmartEditState; factory SmartEditState.initial(SmartEditAction action) => SmartEditState( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart index ff0124aaf7ff..0e7090f95fd4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart'; import 'package:appflowy/startup/startup.dart'; @@ -12,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:toastification/toastification.dart'; class SmartEditBlockKeys { const SmartEditBlockKeys._(); @@ -123,41 +126,44 @@ class _SmartEditBlockComponentWidgetState return BlocProvider.value( value: smartEditBloc, - child: AppFlowyPopover( - controller: popoverController, - direction: PopoverDirection.bottomWithLeftAligned, - triggerActions: PopoverTriggerFlags.none, - margin: EdgeInsets.zero, - offset: const Offset(40, 0), // align the editor block - windowPadding: EdgeInsets.zero, - constraints: BoxConstraints(maxWidth: width), - canClose: () async { - final completer = Completer(); - final state = smartEditBloc.state; - if (state.result.isEmpty) { - completer.complete(true); - } else { - await showCancelAndConfirmDialog( - context: context, - title: LocaleKeys.document_plugins_discardResponse.tr(), - description: '', - confirmLabel: LocaleKeys.button_discard.tr(), - onConfirm: () => completer.complete(true), - onCancel: () => completer.complete(false), + child: BlocListener( + listener: _onListen, + child: AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: const Offset(40, 0), // align the editor block + windowPadding: EdgeInsets.zero, + constraints: BoxConstraints(maxWidth: width), + canClose: () async { + final completer = Completer(); + final state = smartEditBloc.state; + if (state.result.isEmpty) { + completer.complete(true); + } else { + await showCancelAndConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_discardResponse.tr(), + description: '', + confirmLabel: LocaleKeys.button_discard.tr(), + onConfirm: () => completer.complete(true), + onCancel: () => completer.complete(false), + ); + } + return completer.future; + }, + onClose: _removeNode, + popupBuilder: (BuildContext popoverContext) { + return BlocProvider.value( + // request the result when opening the popover + value: smartEditBloc..add(const SmartEditEvent.started()), + child: const SmartEditInputContent(), ); - } - return completer.future; - }, - onClose: _removeNode, - popupBuilder: (BuildContext popoverContext) { - return BlocProvider.value( - // request the result when opening the popover - value: smartEditBloc..add(const SmartEditEvent.started()), - child: const SmartEditInputContent(), - ); - }, - child: const SizedBox( - width: double.infinity, + }, + child: const SizedBox( + width: double.infinity, + ), ), ), ); @@ -179,6 +185,21 @@ class _SmartEditBlockComponentWidgetState final transaction = editorState.transaction..deleteNode(widget.node); editorState.apply(transaction); } + + void _onListen(BuildContext context, SmartEditState state) { + final error = state.requestError; + if (error != null) { + if (error.isLimitExceeded) { + showAILimitDialog(context, error.message); + } else { + showToastNotification( + context, + message: error.message, + type: ToastificationType.error, + ); + } + } + } } class SmartEditInputContent extends StatelessWidget { diff --git a/frontend/appflowy_flutter/test/bloc_test/smart_edit_test/smart_editor_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/smart_edit_test/smart_editor_bloc_test.dart index bc81d388cc78..67de5bc4b40e 100644 --- a/frontend/appflowy_flutter/test/bloc_test/smart_edit_test/smart_editor_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/smart_edit_test/smart_editor_bloc_test.dart @@ -9,7 +9,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -class MockAIRepository extends Mock implements AIRepository { +class _MockAIRepository extends Mock implements AIRepository { @override Future streamCompletion({ required String text, @@ -28,6 +28,26 @@ class MockAIRepository extends Mock implements AIRepository { } } +class _MockErrorRepository extends Mock implements AIRepository { + @override + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }) async { + await onStart(); + onError( + const AIError( + message: 'Error', + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + } +} + void main() { group('SmartEditorBloc: ', () { blocTest( @@ -64,7 +84,7 @@ void main() { ); }, act: (bloc) { - bloc.add(SmartEditEvent.initial(Future.value(MockAIRepository()))); + bloc.add(SmartEditEvent.initial(Future.value(_MockAIRepository()))); bloc.add(const SmartEditEvent.rewrite()); }, expect: () => [ @@ -78,17 +98,56 @@ void main() { isA().having((s) => s.loading, 'loading', false), ], ); - }); -} - -// [ -// _$SmartEditStateImpl:SmartEditState(loading: true, result: , action: SmartEditAction.makeItLonger), -// _$SmartEditStateImpl:SmartEditState(loading: false, result: UPDATED: 1. Select text to style using the toolbar menu. -// 2. Discover more styling options in Aa. -// 3. AppFlowy empowers you to beautifully and effortlessly style your content. + blocTest( + 'exceed the ai response limit', + build: () { + const text1 = '1. Select text to style using the toolbar menu.'; + const text2 = '2. Discover more styling options in Aa.'; + const text3 = + '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final editorState = EditorState(document: document); + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); -// , action: SmartEditAction.makeItLonger), -// _$SmartEditStateImpl:SmartEditState(loading: false, result: -// , action: SmartEditAction.makeItLonger) -// ] \ No newline at end of file + final node = smartEditNode( + action: SmartEditAction.makeItLonger, + content: [text1, text2, text3].join('\n'), + ); + return SmartEditBloc( + node: node, + editorState: editorState, + action: SmartEditAction.makeItLonger, + enableLogging: false, + ); + }, + act: (bloc) { + bloc.add(SmartEditEvent.initial(Future.value(_MockErrorRepository()))); + bloc.add(const SmartEditEvent.rewrite()); + }, + expect: () => [ + isA() + .having((s) => s.loading, 'loading', true) + .having((s) => s.result, 'result', isEmpty), + isA() + .having((s) => s.requestError, 'requestError', isNotNull) + .having( + (s) => s.requestError?.code, + 'requestError.code', + AIErrorCode.aiResponseLimitExceeded, + ), + ], + ); + }); +}