diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart new file mode 100644 index 0000000000..2c1ec6f1f8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:dartz/dartz.dart' hide State; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class OpenAIImageWidget extends StatefulWidget { + const OpenAIImageWidget({ + super.key, + required this.onSelectNetworkImage, + }); + + final void Function(String url) onSelectNetworkImage; + + @override + State createState() => _OpenAIImageWidgetState(); +} + +class _OpenAIImageWidgetState extends State { + Future>>? future; + String query = ''; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: FlowyTextField( + autoFocus: true, + hintText: LocaleKeys.document_imageBlock_ai_placeholder.tr(), + onChanged: (value) => query = value, + onEditingComplete: _search, + ), + ), + const HSpace(4.0), + FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.search_label.tr(), + ), + onTap: _search, + ), + ], + ), + const VSpace(12.0), + if (future != null) + Expanded( + child: FutureBuilder( + future: future, + builder: (context, value) { + final data = value.data; + if (!value.hasData || + value.connectionState != ConnectionState.done || + data == null) { + return const CircularProgressIndicator.adaptive(); + } + return data.fold( + (l) => Center( + child: FlowyText( + l.message, + maxLines: 3, + textAlign: TextAlign.center, + ), + ), + (r) => GridView.count( + crossAxisCount: 3, + mainAxisSpacing: 16.0, + crossAxisSpacing: 10.0, + childAspectRatio: 4 / 3, + children: r + .map( + (e) => GestureDetector( + onTap: () => widget.onSelectNetworkImage(e), + child: Image.network(e), + ), + ) + .toList(), + ), + ); + }, + ), + ) + ], + ); + } + + void _search() async { + final openAI = await getIt.getAsync(); + setState(() { + future = openAI.generateImage( + prompt: query, + n: 6, + ); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart new file mode 100644 index 0000000000..7488fb0905 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:dartz/dartz.dart' hide State; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class StabilityAIImageWidget extends StatefulWidget { + const StabilityAIImageWidget({ + super.key, + required this.onSelectImage, + }); + + final void Function(String url) onSelectImage; + + @override + State createState() => _StabilityAIImageWidgetState(); +} + +class _StabilityAIImageWidgetState extends State { + Future>>? future; + String query = ''; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: FlowyTextField( + autoFocus: true, + hintText: LocaleKeys + .document_imageBlock_stability_ai_placeholder + .tr(), + onChanged: (value) => query = value, + onEditingComplete: _search, + ), + ), + const HSpace(4.0), + FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.search_label.tr(), + ), + onTap: _search, + ), + ], + ), + const VSpace(12.0), + if (future != null) + Expanded( + child: FutureBuilder( + future: future, + builder: (context, value) { + final data = value.data; + if (!value.hasData || + value.connectionState != ConnectionState.done || + data == null) { + return const CircularProgressIndicator.adaptive(); + } + return data.fold( + (l) => Center( + child: FlowyText( + l.message, + maxLines: 3, + textAlign: TextAlign.center, + ), + ), + (r) => GridView.count( + crossAxisCount: 3, + mainAxisSpacing: 16.0, + crossAxisSpacing: 10.0, + childAspectRatio: 4 / 3, + children: r.map( + (e) { + final base64Image = base64Decode(e); + return GestureDetector( + onTap: () async { + final tempDirectory = await getTemporaryDirectory(); + final path = p.join( + tempDirectory.path, + '${uuid()}.png', + ); + File(path).writeAsBytesSync(base64Image); + widget.onSelectImage(path); + }, + child: Image.memory(base64Image), + ); + }, + ).toList(), + ), + ); + }, + ), + ) + ], + ); + } + + void _search() async { + final stabilityAI = await getIt.getAsync(); + setState(() { + future = stabilityAI.generateImage( + prompt: query, + n: 6, + ); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart index eaac7fd9c4..82546b85be 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart @@ -1,7 +1,10 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart'; +import 'package:appflowy/user/application/user_service.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; @@ -11,7 +14,8 @@ enum UploadImageType { local, url, unsplash, - ai; + stabilityAI, + openAI; String get description { switch (this) { @@ -21,8 +25,10 @@ enum UploadImageType { return LocaleKeys.document_imageBlock_embedLink_label.tr(); case UploadImageType.unsplash: return 'Unsplash'; - case UploadImageType.ai: - return 'Generate from AI'; + case UploadImageType.openAI: + return LocaleKeys.document_imageBlock_ai_label.tr(); + case UploadImageType.stabilityAI: + return LocaleKeys.document_imageBlock_stability_ai_label.tr(); } } } @@ -43,11 +49,39 @@ class UploadImageMenu extends StatefulWidget { class _UploadImageMenuState extends State { int currentTabIndex = 0; + List values = UploadImageType.values; + bool supportOpenAI = false; + bool supportStabilityAI = false; + + @override + void initState() { + super.initState(); + + UserBackendService.getCurrentUserProfile().then( + (value) { + final supportOpenAI = value.fold( + (l) => false, + (r) => r.openaiKey.isNotEmpty, + ); + final supportStabilityAI = value.fold( + (l) => false, + (r) => r.stabilityAiKey.isNotEmpty, + ); + if (supportOpenAI != this.supportOpenAI || + supportStabilityAI != this.supportStabilityAI) { + setState(() { + this.supportOpenAI = supportOpenAI; + this.supportStabilityAI = supportStabilityAI; + }); + } + }, + ); + } @override Widget build(BuildContext context) { return DefaultTabController( - length: 3, // UploadImageType.values.length, // ai is not implemented yet + length: values.length, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -62,10 +96,7 @@ class _UploadImageMenuState extends State { ), padding: EdgeInsets.zero, // splashBorderRadius: BorderRadius.circular(4), - tabs: UploadImageType.values - .where( - (element) => element != UploadImageType.ai, - ) // ai is not implemented yet + tabs: values .map( (e) => FlowyHover( style: const HoverStyle(borderRadius: BorderRadius.zero), @@ -115,8 +146,39 @@ class _UploadImageMenuState extends State { ), ), ); - case UploadImageType.ai: - return const FlowyText.medium('ai'); + case UploadImageType.openAI: + return supportOpenAI + ? Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: OpenAIImageWidget( + onSelectNetworkImage: widget.onSubmit, + ), + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyText( + LocaleKeys.document_imageBlock_pleaseInputYourOpenAIKey.tr(), + ), + ); + case UploadImageType.stabilityAI: + return supportStabilityAI + ? Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: StabilityAIImageWidget( + onSelectImage: widget.onPickFile, + ), + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyText( + LocaleKeys.document_imageBlock_pleaseInputYourStabilityAIKey + .tr(), + ), + ); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart index 124c11206a..71dfefe16b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart @@ -1,20 +1,20 @@ +import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_edit.dart'; - -import 'text_completion.dart'; import 'package:dartz/dartz.dart'; -import 'dart:async'; +import 'package:http/http.dart' as http; import 'error.dart'; -import 'package:http/http.dart' as http; +import 'text_completion.dart'; // Please fill in your own API key const apiKey = ''; enum OpenAIRequestType { textCompletion, - textEdit; + textEdit, + imageGenerations; Uri get uri { switch (this) { @@ -22,6 +22,8 @@ enum OpenAIRequestType { return Uri.parse('https://api.openai.com/v1/completions'); case OpenAIRequestType.textEdit: return Uri.parse('https://api.openai.com/v1/edits'); + case OpenAIRequestType.imageGenerations: + return Uri.parse('https://api.openai.com/v1/images/generations'); } } } @@ -64,6 +66,17 @@ abstract class OpenAIRepository { required String instruction, double temperature = 0.3, }); + + /// Generate image from GPT-3 + /// + /// [prompt] is the prompt text + /// [n] is the number of images to generate + /// + /// the result is a list of urls + Future>> generateImage({ + required String prompt, + int n = 1, + }); } class HttpOpenAIRepository implements OpenAIRepository { @@ -228,4 +241,40 @@ class HttpOpenAIRepository implements OpenAIRepository { return Left(OpenAIError.fromJson(json.decode(response.body)['error'])); } } + + @override + Future>> generateImage({ + required String prompt, + int n = 1, + }) async { + final parameters = { + 'prompt': prompt, + 'n': n, + 'size': '512x512', + }; + + try { + final response = await client.post( + OpenAIRequestType.imageGenerations.uri, + headers: headers, + body: json.encode(parameters), + ); + + if (response.statusCode == 200) { + final data = json.decode( + utf8.decode(response.bodyBytes), + )['data'] as List; + final urls = data + .map((e) => e.values) + .expand((e) => e) + .map((e) => e.toString()) + .toList(); + return Right(urls); + } else { + return Left(OpenAIError.fromJson(json.decode(response.body)['error'])); + } + } catch (error) { + return Left(OpenAIError(message: error.toString())); + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart new file mode 100644 index 0000000000..7cae9c11b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart'; +import 'package:dartz/dartz.dart'; +import 'package:http/http.dart' as http; + +enum StabilityAIRequestType { + imageGenerations; + + Uri get uri { + switch (this) { + case StabilityAIRequestType.imageGenerations: + return Uri.parse( + 'https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image', + ); + } + } +} + +abstract class StabilityAIRepository { + /// Generate image from Stability AI + /// + /// [prompt] is the prompt text + /// [n] is the number of images to generate + /// + /// the return value is a list of base64 encoded images + Future>> generateImage({ + required String prompt, + int n = 1, + }); +} + +class HttpStabilityAIRepository implements StabilityAIRepository { + const HttpStabilityAIRepository({ + required this.client, + required this.apiKey, + }); + + final http.Client client; + final String apiKey; + + Map get headers => { + 'Authorization': 'Bearer $apiKey', + 'Content-Type': 'application/json', + }; + + @override + Future>> generateImage({ + required String prompt, + int n = 1, + }) async { + final parameters = { + 'text_prompts': [ + { + 'text': prompt, + } + ], + 'samples': n, + }; + + try { + final response = await client.post( + StabilityAIRequestType.imageGenerations.uri, + headers: headers, + body: json.encode(parameters), + ); + + final data = json.decode( + utf8.decode(response.bodyBytes), + ); + if (response.statusCode == 200) { + final artifacts = data['artifacts'] as List; + final base64Images = artifacts + .map( + (e) => e['base64'].toString(), + ) + .toList(); + return Right(base64Images); + } else { + return Left( + StabilityAIRequestError( + data['message'].toString(), + ), + ); + } + } catch (error) { + return Left( + StabilityAIRequestError( + error.toString(), + ), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart new file mode 100644 index 0000000000..d99a2f20b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart @@ -0,0 +1,10 @@ +class StabilityAIRequestError { + final String message; + + StabilityAIRequestError(this.message); + + @override + String toString() { + return 'StabilityAIRequestError{message: $message}'; + } +} diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 2d76751455..34d138af4b 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -9,12 +9,13 @@ import 'package:appflowy/plugins/database_view/grid/application/grid_header_bloc import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/af_cloud_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/user/application/auth/supabase_mock_auth_service.dart'; import 'package:appflowy/user/application/auth/supabase_auth_service.dart'; +import 'package:appflowy/user/application/auth/supabase_mock_auth_service.dart'; import 'package:appflowy/user/application/prelude.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/user_listener.dart'; @@ -85,6 +86,23 @@ void _resolveCommonService( }, ); + getIt.registerFactoryAsync( + () async { + final result = await UserBackendService.getCurrentUserProfile(); + return result.fold( + (l) { + throw Exception('Failed to get user profile: ${l.msg}'); + }, + (r) { + return HttpStabilityAIRepository( + client: http.Client(), + apiKey: r.stabilityAiKey, + ); + }, + ); + }, + ); + getIt.registerFactory( () => ClipboardService(), ); diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index c498d0b8c5..ad9ee477a6 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:dartz/dartz.dart'; import 'package:fixnum/fixnum.dart'; class UserBackendService { @@ -26,6 +26,7 @@ class UserBackendService { String? email, String? iconUrl, String? openAIKey, + String? stabilityAiKey, }) { final payload = UpdateUserProfilePayloadPB.create()..id = userId; @@ -49,6 +50,10 @@ class UserBackendService { payload.openaiKey = openAIKey; } + if (stabilityAiKey != null) { + payload.stabilityAiKey = stabilityAiKey; + } + return UserEventUpdateUserProfile(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart index 806b244244..a7c7c3fe56 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart @@ -3,9 +3,9 @@ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:dartz/dartz.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:dartz/dartz.dart'; part 'settings_user_bloc.freezed.dart'; @@ -60,6 +60,16 @@ class SettingsUserViewBloc extends Bloc { ); }); }, + updateUserStabilityAIKey: (stabilityAIKey) { + _userService + .updateUserProfile(stabilityAiKey: stabilityAIKey) + .then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, didLoadHistoricalUsers: (List historicalUsers) { emit(state.copyWith(historicalUsers: historicalUsers)); }, @@ -119,6 +129,9 @@ class SettingsUserEvent with _$SettingsUserEvent { const factory SettingsUserEvent.removeUserIcon() = _RemoveUserIcon; const factory SettingsUserEvent.updateUserOpenAIKey(String openAIKey) = _UpdateUserOpenaiKey; + const factory SettingsUserEvent.updateUserStabilityAIKey( + String stabilityAIKey, + ) = _UpdateUserStabilityAIKey; const factory SettingsUserEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart index acfbb1cccc..ffc004cd36 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart @@ -61,6 +61,8 @@ class SettingsUserView extends StatelessWidget { const VSpace(12), _renderCurrentOpenaiKey(context), const VSpace(12), + _renderCurrentStabilityAIKey(context), + const VSpace(12), _renderLoginOrLogoutButton(context, state), const VSpace(12), ], @@ -207,9 +209,29 @@ class SettingsUserView extends StatelessWidget { } Widget _renderCurrentOpenaiKey(BuildContext context) { - final String openAIKey = + final String accessKey = context.read().state.userProfile.openaiKey; - return _OpenaiKeyInput(openAIKey); + return _AIAccessKeyInput( + accessKey: accessKey, + title: 'OpenAI Key', + hintText: LocaleKeys.settings_user_pleaseInputYourOpenAIKey.tr(), + callback: (key) => context + .read() + .add(SettingsUserEvent.updateUserOpenAIKey(key)), + ); + } + + Widget _renderCurrentStabilityAIKey(BuildContext context) { + final String accessKey = + context.read().state.userProfile.stabilityAiKey; + return _AIAccessKeyInput( + accessKey: accessKey, + title: 'Stability AI Key', + hintText: LocaleKeys.settings_user_pleaseInputYourStabilityAIKey.tr(), + callback: (key) => context + .read() + .add(SettingsUserEvent.updateUserStabilityAIKey(key)), + ); } Widget _avatarOverlay({ @@ -379,18 +401,24 @@ class UserEmailInputState extends State { } } -class _OpenaiKeyInput extends StatefulWidget { - final String openAIKey; - const _OpenaiKeyInput( - this.openAIKey, { - Key? key, - }) : super(key: key); +class _AIAccessKeyInput extends StatefulWidget { + const _AIAccessKeyInput({ + required this.accessKey, + required this.title, + required this.hintText, + required this.callback, + }); + + final String accessKey; + final String title; + final String hintText; + final void Function(String key) callback; @override - State<_OpenaiKeyInput> createState() => _OpenaiKeyInputState(); + State<_AIAccessKeyInput> createState() => _AIAccessKeyInputState(); } -class _OpenaiKeyInputState extends State<_OpenaiKeyInput> { +class _AIAccessKeyInputState extends State<_AIAccessKeyInput> { bool visible = false; final textEditingController = TextEditingController(); final debounce = Debounce(); @@ -399,7 +427,7 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> { void initState() { super.initState(); - textEditingController.text = widget.openAIKey; + textEditingController.text = widget.accessKey; } @override @@ -415,12 +443,12 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> { focusedBorder: UnderlineInputBorder( borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), ), - labelText: 'OpenAI Key', + labelText: widget.title, labelStyle: Theme.of(context) .textTheme .titleMedium! .copyWith(fontWeight: FontWeight.w500), - hintText: LocaleKeys.settings_user_pleaseInputYourOpenAIKey.tr(), + hintText: widget.hintText, suffixIcon: FlowyIconButton( width: 40, height: 40, @@ -437,9 +465,7 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> { ), onChanged: (value) { debounce.call(() { - context - .read() - .add(SettingsUserEvent.updateUserOpenAIKey(value)); + widget.callback(value); }); }, ); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 0c431f758e..34722840f6 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -54,8 +54,8 @@ packages: dependency: "direct main" description: path: "." - ref: af8d96b - resolved-ref: af8d96bc1aab07046f4febdd991e1787c75c6e38 + ref: "0abcf7f" + resolved-ref: "0abcf7f6d273b838c895abdc17f6833540613729" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "1.4.3" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 9facd8c57c..0d608c8c67 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -47,7 +47,7 @@ dependencies: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: 'af8d96b' + ref: "0abcf7f" appflowy_popover: path: packages/appflowy_popover @@ -134,7 +134,6 @@ dev_dependencies: url_launcher_platform_interface: any run_with_network_images: ^0.0.1 - dependency_overrides: http: ^1.0.0 diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index f070feaed7..3fccb61e65 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -353,6 +353,7 @@ "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", "pleaseInputYourOpenAIKey": "please input your OpenAI key", + "pleaseInputYourStabilityAIKey": "please input your Stability AI key", "clickToLogout": "Click to logout the current user" }, "shortcuts": { @@ -652,6 +653,14 @@ "label": "Image URL", "placeholder": "Enter image URL" }, + "ai": { + "label": "Generate image from OpenAI", + "placeholder": "Please input the prompt for OpenAI to generate image" + }, + "stability_ai": { + "label": "Generate image from Stability AI", + "placeholder": "Please input the prompt for Stability AI to generate image" + }, "support": "Image size limit is 5MB. Supported formats: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Invalid image", @@ -663,7 +672,9 @@ "label": "Embed link", "placeholder": "Paste or type an image link" }, - "searchForAnImage": "Search for an image" + "searchForAnImage": "Search for an image", + "pleaseInputYourOpenAIKey": "please input your OpenAI key in Settings page", + "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page" }, "codeBlock": { "language": { diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user.rs index f97e062e46..28d0de01d8 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user.rs @@ -116,6 +116,7 @@ where token: token_from_client(client).await.unwrap_or("".to_string()), icon_url: "".to_owned(), openai_key: "".to_owned(), + stability_ai_key: "".to_owned(), workspace_id: match profile.latest_workspace_id { Some(w) => w.to_string(), None => "".to_string(), diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs index 13d63e9cf1..a380658db7 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs @@ -215,6 +215,7 @@ where token: "".to_string(), icon_url: "".to_string(), openai_key: "".to_string(), + stability_ai_key: "".to_string(), workspace_id: response.latest_workspace_id, auth_type: AuthType::Supabase, encryption_type: EncryptionType::from_sign(&response.encryption_sign), diff --git a/frontend/rust-lib/flowy-server/tests/supabase_test/user_test.rs b/frontend/rust-lib/flowy-server/tests/supabase_test/user_test.rs index 18e92fe4af..6bb044b322 100644 --- a/frontend/rust-lib/flowy-server/tests/supabase_test/user_test.rs +++ b/frontend/rust-lib/flowy-server/tests/supabase_test/user_test.rs @@ -64,6 +64,7 @@ async fn supabase_update_user_profile_test() { password: None, icon_url: None, openai_key: None, + stability_ai_key: None, encryption_sign: None, }, ) diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/down.sql new file mode 100644 index 0000000000..35c705de46 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE user_table +DROP COLUMN stability_ai_key; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/up.sql new file mode 100644 index 0000000000..80b41baa4b --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +ALTER TABLE user_table +ADD COLUMN stability_ai_key TEXT NOT NULL DEFAULT ""; diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 02757bdeb1..e10785f9cf 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -31,6 +31,7 @@ diesel::table! { email -> Text, auth_type -> Integer, encryption_type -> Text, + stability_ai_key -> Text, } } diff --git a/frontend/rust-lib/flowy-test/tests/user/local_test/user_profile_test.rs b/frontend/rust-lib/flowy-test/tests/user/local_test/user_profile_test.rs index 43f7cdf325..4e62998dc9 100644 --- a/frontend/rust-lib/flowy-test/tests/user/local_test/user_profile_test.rs +++ b/frontend/rust-lib/flowy-test/tests/user/local_test/user_profile_test.rs @@ -47,6 +47,29 @@ async fn user_update_with_name() { assert_eq!(user_profile.name, new_name,); } +#[tokio::test] +async fn user_update_with_ai_key() { + let sdk = FlowyCoreTest::new(); + let user = sdk.init_user().await; + let openai_key = "openai_key".to_owned(); + let stability_ai_key = "stability_ai_key".to_owned(); + let request = UpdateUserProfilePayloadPB::new(user.id) + .openai_key(&openai_key) + .stability_ai_key(&stability_ai_key); + let _ = EventBuilder::new(sdk.clone()) + .event(UpdateUserProfile) + .payload(request) + .sync_send(); + + let user_profile = EventBuilder::new(sdk.clone()) + .event(GetUserProfile) + .sync_send() + .parse::(); + + assert_eq!(user_profile.openai_key, openai_key,); + assert_eq!(user_profile.stability_ai_key, stability_ai_key,); +} + #[tokio::test] async fn user_update_with_email() { let sdk = FlowyCoreTest::new(); diff --git a/frontend/rust-lib/flowy-user-deps/src/entities.rs b/frontend/rust-lib/flowy-user-deps/src/entities.rs index 1c32dfb83c..1265343f5c 100644 --- a/frontend/rust-lib/flowy-user-deps/src/entities.rs +++ b/frontend/rust-lib/flowy-user-deps/src/entities.rs @@ -191,6 +191,7 @@ pub struct UserProfile { pub token: String, pub icon_url: String, pub openai_key: String, + pub stability_ai_key: String, pub workspace_id: String, pub auth_type: AuthType, // If the encryption_sign is not empty, which means the user has enabled the encryption. @@ -252,6 +253,7 @@ where workspace_id: value.latest_workspace().id.to_owned(), auth_type: auth_type.clone(), encryption_type: value.encryption_type(), + stability_ai_key: "".to_owned(), } } } @@ -264,6 +266,7 @@ pub struct UpdateUserProfileParams { pub password: Option, pub icon_url: Option, pub openai_key: Option, + pub stability_ai_key: Option, pub encryption_sign: Option, } @@ -300,6 +303,11 @@ impl UpdateUserProfileParams { self } + pub fn with_stability_ai_key(mut self, stability_ai_key: &str) -> Self { + self.stability_ai_key = Some(stability_ai_key.to_owned()); + self + } + pub fn with_encryption_type(mut self, encryption_type: EncryptionType) -> Self { let sign = match encryption_type { EncryptionType::NoEncryption => "".to_string(), @@ -316,6 +324,7 @@ impl UpdateUserProfileParams { && self.icon_url.is_none() && self.openai_key.is_none() && self.encryption_sign.is_none() + && self.stability_ai_key.is_none() } } diff --git a/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs b/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs index a31d917b4a..814f8b81da 100644 --- a/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs +++ b/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs @@ -4,6 +4,7 @@ pub use user_id::*; pub use user_name::*; pub use user_openai_key::*; pub use user_password::*; +pub use user_stability_ai_key::*; // https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ mod user_email; @@ -12,3 +13,4 @@ mod user_id; mod user_name; mod user_openai_key; mod user_password; +mod user_stability_ai_key; diff --git a/frontend/rust-lib/flowy-user/src/entities/parser/user_stability_ai_key.rs b/frontend/rust-lib/flowy-user/src/entities/parser/user_stability_ai_key.rs new file mode 100644 index 0000000000..49826c90ea --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/entities/parser/user_stability_ai_key.rs @@ -0,0 +1,16 @@ +use flowy_error::ErrorCode; + +#[derive(Debug)] +pub struct UserStabilityAIKey(pub String); + +impl UserStabilityAIKey { + pub fn parse(s: String) -> Result { + Ok(Self(s)) + } +} + +impl AsRef for UserStabilityAIKey { + fn as_ref(&self) -> &str { + &self.0 + } +} diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index d37ea79102..99275e0350 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -8,6 +8,8 @@ use crate::entities::AuthTypePB; use crate::errors::ErrorCode; use crate::services::entities::HistoricalUser; +use super::parser::UserStabilityAIKey; + #[derive(Default, ProtoBuf)] pub struct UserTokenPB { #[pb(index = 1)] @@ -51,6 +53,9 @@ pub struct UserProfilePB { #[pb(index = 10)] pub workspace_id: String, + + #[pb(index = 11)] + pub stability_ai_key: String, } #[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] @@ -82,6 +87,7 @@ impl std::convert::From for UserProfilePB { encryption_sign, encryption_type: encryption_ty, workspace_id: user_profile.workspace_id, + stability_ai_key: user_profile.stability_ai_key, } } } @@ -105,6 +111,9 @@ pub struct UpdateUserProfilePayloadPB { #[pb(index = 6, one_of)] pub openai_key: Option, + + #[pb(index = 7, one_of)] + pub stability_ai_key: Option, } impl UpdateUserProfilePayloadPB { @@ -139,6 +148,11 @@ impl UpdateUserProfilePayloadPB { self.openai_key = Some(openai_key.to_owned()); self } + + pub fn stability_ai_key(mut self, stability_ai_key: &str) -> Self { + self.stability_ai_key = Some(stability_ai_key.to_owned()); + self + } } impl TryInto for UpdateUserProfilePayloadPB { @@ -170,6 +184,11 @@ impl TryInto for UpdateUserProfilePayloadPB { Some(openai_key) => Some(UserOpenaiKey::parse(openai_key)?.0), }; + let stability_ai_key = match self.stability_ai_key { + None => None, + Some(stability_ai_key) => Some(UserStabilityAIKey::parse(stability_ai_key)?.0), + }; + Ok(UpdateUserProfileParams { uid: self.id, name, @@ -178,6 +197,7 @@ impl TryInto for UpdateUserProfilePayloadPB { icon_url, openai_key, encryption_sign: None, + stability_ai_key, }) } } diff --git a/frontend/rust-lib/flowy-user/src/services/user_sql.rs b/frontend/rust-lib/flowy-user/src/services/user_sql.rs index b5ae98c222..7326c6f507 100644 --- a/frontend/rust-lib/flowy-user/src/services/user_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/user_sql.rs @@ -18,6 +18,7 @@ pub struct UserTable { pub(crate) email: String, pub(crate) auth_type: i32, pub(crate) encryption_type: String, + pub(crate) stability_ai_key: String, } impl UserTable { @@ -41,6 +42,7 @@ impl From<(UserProfile, AuthType)> for UserTable { email: user_profile.email, auth_type: auth_type as i32, encryption_type, + stability_ai_key: user_profile.stability_ai_key, } } } @@ -57,6 +59,7 @@ impl From for UserProfile { workspace_id: table.workspace, auth_type: AuthType::from(table.auth_type), encryption_type: EncryptionType::from_str(&table.encryption_type).unwrap_or_default(), + stability_ai_key: table.stability_ai_key, } } } @@ -71,6 +74,7 @@ pub struct UserTableChangeset { pub icon_url: Option, pub openai_key: Option, pub encryption_type: Option, + pub stability_ai_key: Option, } impl UserTableChangeset { @@ -87,6 +91,7 @@ impl UserTableChangeset { icon_url: params.icon_url, openai_key: params.openai_key, encryption_type, + stability_ai_key: params.stability_ai_key, } } @@ -100,6 +105,7 @@ impl UserTableChangeset { icon_url: Some(user_profile.icon_url), openai_key: Some(user_profile.openai_key), encryption_type: Some(encryption_type), + stability_ai_key: Some(user_profile.stability_ai_key), } } }