mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Feature/smart edit v2 (#1880)
* feat: add edit api to openai client * feat: add translation * chore: format code * feat: add smart edit plugin * fix: close http.client when dispose * fix: insert openai result to wrong position * feat: optimize the replace text logic * test: add test for normalize and getTextInSelection function * chore: update error message
This commit is contained in:
@ -137,7 +137,8 @@
|
|||||||
"esc": "ESC",
|
"esc": "ESC",
|
||||||
"keep": "Keep",
|
"keep": "Keep",
|
||||||
"tryAgain": "Try again",
|
"tryAgain": "Try again",
|
||||||
"discard": "Discard"
|
"discard": "Discard",
|
||||||
|
"replace": "Replace"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"welcome": "Welcome!",
|
"welcome": "Welcome!",
|
||||||
@ -347,7 +348,12 @@
|
|||||||
"autoGeneratorLearnMore": "Learn more",
|
"autoGeneratorLearnMore": "Learn more",
|
||||||
"autoGeneratorGenerate": "Generate",
|
"autoGeneratorGenerate": "Generate",
|
||||||
"autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
|
"autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
|
||||||
"autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key"
|
"autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key",
|
||||||
|
"smartEditTitleName": "Open AI: Smart Edit",
|
||||||
|
"smartEditFixSpelling": "Fix spelling",
|
||||||
|
"smartEditSummarize": "Summarize",
|
||||||
|
"smartEditCouldNotFetchResult": "Could not fetch result from OpenAI",
|
||||||
|
"smartEditCouldNotFetchKey": "Could not fetch OpenAI key"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"board": {
|
"board": {
|
||||||
|
@ -80,8 +80,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
|||||||
if (userProfile.isRight()) {
|
if (userProfile.isRight()) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
loadingState:
|
loadingState: DocumentLoadingState.finish(
|
||||||
DocumentLoadingState.finish(right(userProfile.asRight())),
|
right(userProfile.asRight()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
@ -4,6 +4,8 @@ import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_menu_it
|
|||||||
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
|
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart';
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||||
@ -142,6 +144,8 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
|
|||||||
kCalloutType: CalloutNodeWidgetBuilder(),
|
kCalloutType: CalloutNodeWidgetBuilder(),
|
||||||
// Auto Generator,
|
// Auto Generator,
|
||||||
kAutoCompletionInputType: AutoCompletionInputBuilder(),
|
kAutoCompletionInputType: AutoCompletionInputBuilder(),
|
||||||
|
// Smart Edit,
|
||||||
|
kSmartEditType: SmartEditInputBuilder(),
|
||||||
},
|
},
|
||||||
shortcutEvents: [
|
shortcutEvents: [
|
||||||
// Divider
|
// Divider
|
||||||
@ -172,6 +176,9 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
|
|||||||
autoGeneratorMenuItem,
|
autoGeneratorMenuItem,
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
toolbarItems: [
|
||||||
|
smartEditItem,
|
||||||
|
],
|
||||||
themeData: theme.copyWith(extensions: [
|
themeData: theme.copyWith(extensions: [
|
||||||
...theme.extensions.values,
|
...theme.extensions.values,
|
||||||
customEditorTheme(context),
|
customEditorTheme(context),
|
||||||
@ -203,6 +210,7 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
|
|||||||
}
|
}
|
||||||
final temporaryNodeTypes = [
|
final temporaryNodeTypes = [
|
||||||
kAutoCompletionInputType,
|
kAutoCompletionInputType,
|
||||||
|
kSmartEditType,
|
||||||
];
|
];
|
||||||
final iterator = NodeIterator(
|
final iterator = NodeIterator(
|
||||||
document: document,
|
document: document,
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart';
|
||||||
|
|
||||||
import 'text_completion.dart';
|
import 'text_completion.dart';
|
||||||
import 'package:dartz/dartz.dart';
|
import 'package:dartz/dartz.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@ -38,6 +40,18 @@ abstract class OpenAIRepository {
|
|||||||
int maxTokens = 50,
|
int maxTokens = 50,
|
||||||
double temperature = .3,
|
double temperature = .3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Get edits from GPT-3
|
||||||
|
///
|
||||||
|
/// [input] is the input text
|
||||||
|
/// [instruction] is the instruction text
|
||||||
|
/// [temperature] is the temperature of the model
|
||||||
|
///
|
||||||
|
Future<Either<OpenAIError, TextEditResponse>> getEdits({
|
||||||
|
required String input,
|
||||||
|
required String instruction,
|
||||||
|
double temperature = 0.3,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class HttpOpenAIRepository implements OpenAIRepository {
|
class HttpOpenAIRepository implements OpenAIRepository {
|
||||||
@ -70,7 +84,7 @@ class HttpOpenAIRepository implements OpenAIRepository {
|
|||||||
'stream': false,
|
'stream': false,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await client.post(
|
||||||
OpenAIRequestType.textCompletion.uri,
|
OpenAIRequestType.textCompletion.uri,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: json.encode(parameters),
|
body: json.encode(parameters),
|
||||||
@ -82,4 +96,30 @@ class HttpOpenAIRepository implements OpenAIRepository {
|
|||||||
return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
|
return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<OpenAIError, TextEditResponse>> getEdits({
|
||||||
|
required String input,
|
||||||
|
required String instruction,
|
||||||
|
double temperature = 0.3,
|
||||||
|
}) async {
|
||||||
|
final parameters = {
|
||||||
|
'model': 'text-davinci-edit-001',
|
||||||
|
'input': input,
|
||||||
|
'instruction': instruction,
|
||||||
|
'temperature': temperature,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await client.post(
|
||||||
|
OpenAIRequestType.textEdit.uri,
|
||||||
|
headers: headers,
|
||||||
|
body: json.encode(parameters),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return Right(TextEditResponse.fromJson(json.decode(response.body)));
|
||||||
|
} else {
|
||||||
|
return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
part 'text_edit.freezed.dart';
|
||||||
|
part 'text_edit.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class TextEditChoice with _$TextEditChoice {
|
||||||
|
factory TextEditChoice({
|
||||||
|
required String text,
|
||||||
|
required int index,
|
||||||
|
}) = _TextEditChoice;
|
||||||
|
|
||||||
|
factory TextEditChoice.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$TextEditChoiceFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class TextEditResponse with _$TextEditResponse {
|
||||||
|
const factory TextEditResponse({
|
||||||
|
required List<TextEditChoice> choices,
|
||||||
|
}) = _TextEditResponse;
|
||||||
|
|
||||||
|
factory TextEditResponse.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$TextEditResponseFromJson(json);
|
||||||
|
}
|
@ -167,7 +167,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
|
|||||||
text: '↵',
|
text: '↵',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
), // FIXME: color
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -185,7 +185,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
|
|||||||
text: LocaleKeys.button_esc.tr(),
|
text: LocaleKeys.button_esc.tr(),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
), // FIXME: color
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -198,7 +198,6 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
|
|||||||
Widget _buildFooterWidget(BuildContext context) {
|
Widget _buildFooterWidget(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// FIXME: l10n
|
|
||||||
FlowyRichTextButton(
|
FlowyRichTextButton(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: [
|
children: [
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
|
||||||
|
enum SmartEditAction {
|
||||||
|
summarize,
|
||||||
|
fixSpelling;
|
||||||
|
|
||||||
|
String get toInstruction {
|
||||||
|
switch (this) {
|
||||||
|
case SmartEditAction.summarize:
|
||||||
|
return 'Summarize';
|
||||||
|
case SmartEditAction.fixSpelling:
|
||||||
|
return 'Fix the spelling mistakes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SmartEditActionWrapper extends ActionCell {
|
||||||
|
final SmartEditAction inner;
|
||||||
|
|
||||||
|
SmartEditActionWrapper(this.inner);
|
||||||
|
|
||||||
|
Widget? icon(Color iconColor) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name {
|
||||||
|
switch (inner) {
|
||||||
|
case SmartEditAction.summarize:
|
||||||
|
return LocaleKeys.document_plugins_smartEditSummarize.tr();
|
||||||
|
case SmartEditAction.fixSpelling:
|
||||||
|
return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,277 @@
|
|||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/error.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.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_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||||
|
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||||
|
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:dartz/dartz.dart' as dartz;
|
||||||
|
import 'package:appflowy/util/either_extension.dart';
|
||||||
|
|
||||||
|
const String kSmartEditType = 'smart_edit_input';
|
||||||
|
const String kSmartEditInstructionType = 'smart_edit_instruction';
|
||||||
|
const String kSmartEditInputType = 'smart_edit_input';
|
||||||
|
|
||||||
|
class SmartEditInputBuilder extends NodeWidgetBuilder<Node> {
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => (node) {
|
||||||
|
return SmartEditAction.values.map((e) => e.toInstruction).contains(
|
||||||
|
node.attributes[kSmartEditInstructionType],
|
||||||
|
) &&
|
||||||
|
node.attributes[kSmartEditInputType] is String;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(NodeWidgetContext<Node> context) {
|
||||||
|
return _SmartEditInput(
|
||||||
|
key: context.node.key,
|
||||||
|
node: context.node,
|
||||||
|
editorState: context.editorState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmartEditInput extends StatefulWidget {
|
||||||
|
final Node node;
|
||||||
|
|
||||||
|
final EditorState editorState;
|
||||||
|
const _SmartEditInput({
|
||||||
|
Key? key,
|
||||||
|
required this.node,
|
||||||
|
required this.editorState,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SmartEditInput> createState() => _SmartEditInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmartEditInputState extends State<_SmartEditInput> {
|
||||||
|
String get instruction => widget.node.attributes[kSmartEditInstructionType];
|
||||||
|
String get input => widget.node.attributes[kSmartEditInputType];
|
||||||
|
|
||||||
|
final focusNode = FocusNode();
|
||||||
|
final client = http.Client();
|
||||||
|
dartz.Either<OpenAIError, TextEditResponse>? result;
|
||||||
|
bool loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
widget.editorState.service.keyboardService?.disable(showCursor: true);
|
||||||
|
focusNode.requestFocus();
|
||||||
|
focusNode.addListener(() {
|
||||||
|
if (!focusNode.hasFocus) {
|
||||||
|
widget.editorState.service.keyboardService?.enable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_requestEdits().then(
|
||||||
|
(value) => setState(() {
|
||||||
|
result = value;
|
||||||
|
loading = false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
client.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 5,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(10),
|
||||||
|
child: _buildSmartEditPanel(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSmartEditPanel(BuildContext context) {
|
||||||
|
return RawKeyboardListener(
|
||||||
|
focusNode: focusNode,
|
||||||
|
onKey: (RawKeyEvent event) async {
|
||||||
|
if (event is! RawKeyDownEvent) return;
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||||
|
await _onReplace();
|
||||||
|
await _onExit();
|
||||||
|
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
|
await _onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeaderWidget(context),
|
||||||
|
const Space(0, 10),
|
||||||
|
_buildResultWidget(context),
|
||||||
|
const Space(0, 10),
|
||||||
|
_buildInputFooterWidget(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderWidget(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
FlowyText.medium(
|
||||||
|
LocaleKeys.document_plugins_smartEditTitleName.tr(),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
FlowyText.regular(
|
||||||
|
LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultWidget(BuildContext context) {
|
||||||
|
final loading = SizedBox.fromSize(
|
||||||
|
size: const Size.square(14),
|
||||||
|
child: const CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
if (result == null) {
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
return result!.fold((error) {
|
||||||
|
return Flexible(
|
||||||
|
child: Text(
|
||||||
|
error.message,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, (response) {
|
||||||
|
return Flexible(
|
||||||
|
child: Text(
|
||||||
|
response.choices.map((e) => e.text).join('\n'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInputFooterWidget(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
FlowyRichTextButton(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '${LocaleKeys.button_replace.tr()} ',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '↵',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
_onReplace();
|
||||||
|
_onExit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Space(10, 0),
|
||||||
|
FlowyRichTextButton(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '${LocaleKeys.button_Cancel.tr()} ',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: LocaleKeys.button_esc.tr(),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: () async => await _onExit(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onReplace() async {
|
||||||
|
final selection = widget.editorState.service.selectionService
|
||||||
|
.currentSelection.value?.normalized;
|
||||||
|
final selectedNodes = widget
|
||||||
|
.editorState.service.selectionService.currentSelectedNodes.normalized
|
||||||
|
.whereType<TextNode>();
|
||||||
|
if (selection == null || result == null || result!.isLeft()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final texts = result!.asRight().choices.first.text.split('\n')
|
||||||
|
..removeWhere((element) => element.isEmpty);
|
||||||
|
assert(texts.length == selectedNodes.length);
|
||||||
|
final transaction = widget.editorState.transaction;
|
||||||
|
transaction.replaceTexts(
|
||||||
|
selectedNodes.toList(growable: false),
|
||||||
|
selection,
|
||||||
|
texts,
|
||||||
|
);
|
||||||
|
return widget.editorState.apply(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onExit() async {
|
||||||
|
final transaction = widget.editorState.transaction;
|
||||||
|
transaction.deleteNode(widget.node);
|
||||||
|
return widget.editorState.apply(
|
||||||
|
transaction,
|
||||||
|
options: const ApplyOptions(
|
||||||
|
recordRedo: false,
|
||||||
|
recordUndo: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dartz.Either<OpenAIError, TextEditResponse>> _requestEdits() async {
|
||||||
|
final result = await UserBackendService.getCurrentUserProfile();
|
||||||
|
return result.fold((userProfile) async {
|
||||||
|
final openAIRepository = HttpOpenAIRepository(
|
||||||
|
client: client,
|
||||||
|
apiKey: userProfile.openaiKey,
|
||||||
|
);
|
||||||
|
final edits = await openAIRepository.getEdits(
|
||||||
|
input: input,
|
||||||
|
instruction: instruction,
|
||||||
|
);
|
||||||
|
return edits.fold((error) async {
|
||||||
|
return dartz.Left(
|
||||||
|
OpenAIError(
|
||||||
|
message:
|
||||||
|
LocaleKeys.document_plugins_smartEditCouldNotFetchResult.tr(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, (textEdit) async {
|
||||||
|
return dartz.Right(textEdit);
|
||||||
|
});
|
||||||
|
}, (error) async {
|
||||||
|
// error
|
||||||
|
return dartz.Left(
|
||||||
|
OpenAIError(
|
||||||
|
message: LocaleKeys.document_plugins_smartEditCouldNotFetchKey.tr(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
|
||||||
|
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||||
|
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
ToolbarItem smartEditItem = ToolbarItem(
|
||||||
|
id: 'appflowy.toolbar.smart_edit',
|
||||||
|
type: 0, // headmost
|
||||||
|
validator: (editorState) {
|
||||||
|
// All selected nodes must be text.
|
||||||
|
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
|
return nodes.whereType<TextNode>().length == nodes.length;
|
||||||
|
},
|
||||||
|
itemBuilder: (context, editorState) {
|
||||||
|
return _SmartEditWidget(
|
||||||
|
editorState: editorState,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
class _SmartEditWidget extends StatefulWidget {
|
||||||
|
const _SmartEditWidget({
|
||||||
|
required this.editorState,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EditorState editorState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SmartEditWidget> createState() => _SmartEditWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmartEditWidgetState extends State<_SmartEditWidget> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopoverActionList<SmartEditActionWrapper>(
|
||||||
|
direction: PopoverDirection.bottomWithLeftAligned,
|
||||||
|
actions: SmartEditAction.values
|
||||||
|
.map((action) => SmartEditActionWrapper(action))
|
||||||
|
.toList(),
|
||||||
|
buildChild: (controller) {
|
||||||
|
return FlowyIconButton(
|
||||||
|
tooltipText: 'Smart Edit',
|
||||||
|
preferBelow: false,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.edit,
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
controller.show();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSelected: (action, controller) {
|
||||||
|
controller.close();
|
||||||
|
final selection =
|
||||||
|
widget.editorState.service.selectionService.currentSelection.value;
|
||||||
|
if (selection == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final textNodes = widget
|
||||||
|
.editorState.service.selectionService.currentSelectedNodes
|
||||||
|
.whereType<TextNode>()
|
||||||
|
.toList(growable: false);
|
||||||
|
final input = widget.editorState.getTextInSelection(
|
||||||
|
textNodes.normalized,
|
||||||
|
selection.normalized,
|
||||||
|
);
|
||||||
|
final transaction = widget.editorState.transaction;
|
||||||
|
transaction.insertNode(
|
||||||
|
selection.normalized.end.path.next,
|
||||||
|
Node(
|
||||||
|
type: kSmartEditType,
|
||||||
|
attributes: {
|
||||||
|
kSmartEditInstructionType: action.inner.toInstruction,
|
||||||
|
kSmartEditInputType: input,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.editorState.apply(
|
||||||
|
transaction,
|
||||||
|
options: const ApplyOptions(
|
||||||
|
recordUndo: false,
|
||||||
|
recordRedo: false,
|
||||||
|
),
|
||||||
|
withUpdateCursor: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -121,7 +121,6 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (val) {
|
onSubmitted: (val) {
|
||||||
// TODO: validate key
|
|
||||||
context
|
context
|
||||||
.read<SettingsUserViewBloc>()
|
.read<SettingsUserViewBloc>()
|
||||||
.add(SettingsUserEvent.updateUserOpenAIKey(val));
|
.add(SettingsUserEvent.updateUserOpenAIKey(val));
|
||||||
|
@ -43,6 +43,7 @@ export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart';
|
|||||||
export 'src/plugins/markdown/document_markdown.dart';
|
export 'src/plugins/markdown/document_markdown.dart';
|
||||||
export 'src/plugins/quill_delta/delta_document_encoder.dart';
|
export 'src/plugins/quill_delta/delta_document_encoder.dart';
|
||||||
export 'src/commands/text/text_commands.dart';
|
export 'src/commands/text/text_commands.dart';
|
||||||
|
export 'src/commands/command_extension.dart';
|
||||||
export 'src/render/toolbar/toolbar_item.dart';
|
export 'src/render/toolbar/toolbar_item.dart';
|
||||||
export 'src/extensions/node_extensions.dart';
|
export 'src/extensions/node_extensions.dart';
|
||||||
export 'src/render/action_menu/action_menu.dart';
|
export 'src/render/action_menu/action_menu.dart';
|
||||||
|
@ -51,4 +51,23 @@ extension CommandExtension on EditorState {
|
|||||||
}
|
}
|
||||||
throw Exception('path and textNode cannot be null at the same time');
|
throw Exception('path and textNode cannot be null at the same time');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getTextInSelection(
|
||||||
|
List<TextNode> textNodes,
|
||||||
|
Selection selection,
|
||||||
|
) {
|
||||||
|
List<String> res = [];
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
for (var i = 0; i < textNodes.length; i++) {
|
||||||
|
if (i == 0) {
|
||||||
|
res.add(textNodes[i].toPlainText().substring(selection.startIndex));
|
||||||
|
} else if (i == textNodes.length - 1) {
|
||||||
|
res.add(textNodes[i].toPlainText().substring(0, selection.endIndex));
|
||||||
|
} else {
|
||||||
|
res.add(textNodes[i].toPlainText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.join('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor/src/commands/command_extension.dart';
|
|
||||||
|
|
||||||
extension TextCommands on EditorState {
|
extension TextCommands on EditorState {
|
||||||
/// Insert text at the given index of the given [TextNode] or the [Path].
|
/// Insert text at the given index of the given [TextNode] or the [Path].
|
||||||
|
@ -266,6 +266,9 @@ extension TextTransaction on Transaction {
|
|||||||
textNode.delta.slice(max(index - 1, 0), index).first.attributes;
|
textNode.delta.slice(max(index - 1, 0), index).first.attributes;
|
||||||
if (newAttributes != null) {
|
if (newAttributes != null) {
|
||||||
newAttributes = {...newAttributes}; // make a copy
|
newAttributes = {...newAttributes}; // make a copy
|
||||||
|
} else {
|
||||||
|
newAttributes =
|
||||||
|
textNode.delta.slice(index, index + length).first.attributes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateText(
|
updateText(
|
||||||
@ -282,4 +285,52 @@ extension TextTransaction on Transaction {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void replaceTexts(
|
||||||
|
List<TextNode> textNodes,
|
||||||
|
Selection selection,
|
||||||
|
List<String> texts,
|
||||||
|
) {
|
||||||
|
if (textNodes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.isSingle) {
|
||||||
|
assert(textNodes.length == 1 && texts.length == 1);
|
||||||
|
replaceText(
|
||||||
|
textNodes.first,
|
||||||
|
selection.startIndex,
|
||||||
|
selection.length,
|
||||||
|
texts.first,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final length = textNodes.length;
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final textNode = textNodes[i];
|
||||||
|
final text = texts[i];
|
||||||
|
if (i == 0) {
|
||||||
|
replaceText(
|
||||||
|
textNode,
|
||||||
|
selection.startIndex,
|
||||||
|
textNode.toPlainText().length,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
} else if (i == length - 1) {
|
||||||
|
replaceText(
|
||||||
|
textNode,
|
||||||
|
0,
|
||||||
|
selection.endIndex,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
replaceText(
|
||||||
|
textNode,
|
||||||
|
0,
|
||||||
|
textNode.toPlainText().length,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,3 +37,17 @@ extension NodeExtensions on Node {
|
|||||||
currentSelectedNodes.first == this;
|
currentSelectedNodes.first == this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NodesExtensions<T extends Node> on List<T> {
|
||||||
|
List<T> get normalized {
|
||||||
|
if (isEmpty) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.path > last.path) {
|
||||||
|
return reversed.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -20,20 +20,31 @@ class ToolbarItem {
|
|||||||
ToolbarItem({
|
ToolbarItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.iconBuilder,
|
|
||||||
this.tooltipsMessage = '',
|
this.tooltipsMessage = '',
|
||||||
|
this.iconBuilder,
|
||||||
required this.validator,
|
required this.validator,
|
||||||
required this.highlightCallback,
|
this.highlightCallback,
|
||||||
required this.handler,
|
this.handler,
|
||||||
});
|
this.itemBuilder,
|
||||||
|
}) {
|
||||||
|
assert(
|
||||||
|
(iconBuilder != null && itemBuilder == null) ||
|
||||||
|
(iconBuilder == null && itemBuilder != null),
|
||||||
|
'iconBuilder and itemBuilder must be set one of them',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final int type;
|
final int type;
|
||||||
final Widget Function(bool isHighlight) iconBuilder;
|
|
||||||
final String tooltipsMessage;
|
final String tooltipsMessage;
|
||||||
final ToolbarItemValidator validator;
|
final ToolbarItemValidator validator;
|
||||||
final ToolbarItemEventHandler handler;
|
|
||||||
final ToolbarItemHighlightCallback highlightCallback;
|
final Widget Function(bool isHighlight)? iconBuilder;
|
||||||
|
final ToolbarItemEventHandler? handler;
|
||||||
|
final ToolbarItemHighlightCallback? highlightCallback;
|
||||||
|
|
||||||
|
final Widget Function(BuildContext context, EditorState editorState)?
|
||||||
|
itemBuilder;
|
||||||
|
|
||||||
factory ToolbarItem.divider() {
|
factory ToolbarItem.divider() {
|
||||||
return ToolbarItem(
|
return ToolbarItem(
|
||||||
|
@ -16,6 +16,7 @@ class ToolbarItemWidget extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (item.iconBuilder != null) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@ -28,7 +29,7 @@ class ToolbarItemWidget extends StatelessWidget {
|
|||||||
hoverColor: Colors.transparent,
|
hoverColor: Colors.transparent,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
icon: item.iconBuilder(isHighlight),
|
icon: item.iconBuilder!(isHighlight),
|
||||||
iconSize: 28,
|
iconSize: 28,
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
),
|
),
|
||||||
@ -36,4 +37,6 @@ class ToolbarItemWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -66,12 +66,17 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
|
|||||||
children: widget.items
|
children: widget.items
|
||||||
.map(
|
.map(
|
||||||
(item) => Center(
|
(item) => Center(
|
||||||
child: ToolbarItemWidget(
|
child:
|
||||||
|
item.itemBuilder?.call(context, widget.editorState) ??
|
||||||
|
ToolbarItemWidget(
|
||||||
item: item,
|
item: item,
|
||||||
isHighlight: item.highlightCallback(widget.editorState),
|
isHighlight: item.highlightCallback
|
||||||
|
?.call(widget.editorState) ??
|
||||||
|
false,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
item.handler(widget.editorState, context);
|
item.handler?.call(widget.editorState, context);
|
||||||
widget.editorState.service.keyboardService?.enable();
|
widget.editorState.service.keyboardService
|
||||||
|
?.enable();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -78,7 +78,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
|||||||
assert(items.length == 1, 'The toolbar item\'s id must be unique');
|
assert(items.length == 1, 'The toolbar item\'s id must be unique');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
items.first.handler(widget.editorState, context);
|
items.first.handler?.call(widget.editorState, context);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import '../infra/test_editor.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('command_extension.dart', () {
|
||||||
|
testWidgets('insert a new checkbox after an exsiting checkbox',
|
||||||
|
(tester) async {
|
||||||
|
final editor = tester.editor
|
||||||
|
..insertTextNode(
|
||||||
|
'Welcome',
|
||||||
|
)
|
||||||
|
..insertTextNode(
|
||||||
|
'to',
|
||||||
|
)
|
||||||
|
..insertTextNode(
|
||||||
|
'Appflowy 😁',
|
||||||
|
);
|
||||||
|
await editor.startTesting();
|
||||||
|
final selection = Selection(
|
||||||
|
start: Position(path: [2], offset: 5),
|
||||||
|
end: Position(path: [0], offset: 5),
|
||||||
|
);
|
||||||
|
await editor.updateSelection(selection);
|
||||||
|
final textNodes = editor
|
||||||
|
.editorState.service.selectionService.currentSelectedNodes
|
||||||
|
.whereType<TextNode>()
|
||||||
|
.toList(growable: false);
|
||||||
|
final text = editor.editorState.getTextInSelection(
|
||||||
|
textNodes.normalized,
|
||||||
|
selection.normalized,
|
||||||
|
);
|
||||||
|
expect(text, 'me\nto\nAppfl');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -2,12 +2,13 @@ import 'dart:collection';
|
|||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import '../infra/test_editor.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
|
|
||||||
class MockNode extends Mock implements Node {}
|
class MockNode extends Mock implements Node {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('NodeExtensions::', () {
|
group('node_extension.dart', () {
|
||||||
final selection = Selection(
|
final selection = Selection(
|
||||||
start: Position(path: [0]),
|
start: Position(path: [0]),
|
||||||
end: Position(path: [1]),
|
end: Position(path: [1]),
|
||||||
@ -43,5 +44,36 @@ void main() {
|
|||||||
final result = node.inSelection(selection);
|
final result = node.inSelection(selection);
|
||||||
expect(result, false);
|
expect(result, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('insert a new checkbox after an exsiting checkbox',
|
||||||
|
(tester) async {
|
||||||
|
const text = 'Welcome to Appflowy 😁';
|
||||||
|
final editor = tester.editor
|
||||||
|
..insertTextNode(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
..insertTextNode(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
..insertTextNode(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
await editor.startTesting();
|
||||||
|
final selection = Selection(
|
||||||
|
start: Position(path: [2], offset: 5),
|
||||||
|
end: Position(path: [0], offset: 5),
|
||||||
|
);
|
||||||
|
await editor.updateSelection(selection);
|
||||||
|
final nodes =
|
||||||
|
editor.editorState.service.selectionService.currentSelectedNodes;
|
||||||
|
expect(
|
||||||
|
nodes.map((e) => e.path).toList().toString(),
|
||||||
|
'[[2], [1], [0]]',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
nodes.normalized.map((e) => e.path).toList().toString(),
|
||||||
|
'[[0], [1], [2]]',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ class FlowyIconButton extends StatelessWidget {
|
|||||||
final EdgeInsets iconPadding;
|
final EdgeInsets iconPadding;
|
||||||
final BorderRadius? radius;
|
final BorderRadius? radius;
|
||||||
final String? tooltipText;
|
final String? tooltipText;
|
||||||
|
final bool preferBelow;
|
||||||
|
|
||||||
const FlowyIconButton({
|
const FlowyIconButton({
|
||||||
Key? key,
|
Key? key,
|
||||||
@ -25,6 +26,7 @@ class FlowyIconButton extends StatelessWidget {
|
|||||||
this.iconPadding = EdgeInsets.zero,
|
this.iconPadding = EdgeInsets.zero,
|
||||||
this.radius,
|
this.radius,
|
||||||
this.tooltipText,
|
this.tooltipText,
|
||||||
|
this.preferBelow = true,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@ -44,6 +46,7 @@ class FlowyIconButton extends StatelessWidget {
|
|||||||
constraints:
|
constraints:
|
||||||
BoxConstraints.tightFor(width: size.width, height: size.height),
|
BoxConstraints.tightFor(width: size.width, height: size.height),
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
|
preferBelow: preferBelow,
|
||||||
message: tooltipText ?? '',
|
message: tooltipText ?? '',
|
||||||
showDuration: Duration.zero,
|
showDuration: Duration.zero,
|
||||||
child: RawMaterialButton(
|
child: RawMaterialButton(
|
||||||
|
Reference in New Issue
Block a user