mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: add openai service (#1858)
* feat: add openai service * feat: add openai auto completion plugin * feat: add visible icon for open ai input field * chore: optimize user experience * feat: add auto completion node plugin * feat: support keep and discard the auto generated text * fix: can't delete the auto completion node * feat: disable ai plugins if open ai key is null * fix: wrong auto completion node card color * fix: make sure the previous text node is pure when using auto generator
This commit is contained in:
parent
2f9823d12a
commit
7c3a823078
@ -131,7 +131,12 @@
|
||||
"signIn": "Sign In",
|
||||
"signOut": "Sign Out",
|
||||
"complete": "Complete",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"generate": "Generate",
|
||||
"esc": "ESC",
|
||||
"keep": "Keep",
|
||||
"tryAgain": "Try again",
|
||||
"discard": "Discard"
|
||||
},
|
||||
"label": {
|
||||
"welcome": "Welcome!",
|
||||
@ -334,7 +339,14 @@
|
||||
},
|
||||
"plugins": {
|
||||
"referencedBoard": "Referenced Board",
|
||||
"referencedGrid": "Referenced Grid"
|
||||
"referencedGrid": "Referenced Grid",
|
||||
"autoCompletionMenuItemName": "Auto Completion",
|
||||
"autoGeneratorMenuItemName": "Auto Generator",
|
||||
"autoGeneratorTitleName": "Open AI: Auto Generator",
|
||||
"autoGeneratorLearnMore": "Learn more",
|
||||
"autoGeneratorGenerate": "Generate",
|
||||
"autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
|
||||
"autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
|
@ -1,7 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'package:app_flowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:app_flowy/user/application/user_service.dart';
|
||||
import 'package:app_flowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:app_flowy/plugins/document/application/doc_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
show EditorState, Document, Transaction;
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
|
||||
@ -12,6 +14,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'dart:async';
|
||||
import 'package:app_flowy/util/either_extension.dart';
|
||||
|
||||
part 'doc_bloc.freezed.dart';
|
||||
|
||||
@ -73,6 +76,16 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
}
|
||||
|
||||
Future<void> _initial(Initial value, Emitter<DocumentState> emit) async {
|
||||
final userProfile = await UserService.getCurrentUserProfile();
|
||||
if (userProfile.isRight()) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingState:
|
||||
DocumentLoadingState.finish(right(userProfile.asRight())),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final result = await _documentService.openDocument(view: view);
|
||||
result.fold(
|
||||
(documentData) {
|
||||
@ -82,6 +95,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingState: DocumentLoadingState.finish(left(unit)),
|
||||
userProfilePB: userProfile.asLeft(),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -142,12 +156,14 @@ class DocumentState with _$DocumentState {
|
||||
required DocumentLoadingState loadingState,
|
||||
required bool isDeleted,
|
||||
required bool forceClose,
|
||||
UserProfilePB? userProfilePB,
|
||||
}) = _DocumentState;
|
||||
|
||||
factory DocumentState.initial() => const DocumentState(
|
||||
loadingState: _Loading(),
|
||||
isDeleted: false,
|
||||
forceClose: false,
|
||||
userProfilePB: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,8 @@ import 'package:app_flowy/plugins/document/presentation/plugins/board/board_menu
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/grid/grid_menu_item.dart';
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
@ -83,6 +85,7 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
if (state.isDeleted) _renderBanner(context),
|
||||
// AppFlowy Editor
|
||||
_renderAppFlowyEditor(
|
||||
context,
|
||||
context.read<DocumentBloc>().editorState,
|
||||
),
|
||||
],
|
||||
@ -99,7 +102,11 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderAppFlowyEditor(EditorState editorState) {
|
||||
Widget _renderAppFlowyEditor(BuildContext context, EditorState editorState) {
|
||||
// enable open ai features if needed.
|
||||
final userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
|
||||
final openAIKey = userProfilePB?.openaiKey;
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final editor = AppFlowyEditor(
|
||||
editorState: editorState,
|
||||
@ -117,6 +124,8 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
kGridType: GridNodeWidgetBuilder(),
|
||||
// Card
|
||||
kCalloutType: CalloutNodeWidgetBuilder(),
|
||||
// Auto Generator,
|
||||
kAutoCompletionInputType: AutoCompletionInputBuilder(),
|
||||
},
|
||||
shortcutEvents: [
|
||||
// Divider
|
||||
@ -141,6 +150,10 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
gridMenuItem,
|
||||
// Callout
|
||||
calloutMenuItem,
|
||||
// AI
|
||||
if (openAIKey != null && openAIKey.isNotEmpty) ...[
|
||||
autoGeneratorMenuItem,
|
||||
]
|
||||
],
|
||||
themeData: theme.copyWith(extensions: [
|
||||
...theme.extensions.values,
|
||||
|
@ -0,0 +1,14 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'error.freezed.dart';
|
||||
part 'error.g.dart';
|
||||
|
||||
@freezed
|
||||
class OpenAIError with _$OpenAIError {
|
||||
const factory OpenAIError({
|
||||
String? code,
|
||||
required String message,
|
||||
}) = _OpenAIError;
|
||||
|
||||
factory OpenAIError.fromJson(Map<String, Object?> json) =>
|
||||
_$OpenAIErrorFromJson(json);
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'text_completion.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'error.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
// Please fill in your own API key
|
||||
const apiKey = '';
|
||||
|
||||
enum OpenAIRequestType {
|
||||
textCompletion,
|
||||
textEdit;
|
||||
|
||||
Uri get uri {
|
||||
switch (this) {
|
||||
case OpenAIRequestType.textCompletion:
|
||||
return Uri.parse('https://api.openai.com/v1/completions');
|
||||
case OpenAIRequestType.textEdit:
|
||||
return Uri.parse('https://api.openai.com/v1/edits');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class OpenAIRepository {
|
||||
/// Get completions from GPT-3
|
||||
///
|
||||
/// [prompt] is the prompt text
|
||||
/// [suffix] is the suffix text
|
||||
/// [maxTokens] is the maximum number of tokens to generate
|
||||
/// [temperature] is the temperature of the model
|
||||
///
|
||||
Future<Either<OpenAIError, TextCompletionResponse>> getCompletions({
|
||||
required String prompt,
|
||||
String? suffix,
|
||||
int maxTokens = 50,
|
||||
double temperature = .3,
|
||||
});
|
||||
}
|
||||
|
||||
class HttpOpenAIRepository implements OpenAIRepository {
|
||||
const HttpOpenAIRepository({
|
||||
required this.client,
|
||||
required this.apiKey,
|
||||
});
|
||||
|
||||
final http.Client client;
|
||||
final String apiKey;
|
||||
|
||||
Map<String, String> get headers => {
|
||||
'Authorization': 'Bearer $apiKey',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<Either<OpenAIError, TextCompletionResponse>> getCompletions({
|
||||
required String prompt,
|
||||
String? suffix,
|
||||
int maxTokens = 50,
|
||||
double temperature = 0.3,
|
||||
}) async {
|
||||
final parameters = {
|
||||
'model': 'text-davinci-003',
|
||||
'prompt': prompt,
|
||||
'suffix': suffix,
|
||||
'max_tokens': maxTokens,
|
||||
'temperature': temperature,
|
||||
'stream': false,
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
OpenAIRequestType.textCompletion.uri,
|
||||
headers: headers,
|
||||
body: json.encode(parameters),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return Right(TextCompletionResponse.fromJson(json.decode(response.body)));
|
||||
} else {
|
||||
return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'text_completion.freezed.dart';
|
||||
part 'text_completion.g.dart';
|
||||
|
||||
@freezed
|
||||
class TextCompletionChoice with _$TextCompletionChoice {
|
||||
factory TextCompletionChoice({
|
||||
required String text,
|
||||
required int index,
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(name: 'finish_reason') required String finishReason,
|
||||
}) = _TextCompletionChoice;
|
||||
|
||||
factory TextCompletionChoice.fromJson(Map<String, Object?> json) =>
|
||||
_$TextCompletionChoiceFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class TextCompletionResponse with _$TextCompletionResponse {
|
||||
const factory TextCompletionResponse({
|
||||
required List<TextCompletionChoice> choices,
|
||||
}) = _TextCompletionResponse;
|
||||
|
||||
factory TextCompletionResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$TextCompletionResponseFromJson(json);
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
enum TextRobotInputType {
|
||||
character,
|
||||
word,
|
||||
}
|
||||
|
||||
extension TextRobot on EditorState {
|
||||
Future<void> autoInsertText(
|
||||
String text, {
|
||||
TextRobotInputType inputType = TextRobotInputType.word,
|
||||
Duration delay = const Duration(milliseconds: 10),
|
||||
}) async {
|
||||
final lines = text.split('\n');
|
||||
for (final line in lines) {
|
||||
if (line.isEmpty) continue;
|
||||
switch (inputType) {
|
||||
case TextRobotInputType.character:
|
||||
final iterator = line.runes.iterator;
|
||||
while (iterator.moveNext()) {
|
||||
await insertTextAtCurrentSelection(
|
||||
iterator.currentAsString,
|
||||
);
|
||||
await Future.delayed(delay, () {});
|
||||
}
|
||||
break;
|
||||
case TextRobotInputType.word:
|
||||
final words = line.split(' ').map((e) => '$e ');
|
||||
for (final word in words) {
|
||||
await insertTextAtCurrentSelection(
|
||||
word,
|
||||
);
|
||||
await Future.delayed(delay, () {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// insert new line
|
||||
if (lines.length > 1) {
|
||||
await insertNewLineAtCurrentSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,344 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/loading.dart';
|
||||
import 'package:app_flowy/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/style_widget/text_field.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:app_flowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import '../util/editor_extension.dart';
|
||||
|
||||
const String kAutoCompletionInputType = 'auto_completion_input';
|
||||
const String kAutoCompletionInputString = 'auto_completion_input_string';
|
||||
const String kAutoCompletionInputStartSelection =
|
||||
'auto_completion_input_start_selection';
|
||||
|
||||
class AutoCompletionInputBuilder extends NodeWidgetBuilder<Node> {
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (node) {
|
||||
return node.attributes[kAutoCompletionInputString] is String;
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return _AutoCompletionInput(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AutoCompletionInput extends StatefulWidget {
|
||||
final Node node;
|
||||
|
||||
final EditorState editorState;
|
||||
const _AutoCompletionInput({
|
||||
Key? key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AutoCompletionInput> createState() => _AutoCompletionInputState();
|
||||
}
|
||||
|
||||
class _AutoCompletionInputState extends State<_AutoCompletionInput> {
|
||||
String get text => widget.node.attributes[kAutoCompletionInputString];
|
||||
|
||||
final controller = TextEditingController();
|
||||
final focusNode = FocusNode();
|
||||
final textFieldFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
focusNode.addListener(() {
|
||||
if (focusNode.hasFocus) {
|
||||
widget.editorState.service.selectionService.clearSelection();
|
||||
} else {
|
||||
widget.editorState.service.keyboardService?.enable();
|
||||
}
|
||||
});
|
||||
textFieldFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
|
||||
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: _buildAutoGeneratorPanel(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAutoGeneratorPanel(BuildContext context) {
|
||||
if (text.isEmpty) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeaderWidget(context),
|
||||
const Space(0, 10),
|
||||
_buildInputWidget(context),
|
||||
const Space(0, 10),
|
||||
_buildInputFooterWidget(context),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeaderWidget(context),
|
||||
const Space(0, 10),
|
||||
_buildFooterWidget(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHeaderWidget(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowyText.medium(
|
||||
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
|
||||
fontSize: 14,
|
||||
),
|
||||
const Spacer(),
|
||||
FlowyText.regular(
|
||||
LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputWidget(BuildContext context) {
|
||||
return RawKeyboardListener(
|
||||
focusNode: focusNode,
|
||||
onKey: (RawKeyEvent event) async {
|
||||
if (event is! RawKeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (controller.text.isNotEmpty) {
|
||||
textFieldFocusNode.unfocus();
|
||||
await _onGenerate();
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
await _onExit();
|
||||
}
|
||||
},
|
||||
child: FlowyTextField(
|
||||
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
|
||||
controller: controller,
|
||||
maxLines: 3,
|
||||
focusNode: textFieldFocusNode,
|
||||
autoFocus: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputFooterWidget(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowyRichTextButton(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.button_generate.tr()} ',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: '↵',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
), // FIXME: color
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () async => await _onGenerate(),
|
||||
),
|
||||
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,
|
||||
), // FIXME: color
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () async => await _onExit(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooterWidget(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// FIXME: l10n
|
||||
FlowyRichTextButton(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.button_keep.tr()} ',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () => _onExit(),
|
||||
),
|
||||
const Space(10, 0),
|
||||
FlowyRichTextButton(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.button_discard.tr()} ',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () => _onDiscard(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onExit() async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.deleteNode(widget.node);
|
||||
await widget.editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordRedo: false,
|
||||
recordUndo: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onGenerate() async {
|
||||
final loading = Loading(context);
|
||||
loading.start();
|
||||
await _updateEditingText();
|
||||
final result = await UserService.getCurrentUserProfile();
|
||||
result.fold((userProfile) async {
|
||||
final openAIRepository = HttpOpenAIRepository(
|
||||
client: http.Client(),
|
||||
apiKey: userProfile.openaiKey,
|
||||
);
|
||||
final completions = await openAIRepository.getCompletions(
|
||||
prompt: controller.text,
|
||||
);
|
||||
completions.fold((error) async {
|
||||
loading.stop();
|
||||
await _showError(error.message);
|
||||
}, (textCompletion) async {
|
||||
loading.stop();
|
||||
await _makeSurePreviousNodeIsEmptyTextNode();
|
||||
await widget.editorState.autoInsertText(
|
||||
textCompletion.choices.first.text,
|
||||
);
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
}, (error) async {
|
||||
loading.stop();
|
||||
await _showError(
|
||||
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onDiscard() async {
|
||||
final selection =
|
||||
widget.node.attributes[kAutoCompletionInputStartSelection];
|
||||
if (selection != null) {
|
||||
final start = Selection.fromJson(json.decode(selection)).start.path;
|
||||
final end = widget.node.previous?.path;
|
||||
if (end != null) {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.deleteNodesAtPath(
|
||||
start,
|
||||
end.last - start.last,
|
||||
);
|
||||
await widget.editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
_onExit();
|
||||
}
|
||||
|
||||
Future<void> _updateEditingText() async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(
|
||||
widget.node,
|
||||
{
|
||||
kAutoCompletionInputString: controller.text,
|
||||
},
|
||||
);
|
||||
await widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _makeSurePreviousNodeIsEmptyTextNode() async {
|
||||
// make sure the previous node is a empty text node without any styles.
|
||||
final transaction = widget.editorState.transaction;
|
||||
final Selection selection;
|
||||
if (widget.node.previous is! TextNode ||
|
||||
(widget.node.previous as TextNode).toPlainText().isNotEmpty ||
|
||||
(widget.node.previous as TextNode).subtype != null) {
|
||||
transaction.insertNode(
|
||||
widget.node.path,
|
||||
TextNode.empty(),
|
||||
);
|
||||
selection = Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
);
|
||||
transaction.afterSelection = selection;
|
||||
} else {
|
||||
selection = Selection.single(
|
||||
path: widget.node.path.previous,
|
||||
startOffset: 0,
|
||||
);
|
||||
transaction.afterSelection = selection;
|
||||
}
|
||||
transaction.updateNode(widget.node, {
|
||||
kAutoCompletionInputStartSelection: jsonEncode(selection.toJson()),
|
||||
});
|
||||
await widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _showError(String message) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
action: SnackBarAction(
|
||||
label: LocaleKeys.button_Cancel.tr(),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
content: FlowyText(message),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
|
||||
name: 'Auto Generator',
|
||||
iconData: Icons.generating_tokens,
|
||||
keywords: ['autogenerator', 'auto generator'],
|
||||
nodeBuilder: (editorState) {
|
||||
final node = Node(
|
||||
type: kAutoCompletionInputType,
|
||||
attributes: {
|
||||
kAutoCompletionInputString: '',
|
||||
},
|
||||
);
|
||||
return node;
|
||||
},
|
||||
replace: (_, textNode) => textNode.toPlainText().isEmpty,
|
||||
updateSelection: null,
|
||||
);
|
@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Loading {
|
||||
Loading(
|
||||
this.context,
|
||||
);
|
||||
|
||||
late BuildContext loadingContext;
|
||||
final BuildContext context;
|
||||
|
||||
Future<void> start() async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
loadingContext = context;
|
||||
return const SimpleDialog(
|
||||
elevation: 0.0,
|
||||
backgroundColor:
|
||||
Colors.transparent, // can change this to your prefered color
|
||||
children: <Widget>[
|
||||
Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
return Navigator.of(loadingContext).pop();
|
||||
}
|
||||
}
|
@ -7,12 +7,13 @@ import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
|
||||
class UserService {
|
||||
final String userId;
|
||||
UserService({
|
||||
required this.userId,
|
||||
});
|
||||
Future<Either<UserProfilePB, FlowyError>> getUserProfile(
|
||||
{required String userId}) {
|
||||
|
||||
final String userId;
|
||||
|
||||
static Future<Either<UserProfilePB, FlowyError>> getCurrentUserProfile() {
|
||||
return UserEventGetUserProfile().send();
|
||||
}
|
||||
|
||||
|
6
frontend/app_flowy/lib/util/either_extension.dart
Normal file
6
frontend/app_flowy/lib/util/either_extension.dart
Normal file
@ -0,0 +1,6 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
extension EitherX<L, R> on Either<L, R> {
|
||||
R asRight() => (this as Right).value;
|
||||
L asLeft() => (this as Left).value;
|
||||
}
|
@ -43,7 +43,7 @@ class SettingsUserViewBloc extends Bloc<SettingsUserEvent, SettingsUserState> {
|
||||
);
|
||||
});
|
||||
},
|
||||
updateUserOpenaiKey: (openAIKey) {
|
||||
updateUserOpenAIKey: (openAIKey) {
|
||||
_userService.updateUserProfile(openAIKey: openAIKey).then((result) {
|
||||
result.fold(
|
||||
(l) => null,
|
||||
@ -81,7 +81,7 @@ class SettingsUserEvent with _$SettingsUserEvent {
|
||||
const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName;
|
||||
const factory SettingsUserEvent.updateUserIcon(String iconUrl) =
|
||||
_UpdateUserIcon;
|
||||
const factory SettingsUserEvent.updateUserOpenaiKey(String openAIKey) =
|
||||
const factory SettingsUserEvent.updateUserOpenAIKey(String openAIKey) =
|
||||
_UpdateUserOpenaiKey;
|
||||
const factory SettingsUserEvent.didReceiveUserProfile(
|
||||
UserProfilePB newUserProfile) = _DidReceiveUserProfile;
|
||||
|
@ -85,26 +85,46 @@ class UserNameInput extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenaiKeyInput extends StatelessWidget {
|
||||
class _OpenaiKeyInput extends StatefulWidget {
|
||||
final String openAIKey;
|
||||
const _OpenaiKeyInput(
|
||||
this.openAIKey, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_OpenaiKeyInput> createState() => _OpenaiKeyInputState();
|
||||
}
|
||||
|
||||
class _OpenaiKeyInputState extends State<_OpenaiKeyInput> {
|
||||
bool visible = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: TextEditingController()..text = openAIKey,
|
||||
controller: TextEditingController()..text = widget.openAIKey,
|
||||
obscureText: !visible,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Openai Key',
|
||||
labelText: 'OpenAI Key',
|
||||
hintText: LocaleKeys.settings_user_pleaseInputYourOpenAIKey.tr(),
|
||||
suffixIcon: IconButton(
|
||||
iconSize: 15.0,
|
||||
icon: Icon(visible ? Icons.visibility : Icons.visibility_off),
|
||||
padding: EdgeInsets.zero,
|
||||
hoverColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
visible = !visible;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) {
|
||||
// TODO: validate key
|
||||
context
|
||||
.read<SettingsUserViewBloc>()
|
||||
.add(SettingsUserEvent.updateUserOpenaiKey(val));
|
||||
.add(SettingsUserEvent.updateUserOpenAIKey(val));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -47,3 +47,4 @@ export 'src/render/toolbar/toolbar_item.dart';
|
||||
export 'src/extensions/node_extensions.dart';
|
||||
export 'src/render/action_menu/action_menu.dart';
|
||||
export 'src/render/action_menu/action_menu_item.dart';
|
||||
export 'src/core/document/node_iterator.dart';
|
||||
|
@ -9,6 +9,15 @@ class Position {
|
||||
this.offset = 0,
|
||||
});
|
||||
|
||||
factory Position.fromJson(Map<String, dynamic> json) {
|
||||
final path = Path.from(json['path'] as List);
|
||||
final offset = json['offset'];
|
||||
return Position(
|
||||
path: path,
|
||||
offset: offset ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
@ -15,6 +15,13 @@ class Selection {
|
||||
required this.end,
|
||||
});
|
||||
|
||||
factory Selection.fromJson(Map<String, dynamic> json) {
|
||||
return Selection(
|
||||
start: Position.fromJson(json['start']),
|
||||
end: Position.fromJson(json['end']),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a selection with [Path], [startOffset] and [endOffset].
|
||||
///
|
||||
/// The [endOffset] is optional.
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/infra/clipboard.dart';
|
||||
import 'package:appflowy_editor/src/infra/html_converter.dart';
|
||||
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() async {
|
||||
|
@ -203,3 +203,93 @@ class FlowyTextButton extends StatelessWidget {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class FlowyRichTextButton extends StatelessWidget {
|
||||
final InlineSpan text;
|
||||
final TextOverflow overflow;
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final EdgeInsets padding;
|
||||
final Widget? heading;
|
||||
final Color? hoverColor;
|
||||
final Color? fillColor;
|
||||
final BorderRadius? radius;
|
||||
final MainAxisAlignment mainAxisAlignment;
|
||||
final String? tooltip;
|
||||
final BoxConstraints constraints;
|
||||
|
||||
final TextDecoration? decoration;
|
||||
|
||||
// final HoverDisplayConfig? hoverDisplay;
|
||||
const FlowyRichTextButton(
|
||||
this.text, {
|
||||
Key? key,
|
||||
this.onPressed,
|
||||
this.overflow = TextOverflow.ellipsis,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
this.hoverColor,
|
||||
this.fillColor,
|
||||
this.heading,
|
||||
this.radius,
|
||||
this.mainAxisAlignment = MainAxisAlignment.start,
|
||||
this.tooltip,
|
||||
this.constraints = const BoxConstraints(minWidth: 58.0, minHeight: 30.0),
|
||||
this.decoration,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<Widget> children = [];
|
||||
if (heading != null) {
|
||||
children.add(heading!);
|
||||
children.add(const HSpace(6));
|
||||
}
|
||||
children.add(
|
||||
RichText(
|
||||
text: text,
|
||||
overflow: overflow,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
|
||||
Widget child = Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: mainAxisAlignment,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
|
||||
child = RawMaterialButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
hoverElevation: 0,
|
||||
highlightElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border),
|
||||
fillColor: fillColor ?? Theme.of(context).colorScheme.secondaryContainer,
|
||||
hoverColor: hoverColor ?? Theme.of(context).colorScheme.secondary,
|
||||
focusColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
constraints: constraints,
|
||||
onPressed: () {},
|
||||
child: child,
|
||||
);
|
||||
|
||||
child = IgnoreParentGestureWidget(
|
||||
onPress: onPressed,
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (tooltip != null) {
|
||||
child = Tooltip(
|
||||
message: tooltip!,
|
||||
textStyle: AFThemeExtension.of(context).caption.textColor(Colors.white),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ class FlowyTextField extends StatefulWidget {
|
||||
final bool submitOnLeave;
|
||||
final Duration? debounceDuration;
|
||||
final String? errorText;
|
||||
final int maxLines;
|
||||
|
||||
const FlowyTextField({
|
||||
this.hintText = "",
|
||||
@ -36,6 +37,7 @@ class FlowyTextField extends StatefulWidget {
|
||||
this.submitOnLeave = false,
|
||||
this.debounceDuration,
|
||||
this.errorText,
|
||||
this.maxLines = 1,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@ -103,7 +105,7 @@ class FlowyTextFieldState extends State<FlowyTextField> {
|
||||
},
|
||||
onSubmitted: (text) => _onSubmitted(text),
|
||||
onEditingComplete: widget.onEditingComplete,
|
||||
maxLines: 1,
|
||||
maxLines: widget.maxLines,
|
||||
maxLength: widget.maxLength,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
|
@ -601,7 +601,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.15.1"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
url: "https://pub.dartlang.org"
|
||||
@ -662,12 +662,19 @@ packages:
|
||||
source: hosted
|
||||
version: "0.6.4"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
version: "4.7.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.5.4"
|
||||
linked_scroll_controller:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1128,6 +1135,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.6"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -93,6 +93,8 @@ dependencies:
|
||||
path: packages/appflowy_editor_plugins
|
||||
calendar_view: ^1.0.1
|
||||
window_manager: ^0.3.0
|
||||
http: ^0.13.5
|
||||
json_annotation: ^4.7.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.1
|
||||
@ -104,6 +106,7 @@ dev_dependencies:
|
||||
build_runner: ^2.2.0
|
||||
freezed: ^2.1.0+1
|
||||
bloc_test: ^9.0.2
|
||||
json_serializable: ^6.5.4
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
|
Loading…
Reference in New Issue
Block a user