From e81084deb402eee7d63abe24d88c17d6716a8264 Mon Sep 17 00:00:00 2001 From: mylxsw Date: Tue, 14 Nov 2023 10:08:30 +0800 Subject: [PATCH 1/6] =?UTF-8?q?API=20Keys=20=E7=AE=A1=E7=90=86=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bloc/user_api_keys_bloc.dart | 39 ++++ lib/bloc/user_api_keys_event.dart | 24 ++ lib/bloc/user_api_keys_state.dart | 24 ++ lib/helper/constant.dart | 2 +- lib/lang/lang.dart | 3 + lib/main.dart | 18 ++ lib/page/component/dialog.dart | 7 +- lib/page/component/theme/custom_theme.dart | 2 +- lib/page/setting/setting_screen.dart | 12 + lib/page/setting/user_api_keys.dart | 245 +++++++++++++++++++++ lib/repo/api/keys.dart | 47 ++++ lib/repo/api_server.dart | 32 +++ pubspec.yaml | 2 +- 13 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 lib/bloc/user_api_keys_bloc.dart create mode 100644 lib/bloc/user_api_keys_event.dart create mode 100644 lib/bloc/user_api_keys_state.dart create mode 100644 lib/page/setting/user_api_keys.dart create mode 100644 lib/repo/api/keys.dart diff --git a/lib/bloc/user_api_keys_bloc.dart b/lib/bloc/user_api_keys_bloc.dart new file mode 100644 index 00000000..b8f937d5 --- /dev/null +++ b/lib/bloc/user_api_keys_bloc.dart @@ -0,0 +1,39 @@ +import 'package:askaide/repo/api/keys.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; + +part 'user_api_keys_event.dart'; +part 'user_api_keys_state.dart'; + +class UserApiKeysBloc extends Bloc { + UserApiKeysBloc() : super(UserApiKeysInitial()) { + // 加载用户 API Key 列表 + on((event, emit) async { + final keys = await APIServer().userAPIKeys(); + emit(UserApiKeysLoaded(keys: keys)); + }); + + // 加载用户 API Key + on((event, emit) async { + final key = await APIServer().userAPIKeyDetail(id: event.id); + emit(UserApiKeyLoaded(key: key)); + }); + + // 创建用户 API Key + on((event, emit) async { + final key = await APIServer().createAPIKey(name: event.name); + emit(UserApiKeyCreated(key: key)); + + final keys = await APIServer().userAPIKeys(); + emit(UserApiKeysLoaded(keys: keys)); + }); + + // 删除用户 API Key + on((event, emit) async { + await APIServer().deleteAPIKey(id: event.id); + final keys = await APIServer().userAPIKeys(); + emit(UserApiKeysLoaded(keys: keys)); + }); + } +} diff --git a/lib/bloc/user_api_keys_event.dart b/lib/bloc/user_api_keys_event.dart new file mode 100644 index 00000000..ed5441a7 --- /dev/null +++ b/lib/bloc/user_api_keys_event.dart @@ -0,0 +1,24 @@ +part of 'user_api_keys_bloc.dart'; + +@immutable +sealed class UserApiKeysEvent {} + +class UserApiKeysLoad extends UserApiKeysEvent {} + +class UserApiKeyLoad extends UserApiKeysEvent { + final int id; + + UserApiKeyLoad(this.id); +} + +class UserApiKeyCreate extends UserApiKeysEvent { + final String name; + + UserApiKeyCreate(this.name); +} + +class UserApiKeyDelete extends UserApiKeysEvent { + final int id; + + UserApiKeyDelete(this.id); +} diff --git a/lib/bloc/user_api_keys_state.dart b/lib/bloc/user_api_keys_state.dart new file mode 100644 index 00000000..ce9b429d --- /dev/null +++ b/lib/bloc/user_api_keys_state.dart @@ -0,0 +1,24 @@ +part of 'user_api_keys_bloc.dart'; + +@immutable +sealed class UserApiKeysState {} + +final class UserApiKeysInitial extends UserApiKeysState {} + +class UserApiKeysLoaded extends UserApiKeysState { + final List keys; + + UserApiKeysLoaded({required this.keys}); +} + +class UserApiKeyLoaded extends UserApiKeysState { + final UserAPIKey key; + + UserApiKeyLoaded({required this.key}); +} + +class UserApiKeyCreated extends UserApiKeysState { + final String key; + + UserApiKeyCreated({required this.key}); +} diff --git a/lib/helper/constant.dart b/lib/helper/constant.dart index 13af102d..a8083bc4 100644 --- a/lib/helper/constant.dart +++ b/lib/helper/constant.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; // 客户端应用版本号 -const clientVersion = '1.0.8'; +const clientVersion = '1.0.9'; // 本地数据库版本号 const databaseVersion = 25; diff --git a/lib/lang/lang.dart b/lib/lang/lang.dart index 3a82c68c..444cc7e2 100644 --- a/lib/lang/lang.dart +++ b/lib/lang/lang.dart @@ -203,6 +203,7 @@ mixin AppLocale { static const String discover = 'discover'; static const String customHomeModels = 'custom-home-models'; + static const String userApiKeys = "user-api-keys"; static const Map zh = { required: '必填', @@ -396,6 +397,7 @@ mixin AppLocale { toPay: '立即支付', discover: '绘玩', customHomeModels: '常用模型', + userApiKeys: 'API Keys', }; static const Map en = { @@ -604,6 +606,7 @@ mixin AppLocale { toPay: 'To pay', discover: 'Discover', customHomeModels: 'Favorite Models', + userApiKeys: 'API Keys', }; } diff --git a/lib/main.dart b/lib/main.dart index 89459cb8..f0f8b6ba 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:askaide/bloc/free_count_bloc.dart'; import 'package:askaide/bloc/gallery_bloc.dart'; import 'package:askaide/bloc/group_chat_bloc.dart'; import 'package:askaide/bloc/payment_bloc.dart'; +import 'package:askaide/bloc/user_api_keys_bloc.dart'; import 'package:askaide/bloc/version_bloc.dart'; import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/cache.dart'; @@ -55,6 +56,7 @@ import 'package:askaide/page/balance/payment_history.dart'; import 'package:askaide/page/setting/retrieve_password_screen.dart'; import 'package:askaide/page/auth/signup_screen.dart'; import 'package:askaide/page/lab/user_center.dart'; +import 'package:askaide/page/setting/user_api_keys.dart'; import 'package:askaide/repo/api/info.dart'; import 'package:askaide/repo/api_server.dart'; import 'package:askaide/repo/cache_repo.dart'; @@ -843,6 +845,22 @@ class MyApp extends StatefulWidget { ); }, ), + GoRoute( + name: 'user-api-keys', + path: '/setting/user-api-keys', + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: ((context) => UserApiKeysBloc()), + ), + ], + child: UserAPIKeysScreen(setting: settingRepo), + ), + ); + }, + ), ], ) ], diff --git a/lib/page/component/dialog.dart b/lib/page/component/dialog.dart index 83184e07..67909396 100644 --- a/lib/page/component/dialog.dart +++ b/lib/page/component/dialog.dart @@ -109,11 +109,13 @@ showBeautyDialog( BuildContext context, { required QuickAlertType type, required String text, + String? title, String confirmBtnText = '确定', String? cancelBtnText, Function()? onConfirmBtnTap, Function()? onCancelBtnTap, bool showCancelBtn = false, + bool barrierDismissible = false, // 禁止点击外部关闭 }) { final customColors = Theme.of(context).extension()!; @@ -122,7 +124,7 @@ showBeautyDialog( type: type, text: text, width: MediaQuery.of(context).size.width > 600 ? 400 : null, - barrierDismissible: false, // 禁止点击外部关闭 + barrierDismissible: barrierDismissible, showCancelBtn: showCancelBtn, confirmBtnText: confirmBtnText, cancelBtnText: cancelBtnText ?? AppLocale.cancel.getString(context), @@ -134,7 +136,8 @@ showBeautyDialog( color: Colors.white, fontWeight: FontWeight.normal, ), - title: '', + title: title ?? '', + titleColor: customColors.dialogDefaultTextColor!, textColor: customColors.dialogDefaultTextColor!, cancelBtnTextStyle: TextStyle( color: customColors.dialogDefaultTextColor, diff --git a/lib/page/component/theme/custom_theme.dart b/lib/page/component/theme/custom_theme.dart index 7966a05d..4c7a9e40 100644 --- a/lib/page/component/theme/custom_theme.dart +++ b/lib/page/component/theme/custom_theme.dart @@ -343,7 +343,7 @@ class CustomColors extends ThemeExtension { columnBlockBorderColor: Color.fromARGB(255, 72, 72, 72), columnBlockBackgroundColor: Color.fromARGB(255, 52, 52, 52), columnBlockDividerColor: Color.fromARGB(160, 60, 60, 60), - textfieldHintColor: Color.fromARGB(255, 84, 84, 84), + textfieldHintColor: Color.fromARGB(255, 105, 105, 105), textfieldHintDeepColor: Color.fromARGB(255, 170, 170, 170), textfieldLabelColor: Color.fromARGB(255, 218, 218, 218), textfieldValueColor: Color.fromARGB(255, 207, 207, 207), diff --git a/lib/page/setting/setting_screen.dart b/lib/page/setting/setting_screen.dart index d5d1b5db..bc436f08 100644 --- a/lib/page/setting/setting_screen.dart +++ b/lib/page/setting/setting_screen.dart @@ -97,6 +97,8 @@ class _SettingScreenState extends State { // OpenAI 自定义配置 if (Ability().enableOpenAI) _buildOpenAISelfHostedSetting(customColors), + // 用户 API Keys 配置 + _buildUserAPIKeySetting(customColors), ], ), @@ -583,6 +585,16 @@ class _SettingScreenState extends State { ); } + /// 用户 API Key 配置 + SettingsTile _buildUserAPIKeySetting(CustomColors customColors) { + return SettingsTile.navigation( + title: Text(AppLocale.userApiKeys.getString(context)), + onPressed: (context) { + context.push('/setting/user-api-keys'); + }, + ); + } + SettingsTile _buildServerSelfHostedSetting(CustomColors customColors) { return SettingsTile( title: const Text('自定义服务器'), diff --git a/lib/page/setting/user_api_keys.dart b/lib/page/setting/user_api_keys.dart new file mode 100644 index 00000000..d35b3008 --- /dev/null +++ b/lib/page/setting/user_api_keys.dart @@ -0,0 +1,245 @@ +import 'package:askaide/bloc/user_api_keys_bloc.dart'; +import 'package:askaide/helper/helper.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/loading.dart'; +import 'package:askaide/page/component/message_box.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:bot_toast/bot_toast.dart'; +import 'package:clipboard/clipboard.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; +import 'package:quickalert/quickalert.dart'; + +class UserAPIKeysScreen extends StatefulWidget { + final SettingRepository setting; + + const UserAPIKeysScreen({super.key, required this.setting}); + + @override + State createState() => _UserAPIKeysScreenState(); +} + +class _UserAPIKeysScreenState extends State { + Function? cancelDialog; + + @override + void initState() { + super.initState(); + context.read().add(UserApiKeysLoad()); + } + + @override + Widget build(BuildContext context) { + var customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: Text( + AppLocale.userApiKeys.getString(context), + style: const TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + openTextFieldDialog( + context, + title: 'API Key', + hint: 'API Key 名称', + onSubmit: (value) { + context.read().add(UserApiKeyCreate(value)); + return true; + }, + ); + }, + ), + ], + ), + backgroundColor: customColors.backgroundContainerColor, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const MessageBox( + message: '你可以在其它应用中使用 API Key 访问你的数据,接口协议全面兼容 OpenAI 官方 API。', + type: MessageBoxType.info, + ), + const SizedBox(height: 10), + BlocConsumer( + listener: (context, state) { + if (state is UserApiKeyLoaded) { + showBeautyDialog( + context, + type: QuickAlertType.success, + title: 'API Key', + text: state.key.token, + confirmBtnText: '复制到剪切板', + onConfirmBtnTap: () { + FlutterClipboard.copy(state.key.token).then((value) { + showSuccessMessage('已复制到剪贴板'); + context.pop(); + }); + + cancelDialog?.call(); + }, + showCancelBtn: true, + onCancelBtnTap: () => context.pop(), + barrierDismissible: true, + ); + } + }, + buildWhen: (previous, current) => current is UserApiKeysLoaded, + builder: (context, state) { + if (state is UserApiKeysLoaded) { + if (state.keys.isEmpty) { + return Container( + margin: const EdgeInsets.only(top: 50), + alignment: Alignment.center, + child: Center( + child: Text( + '你还没有创建任何 API Key', + style: TextStyle( + fontSize: 14, + color: customColors.weakTextColor, + ), + ), + ), + ); + } + return ListView.builder( + itemCount: state.keys.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final item = state.keys[index]; + return Container( + margin: const EdgeInsets.symmetric(vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + ), + child: Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + const SizedBox(width: 10), + SlidableAction( + label: AppLocale.delete.getString(context), + borderRadius: BorderRadius.circular(10), + backgroundColor: Colors.red, + icon: Icons.delete, + onPressed: (_) { + openConfirmDialog( + context, + AppLocale.confirmDelete + .getString(context), + () { + context + .read() + .add(UserApiKeyDelete(item.id)); + }, + danger: true, + ); + }, + ), + ], + ), + child: Material( + color: + customColors.backgroundColor?.withAlpha(200), + borderRadius: BorderRadius.all( + Radius.circular(customColors.borderRadius ?? 8), + ), + child: InkWell( + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + customColors.borderRadius ?? 8), + ), + title: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + item.name, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 15, + ), + maxLines: 1, + ), + ), + Text( + humanTime(DateTime.now()), + style: TextStyle( + color: customColors.weakTextColor + ?.withAlpha(65), + fontSize: 12, + ), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 5), + child: Text( + item.token, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: customColors.weakTextColor + ?.withAlpha(150), + fontSize: 12, + overflow: TextOverflow.ellipsis, + ), + ), + ), + dense: true, + onTap: () { + cancelDialog = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: "正在上传图片,请稍后...", + ); + }, + allowClick: false, + ); + + context.read().add( + UserApiKeyLoad(item.id), + ); + }, + ), + ), + ), + ), + ); + }, + ); + } + + return const Center(child: LoadingIndicator()); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/repo/api/keys.dart b/lib/repo/api/keys.dart new file mode 100644 index 00000000..98c6beef --- /dev/null +++ b/lib/repo/api/keys.dart @@ -0,0 +1,47 @@ +class UserAPIKey { + int id; + int? userId; + String name; + String token; + int status; + DateTime? validBefore; + DateTime? createdAt; + + UserAPIKey({ + required this.id, + this.userId, + required this.name, + required this.token, + required this.status, + this.validBefore, + this.createdAt, + }); + + factory UserAPIKey.fromJson(Map json) { + return UserAPIKey( + id: json['id'], + userId: json['user_id'], + name: json['name'], + token: json['token'], + status: json['status'], + validBefore: json['valid_before'] != null + ? DateTime.parse(json['valid_before']) + : null, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'name': name, + 'token': token, + 'status': status, + 'valid_before': validBefore?.toIso8601String(), + 'created_at': createdAt?.toIso8601String(), + }; + } +} diff --git a/lib/repo/api_server.dart b/lib/repo/api_server.dart index 5251f4fa..3de5ab43 100644 --- a/lib/repo/api_server.dart +++ b/lib/repo/api_server.dart @@ -9,6 +9,7 @@ import 'package:askaide/helper/platform.dart'; import 'package:askaide/repo/api/creative.dart'; import 'package:askaide/repo/api/image_model.dart'; import 'package:askaide/repo/api/info.dart'; +import 'package:askaide/repo/api/keys.dart'; import 'package:askaide/repo/api/page.dart'; import 'package:askaide/repo/api/payment.dart'; import 'package:askaide/repo/api/quota.dart'; @@ -1736,4 +1737,35 @@ class APIServer { return sendDeleteRequest( '/v1/group-chat/$groupId/chat/$messageId', (resp) {}); } + + /// API 模式 //////////////////////////////////////////////////////////////////// + /// 查询用户所有的 API Keys + Future> userAPIKeys() async { + return sendGetRequest('/v1/api-keys', (data) { + return ((data.data['data'] ?? []) as List) + .map((e) => UserAPIKey.fromJson(e)) + .toList(); + }); + } + + /// 查询指定 API Key + Future userAPIKeyDetail({required int id}) async { + return sendGetRequest('/v1/api-keys/$id', (data) { + return UserAPIKey.fromJson(data.data['data']); + }); + } + + /// 创建 API Key + Future createAPIKey({required String name}) async { + return sendPostRequest( + '/v1/api-keys', + (data) => data.data['key'], + formData: {'name': name}, + ); + } + + /// 删除 API Key + Future deleteAPIKey({required int id}) async { + return sendDeleteRequest('/v1/api-keys/$id', (data) {}); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 4f36e24f..217dda21 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. # 应用正式发布时,需要同步修改 lib/helper/constant.dart 中的 VERSION 值 -version: 1.0.8+1 +version: 1.0.9+1 environment: sdk: '>=3.0.0 <4.0.0' From ba2ec80f5eeefc67af1826db25cb1d39ad98d80e Mon Sep 17 00:00:00 2001 From: mylxsw Date: Wed, 15 Nov 2023 15:59:29 +0800 Subject: [PATCH 2/6] =?UTF-8?q?API=20Keys=20=E5=8A=9F=E8=83=BD=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E5=90=AF=E7=94=A8=E7=94=B1=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/helper/ability.dart | 15 ++++++++++----- lib/helper/error.dart | 2 +- lib/page/chat/rooms.dart | 2 +- lib/page/component/chat/chat_input.dart | 2 +- lib/page/component/chat/chat_preview.dart | 4 ++-- lib/page/setting/setting_screen.dart | 3 ++- lib/repo/api/info.dart | 6 ++++++ lib/repo/openai_repo.dart | 8 ++++---- 8 files changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/helper/ability.dart b/lib/helper/ability.dart index 570c0a69..e8fd32e0 100644 --- a/lib/helper/ability.dart +++ b/lib/helper/ability.dart @@ -21,10 +21,15 @@ class Ability { } /// 是否支持 Websocket - bool supportWebSocket() { + bool get supportWebSocket { return capabilities.supportWebsocket; } + /// 是否支持 API Keys 功能 + bool get supportAPIKeys { + return capabilities.supportAPIKeys; + } + /// 更新能力 updateCapabilities(Capabilities capabilities) { this.capabilities = capabilities; @@ -100,7 +105,7 @@ class Ability { } /// 是否启用了 OpenAI 自定义设置 - bool enableLocalOpenAI() { + bool get enableLocalOpenAI { return setting.boolDefault(settingOpenAISelfHosted, false); } @@ -111,13 +116,13 @@ class Ability { } /// 是否支持翻译功能 - bool supportTranslate() { + bool get supportTranslate { return false; // return setting.stringDefault(settingAPIServerToken, '') != ''; } /// 是否支持语音合成功能 - bool supportSpeak() { + bool get supportSpeak { // return setting.stringDefault(settingAPIServerToken, '') != ''; if (PlatformTool.isWeb()) { return false; @@ -127,7 +132,7 @@ class Ability { } /// 是否支持图片上传功能 - bool supportImageUploader() { + bool get supportImageUploader { return supportImglocUploader() || supportQiniuUploader(); } diff --git a/lib/helper/error.dart b/lib/helper/error.dart index 30d77ca2..6ebca232 100644 --- a/lib/helper/error.dart +++ b/lib/helper/error.dart @@ -23,7 +23,7 @@ Object? resolveHTTPStatusCode(int statusCode, case 400: return const LanguageText('请求参数错误'); case 401: - if (Ability().enableLocalOpenAI()) { + if (Ability().enableLocalOpenAI) { return const LanguageText(AppLocale.openAIAuthFailed); } diff --git a/lib/page/chat/rooms.dart b/lib/page/chat/rooms.dart index 16813362..9edd1552 100644 --- a/lib/page/chat/rooms.dart +++ b/lib/page/chat/rooms.dart @@ -84,7 +84,7 @@ class _RoomsPageState extends State { }, ), if (Ability().enableAPIServer() && - !Ability().enableLocalOpenAI()) + !Ability().enableLocalOpenAI) EnhancedPopupMenuItem( title: '发起群聊', icon: Icons.chat_bubble_outline, diff --git a/lib/page/component/chat/chat_input.dart b/lib/page/component/chat/chat_input.dart index 58233c69..f5cf5adb 100644 --- a/lib/page/component/chat/chat_input.dart +++ b/lib/page/component/chat/chat_input.dart @@ -143,7 +143,7 @@ class _ChatInputState extends State { Row( children: [ if (widget.enableImageUpload && - Ability().supportImageUploader()) + Ability().supportImageUploader) _buildImageUploadButton( context, setting, customColors), if (widget.leftSideToolsBuilder != null) diff --git a/lib/page/component/chat/chat_preview.dart b/lib/page/component/chat/chat_preview.dart index a4c53bb5..342ff0f9 100644 --- a/lib/page/component/chat/chat_preview.dart +++ b/lib/page/component/chat/chat_preview.dart @@ -628,7 +628,7 @@ class _ChatPreviewState extends State { ], ), ), - if (Ability().supportTranslate()) + if (Ability().supportTranslate) TextButton.icon( onPressed: () { cancel(); @@ -798,7 +798,7 @@ class _ChatPreviewState extends State { ], ), ), - if (Ability().supportSpeak() && widget.onSpeakEvent != null) + if (Ability().supportSpeak && widget.onSpeakEvent != null) TextButton.icon( onPressed: () { cancel(); diff --git a/lib/page/setting/setting_screen.dart b/lib/page/setting/setting_screen.dart index bc436f08..212451f8 100644 --- a/lib/page/setting/setting_screen.dart +++ b/lib/page/setting/setting_screen.dart @@ -98,7 +98,8 @@ class _SettingScreenState extends State { if (Ability().enableOpenAI) _buildOpenAISelfHostedSetting(customColors), // 用户 API Keys 配置 - _buildUserAPIKeySetting(customColors), + if (Ability().supportAPIKeys) + _buildUserAPIKeySetting(customColors), ], ), diff --git a/lib/repo/api/info.dart b/lib/repo/api/info.dart index 95350d5d..f2007235 100644 --- a/lib/repo/api/info.dart +++ b/lib/repo/api/info.dart @@ -27,6 +27,9 @@ class Capabilities { /// 是否支持 Websocket final bool supportWebsocket; + /// 是否支持 API Keys 功能 + final bool supportAPIKeys; + /// 是否显示绘玩 final bool disableGallery; @@ -49,6 +52,7 @@ class Capabilities { this.homeRoute = '/chat-chat', this.showHomeModelDescription = true, this.supportWebsocket = false, + this.supportAPIKeys = false, this.disableGallery = false, this.disableCreationIsland = false, this.disableDigitalHuman = false, @@ -68,6 +72,7 @@ class Capabilities { homeRoute: json['home_route'] ?? '/chat-chat', showHomeModelDescription: json['show_home_model_description'] ?? true, supportWebsocket: json['support_websocket'] ?? false, + supportAPIKeys: json['support_api_keys'] ?? false, disableGallery: json['disable_gallery'] ?? false, disableCreationIsland: json['disable_creation_island'] ?? false, disableDigitalHuman: json['disable_digital_human'] ?? false, @@ -86,6 +91,7 @@ class Capabilities { 'home_route': homeRoute, 'show_home_model_description': showHomeModelDescription, 'support_websocket': supportWebsocket, + 'support_api_keys': supportAPIKeys, 'disable_gallery': disableGallery, 'disable_creation_island': disableCreationIsland, 'disable_digital_human': disableDigitalHuman, diff --git a/lib/repo/openai_repo.dart b/lib/repo/openai_repo.dart index 08e86f29..82785a00 100644 --- a/lib/repo/openai_repo.dart +++ b/lib/repo/openai_repo.dart @@ -256,7 +256,7 @@ class OpenAIRepository { try { bool canUseWebsocket = true; - if (Ability().enableLocalOpenAI()) { + if (Ability().enableLocalOpenAI) { if (supportForChat.containsKey(model) || model.startsWith('openai:')) { canUseWebsocket = false; } @@ -266,7 +266,7 @@ class OpenAIRepository { canUseWebsocket = false; } - if (Ability().supportWebSocket() && canUseWebsocket) { + if (Ability().supportWebSocket && canUseWebsocket) { final serverURL = settings.getDefault(settingServerURL, apiServerURL); final wsURL = serverURL.startsWith('https://') ? serverURL.replaceFirst('https://', 'wss://') @@ -332,7 +332,7 @@ class OpenAIRepository { 'temperature': temperature, 'user': user, 'max_tokens': maxTokens, - 'n': Ability().enableLocalOpenAI() && + 'n': Ability().enableLocalOpenAI && (model.startsWith('openai:') || model.startsWith('gpt-')) ? null : roomId, // n 参数暂时用不到,复用作为 roomId @@ -344,7 +344,7 @@ class OpenAIRepository { temperature: temperature, user: user, maxTokens: maxTokens, - n: Ability().enableLocalOpenAI() + n: Ability().enableLocalOpenAI ? null : roomId, // n 参数暂时用不到,复用作为 roomId ); From 8ec7499728a231b712ca5ef8cd62e746fecb6ed3 Mon Sep 17 00:00:00 2001 From: mylxsw Date: Wed, 15 Nov 2023 19:07:44 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=90=8E,=E8=87=AA=E5=8A=A8=E5=A4=B1?= =?UTF-8?q?=E5=8E=BB=E7=84=A6=E7=82=B9,=E6=94=B6=E8=B5=B7=E9=94=AE?= =?UTF-8?q?=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 42 +++++++++++++++++++++++++ lib/page/chat/group/chat.dart | 5 ++- lib/page/chat/home_chat.dart | 5 ++- lib/page/chat/room_chat.dart | 5 ++- lib/page/component/chat/chat_input.dart | 2 -- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index f0f8b6ba..e8dd1b33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -284,6 +284,7 @@ class MyApp extends StatefulWidget { routes: [ GoRoute( path: '/login', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -298,6 +299,7 @@ class MyApp extends StatefulWidget { ), GoRoute( path: '/signin-or-signup', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -314,18 +316,21 @@ class MyApp extends StatefulWidget { ), GoRoute( path: '/user/change-password', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( ChangePasswordScreen(setting: settingRepo), ), ), GoRoute( path: '/user/destroy', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( DestroyAccountScreen(setting: settingRepo), ), ), GoRoute( path: '/signup', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( SignupScreen( settings: settingRepo, @@ -335,6 +340,7 @@ class MyApp extends StatefulWidget { ), GoRoute( path: '/retrieve-password', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( RetrievePasswordScreen( username: state.queryParameters['username'], @@ -345,6 +351,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'chat_anywhere', path: '/chat-anywhere', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -378,6 +385,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'chat_chat', path: '/chat-chat', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -398,6 +406,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'chat_chat_history', path: '/chat-chat/history', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -413,12 +422,14 @@ class MyApp extends StatefulWidget { ), GoRoute( path: '/lab/avatar-selector', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( const AvatarSelectorScreen(usage: AvatarUsage.room), ), ), GoRoute( path: '/lab/draw-board', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( const DrawboardScreen(), ), @@ -426,6 +437,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'characters', path: '/', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [BlocProvider.value(value: chatRoomBloc)], @@ -436,6 +448,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'create-room', path: '/create-room', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [BlocProvider.value(value: chatRoomBloc)], @@ -446,6 +459,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'chat', path: '/room/:room_id/chat', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { final roomId = int.parse(state.pathParameters['room_id']!); return transitionResolver( @@ -470,6 +484,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'room_setting', path: '/room/:room_id/setting', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { final roomId = int.parse(state.pathParameters['room_id']!); return transitionResolver( @@ -488,6 +503,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'account-security-setting', path: '/setting/account-security', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -502,6 +518,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'lab-user-center', path: '/lab/user-center', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -516,6 +533,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'setting', path: '/setting', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -529,6 +547,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'setting-background-selector', path: '/setting/background-selector', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( BlocProvider( create: (context) => BackgroundImageBloc(), @@ -539,6 +558,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'setting-openai-custom', path: '/setting/openai-custom', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( OpenAISettingScreen( settings: settingRepo, @@ -549,6 +569,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-draw', path: '/creative-draw', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -563,6 +584,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-upscale', path: '/creative-draw/create-upscale', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -580,6 +602,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-colorize', path: '/creative-draw/create-colorize', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -597,6 +620,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-draw-gallery-preview', path: '/creative-draw/gallery/:id', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -612,6 +636,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-draw-create', path: '/creative-draw/create', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -631,6 +656,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-island-history-all', path: '/creative-island/history', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { return transitionResolver( MultiBlocProvider( @@ -648,6 +674,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-island-models', path: '/creative-island/models', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { return transitionResolver( MultiBlocProvider( @@ -662,6 +689,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-island-history-item', path: '/creative-island/:id/history/:item_id', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { final id = state.pathParameters['id']!; final itemId = int.tryParse(state.pathParameters['item_id']!); @@ -685,6 +713,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'quota-details', path: '/quota-details', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( PaymentHistoryScreen(setting: settingRepo), ), @@ -692,6 +721,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'quota-usage-statistics', path: '/quota-usage-statistics', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( QuotaUsageStatisticsScreen(setting: settingRepo), ), @@ -699,6 +729,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'quota-usage-daily-details', path: '/quota-usage-daily-details', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( QuotaUsageDetailScreen( setting: settingRepo, @@ -710,6 +741,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'prompt-editor', path: '/prompt-editor', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { var prompt = state.queryParameters['prompt'] ?? ''; return transitionResolver(PromptScreen(prompt: prompt)); @@ -718,6 +750,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'payment', path: '/payment', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { return transitionResolver( MultiBlocProvider( @@ -732,6 +765,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'bind-phone', path: '/bind-phone', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { return transitionResolver( MultiBlocProvider( @@ -749,6 +783,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'creative-gallery', path: '/creative-gallery', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [ @@ -761,6 +796,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'diagnosis', path: '/diagnosis', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( DiagnosisScreen(setting: settingRepo), ), @@ -768,6 +804,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'free-statistics', path: '/free-statistics', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( MultiBlocProvider( providers: [BlocProvider.value(value: freeCountBloc)], @@ -778,6 +815,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'custom-home-models', path: '/setting/custom-home-models', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => transitionResolver( CustomHomeModelsPage(setting: settingRepo), ), @@ -785,6 +823,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'group-chat-chat', path: '/group-chat/:group_id/chat', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { final groupId = int.tryParse(state.pathParameters['group_id']!); @@ -809,6 +848,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'group-chat-create', path: '/group-chat-create', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { return transitionResolver( MultiBlocProvider( @@ -827,6 +867,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'group-chat-edit', path: '/group-chat/:group_id/edit', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { return transitionResolver( MultiBlocProvider( @@ -848,6 +889,7 @@ class MyApp extends StatefulWidget { GoRoute( name: 'user-api-keys', path: '/setting/user-api-keys', + parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) { return transitionResolver( MultiBlocProvider( diff --git a/lib/page/chat/group/chat.dart b/lib/page/chat/group/chat.dart index 7001ff15..5e55e597 100644 --- a/lib/page/chat/group/chat.dart +++ b/lib/page/chat/group/chat.dart @@ -172,7 +172,10 @@ class _GroupChatPageState extends State { : ChatInput( enableNotifier: _inputEnabled, enableImageUpload: false, - onSubmit: _handleSubmit, + onSubmit: (value) { + _handleSubmit(value); + FocusManager.instance.primaryFocus?.unfocus(); + }, onNewChat: () => handleResetContext(context), hintText: '有问题尽管问我', onVoiceRecordTappedEvent: () { diff --git a/lib/page/chat/home_chat.dart b/lib/page/chat/home_chat.dart index 3e1662db..a5f943ff 100644 --- a/lib/page/chat/home_chat.dart +++ b/lib/page/chat/home_chat.dart @@ -328,7 +328,10 @@ class _HomeChatPageState extends State { return SafeArea( child: ChatInput( enableNotifier: _inputEnabled, - onSubmit: _handleSubmit, + onSubmit: (value) { + _handleSubmit(value); + FocusManager.instance.primaryFocus?.unfocus(); + }, enableImageUpload: false, hintText: hintText, onVoiceRecordTappedEvent: () { diff --git a/lib/page/chat/room_chat.dart b/lib/page/chat/room_chat.dart index 749de615..ca66ff76 100644 --- a/lib/page/chat/room_chat.dart +++ b/lib/page/chat/room_chat.dart @@ -160,7 +160,10 @@ class _RoomChatPageState extends State { : ChatInput( enableNotifier: _inputEnabled, enableImageUpload: false, - onSubmit: _handleSubmit, + onSubmit: (value) { + _handleSubmit(value); + FocusManager.instance.primaryFocus?.unfocus(); + }, onNewChat: () => handleResetContext(context), hintText: hintText, onVoiceRecordTappedEvent: () { diff --git a/lib/page/component/chat/chat_input.dart b/lib/page/component/chat/chat_input.dart index f5cf5adb..30f0c91f 100644 --- a/lib/page/component/chat/chat_input.dart +++ b/lib/page/component/chat/chat_input.dart @@ -327,7 +327,5 @@ class _ChatInputState extends State { widget.onSubmit(text); _textController.clear(); } - - _focusNode.requestFocus(); } } From db3218912bb00eb9e2505c9dc198cd1ab886365e Mon Sep 17 00:00:00 2001 From: mylxsw Date: Mon, 20 Nov 2023 17:06:03 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E8=89=BA=E6=9C=AF=E5=AD=97\=E8=89=BA?= =?UTF-8?q?=E6=9C=AF=E4=BA=8C=E7=BB=B4=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 21 + .../creative_island/draw/artistic_text.dart | 627 ++++++++++++++++++ .../components/artistic_style_selector.dart | 137 ++++ .../creative_island/draw/draw_create.dart | 2 +- lib/repo/api/creative.dart | 32 + lib/repo/api_server.dart | 12 + 6 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 lib/page/creative_island/draw/artistic_text.dart create mode 100644 lib/page/creative_island/draw/components/artistic_style_selector.dart diff --git a/lib/main.dart b/lib/main.dart index e8dd1b33..0ea476d9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:askaide/helper/platform.dart'; import 'package:askaide/lang/lang.dart'; import 'package:askaide/data/migrate.dart'; import 'package:askaide/page/balance/quota_usage_details.dart'; +import 'package:askaide/page/creative_island/draw/artistic_text.dart'; import 'package:askaide/page/setting/account_security.dart'; import 'package:askaide/page/app_scaffold.dart'; import 'package:askaide/page/lab/avatar_selector.dart'; @@ -653,6 +654,26 @@ class MyApp extends StatefulWidget { ), ), ), + GoRoute( + name: 'creative-artistic-text', + path: '/creative-draw/artistic-text', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) => transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: galleryBloc), + ], + child: ArtisticTextScreen( + setting: settingRepo, + galleryCopyId: int.tryParse( + state.queryParameters['gallery_copy_id'] ?? '', + ), + type: state.queryParameters['type']!, + id: state.queryParameters['id']!, + ), + ), + ), + ), GoRoute( name: 'creative-island-history-all', path: '/creative-island/history', diff --git a/lib/page/creative_island/draw/artistic_text.dart b/lib/page/creative_island/draw/artistic_text.dart new file mode 100644 index 00000000..f0019e02 --- /dev/null +++ b/lib/page/creative_island/draw/artistic_text.dart @@ -0,0 +1,627 @@ +import 'dart:math'; + +import 'package:askaide/helper/haptic_feedback.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/column_block.dart'; +import 'package:askaide/page/component/enhanced_button.dart'; +import 'package:askaide/page/component/enhanced_input.dart'; +import 'package:askaide/page/component/enhanced_textfield.dart'; +import 'package:askaide/page/component/item_selector_search.dart'; +import 'package:askaide/page/component/loading.dart'; +import 'package:askaide/page/component/prompt_tags_selector.dart'; +import 'package:askaide/page/creative_island/draw/components/artistic_style_selector.dart'; +import 'package:askaide/page/creative_island/draw/components/content_preview.dart'; +import 'package:askaide/page/creative_island/draw/draw_result.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/creative.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/model/misc.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:go_router/go_router.dart'; +import 'package:quickalert/models/quickalert_type.dart'; + +class ArtisticTextScreen extends StatefulWidget { + final SettingRepository setting; + final int? galleryCopyId; + final String type; + final String id; + const ArtisticTextScreen({ + super.key, + required this.id, + required this.setting, + this.galleryCopyId, + required this.type, + }); + + @override + State createState() => _ArtisticTextScreenState(); +} + +class _ArtisticTextScreenState extends State { + bool enableAIRewrite = false; + bool showAdvancedOptions = false; + + CreativeIslandCapacity? capacity; + + CreativeIslandArtisticStyle? selectedStyle; + + /// 是否停止周期性查询任务执行状态 + var stopPeriodQuery = false; + + int generationImageCount = 1; + double? textWeight = 1.35; + + TextEditingController promptController = TextEditingController(); + TextEditingController negativePromptController = TextEditingController(); + TextEditingController textController = TextEditingController(); + TextEditingController seedController = TextEditingController(); + + @override + void dispose() { + promptController.dispose(); + negativePromptController.dispose(); + textController.dispose(); + seedController.dispose(); + super.dispose(); + } + + @override + void initState() { + APIServer() + .creativeIslandCapacity(mode: widget.type, id: widget.id) + .then((cap) { + setState(() { + capacity = cap; + }); + + if (widget.galleryCopyId != null && widget.galleryCopyId! > 0) { + APIServer() + .creativeGalleryItem(id: widget.galleryCopyId!) + .then((response) { + final gallery = response.item; + if (gallery.prompt != null && gallery.prompt!.isNotEmpty) { + promptController.text = gallery.prompt!; + } + + if (gallery.metaMap['real_prompt'] != null && + gallery.metaMap['real_prompt'] != '') { + promptController.text = gallery.metaMap['real_prompt']!; + } + + if (gallery.metaMap['negative_prompt'] != null && + gallery.metaMap['negative_prompt'] != '') { + negativePromptController.text = gallery.metaMap['negative_prompt']!; + } + + if (gallery.metaMap['real_negative_prompt'] != null && + gallery.metaMap['real_negative_prompt'] != '') { + negativePromptController.text = + gallery.metaMap['real_negative_prompt']!; + } + + // 创建同款时,默认关闭 AI 优化,除非该同款包含 ai_rewrite 的设定 + enableAIRewrite = false; + if ((gallery.metaMap['real_prompt'] == null || + gallery.metaMap['real_prompt'] == '') && + gallery.metaMap['ai_rewrite'] != null && + gallery.metaMap['ai_rewrite']) { + enableAIRewrite = gallery.metaMap['ai_rewrite']; + } + + setState(() {}); + }); + } + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + return Scaffold( + appBar: AppBar( + title: Text( + widget.type == 'qr' ? '艺术二维码' : '艺术字', + style: const TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + leading: IconButton( + onPressed: () { + context.pop(); + }, + icon: const Icon(Icons.arrow_back_ios), + ), + toolbarHeight: CustomSize.toolbarHeight, + backgroundColor: customColors.backgroundContainerColor, + ), + backgroundColor: customColors.backgroundContainerColor, + body: BackgroundContainer( + setting: widget.setting, + enabled: true, + maxWidth: CustomSize.smallWindowSize, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + height: double.infinity, + child: SingleChildScrollView( + child: buildEditPanel(context, customColors), + ), + ), + ), + ); + } + + Widget buildEditPanel(BuildContext context, CustomColors customColors) { + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ColumnBlock( + innerPanding: 10, + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15), + children: [ + if (capacity != null && capacity!.artisticStyles.isNotEmpty) + ArtisticStyleSelector( + styles: capacity!.artisticStyles, + onSelected: (style) { + setState(() { + selectedStyle = style; + }); + }, + selectedStyle: selectedStyle, + ), + ], + ), + ColumnBlock( + innerPanding: 10, + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15), + children: [ + EnhancedTextField( + labelPosition: LabelPosition.top, + labelText: widget.type == 'qr' ? '链接地址' : '文字内容', + customColors: customColors, + controller: textController, + textAlignVertical: TextAlignVertical.top, + hintText: widget.type == 'qr' ? '要生成的二维码链接地址。' : '要在画面中绘制的文字。', + maxLength: widget.type == 'qr' ? 250 : 20, + maxLines: 3, + minLines: 1, + showCounter: false, + ), + // 生成内容 + ...buildPromptField(customColors), + // AI 优化配置 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + AppLocale.smartOptimization.getString(context), + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 5), + InkWell( + onTap: () { + showBeautyDialog( + context, + type: QuickAlertType.info, + text: AppLocale.onceEnabledSmartOptimization + .getString(context), + confirmBtnText: AppLocale.gotIt.getString(context), + showCancelBtn: false, + ); + }, + child: Icon( + Icons.help_outline, + size: 16, + color: customColors.weakLinkColor?.withAlpha(150), + ), + ), + ], + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: enableAIRewrite, + onChanged: (value) { + setState(() { + enableAIRewrite = value; + }); + }, + ), + ], + ), + ], + ), + + if (showAdvancedOptions) + ColumnBlock( + innerPanding: 10, + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15), + children: [ + // 反向提示语 + EnhancedTextField( + labelPosition: LabelPosition.top, + labelText: AppLocale.excludeContents.getString(context), + customColors: customColors, + controller: negativePromptController, + textAlignVertical: TextAlignVertical.top, + hintText: AppLocale.unwantedElements.getString(context), + maxLength: 500, + maxLines: 5, + minLines: 3, + showCounter: false, + ), + // 权重 + Row( + children: [ + Row( + children: [ + const Text('文本权重'), + const SizedBox(width: 5), + InkWell( + onTap: () { + showBeautyDialog( + context, + type: QuickAlertType.info, + text: '文本权重\n\n权重越高,图像中出现的文本痕迹越明显。', + confirmBtnText: + AppLocale.gotIt.getString(context), + showCancelBtn: false, + ); + }, + child: Icon( + Icons.help_outline, + size: 16, + color: customColors.weakLinkColor?.withAlpha(150), + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Slider( + value: textWeight ?? 1.35, + min: 0, + max: 3, + divisions: 60, + activeColor: customColors.linkColor, + onChanged: (value) { + setState(() { + textWeight = value; + }); + }, + ), + ), + Text( + (textWeight ?? 1.38).toStringAsFixed(2), + style: TextStyle( + fontSize: 12, + color: customColors.weakTextColor, + ), + ), + ], + ), + // 图片数量 + EnhancedInput( + title: Text( + AppLocale.imageCount.getString(context), + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Text(generationImageCount.toString()), + onPressed: () { + openListSelectDialog( + context, + [ + SelectorItem( + const Text('1', textAlign: TextAlign.center), 1), + SelectorItem( + const Text('2', textAlign: TextAlign.center), 2), + SelectorItem( + const Text('3', textAlign: TextAlign.center), 3), + SelectorItem( + const Text('4', textAlign: TextAlign.center), 4), + ], + (value) { + setState(() { + generationImageCount = value.value; + }); + return true; + }, + heightFactor: 0.4, + value: generationImageCount, + ); + }, + ), + // Seed + EnhancedTextField( + controller: seedController, + customColors: customColors, + labelText: 'Seed', + labelPosition: LabelPosition.left, + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + hintText: '默认随机', + textDirection: TextDirection.rtl, + ), + ], + ), + // 生成按钮 + const SizedBox(height: 20), + Row( + children: [ + EnhancedButton( + title: showAdvancedOptions + ? AppLocale.simpleMode.getString(context) + : AppLocale.professionalMode.getString(context), + width: 120, + backgroundColor: Colors.transparent, + color: customColors.weakLinkColor, + fontSize: 15, + icon: Icon( + showAdvancedOptions ? Icons.unfold_less : Icons.unfold_more, + color: customColors.weakLinkColor, + size: 15, + ), + onPressed: () { + setState(() { + showAdvancedOptions = !showAdvancedOptions; + }); + }, + ), + const SizedBox(width: 10), + Expanded( + flex: 1, + child: EnhancedButton( + title: AppLocale.generate.getString(context), + onPressed: onGenerate, + ), + ), + ], + ), + const SizedBox(height: 20), + ], + ), + ); + } + + List buildPromptField(CustomColors customColors) { + return [ + EnhancedTextField( + labelPosition: LabelPosition.top, + labelText: AppLocale.yourIdeas.getString(context), + customColors: customColors, + controller: promptController, + textAlignVertical: TextAlignVertical.top, + hintText: AppLocale.keywordsSeparatedByCommas.getString(context), + maxLines: 10, + minLines: 2, + maxLength: 460, + showCounter: false, + inputSelector: IconButton( + onPressed: () { + openModalBottomSheet( + context, + (context) { + return PromptTagsSelector( + selectedTags: selectedTags, + onSubmit: (tags) { + setState(() { + selectedTags = tags; + }); + context.pop(); + }, + ); + }, + heightFactor: 0.8, + useSafeArea: true, + ); + }, + icon: Icon( + Icons.lightbulb_outline, + color: customColors.linkColor, + size: 16, + ), + ), + middleWidget: Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 30), + child: Wrap( + spacing: 3, + runSpacing: 3, + children: selectedTags + .map( + (e) => Tag( + name: e.name, + backgroundColor: customColors.linkColor, + textColor: Colors.white, + fontsize: 10, + onDeleted: () { + setState(() { + selectedTags.remove(e); + }); + }, + ), + ) + .toList(), + ), + ), + bottomButton: Row( + children: [ + Icon( + Icons.shuffle, + size: 13, + color: customColors.linkColor?.withAlpha(150), + ), + const SizedBox(width: 5), + Text( + AppLocale.random.getString(context), + style: TextStyle( + color: customColors.linkColor?.withAlpha(150), + fontSize: 13, + ), + ), + ], + ), + bottomButtonOnPressed: () async { + final examples = await APIServer().exampleByTag('artistic-text'); + if (examples.isEmpty) { + return; + } + + // 随机选取一个例子 + final example = examples[Random().nextInt(examples.length)]; + promptController.text = example.text; + }, + ), + ]; + } + + List selectedTags = []; + + void onGenerate() async { + FocusScope.of(context).requestFocus(FocusNode()); + HapticFeedbackHelper.mediumImpact(); + + final prompt = promptController.text.trim(); + if (prompt.isEmpty) { + showErrorMessage(AppLocale.contentIsRequired.getString(context)); + return; + } + + final text = textController.text.trim(); + if (text.isEmpty) { + showErrorMessage('${widget.type == "qr" ? "链接地址" : "文本内容"}不能为空'); + return; + } + + final seed = int.tryParse(seedController.text); + if (seed != null && (seed < 0 || seed > 2147483647)) { + showErrorMessage('Seed 取值范围为 0 ~ 2147483647'); + return; + } + + var params = { + 'prompt': prompt, + 'prompt_tags': selectedTags.map((e) => e.value).join(','), + 'negative_prompt': negativePromptController.text, + 'ai_rewrite': enableAIRewrite, + 'gallery_copy_id': widget.galleryCopyId, + 'text': text, + 'type': widget.type, + 'seed': seed, + 'image_count': generationImageCount, + 'control_weight': textWeight, + 'style_preset': selectedStyle?.id, + }; + + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: '思考中,请稍候...', + ); + }, + allowClick: false, + duration: const Duration(seconds: 15), + ); + + request(int waitDuration) async { + try { + final taskId = await APIServer() + .creativeIslandArtisticTextCompletionsAsyncV2(params); + + stopPeriodQuery = false; + + cancel(); + // ignore: use_build_context_synchronously + Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => DrawResultPage( + future: Future.delayed(const Duration(seconds: 10), () async { + return await queryCompletionTaskStatus( + taskId: taskId, + retryTimes: 0, + delaySeconds: 3, + params: params, + ); + }), + waitDuration: waitDuration, + ), + ), + ).whenComplete(() { + stopPeriodQuery = true; + }); + } catch (e) { + stopPeriodQuery = true; + cancel(); + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, e); + } + } + + try { + request(30); + } catch (e) { + cancel(); + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, e); + } + } + + Future queryCompletionTaskStatus({ + required String taskId, + required int retryTimes, + required int delaySeconds, + Map? params, + }) async { + if (retryTimes > 60) { + return Future.error(AppLocale.generateTimeout.getString(context)); + } + + final resp = await APIServer().asyncTaskStatus(taskId); + switch (resp.status) { + case 'success': + if (params != null && + resp.originImage != null && + resp.originImage != '') { + params['image'] = resp.originImage; + } + return IslandResult( + result: resp.resources ?? const [], + params: params, + ); + case 'failed': + return Future.error(resp.errors!.join(";")); + default: + if (stopPeriodQuery) { + // ignore: use_build_context_synchronously + return Future.error(AppLocale.generateTimeout.getString(context)); + } + + return await Future.delayed(Duration(seconds: delaySeconds), () async { + return await queryCompletionTaskStatus( + taskId: taskId, + retryTimes: retryTimes + 1, + delaySeconds: 3, + params: params, + ); + }); + } + } +} diff --git a/lib/page/creative_island/draw/components/artistic_style_selector.dart b/lib/page/creative_island/draw/components/artistic_style_selector.dart new file mode 100644 index 00000000..4d4ad1bd --- /dev/null +++ b/lib/page/creative_island/draw/components/artistic_style_selector.dart @@ -0,0 +1,137 @@ +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/enhanced_input.dart'; +import 'package:askaide/page/component/image.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/creative.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localization/flutter_localization.dart'; + +class ArtisticStyleSelector extends StatelessWidget { + final List styles; + final Function(CreativeIslandArtisticStyle style) onSelected; + final CreativeIslandArtisticStyle? selectedStyle; + + const ArtisticStyleSelector({ + super.key, + required this.styles, + required this.onSelected, + this.selectedStyle, + }); + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + return EnhancedInput( + title: Text( + AppLocale.style.getString(context), + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + // Text(selectedStyle == null || selectedStyle!.name == '' + // ? AppLocale.auto.getString(context) + // : selectedStyle!.name), + // const SizedBox(width: 10), + _buildImageStyleItemPreview( + customColors, + selectedStyle == null + ? CreativeIslandArtisticStyle( + id: '', name: '', previewImage: '') + : selectedStyle!, + size: 50, + ), + ], + ), + onPressed: () { + openModalBottomSheet( + context, + (context) { + return GridView.count( + crossAxisCount: 3, + crossAxisSpacing: 20, + mainAxisSpacing: 20, + padding: const EdgeInsets.only(top: 20, bottom: 20), + children: [ + for (var item in [ + CreativeIslandArtisticStyle( + id: '', name: '自动', previewImage: ''), + ...styles + ]) + InkWell( + onTap: () { + onSelected(item); + + Navigator.pop(context); + }, + child: Column( + children: [ + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: _buildImageStyleItemPreview( + customColors, + item, + showSelected: true, + ), + ), + ), + const SizedBox(height: 10), + Text( + item.name, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ], + ); + }, + heightFactor: 0.8, + ); + }, + ); + } + + Widget _buildImageStyleItemPreview( + CustomColors customColors, + CreativeIslandArtisticStyle style, { + double? size, + bool showSelected = false, + }) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + border: showSelected && + (selectedStyle != null && style.id == selectedStyle!.id) + ? Border.all( + color: customColors.linkColor ?? Colors.green, + width: 1, + ) + : null, + image: style.previewImage != null && style.previewImage != '' + ? DecorationImage( + image: + CachedNetworkImageProviderEnhanced(style.previewImage!), + fit: BoxFit.cover, + ) + : null, + ), + child: style.previewImage == '' + ? const Center( + child: Icon( + Icons.interests, + color: Colors.grey, + size: 40, + ), + ) + : null); + } +} diff --git a/lib/page/creative_island/draw/draw_create.dart b/lib/page/creative_island/draw/draw_create.dart index 9f578375..679dd456 100644 --- a/lib/page/creative_island/draw/draw_create.dart +++ b/lib/page/creative_island/draw/draw_create.dart @@ -53,7 +53,7 @@ class _DrawCreateScreenState extends State { String? selectedImagePath; Uint8List? selectedImageData; - bool enableAIRewrite = true; + bool enableAIRewrite = false; int generationImageCount = 1; CreativeIslandVendorModel? selectedModel; String? upscaleBy; diff --git a/lib/repo/api/creative.dart b/lib/repo/api/creative.dart index f6577e75..d6d219a8 100644 --- a/lib/repo/api/creative.dart +++ b/lib/repo/api/creative.dart @@ -137,6 +137,7 @@ class CreativeIslandCapacity { List allowUpscaleBy; bool showImageStrength; List filters; + List artisticStyles; CreativeIslandCapacity({ required this.showAIRewrite, @@ -151,6 +152,7 @@ class CreativeIslandCapacity { this.allowUpscaleBy = const [], this.showImageStrength = false, this.filters = const [], + this.artisticStyles = const [], }); toJson() => { @@ -166,6 +168,7 @@ class CreativeIslandCapacity { 'allow_upscale_by': allowUpscaleBy, 'show_image_strength': showImageStrength, 'filters': filters.map((e) => e.toJson()).toList(), + 'artistic_styles': artisticStyles.map((e) => e.toJson()).toList(), }; static CreativeIslandCapacity fromJson(Map json) { @@ -190,6 +193,35 @@ class CreativeIslandCapacity { filters: ((json['filters'] ?? []) as List) .map((e) => CreativeIslandImageFilter.fromJson(e)) .toList(), + artisticStyles: ((json['artistic_styles'] ?? []) as List) + .map((e) => CreativeIslandArtisticStyle.fromJson(e)) + .toList(), + ); + } +} + +class CreativeIslandArtisticStyle { + String id; + String name; + String? previewImage; + + CreativeIslandArtisticStyle({ + required this.id, + required this.name, + this.previewImage, + }); + + toJson() => { + 'id': id, + 'name': name, + 'preview_image': previewImage, + }; + + static CreativeIslandArtisticStyle fromJson(Map json) { + return CreativeIslandArtisticStyle( + id: json['id'], + name: json['name'], + previewImage: json['preview_image'], ); } } diff --git a/lib/repo/api_server.dart b/lib/repo/api_server.dart index 3de5ab43..e84b6ef9 100644 --- a/lib/repo/api_server.dart +++ b/lib/repo/api_server.dart @@ -690,6 +690,18 @@ class APIServer { ); } + Future creativeIslandArtisticTextCompletionsAsyncV2( + Map params) async { + return sendPostRequest( + '/v2/creative-island/completions/artistic-text', + (resp) { + final cicResp = CreativeIslandCompletionAsyncResp.fromJson(resp.data); + return cicResp.taskId; + }, + formData: params, + ); + } + Future creativeIslandImageDirectEdit( String endpoint, Map params, From 89dd1c4f92f40e5c49eae404cf9b4a9dd202b1b8 Mon Sep 17 00:00:00 2001 From: mylxsw Date: Thu, 23 Nov 2023 18:16:11 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E8=BF=94=E5=9B=9E,=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=A7=86=E8=A7=89=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/migrate.dart | 5 + lib/helper/constant.dart | 2 +- lib/helper/global_store.dart | 13 + lib/helper/model.dart | 18 +- lib/helper/model_resolver.dart | 17 +- lib/helper/upload.dart | 107 ++++-- lib/main.dart | 12 + lib/page/chat/group/chat.dart | 15 +- lib/page/chat/home.dart | 156 ++++++-- lib/page/chat/home_chat.dart | 139 +++++-- lib/page/chat/room_chat.dart | 102 ++++- lib/page/component/audio_player.dart | 64 +++- lib/page/component/chat/chat_input.dart | 141 ++++--- lib/page/component/chat/chat_preview.dart | 440 ++++++++++++---------- lib/page/component/chat/chat_share.dart | 38 +- lib/page/component/chat/file_upload.dart | 64 ++++ lib/page/component/chat/markdown.dart | 17 +- lib/page/component/model_indicator.dart | 2 + lib/repo/api/info.dart | 6 + lib/repo/model/chat_message.dart | 34 ++ lib/repo/model/message.dart | 20 +- lib/repo/model/misc.dart | 4 + lib/repo/model/model.dart | 2 + lib/repo/openai_repo.dart | 3 +- macos/Runner/Info.plist | 2 + 25 files changed, 1039 insertions(+), 384 deletions(-) create mode 100644 lib/helper/global_store.dart create mode 100644 lib/page/component/chat/file_upload.dart create mode 100644 lib/repo/model/chat_message.dart diff --git a/lib/data/migrate.dart b/lib/data/migrate.dart index 56be1997..c224bc90 100644 --- a/lib/data/migrate.dart +++ b/lib/data/migrate.dart @@ -114,6 +114,10 @@ Future migrate(db, oldVersion, newVersion) async { await db.execute( 'ALTER TABLE chat_message ADD COLUMN status INTEGER DEFAULT 1;'); } + + if (oldVersion < 26) { + await db.execute('ALTER TABLE chat_message ADD COLUMN images TEXT NULL;'); + } } /// 数据库初始化 @@ -159,6 +163,7 @@ void initDatabase(db, version) async { token_consumed INTEGER NULL, quota_consumed INTEGER NULL, model TEXT, + images TEXT NULL, ts INTEGER NOT NULL ) '''); diff --git a/lib/helper/constant.dart b/lib/helper/constant.dart index a8083bc4..3f6090d5 100644 --- a/lib/helper/constant.dart +++ b/lib/helper/constant.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; // 客户端应用版本号 const clientVersion = '1.0.9'; // 本地数据库版本号 -const databaseVersion = 25; +const databaseVersion = 26; const maxRoomNumForNonVIP = 50; const coinSign = '个'; diff --git a/lib/helper/global_store.dart b/lib/helper/global_store.dart new file mode 100644 index 00000000..4a518bd8 --- /dev/null +++ b/lib/helper/global_store.dart @@ -0,0 +1,13 @@ +import 'package:askaide/helper/upload.dart'; +import 'package:askaide/page/component/chat/file_upload.dart'; + +class GlobalStore { + static final GlobalStore _instance = GlobalStore._internal(); + GlobalStore._internal(); + + factory GlobalStore() { + return _instance; + } + + List uploadedFiles = []; +} diff --git a/lib/helper/model.dart b/lib/helper/model.dart index 08a06793..5b84ac53 100644 --- a/lib/helper/model.dart +++ b/lib/helper/model.dart @@ -33,6 +33,7 @@ class ModelAggregate { category: e.category, tag: e.tag, avatarUrl: e.avatarUrl, + supportVision: e.supportVision, ), ) .toList()); @@ -63,25 +64,10 @@ class ModelAggregate { /// 根据模型唯一id查找模型 static Future model(String uid) async { - if (uid.split(':').length == 1) { - uid = '$modelTypeOpenAI:$uid'; - } - final supportModels = await models(); - // if (uid.startsWith('$modelTypeOpenAI:')) { - // models.addAll(OpenAIRepository.supportModels()); - // } - - // if (uid.startsWith('$modelTypeDeepAI:')) { - // models.addAll(DeepAIRepository.supportModels()); - // } - - // if (uid.startsWith('$modelTypeStabilityAI:')) { - // models.addAll(StabilityAIRepository.supportModels()); - // } return supportModels.firstWhere( - (element) => element.uid() == uid, + (element) => element.uid() == uid || element.id == uid, orElse: () => mm.Model(defaultChatModel, defaultChatModel, 'openai', category: modelTypeOpenAI), ); diff --git a/lib/helper/model_resolver.dart b/lib/helper/model_resolver.dart index 860e4757..9e1b35e7 100644 --- a/lib/helper/model_resolver.dart +++ b/lib/helper/model_resolver.dart @@ -5,6 +5,7 @@ import 'package:askaide/helper/error.dart'; import 'package:askaide/helper/helper.dart'; import 'package:askaide/repo/api_server.dart'; import 'package:askaide/repo/deepai_repo.dart'; +import 'package:askaide/repo/model/chat_message.dart'; import 'package:askaide/repo/model/message.dart'; import 'package:askaide/repo/model/room.dart'; import 'package:askaide/repo/openai_repo.dart'; @@ -172,7 +173,7 @@ class ModelResolver { } /// 构建机器人请求上下文 - List _buildRequestContext( + List _buildRequestContext( Room room, List messages, ) { @@ -192,10 +193,14 @@ class ModelResolver { .where((e) => !e.isSystem() && !e.isInitMessage()) .where((e) => !e.statusIsFailed()) .map((e) => e.role == Role.receiver - ? OpenAIChatCompletionChoiceMessageModel( - role: OpenAIChatMessageRole.assistant, content: e.text) - : OpenAIChatCompletionChoiceMessageModel( - role: OpenAIChatMessageRole.user, content: e.text)) + ? ChatMessage( + role: OpenAIChatMessageRole.assistant, + content: e.text, + images: e.images) + : ChatMessage( + role: OpenAIChatMessageRole.user, + content: e.text, + images: e.images)) .toList(); if (contextMessages.length > room.maxContext * 2) { @@ -206,7 +211,7 @@ class ModelResolver { if (room.systemPrompt != null && room.systemPrompt != '') { contextMessages.insert( 0, - OpenAIChatCompletionChoiceMessageModel( + ChatMessage( role: OpenAIChatMessageRole.system, content: room.systemPrompt!, ), diff --git a/lib/helper/upload.dart b/lib/helper/upload.dart index f43eb43d..b1abcdaa 100644 --- a/lib/helper/upload.dart +++ b/lib/helper/upload.dart @@ -19,9 +19,10 @@ class ImageUploader { _qiniuUploader = QiniuUploader(setting); } - final _compressWidth = 1920; - final _compressHeight = 1080; + final _compressWidth = 1024; + final _compressHeight = 1024; + /// 上传文件 Future upload(String path, {String? usage}) async { Uint8List? data = await _imageCompress(path); if (data == null || data.isEmpty) { @@ -31,6 +32,42 @@ class ImageUploader { return _qiniuUploader!.upload(path, data, usage: usage); } + /// 文件压缩后转为 base64 + Future base64({ + String? path, + Uint8List? imageData, + int? compressWidth, + int? compressHeight, + int? maxSize, + }) async { + if (imageData != null) { + Uint8List? data = await _imageDataCompress( + imageData, + compressWidth: compressWidth, + compressHeight: compressHeight, + maxSize: maxSize ?? 1024 * 1024 * 2, + ); + if (data == null || data.isEmpty) { + throw Exception('图片读取失败'); + } + + return 'data:image/png;base64,${base64Encode(data)}'; + } + + Uint8List? data = await _imageCompress( + path!, + compressWidth: compressWidth, + compressHeight: compressHeight, + maxSize: maxSize ?? 1024 * 1024 * 2, + ); + if (data == null || data.isEmpty) { + throw Exception('图片读取失败'); + } + + return 'data:image/png;base64,${base64Encode(data)}'; + } + + // 上传文件数据 Future uploadData(Uint8List imageData, {String? usage}) async { Uint8List? data = await _imageDataCompress(imageData); if (data == null || data.isEmpty) { @@ -40,16 +77,20 @@ class ImageUploader { return _qiniuUploader!.upload("${randomId()}.jpg", data, usage: usage); } - Future _imageDataCompress(Uint8List imageData) async { + Future _imageDataCompress(Uint8List imageData, + {int? compressWidth, + int? compressHeight, + int quality = 80, + int maxSize = 1024 * 1024 * 2}) async { Uint8List? data = imageData; // 优先使用平台支持的压缩工具 if (PlatformTool.isAndroid() || PlatformTool.isIOS()) { try { data = await FlutterImageCompress.compressWithList( data, - quality: 80, - minWidth: _compressWidth, - minHeight: _compressHeight, + quality: quality, + minWidth: compressWidth ?? _compressWidth, + minHeight: compressHeight ?? _compressHeight, ); } catch (e) { // ignore @@ -58,8 +99,9 @@ class ImageUploader { if (data == null || data.isEmpty) { try { data = await IsolateImage.data(data!).compress( - maxResolution: ImageResolution(_compressWidth, _compressHeight), - maxSize: 1024 * 1024 * 2, + maxResolution: ImageResolution(compressWidth ?? _compressWidth, + compressHeight ?? _compressHeight), + maxSize: maxSize, ); } catch (e) { // ignore @@ -68,8 +110,11 @@ class ImageUploader { } else { try { data = await IsolateImage.data(data).compress( - maxResolution: ImageResolution(_compressWidth, _compressHeight), - maxSize: 1024 * 1024 * 2, + maxResolution: ImageResolution( + compressWidth ?? _compressWidth, + compressHeight ?? _compressHeight, + ), + maxSize: maxSize, ); } catch (e) { // ignore @@ -83,11 +128,14 @@ class ImageUploader { if (img != null) { Image thumbnail = copyResize( img, - width: img.width > img.height ? _compressWidth : null, - height: img.width <= img.height ? _compressHeight : null, + width: + img.width > img.height ? compressWidth ?? _compressWidth : null, + height: img.width <= img.height + ? compressHeight ?? _compressHeight + : null, ); - data = encodeJpg(thumbnail, quality: 80); + data = encodeJpg(thumbnail, quality: quality); } } catch (e) { // ignore @@ -102,7 +150,11 @@ class ImageUploader { return data; } - Future _imageCompress(String path) async { + Future _imageCompress(String path, + {int? compressWidth, + int? compressHeight, + int quality = 80, + int maxSize = 1024 * 1024 * 2}) async { Uint8List? data; // 优先使用平台支持的压缩工具 @@ -110,9 +162,9 @@ class ImageUploader { try { data = await FlutterImageCompress.compressWithFile( path, - quality: 80, - minWidth: _compressWidth, - minHeight: _compressHeight, + quality: quality, + minWidth: compressWidth ?? _compressWidth, + minHeight: compressHeight ?? _compressHeight, ); } catch (e) { // ignore @@ -121,8 +173,9 @@ class ImageUploader { if (data == null || data.isEmpty) { try { data = await IsolateImage.path(path).compress( - maxResolution: ImageResolution(_compressWidth, _compressHeight), - maxSize: 1024 * 1024 * 2, + maxResolution: ImageResolution(compressWidth ?? _compressWidth, + compressHeight ?? _compressHeight), + maxSize: maxSize, ); } catch (e) { // ignore @@ -131,8 +184,11 @@ class ImageUploader { } else { try { data = await IsolateImage.path(path).compress( - maxResolution: ImageResolution(_compressWidth, _compressHeight), - maxSize: 1024 * 1024 * 2, + maxResolution: ImageResolution( + compressWidth ?? _compressWidth, + compressHeight ?? _compressHeight, + ), + maxSize: maxSize, ); } catch (e) { // ignore @@ -146,13 +202,16 @@ class ImageUploader { if (img != null) { Image thumbnail = copyResize( img, - width: img.width > img.height ? _compressWidth : null, - height: img.width <= img.height ? _compressHeight : null, + width: + img.width > img.height ? compressWidth ?? _compressWidth : null, + height: img.width <= img.height + ? compressHeight ?? _compressHeight + : null, ); var n = path.toLowerCase(); if (n.endsWith('.jpg') || n.endsWith('.jpeg')) { - data = encodeJpg(thumbnail, quality: 80); + data = encodeJpg(thumbnail, quality: quality); } else if (n.endsWith('.png')) { data = encodePng(thumbnail, level: 4); } else { diff --git a/lib/main.dart b/lib/main.dart index 0ea476d9..e19264df 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -71,6 +71,7 @@ import 'package:bot_toast/bot_toast.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/page/component/theme/custom_theme.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter_localization/flutter_localization.dart'; import 'package:fluwx/fluwx.dart'; import 'package:go_router/go_router.dart'; @@ -1024,6 +1025,17 @@ class _MyAppState extends State { supportedLocales: widget.localization.supportedLocales, localizationsDelegates: widget.localization.localizationsDelegates, + scrollBehavior: + PlatformTool.isAndroid() || PlatformTool.isIOS() + ? null + : const MaterialScrollBehavior().copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.trackpad, + }, + ), ); }, ); diff --git a/lib/page/chat/group/chat.dart b/lib/page/chat/group/chat.dart index 5e55e597..0050ebc8 100644 --- a/lib/page/chat/group/chat.dart +++ b/lib/page/chat/group/chat.dart @@ -49,8 +49,9 @@ class _GroupChatPageState extends State { final ValueNotifier _inputEnabled = ValueNotifier(true); final ChatPreviewController _chatPreviewController = ChatPreviewController(); final AudioPlayerController _audioPlayerController = - AudioPlayerController(useRemoteAPI: false); + AudioPlayerController(useRemoteAPI: true); bool showAudioPlayer = false; + bool audioLoadding = false; List? selectedMembers = []; List messages = []; @@ -79,6 +80,12 @@ class _GroupChatPageState extends State { showAudioPlayer = true; }); }; + + _audioPlayerController.onPlayAudioLoading = (loading) { + setState(() { + audioLoadding = loading; + }); + }; } @override @@ -143,7 +150,10 @@ class _GroupChatPageState extends State { children: [ // 语音输出中提示 if (showAudioPlayer) - EnhancedAudioPlayer(controller: _audioPlayerController), + EnhancedAudioPlayer( + controller: _audioPlayerController, + loading: audioLoadding, + ), // 聊天内容窗口 Expanded( child: _buildChatPreviewArea( @@ -604,6 +614,7 @@ class _GroupChatPageState extends State { username: e.message.senderName, avatarURL: e.message.avatarUrl, leftSide: e.message.role == Role.receiver, + images: e.message.images, )) .toList(), ), diff --git a/lib/page/chat/home.dart b/lib/page/chat/home.dart index e03f2697..525fde9c 100644 --- a/lib/page/chat/home.dart +++ b/lib/page/chat/home.dart @@ -1,15 +1,18 @@ +import 'dart:io'; import 'dart:math'; import 'package:askaide/bloc/chat_chat_bloc.dart'; import 'package:askaide/bloc/free_count_bloc.dart'; import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/color.dart'; +import 'package:askaide/helper/global_store.dart'; import 'package:askaide/helper/haptic_feedback.dart'; import 'package:askaide/helper/helper.dart'; import 'package:askaide/helper/cache.dart'; import 'package:askaide/lang/lang.dart'; import 'package:askaide/page/component/background_container.dart'; import 'package:askaide/page/component/chat/empty.dart'; +import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/voice_record.dart'; import 'package:askaide/page/component/column_block.dart'; import 'package:askaide/page/component/enhanced_textfield.dart'; @@ -24,6 +27,7 @@ import 'package:askaide/repo/model/chat_history.dart'; import 'package:askaide/repo/model/misc.dart'; import 'package:askaide/repo/settings_repo.dart'; import 'package:custom_sliding_segmented_control/custom_sliding_segmented_control.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -88,6 +92,8 @@ class _HomePageState extends State { /// 是否显示提示消息对话框 bool showFreeModelNotifyMessage = false; + List selectedImageFiles = []; + /// 促销事件 PromotionEvent? promotionEvent; @@ -125,6 +131,7 @@ class _HomePageState extends State { description: e.desc, icon: e.powerful ? Icons.auto_awesome : Icons.bolt, activeColor: stringToColor(e.color), + supportVision: e.supportVision, )) .toList(); } @@ -140,6 +147,7 @@ class _HomePageState extends State { description: e.desc, icon: e.powerful ? Icons.auto_awesome : Icons.bolt, activeColor: stringToColor(e.color), + supportVision: e.supportVision, )) .toList(); @@ -503,6 +511,68 @@ class _HomePageState extends State { customColors, ), ), + if (selectedImageFiles.isNotEmpty && + currentModel != null && + currentModel!.supportVision) + SizedBox( + height: 110, + child: ListView( + scrollDirection: Axis.horizontal, + children: selectedImageFiles + .map( + (e) => Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.all(5), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: e.file.bytes != null + ? Image.memory( + e.file.bytes!, + fit: BoxFit.cover, + width: 100, + height: 100, + ) + : Image.file( + File(e.file.path!), + fit: BoxFit.cover, + width: 100, + height: 100, + ), + ), + Positioned( + right: 5, + top: 5, + child: InkWell( + onTap: () { + setState(() { + selectedImageFiles.remove(e); + }); + }, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(10), + color: customColors + .chatRoomBackground, + ), + child: Icon( + Icons.close, + size: 10, + color: customColors.weakTextColor, + ), + ), + ), + ), + ], + ), + ), + ) + .toList(), + ), + ) ], ) ], @@ -707,30 +777,66 @@ class _HomePageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ - InkWell( - onTap: () { - HapticFeedbackHelper.mediumImpact(); - - openModalBottomSheet( - context, - (context) { - return VoiceRecord( - onFinished: (text) { - _textController.text = _textController.text + text; - Navigator.pop(context); + Row( + children: [ + InkWell( + onTap: () { + HapticFeedbackHelper.mediumImpact(); + + openModalBottomSheet( + context, + (context) { + return VoiceRecord( + onFinished: (text) { + _textController.text = _textController.text + text; + Navigator.pop(context); + }, + onStart: () {}, + ); }, - onStart: () {}, + isScrollControlled: false, + heightFactor: 0.8, ); }, - isScrollControlled: false, - heightFactor: 0.8, - ); - }, - child: Icon( - Icons.mic, - color: customColors.chatInputPanelText, - size: 28, - ), + child: Icon( + Icons.mic, + color: customColors.chatInputPanelText, + size: 28, + ), + ), + const SizedBox(width: 10), + if (currentModel != null && currentModel!.supportVision) + InkWell( + onTap: () async { + // 上传图片 + HapticFeedbackHelper.mediumImpact(); + if (selectedImageFiles.length >= 4) { + showSuccessMessage('最多只能上传 4 张图片'); + return; + } + + FilePickerResult? result = + await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: true, + ); + if (result != null && result.files.isNotEmpty) { + final files = selectedImageFiles; + files.addAll( + result.files.map((e) => FileUpload(file: e)).toList()); + setState(() { + selectedImageFiles = + files.sublist(0, files.length > 4 ? 4 : files.length); + }); + } + }, + child: Icon( + Icons.camera_alt, + color: customColors.chatInputPanelText, + size: 28, + ), + ), + ], ), BlocBuilder( buildWhen: (previous, current) => current is FreeCountLoadedState, @@ -776,6 +882,12 @@ class _HomePageState extends State { return; } + if (currentModel != null && currentModel!.supportVision) { + GlobalStore().uploadedFiles = selectedImageFiles; + } + + selectedImageFiles = []; + context .push(Uri(path: '/chat-anywhere', queryParameters: { 'init_message': text, @@ -783,6 +895,8 @@ class _HomePageState extends State { }).toString()) .whenComplete(() { _textController.clear(); + GlobalStore().uploadedFiles.clear(); + FocusScope.of(context).requestFocus(FocusNode()); context.read().add(ChatChatLoadRecentHistories()); }); diff --git a/lib/page/chat/home_chat.dart b/lib/page/chat/home_chat.dart index a5f943ff..f6aac73c 100644 --- a/lib/page/chat/home_chat.dart +++ b/lib/page/chat/home_chat.dart @@ -3,7 +3,9 @@ import 'package:askaide/bloc/free_count_bloc.dart'; import 'package:askaide/bloc/notify_bloc.dart'; import 'package:askaide/bloc/room_bloc.dart'; import 'package:askaide/helper/constant.dart'; +import 'package:askaide/helper/global_store.dart'; import 'package:askaide/helper/model.dart'; +import 'package:askaide/helper/upload.dart'; import 'package:askaide/lang/lang.dart'; import 'package:askaide/page/chat/room_chat.dart'; import 'package:askaide/page/component/audio_player.dart'; @@ -11,9 +13,11 @@ import 'package:askaide/page/component/background_container.dart'; import 'package:askaide/page/component/chat/chat_input.dart'; import 'package:askaide/page/component/chat/chat_preview.dart'; import 'package:askaide/page/component/chat/empty.dart'; +import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/help_tips.dart'; import 'package:askaide/page/component/chat/message_state_manager.dart'; import 'package:askaide/page/component/enhanced_error.dart'; +import 'package:askaide/page/component/loading.dart'; import 'package:askaide/page/component/random_avatar.dart'; import 'package:askaide/page/component/dialog.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; @@ -23,6 +27,7 @@ import 'package:askaide/repo/model/message.dart'; import 'package:askaide/repo/model/misc.dart'; import 'package:askaide/repo/model/room.dart'; import 'package:askaide/repo/settings_repo.dart'; +import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localization/flutter_localization.dart'; @@ -55,11 +60,13 @@ class _HomeChatPageState extends State { final ScrollController _scrollController = ScrollController(); final ValueNotifier _inputEnabled = ValueNotifier(true); final AudioPlayerController _audioPlayerController = - AudioPlayerController(useRemoteAPI: false); + AudioPlayerController(useRemoteAPI: true); int? chatId; + List selectedImageFiles = []; bool showAudioPlayer = false; + bool audioLoadding = false; List supportModels = []; @@ -79,14 +86,6 @@ class _HomeChatPageState extends State { setState(() {}); }); - if (widget.initialMessage != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 500), () { - _handleSubmit(widget.initialMessage!); - }); - }); - } - _audioPlayerController.onPlayStopped = () { setState(() { showAudioPlayer = false; @@ -97,6 +96,11 @@ class _HomeChatPageState extends State { showAudioPlayer = true; }); }; + _audioPlayerController.onPlayAudioLoading = (loading) { + setState(() { + audioLoadding = loading; + }); + }; // 加载模型列表,用于查询模型名称 ModelAggregate.models().then((value) { @@ -105,6 +109,15 @@ class _HomeChatPageState extends State { }); }); + if (widget.initialMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 500), () { + selectedImageFiles = GlobalStore().uploadedFiles; + _handleSubmit(widget.initialMessage!); + }); + }); + } + super.initState(); } @@ -246,7 +259,10 @@ class _HomeChatPageState extends State { return Column( children: [ if (showAudioPlayer) - EnhancedAudioPlayer(controller: _audioPlayerController), + EnhancedAudioPlayer( + controller: _audioPlayerController, + loading: audioLoadding, + ), // 聊天内容窗口 Expanded( child: BlocConsumer( @@ -257,8 +273,13 @@ class _HomeChatPageState extends State { }); } + if (state is ChatMessagesLoaded && state.error == null) { + setState(() { + selectedImageFiles = []; + }); + } // 显示错误提示 - if (state is ChatMessagesLoaded && state.error != null) { + else if (state is ChatMessagesLoaded && state.error != null) { showErrorMessageEnhanced(context, state.error); } else if (state is ChatMessageUpdated) { // 聊天内容窗口滚动到底部 @@ -326,16 +347,38 @@ class _HomeChatPageState extends State { } return SafeArea( - child: ChatInput( - enableNotifier: _inputEnabled, - onSubmit: (value) { - _handleSubmit(value); - FocusManager.instance.primaryFocus?.unfocus(); - }, - enableImageUpload: false, - hintText: hintText, - onVoiceRecordTappedEvent: () { - _audioPlayerController.stop(); + child: BlocBuilder( + buildWhen: (previous, current) => + current is ChatMessagesLoaded, + builder: (context, state) { + var enableImageUpload = false; + if (state is ChatMessagesLoaded) { + var model = state.chatHistory?.model ?? room.room.model; + final cur = supportModels + .where((e) => e.id == model) + .firstOrNull; + enableImageUpload = cur?.supportVision ?? false; + } + + return ChatInput( + enableNotifier: _inputEnabled, + onSubmit: (value) { + _handleSubmit(value); + FocusManager.instance.primaryFocus?.unfocus(); + }, + enableImageUpload: enableImageUpload, + onImageSelected: (files) { + setState(() { + selectedImageFiles = files; + }); + }, + selectedImageFiles: + enableImageUpload ? selectedImageFiles : [], + hintText: hintText, + onVoiceRecordTappedEvent: () { + _audioPlayerController.stop(); + }, + ); }, ), ); @@ -437,11 +480,57 @@ class _HomeChatPageState extends State { messagetType = MessageType.text, int? index, bool isResent = false, - }) { + }) async { setState(() { _inputEnabled.value = false; }); + if (selectedImageFiles.isNotEmpty) { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: '正在上传图片,请稍后...', + ); + }, + allowClick: false, + ); + + try { + final uploader = ImageUploader(widget.setting); + + for (var file in selectedImageFiles) { + if (file.uploaded) { + continue; + } + + if (file.file.bytes != null) { + final res = await uploader.base64( + imageData: file.file.bytes, + maxSize: 1024 * 1024, + compressWidth: 512, + compressHeight: 512, + ); + file.setUrl(res); + } else { + final res = await uploader.base64( + path: file.file.path!, + maxSize: 1024 * 1024, + compressWidth: 512, + compressHeight: 512, + ); + file.setUrl(res); + } + } + } catch (e) { + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, e); + return; + } finally { + cancel(); + } + } + + // ignore: use_build_context_synchronously context.read().add( ChatMessageSendEvent( Message( @@ -452,13 +541,19 @@ class _HomeChatPageState extends State { model: widget.model, type: messagetType, chatHistoryId: chatId, + images: selectedImageFiles + .where((e) => e.uploaded) + .map((e) => e.url!) + .toList(), ), index: index, isResent: isResent, ), ); + // ignore: use_build_context_synchronously context.read().add(NotifyResetEvent()); + // ignore: use_build_context_synchronously context .read() .add(RoomLoadEvent(chatAnywhereRoomId, cascading: false)); diff --git a/lib/page/chat/room_chat.dart b/lib/page/chat/room_chat.dart index ca66ff76..60824182 100644 --- a/lib/page/chat/room_chat.dart +++ b/lib/page/chat/room_chat.dart @@ -2,16 +2,20 @@ import 'package:askaide/bloc/free_count_bloc.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/haptic_feedback.dart'; import 'package:askaide/helper/image.dart'; +import 'package:askaide/helper/model.dart'; +import 'package:askaide/helper/upload.dart'; import 'package:askaide/lang/lang.dart'; import 'package:askaide/page/component/audio_player.dart'; import 'package:askaide/page/component/background_container.dart'; import 'package:askaide/page/component/chat/chat_share.dart'; import 'package:askaide/page/component/chat/empty.dart'; +import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/help_tips.dart'; import 'package:askaide/page/component/chat/message_state_manager.dart'; import 'package:askaide/page/component/effect/glass.dart'; import 'package:askaide/page/component/enhanced_popup_menu.dart'; import 'package:askaide/page/component/enhanced_textfield.dart'; +import 'package:askaide/page/component/loading.dart'; import 'package:askaide/page/component/random_avatar.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/page/component/theme/custom_theme.dart'; @@ -24,11 +28,13 @@ import 'package:askaide/repo/model/message.dart'; import 'package:askaide/repo/model/misc.dart'; import 'package:askaide/repo/model/room.dart'; import 'package:askaide/repo/settings_repo.dart'; +import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_initicon/flutter_initicon.dart'; import 'package:flutter_localization/flutter_localization.dart'; import 'package:go_router/go_router.dart'; +import 'package:askaide/repo/model/model.dart' as mm; import '../component/dialog.dart'; @@ -53,8 +59,11 @@ class _RoomChatPageState extends State { final ValueNotifier _inputEnabled = ValueNotifier(true); final ChatPreviewController _chatPreviewController = ChatPreviewController(); final AudioPlayerController _audioPlayerController = - AudioPlayerController(useRemoteAPI: false); + AudioPlayerController(useRemoteAPI: true); bool showAudioPlayer = false; + bool audioLoadding = false; + + List selectedImageFiles = []; @override void initState() { @@ -77,6 +86,11 @@ class _RoomChatPageState extends State { showAudioPlayer = true; }); }; + _audioPlayerController.onPlayAudioLoading = (loading) { + setState(() { + audioLoadding = loading; + }); + }; } @override @@ -101,6 +115,8 @@ class _RoomChatPageState extends State { ); } + mm.Model? roomModel; + Widget _buildChatComponents(CustomColors customColors) { return BlocConsumer( listenWhen: (previous, current) => current is RoomLoaded, @@ -111,6 +127,14 @@ class _RoomChatPageState extends State { .read() .add(FreeCountReloadEvent(model: state.room.model)); } + + if (state is RoomLoaded) { + ModelAggregate.model(state.room.model).then((value) { + setState(() { + roomModel = value; + }); + }); + } }, buildWhen: (previous, current) => current is RoomLoaded, builder: (context, room) { @@ -122,7 +146,10 @@ class _RoomChatPageState extends State { children: [ // 语音输出中提示 if (showAudioPlayer) - EnhancedAudioPlayer(controller: _audioPlayerController), + EnhancedAudioPlayer( + controller: _audioPlayerController, + loading: audioLoadding, + ), // 聊天内容窗口 Expanded( child: _buildChatPreviewArea( @@ -159,11 +186,18 @@ class _RoomChatPageState extends State { ) : ChatInput( enableNotifier: _inputEnabled, - enableImageUpload: false, onSubmit: (value) { _handleSubmit(value); FocusManager.instance.primaryFocus?.unfocus(); }, + enableImageUpload: roomModel != null && + roomModel!.supportVision, + onImageSelected: (files) { + setState(() { + selectedImageFiles = files; + }); + }, + selectedImageFiles: selectedImageFiles, onNewChat: () => handleResetContext(context), hintText: hintText, onVoiceRecordTappedEvent: () { @@ -191,8 +225,13 @@ class _RoomChatPageState extends State { ) { return BlocConsumer( listener: (context, state) { + if (state is ChatMessagesLoaded && state.error == null) { + setState(() { + selectedImageFiles = []; + }); + } // 显示错误提示 - if (state is ChatMessagesLoaded && state.error != null) { + else if (state is ChatMessagesLoaded && state.error != null) { showErrorMessageEnhanced(context, state.error); } else if (state is ChatMessageUpdated) { // 聊天内容窗口滚动到底部 @@ -400,11 +439,57 @@ class _RoomChatPageState extends State { messagetType = MessageType.text, int? index, bool isResent = false, - }) { + }) async { setState(() { _inputEnabled.value = false; }); + if (selectedImageFiles.isNotEmpty) { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: '正在上传图片,请稍后...', + ); + }, + allowClick: false, + ); + + try { + final uploader = ImageUploader(widget.setting); + + for (var file in selectedImageFiles) { + if (file.uploaded) { + continue; + } + + if (file.file.bytes != null) { + final res = await uploader.base64( + imageData: file.file.bytes, + maxSize: 1024 * 1024, + compressWidth: 512, + compressHeight: 512, + ); + file.setUrl(res); + } else { + final res = await uploader.base64( + path: file.file.path!, + maxSize: 1024 * 1024, + compressWidth: 512, + compressHeight: 512, + ); + file.setUrl(res); + } + } + } catch (e) { + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, e); + return; + } finally { + cancel(); + } + } + + // ignore: use_build_context_synchronously context.read().add( ChatMessageSendEvent( Message( @@ -413,13 +498,19 @@ class _RoomChatPageState extends State { user: 'me', ts: DateTime.now(), type: messagetType, + images: selectedImageFiles + .where((e) => e.uploaded) + .map((e) => e.url!) + .toList(), ), index: index, isResent: isResent, ), ); + // ignore: use_build_context_synchronously context.read().add(NotifyResetEvent()); + // ignore: use_build_context_synchronously context .read() .add(RoomLoadEvent(widget.roomId, cascading: false)); @@ -610,6 +701,7 @@ Widget buildSelectModeToolbars( username: e.message.senderName, avatarURL: e.message.avatarUrl, leftSide: e.message.role == Role.receiver, + images: e.message.images, )) .toList(), ), diff --git a/lib/page/component/audio_player.dart b/lib/page/component/audio_player.dart index ad2bdd72..0f5a67ff 100644 --- a/lib/page/component/audio_player.dart +++ b/lib/page/component/audio_player.dart @@ -16,6 +16,7 @@ class AudioPlayerController { Function()? onPlayStopped; Function()? onPlayAudioStarted; + Function(bool loading)? onPlayAudioLoading; final bool useRemoteAPI; @@ -88,8 +89,9 @@ class AudioPlayerController { if (onPlayAudioStarted != null) { onPlayAudioStarted!(); } - + onPlayAudioLoading?.call(true); resetAudioSourcesForPlayer(await APIServer().textToVoice(text: text)); + onPlayAudioLoading?.call(false); playNextAudioForPlayer(); } else { flutterTts.speak(text); @@ -128,7 +130,9 @@ class AudioPlayerController { class EnhancedAudioPlayer extends StatelessWidget { final AudioPlayerController controller; - const EnhancedAudioPlayer({super.key, required this.controller}); + final bool loading; + const EnhancedAudioPlayer( + {super.key, required this.controller, this.loading = false}); @override Widget build(BuildContext context) { @@ -139,27 +143,47 @@ class EnhancedAudioPlayer extends StatelessWidget { const SizedBox(), Padding( padding: const EdgeInsets.only(left: 70), - child: LoadingAnimationWidget.staggeredDotsWave( - color: const Color.fromARGB(255, 254, 170, 74), - size: 25, - ), + child: loading + ? Row( + children: [ + Text( + '语音合成中,请稍候', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12, + ), + ), + const SizedBox(width: 10), + LoadingAnimationWidget.bouncingBall( + color: const Color.fromARGB(255, 254, 170, 74), + size: 25, + ), + ], + ) + : LoadingAnimationWidget.staggeredDotsWave( + color: const Color.fromARGB(255, 254, 170, 74), + size: 25, + ), ), - TextButton.icon( - onPressed: () { - controller.stop(); - }, - icon: Icon( - Icons.stop_circle_outlined, - color: customColors.weakLinkColor, - ), - label: Text( - '停止', - style: TextStyle( + if (!loading) + TextButton.icon( + onPressed: () { + controller.stop(); + }, + icon: Icon( + Icons.stop_circle_outlined, color: customColors.weakLinkColor, - fontSize: 12, ), - ), - ), + label: Text( + '停止', + style: TextStyle( + color: customColors.weakLinkColor, + fontSize: 12, + ), + ), + ) + else + const SizedBox(), ], ); } diff --git a/lib/page/component/chat/chat_input.dart b/lib/page/component/chat/chat_input.dart index 30f0c91f..16109d7d 100644 --- a/lib/page/component/chat/chat_input.dart +++ b/lib/page/component/chat/chat_input.dart @@ -1,13 +1,14 @@ +import 'dart:io'; + import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/haptic_feedback.dart'; import 'package:askaide/helper/platform.dart'; -import 'package:askaide/helper/upload.dart'; import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/voice_record.dart'; import 'package:askaide/page/component/dialog.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/repo/settings_repo.dart'; -import 'package:bot_toast/bot_toast.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -22,6 +23,8 @@ class ChatInput extends StatefulWidget { final ValueNotifier enableNotifier; final Widget? toolbar; final bool enableImageUpload; + final Function(List files)? onImageSelected; + final List? selectedImageFiles; final Function()? onNewChat; final String hintText; final Function()? onVoiceRecordTappedEvent; @@ -37,6 +40,8 @@ class ChatInput extends StatefulWidget { this.hintText = '', this.onVoiceRecordTappedEvent, this.leftSideToolsBuilder, + this.onImageSelected, + this.selectedImageFiles, }); @override @@ -99,20 +104,70 @@ class _ChatInputState extends State { final setting = context.read(); return Column( children: [ - // 工具栏 - if (widget.toolbar != null) - Row( - children: [ - Expanded(child: widget.toolbar!), - Text( - "${_textController.text.length}/$maxLength", - textScaleFactor: 0.8, - style: TextStyle( - color: customColors.chatInputPanelText, - ), - ), - ], + if (widget.selectedImageFiles != null && + widget.selectedImageFiles!.isNotEmpty) + SizedBox( + height: 110, + child: ListView( + scrollDirection: Axis.horizontal, + children: widget.selectedImageFiles! + .map( + (e) => Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.all(5), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: e.file.bytes != null + ? Image.memory( + e.file.bytes!, + fit: BoxFit.cover, + width: 100, + height: 100, + ) + : Image.file( + File(e.file.path!), + fit: BoxFit.cover, + width: 100, + height: 100, + ), + ), + if (widget.enableNotifier.value) + Positioned( + right: 5, + top: 5, + child: InkWell( + onTap: () { + setState(() { + widget.selectedImageFiles!.remove(e); + widget.onImageSelected + ?.call(widget.selectedImageFiles!); + }); + }, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: customColors.chatRoomBackground, + ), + child: Icon( + Icons.close, + size: 10, + color: customColors.weakTextColor, + ), + ), + ), + ), + ], + ), + ), + ) + .toList(), + ), ), + // 工具栏 + if (widget.toolbar != null) widget.toolbar!, // if (widget.toolbar != null) const SizedBox(height: 8), // 聊天内容输入栏 @@ -142,8 +197,11 @@ class _ChatInputState extends State { // 聊天功能按钮 Row( children: [ - if (widget.enableImageUpload && - Ability().supportImageUploader) + if (widget.enableNotifier.value && + widget.enableImageUpload && + Ability().supportImageUploader && + widget.onImageSelected != null && + Ability().supportWebSocket) _buildImageUploadButton( context, setting, customColors), if (widget.leftSideToolsBuilder != null) @@ -257,42 +315,21 @@ class _ChatInputState extends State { return IconButton( onPressed: () async { HapticFeedbackHelper.mediumImpact(); - FilePickerResult? result = - await FilePicker.platform.pickFiles(type: FileType.image); - if (result != null && result.files.isNotEmpty) { - var cancel = BotToast.showCustomLoading( - toastBuilder: (void Function() cancelFunc) { - return Container( - padding: const EdgeInsets.all(15), - decoration: const BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.all(Radius.circular(8))), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - AppLocale.uploading.getString(context), - style: const TextStyle(color: Colors.white), - ), - const SizedBox(height: 10), - const CircularProgressIndicator( - backgroundColor: Colors.white, - ), - ], - ), - ); - }); - - var upload = ImageUploader(setting).upload(result.files.single.path!); + if (widget.selectedImageFiles != null && + widget.selectedImageFiles!.length >= 4) { + showSuccessMessage('最多只能上传 4 张图片'); + return; + } - upload.then((value) { - _handleSubmited( - '![${value.name}](${value.url})', - notSend: true, - ); - }).onError((error, stackTrace) { - showErrorMessageEnhanced(context, error!); - }).whenComplete(() => cancel()); + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: true, + ); + if (result != null && result.files.isNotEmpty) { + final files = widget.selectedImageFiles ?? []; + files.addAll(result.files.map((e) => FileUpload(file: e)).toList()); + widget.onImageSelected + ?.call(files.sublist(0, files.length > 4 ? 4 : files.length)); } }, icon: const Icon(Icons.camera_alt), diff --git a/lib/page/component/chat/chat_preview.dart b/lib/page/component/chat/chat_preview.dart index 342ff0f9..a1cd5911 100644 --- a/lib/page/component/chat/chat_preview.dart +++ b/lib/page/component/chat/chat_preview.dart @@ -9,8 +9,10 @@ import 'package:askaide/helper/helper.dart'; import 'package:askaide/lang/lang.dart'; import 'package:askaide/page/component/attached_button_panel.dart'; import 'package:askaide/page/component/chat/chat_share.dart'; +import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/message_state_manager.dart'; import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/image_preview.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/repo/api_server.dart'; import 'package:bot_toast/bot_toast.dart'; @@ -231,226 +233,247 @@ class _ChatPreviewState extends State { message.role == Role.sender ? Alignment.topRight : Alignment.topLeft, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: _chatBoxMaxWidth(context)), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - // 消息头像 - buildAvatar(message), - // 消息内容部分 - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: _chatBoxMaxWidth(context) - 80, + if (message.images != null && message.images!.isNotEmpty) + Container( + margin: message.role == Role.sender + ? const EdgeInsets.fromLTRB(0, 0, 10, 7) + : const EdgeInsets.fromLTRB(10, 0, 0, 7), + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: _chatBoxMaxWidth(context) / 2), + child: FileUploadPreview(images: message.images ?? []), + ), ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 发送人名称 - if (message.role == Role.receiver && - widget.senderNameBuilder != null) - widget.senderNameBuilder!(message) ?? const SizedBox(), - Wrap( - crossAxisAlignment: WrapCrossAlignment.end, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 消息头像 + buildAvatar(message), + // 消息内容部分 + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: _chatBoxMaxWidth(context) - 80, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 错误指示器 - if (message.role == Role.sender && - message.statusIsFailed()) - buildErrorIndicator(message, state, context, index), - // 消息主体 - GestureDetector( - // 选择模式下,单击切换选择与否 - // 非选择模式下,单击隐藏键盘 - onTap: () { - if (widget.controller.selectMode) { - widget.controller - .toggleMessageSelected(message.id!); - } - FocusScope.of(context).requestFocus(FocusNode()); - }, - // 长按或者双击显示上下文菜单 - onLongPressStart: (detail) { - _handleMessageTapControl( - context, - detail.globalPosition, - message, - state, - index, - ); - }, - onDoubleTapDown: (details) { - _handleMessageTapControl( - context, - details.globalPosition, - message, - state, - index, - ); - }, - child: Stack( - children: [ - Container( - margin: message.role == Role.sender - ? const EdgeInsets.fromLTRB(0, 0, 10, 7) - : const EdgeInsets.fromLTRB(10, 0, 0, 7), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: message.role == Role.receiver - ? customColors.chatRoomReplyBackground - : customColors.chatRoomSenderBackground, - ), - padding: const EdgeInsets.symmetric( - horizontal: 13, - vertical: 13, - ), - child: Builder( - builder: (context) { - if ((message.statusPending() || - !message.isReady) && - message.text.isEmpty) { - return LoadingAnimationWidget.waveDots( - color: customColors.weakLinkColor!, - size: 25, - ); - } - - var text = message.text; - if (!message.isReady && text != '') { - text += ' ▌'; - } - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - state.showMarkdown - ? Markdown( - data: text.trim(), - onUrlTap: (value) => - launchUrlString(value), - ) - : SelectableText( - text, - style: TextStyle( - color: customColors - .chatRoomSenderText, - ), - ), - if (message.quotaConsumed != null && - message.quotaConsumed! > 0) - Row( - children: [ - const Icon(Icons.check_circle, - size: 12, color: Colors.green), - const SizedBox(width: 5), - Expanded( - child: Text( - '共 ${message.tokenConsumed} 个 Token, 消耗 ${message.quotaConsumed} 个智慧果', - style: TextStyle( - fontSize: 14, - color: customColors - .weakTextColor, + // 发送人名称 + if (message.role == Role.receiver && + widget.senderNameBuilder != null) + widget.senderNameBuilder!(message) ?? const SizedBox(), + Wrap( + crossAxisAlignment: WrapCrossAlignment.end, + children: [ + // 错误指示器 + if (message.role == Role.sender && + message.statusIsFailed()) + buildErrorIndicator(message, state, context, index), + // 消息主体 + GestureDetector( + // 选择模式下,单击切换选择与否 + // 非选择模式下,单击隐藏键盘 + onTap: () { + if (widget.controller.selectMode) { + widget.controller + .toggleMessageSelected(message.id!); + } + FocusScope.of(context).requestFocus(FocusNode()); + }, + // 长按或者双击显示上下文菜单 + onLongPressStart: (detail) { + _handleMessageTapControl( + context, + detail.globalPosition, + message, + state, + index, + ); + }, + onDoubleTapDown: (details) { + _handleMessageTapControl( + context, + details.globalPosition, + message, + state, + index, + ); + }, + child: Stack( + children: [ + Container( + margin: message.role == Role.sender + ? const EdgeInsets.fromLTRB(0, 0, 10, 7) + : const EdgeInsets.fromLTRB(10, 0, 0, 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: message.role == Role.receiver + ? customColors.chatRoomReplyBackground + : customColors.chatRoomSenderBackground, + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + child: Builder( + builder: (context) { + if ((message.statusPending() || + !message.isReady) && + message.text.isEmpty) { + return LoadingAnimationWidget.waveDots( + color: customColors.weakLinkColor!, + size: 25, + ); + } + + var text = message.text; + if (!message.isReady && text != '') { + text += ' ▌'; + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + state.showMarkdown + ? Markdown( + data: text.trim(), + onUrlTap: (value) => + launchUrlString(value), + ) + : SelectableText( + text, + style: TextStyle( + color: customColors + .chatRoomSenderText, + ), + ), + if (message.quotaConsumed != null && + message.quotaConsumed! > 0) + Row( + children: [ + const Icon(Icons.check_circle, + size: 12, + color: Colors.green), + const SizedBox(width: 5), + Expanded( + child: Text( + '共 ${message.tokenConsumed} 个 Token, 消耗 ${message.quotaConsumed} 个智慧果', + style: TextStyle( + fontSize: 14, + color: customColors + .weakTextColor, + ), + ), ), - ), + ], ), - ], - ), - ], - ); - }, - ), - ), - if (extraInfo.isNotEmpty) - Positioned( - top: 5, - right: 5, - child: InkWell( - onTap: () { - showCustomBeautyDialog( - context, - type: QuickAlertType.warning, - confirmBtnText: - AppLocale.gotIt.getString(context), - showCancelBtn: false, - title: '温馨提示', - child: Markdown( - data: extraInfo, - onUrlTap: (value) { - onMarkdownUrlTap(value); - context.pop(); - }, - textStyle: TextStyle( - fontSize: 14, - color: customColors - .dialogDefaultTextColor, - ), - ), - ); - }, - child: Icon( - Icons.info_outline, - size: 16, - color: customColors.weakLinkColor - ?.withAlpha(50), + ], + ); + }, ), ), - ), - ], - ), - ), - ], - ), - if (showTranslate) - Container( - margin: message.role == Role.sender - ? const EdgeInsets.fromLTRB(7, 10, 14, 7) - : const EdgeInsets.fromLTRB(10, 10, 0, 7), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: message.role == Role.receiver - ? customColors.chatRoomReplyBackgroundSecondary - : customColors.chatRoomSenderBackgroundSecondary, - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - child: Builder( - builder: (context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - state.showMarkdown - ? Markdown(data: state.translateText!) - : SelectableText( - state.translateText!, - style: TextStyle( - color: customColors.chatRoomSenderText, + if (extraInfo.isNotEmpty) + Positioned( + top: 5, + right: 5, + child: InkWell( + onTap: () { + showCustomBeautyDialog( + context, + type: QuickAlertType.warning, + confirmBtnText: AppLocale.gotIt + .getString(context), + showCancelBtn: false, + title: '温馨提示', + child: Markdown( + data: extraInfo, + onUrlTap: (value) { + onMarkdownUrlTap(value); + context.pop(); + }, + textStyle: TextStyle( + fontSize: 14, + color: customColors + .dialogDefaultTextColor, + ), + ), + ); + }, + child: Icon( + Icons.info_outline, + size: 16, + color: customColors.weakLinkColor + ?.withAlpha(50), ), ), - const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.check_circle, - size: 12, - color: Colors.green, ), - SizedBox(width: 5), - Text( - '翻译完成', - style: TextStyle( - fontSize: 12, - color: Color.fromARGB(255, 145, 145, 145), - ), + ], + ), + ), + ], + ), + if (showTranslate) + Container( + margin: message.role == Role.sender + ? const EdgeInsets.fromLTRB(7, 10, 14, 7) + : const EdgeInsets.fromLTRB(10, 10, 0, 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: message.role == Role.receiver + ? customColors.chatRoomReplyBackgroundSecondary + : customColors + .chatRoomSenderBackgroundSecondary, + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + child: Builder( + builder: (context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + state.showMarkdown + ? Markdown(data: state.translateText!) + : SelectableText( + state.translateText!, + style: TextStyle( + color: + customColors.chatRoomSenderText, + ), + ), + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 12, + color: Colors.green, + ), + SizedBox(width: 5), + Text( + '翻译完成', + style: TextStyle( + fontSize: 12, + color: Color.fromARGB( + 255, 145, 145, 145), + ), + ), + ], ), ], - ), - ], - ); - }, - ), - ) - ], - ), + ); + }, + ), + ) + ], + ), + ), + ], ), ], ), @@ -703,6 +726,7 @@ class _ChatPreviewState extends State { var q = questions.first; messages.add(ChatShareMessage( content: q.message.text, + images: q.message.images, leftSide: false, )); } @@ -710,6 +734,7 @@ class _ChatPreviewState extends State { messages.add(ChatShareMessage( content: message.text, + images: message.images, leftSide: message.role == Role.receiver, avatarURL: message.avatarUrl, username: message.senderName, @@ -723,6 +748,7 @@ class _ChatPreviewState extends State { for (var a in answers) { messages.add(ChatShareMessage( content: a.message.text, + images: a.message.images, leftSide: true, avatarURL: a.message.avatarUrl, username: a.message.senderName, diff --git a/lib/page/component/chat/chat_share.dart b/lib/page/component/chat/chat_share.dart index de6bdb16..c10e9074 100644 --- a/lib/page/component/chat/chat_share.dart +++ b/lib/page/component/chat/chat_share.dart @@ -1,12 +1,16 @@ +import 'dart:convert'; + import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/helper.dart'; import 'package:askaide/helper/image.dart'; import 'package:askaide/helper/platform.dart'; import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/markdown.dart'; import 'package:askaide/page/component/enhanced_popup_menu.dart'; import 'package:askaide/page/component/image.dart'; +import 'package:askaide/page/component/image_preview.dart'; import 'package:askaide/page/component/loading.dart'; import 'package:askaide/page/component/random_avatar.dart'; import 'package:askaide/page/component/share.dart'; @@ -27,12 +31,14 @@ class ChatShareMessage { final String content; final String? avatarURL; final bool leftSide; + final List? images; const ChatShareMessage({ this.username, required this.content, this.avatarURL, this.leftSide = true, + this.images, }); } @@ -295,6 +301,15 @@ class _ChatShareScreenState extends State { ), ], ), + if (message.images != null && message.images!.isNotEmpty) + Container( + margin: const EdgeInsets.fromLTRB(0, 10, 10, 0), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: _chatBoxMaxWidth(context) / 2), + child: FileUploadPreview(images: message.images ?? []), + ), + ), ConstrainedBox( constraints: BoxConstraints( maxWidth: _chatBoxMaxWidth(context), @@ -308,8 +323,8 @@ class _ChatShareScreenState extends State { : customColors.chatRoomSenderBackground, ), padding: const EdgeInsets.symmetric( - horizontal: 13, - vertical: 13, + horizontal: 10, + vertical: 8, ), child: Builder( builder: (context) { @@ -358,8 +373,21 @@ class _ChatShareScreenState extends State { ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: message.leftSide + ? CrossAxisAlignment.start + : CrossAxisAlignment.end, children: [ + if (message.images != null && + message.images!.isNotEmpty) + Container( + margin: const EdgeInsets.fromLTRB(0, 0, 10, 7), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: _chatBoxMaxWidth(context) / 2), + child: FileUploadPreview( + images: message.images ?? []), + ), + ), if (message.username != null && message.leftSide) Container( margin: const EdgeInsets.fromLTRB(0, 0, 10, 7), @@ -384,8 +412,8 @@ class _ChatShareScreenState extends State { : customColors.chatRoomSenderBackground, ), padding: const EdgeInsets.symmetric( - horizontal: 13, - vertical: 13, + horizontal: 10, + vertical: 8, ), child: Builder( builder: (context) { diff --git a/lib/page/component/chat/file_upload.dart b/lib/page/component/chat/file_upload.dart new file mode 100644 index 00000000..96f60250 --- /dev/null +++ b/lib/page/component/chat/file_upload.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:askaide/page/component/image_preview.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/widgets.dart'; + +class FileUpload { + final PlatformFile file; + String? url; + + FileUpload({required this.file, this.url}); + + bool get uploaded => url != null; + + setUrl(String url) { + this.url = url; + } +} + +class FileUploadPreview extends StatelessWidget { + final List images; + const FileUploadPreview({super.key, required this.images}); + + @override + Widget build(BuildContext context) { + final children = images + .map((e) { + if (e.startsWith('http://') || e.startsWith('https://')) { + return NetworkImagePreviewer( + url: e, + hidePreviewButton: true, + ); + } + + if (e.startsWith('data:')) { + return ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.memory( + const Base64Decoder().convert(e.split(',')[1]), + fit: BoxFit.cover, + ), + ); + } + return const SizedBox(); + }) + .map((e) => Padding( + padding: const EdgeInsets.only(bottom: 5, left: 5), + child: e, + )) + .toList(); + if (children.length > 1) { + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + children: children, + ); + } + + return ListView( + shrinkWrap: true, + children: children, + ); + } +} diff --git a/lib/page/component/chat/markdown.dart b/lib/page/component/chat/markdown.dart index edcf050f..dcccc96d 100644 --- a/lib/page/component/chat/markdown.dart +++ b/lib/page/component/chat/markdown.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:askaide/helper/platform.dart'; import 'package:askaide/page/component/chat/markdown/latex.dart'; import 'package:askaide/page/component/image_preview.dart'; @@ -80,7 +82,10 @@ class Markdown extends StatelessWidget { ); } - return Image.network(uri.toString()); + return ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.network(uri.toString()), + ); }, extensionSet: ExtensionSet.gitHubFlavored, data: data, @@ -140,6 +145,16 @@ class MarkdownPlus extends StatelessWidget { return const SizedBox(); } + if (url.startsWith('data:')) { + return ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.memory( + const Base64Decoder().convert(url.split(',')[1]), + fit: BoxFit.cover, + ), + ); + } + return NetworkImagePreviewer( url: url, hidePreviewButton: true, diff --git a/lib/page/component/model_indicator.dart b/lib/page/component/model_indicator.dart index b6f6465c..b5dcaed7 100644 --- a/lib/page/component/model_indicator.dart +++ b/lib/page/component/model_indicator.dart @@ -7,6 +7,7 @@ class ModelIndicatorInfo { String modelId; String modelName; String description; + bool supportVision; ModelIndicatorInfo({ required this.modelName, @@ -14,6 +15,7 @@ class ModelIndicatorInfo { required this.description, required this.icon, required this.activeColor, + this.supportVision = false, }); } diff --git a/lib/repo/api/info.dart b/lib/repo/api/info.dart index f2007235..d021ffb8 100644 --- a/lib/repo/api/info.dart +++ b/lib/repo/api/info.dart @@ -117,12 +117,16 @@ class HomeModel { /// 是否是强大的模型 final bool powerful; + /// 是否支持视觉 + final bool supportVision; + HomeModel({ required this.name, required this.modelId, required this.desc, required this.color, this.powerful = false, + this.supportVision = false, }); factory HomeModel.fromJson(Map json) => HomeModel( @@ -131,6 +135,7 @@ class HomeModel { desc: json["desc"] ?? '', color: json["color"] ?? 'FF67AC5C', powerful: json['powerful'] ?? false, + supportVision: json['support_vision'] ?? false, ); Map toJson() => { @@ -139,5 +144,6 @@ class HomeModel { "desc": desc, "color": color, "powerful": powerful, + "support_vision": supportVision, }; } diff --git a/lib/repo/model/chat_message.dart b/lib/repo/model/chat_message.dart new file mode 100644 index 00000000..cf98d7bb --- /dev/null +++ b/lib/repo/model/chat_message.dart @@ -0,0 +1,34 @@ +import 'package:dart_openai/openai.dart'; + +class ChatMessage extends OpenAIChatCompletionChoiceMessageModel { + final List? images; + ChatMessage({required super.role, required super.content, this.images}); + + @override + Map toMap() { + if (images == null || images!.isEmpty) { + return { + "role": role.name, + "content": content, + }; + } + + return { + "role": role.name, + "content": content, + "multipart_content": [ + ...(images + ?.map((e) => { + 'type': 'image_url', + 'image_url': {'url': e} + }) + .toList() ?? + []), + { + 'type': 'text', + 'text': content, + }, + ], + }; + } +} diff --git a/lib/repo/model/message.dart b/lib/repo/model/message.dart index 7b5d175e..15838797 100644 --- a/lib/repo/model/message.dart +++ b/lib/repo/model/message.dart @@ -61,6 +61,9 @@ class Message { /// 消息发送者的名称,不需要持久化 String? senderName; + /// 消息图片列表 + List? images; + Message( this.role, this.text, { @@ -80,6 +83,7 @@ class Message { this.tokenConsumed, this.avatarUrl, this.senderName, + this.images, }); /// 获取消息附加信息 @@ -133,6 +137,15 @@ class Message { return status == 0; } + String get markdownWithImages { + var t = text; + if (images != null && images!.isNotEmpty) { + t = images!.map((e) => '![img]($e)\n\n').join('') + t; + } + + return t; + } + Map toMap() { return { 'id': id, @@ -151,6 +164,7 @@ class Message { 'status': status, 'token_consumed': tokenConsumed, 'quota_consumed': quotaConsumed, + 'images': images != null ? jsonEncode(images) : null, }; } @@ -172,7 +186,11 @@ class Message { ts = map['ts'] == null ? null : DateTime.fromMillisecondsSinceEpoch(map['ts'] as int), - roomId = map['room_id'] as int?; + roomId = map['room_id'] as int?, + images = map['images'] == null + ? null + : (jsonDecode(map['images'] as String) as List) + .cast(); } enum Role { diff --git a/lib/repo/model/misc.dart b/lib/repo/model/misc.dart index 7ac5dae9..48507377 100644 --- a/lib/repo/model/misc.dart +++ b/lib/repo/model/misc.dart @@ -511,6 +511,7 @@ class Model { bool disabled; String? tag; String? avatarUrl; + bool supportVision; String get realModelId { return id.split(':').last; @@ -527,6 +528,7 @@ class Model { this.disabled = false, this.tag, this.avatarUrl, + this.supportVision = false, }); toJson() => { @@ -540,6 +542,7 @@ class Model { 'disabled': disabled, 'tag': tag, 'avatar_url': avatarUrl, + 'support_vision': supportVision, }; static Model fromJson(Map json) { @@ -554,6 +557,7 @@ class Model { disabled: json['disabled'] ?? false, tag: json['tag'], avatarUrl: json['avatar_url'], + supportVision: json['support_vision'] ?? false, ); } } diff --git a/lib/repo/model/model.dart b/lib/repo/model/model.dart index 190f8243..9bad2b21 100644 --- a/lib/repo/model/model.dart +++ b/lib/repo/model/model.dart @@ -9,6 +9,7 @@ class Model { bool disabled; String? tag; String? avatarUrl; + bool supportVision = false; Model( this.id, @@ -21,6 +22,7 @@ class Model { this.disabled = false, this.tag, this.avatarUrl, + this.supportVision = false, }); String uid() { diff --git a/lib/repo/openai_repo.dart b/lib/repo/openai_repo.dart index 82785a00..55d1d836 100644 --- a/lib/repo/openai_repo.dart +++ b/lib/repo/openai_repo.dart @@ -5,6 +5,7 @@ import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/env.dart'; import 'package:askaide/helper/platform.dart'; +import 'package:askaide/repo/model/chat_message.dart'; import 'package:askaide/repo/model/model.dart' as mm; import 'package:dart_openai/openai.dart'; import 'package:askaide/repo/data/settings_data.dart'; @@ -244,7 +245,7 @@ class OpenAIRepository { } Future chatStream( - List messages, + List messages, void Function(ChatStreamRespData data) onData, { double temperature = 1.0, user = 'user', diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index a0f6f672..ddccb752 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -30,6 +30,8 @@ NSApplication NSMicrophoneUsageDescription We need to access to the microphone to record audio file + NSPhotoLibraryUsageDescription + We need to access to the photo library to pick files for upload NSAppTransportSecurity NSAllowsArbitraryLoads From d871417f950868ba626e7fba0d60cfccb4db4da0 Mon Sep 17 00:00:00 2001 From: mylxsw Date: Fri, 24 Nov 2023 18:21:23 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E5=88=9B=E4=BD=9C=E5=B2=9B=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=B8=8D=E5=90=8C=E5=A4=A7=E5=B0=8F=E7=9A=84=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=A8=A1=E5=9D=97=E5=B1=95=E7=A4=BA,=E7=BE=A4?= =?UTF-8?q?=E8=81=8A=E9=BB=98=E8=AE=A4=E5=85=A8=E9=80=89=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=88=90=E5=91=98,=E8=AF=AD=E9=9F=B3=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E6=A0=B7=E5=BC=8F=E5=BE=AE=E8=B0=83,=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E6=A8=A1=E5=9E=8B=E5=9B=BE=E7=89=87=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E6=94=BE=E5=A4=A7=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bloc/group_chat_bloc.dart | 8 +- lib/helper/global_store.dart | 1 - lib/page/component/audio_player.dart | 74 +++--- lib/page/component/chat/chat_preview.dart | 35 ++- lib/page/component/chat/chat_share.dart | 54 ++--- lib/page/component/chat/file_upload.dart | 18 +- lib/page/component/image_preview.dart | 218 ++++++++++++------ .../draw/components/creative_item.dart | 88 +++++-- lib/page/creative_island/draw/draw_list.dart | 104 ++++++--- lib/repo/api/creative.dart | 4 + 10 files changed, 392 insertions(+), 212 deletions(-) diff --git a/lib/bloc/group_chat_bloc.dart b/lib/bloc/group_chat_bloc.dart index 1e6a3b39..e269d54f 100644 --- a/lib/bloc/group_chat_bloc.dart +++ b/lib/bloc/group_chat_bloc.dart @@ -23,10 +23,14 @@ class GroupChatBloc extends Bloc { await APIServer().chatGroup(event.groupId, cache: !event.forceUpdate); final states = await stateManager.loadRoomStates(event.groupId); + final defaultChatMembers = await loadDefaultChatMembers(event.groupId); + emit(GroupChatLoaded( group: group, states: states, - defaultChatMembers: await loadDefaultChatMembers(event.groupId), + defaultChatMembers: defaultChatMembers.isEmpty + ? group.members.map((e) => e.id!).toList() + : defaultChatMembers, )); }); @@ -68,8 +72,6 @@ class GroupChatBloc extends Bloc { ), ); - Logger.instance.d(resp.toJson()); - // 记录默认聊天成员 updateDefaultChatMembers( event.groupId, diff --git a/lib/helper/global_store.dart b/lib/helper/global_store.dart index 4a518bd8..3b62c84c 100644 --- a/lib/helper/global_store.dart +++ b/lib/helper/global_store.dart @@ -1,4 +1,3 @@ -import 'package:askaide/helper/upload.dart'; import 'package:askaide/page/component/chat/file_upload.dart'; class GlobalStore { diff --git a/lib/page/component/audio_player.dart b/lib/page/component/audio_player.dart index 0f5a67ff..c1c6e4b0 100644 --- a/lib/page/component/audio_player.dart +++ b/lib/page/component/audio_player.dart @@ -140,50 +140,50 @@ class EnhancedAudioPlayer extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const SizedBox(), - Padding( - padding: const EdgeInsets.only(left: 70), - child: loading - ? Row( - children: [ - Text( - '语音合成中,请稍候', - style: TextStyle( - color: customColors.weakTextColor, - fontSize: 12, - ), + const SizedBox(width: 100), + loading + ? Row( + children: [ + Text( + '语音合成中,请稍候', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12, ), - const SizedBox(width: 10), - LoadingAnimationWidget.bouncingBall( - color: const Color.fromARGB(255, 254, 170, 74), - size: 25, - ), - ], - ) - : LoadingAnimationWidget.staggeredDotsWave( - color: const Color.fromARGB(255, 254, 170, 74), - size: 25, - ), - ), + ), + const SizedBox(width: 10), + LoadingAnimationWidget.fourRotatingDots( + color: const Color.fromARGB(255, 254, 170, 74), + size: 12, + ), + ], + ) + : LoadingAnimationWidget.staggeredDotsWave( + color: const Color.fromARGB(255, 254, 170, 74), + size: 25, + ), if (!loading) - TextButton.icon( - onPressed: () { - controller.stop(); - }, - icon: Icon( - Icons.stop_circle_outlined, - color: customColors.weakLinkColor, - ), - label: Text( - '停止', - style: TextStyle( + SizedBox( + width: 100, + child: TextButton.icon( + onPressed: () { + controller.stop(); + }, + icon: Icon( + Icons.stop_circle_outlined, color: customColors.weakLinkColor, - fontSize: 12, + ), + label: Text( + '停止', + style: TextStyle( + color: customColors.weakLinkColor, + fontSize: 12, + ), ), ), ) else - const SizedBox(), + const SizedBox(width: 100), ], ); } diff --git a/lib/page/component/chat/chat_preview.dart b/lib/page/component/chat/chat_preview.dart index a1cd5911..8f0085d3 100644 --- a/lib/page/component/chat/chat_preview.dart +++ b/lib/page/component/chat/chat_preview.dart @@ -12,7 +12,6 @@ import 'package:askaide/page/component/chat/chat_share.dart'; import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/message_state_manager.dart'; import 'package:askaide/page/component/dialog.dart'; -import 'package:askaide/page/component/image_preview.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/repo/api_server.dart'; import 'package:bot_toast/bot_toast.dart'; @@ -201,25 +200,6 @@ class _ChatPreviewState extends State { ); } - // 初始消息 - // if (message.isInitMessage()) { - // return Align( - // alignment: Alignment.center, - // child: Container( - // padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - // margin: const EdgeInsets.symmetric(horizontal: 30), - // decoration: BoxDecoration( - // borderRadius: BorderRadius.circular(10), - // color: customColors.chatRoomReplyBackground, - // ), - // child: Text( - // message.text, - // style: Theme.of(context).textTheme.bodySmall, - // ), - // ), - // ); - // } - final showTranslate = state.showTranslate && state.translateText != null && state.translateText != ''; @@ -243,8 +223,12 @@ class _ChatPreviewState extends State { ? const EdgeInsets.fromLTRB(0, 0, 10, 7) : const EdgeInsets.fromLTRB(10, 0, 0, 7), child: ConstrainedBox( - constraints: - BoxConstraints(maxWidth: _chatBoxMaxWidth(context) / 2), + constraints: BoxConstraints( + maxWidth: _chatBoxImagePreviewWidth( + context, + (message.images ?? []).length, + ), + ), child: FileUploadPreview(images: message.images ?? []), ), ), @@ -882,6 +866,13 @@ class _ChatPreviewState extends State { return screenWidth; } + + /// 获取图片预览的最大宽度 + double _chatBoxImagePreviewWidth(BuildContext context, int imageCount) { + final expect = _chatBoxMaxWidth(context) / 1.3; + final max = imageCount > 1 ? 400.0 : 300.0; + return expect > max ? max : expect; + } } /// ChatPreview 控制器 diff --git a/lib/page/component/chat/chat_share.dart b/lib/page/component/chat/chat_share.dart index c10e9074..446d5662 100644 --- a/lib/page/component/chat/chat_share.dart +++ b/lib/page/component/chat/chat_share.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/helper.dart'; @@ -10,7 +8,6 @@ import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/markdown.dart'; import 'package:askaide/page/component/enhanced_popup_menu.dart'; import 'package:askaide/page/component/image.dart'; -import 'package:askaide/page/component/image_preview.dart'; import 'package:askaide/page/component/loading.dart'; import 'package:askaide/page/component/random_avatar.dart'; import 'package:askaide/page/component/share.dart'; @@ -181,7 +178,7 @@ class _ChatShareScreenState extends State { alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints( - maxWidth: CustomSize.smallWindowSize, + maxWidth: CustomSize.maxWindowSize, ), child: SafeArea( child: SingleChildScrollView( @@ -211,25 +208,21 @@ class _ChatShareScreenState extends State { Widget buildShareWindow(CustomColors customColors, BuildContext context, AsyncSnapshot snapshot) { - return Column( - children: [ - WidgetsToImage( - controller: controller, - child: Container( - color: customColors.backgroundContainerColor, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - usingChatStyle - ? buildChatPreview(context, customColors) - : buildListPreview(context, customColors), - if (showQRCode) buildQRCodePanel(customColors, snapshot), - ], - ), - ), + return WidgetsToImage( + controller: controller, + child: Container( + color: customColors.backgroundContainerColor, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + usingChatStyle + ? buildChatPreview(context, customColors) + : buildListPreview(context, customColors), + if (showQRCode) buildQRCodePanel(customColors, snapshot), + ], ), - ], + ), ); } @@ -306,7 +299,8 @@ class _ChatShareScreenState extends State { margin: const EdgeInsets.fromLTRB(0, 10, 10, 0), child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: _chatBoxMaxWidth(context) / 2), + maxWidth: _chatBoxImagePreviewWidth( + context, (message.images ?? []).length)), child: FileUploadPreview(images: message.images ?? []), ), ), @@ -383,7 +377,8 @@ class _ChatShareScreenState extends State { margin: const EdgeInsets.fromLTRB(0, 0, 10, 7), child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: _chatBoxMaxWidth(context) / 2), + maxWidth: _chatBoxImagePreviewWidth(context, + (message.images ?? []).length)), child: FileUploadPreview( images: message.images ?? []), ), @@ -437,13 +432,20 @@ class _ChatShareScreenState extends State { /// 获取聊天框的最大宽度 double _chatBoxMaxWidth(BuildContext context) { var screenWidth = MediaQuery.of(context).size.width; - if (screenWidth >= CustomSize.smallWindowSize) { - return CustomSize.smallWindowSize; + if (screenWidth >= CustomSize.maxWindowSize) { + return CustomSize.maxWindowSize; } return screenWidth; } + /// 获取图片预览的最大宽度 + double _chatBoxImagePreviewWidth(BuildContext context, int imageCount) { + final expect = _chatBoxMaxWidth(context) / 1.3; + final max = imageCount > 1 ? 500.0 : 300.0; + return expect > max ? max : expect; + } + Widget _buildAvatar({String? avatarUrl, int? id, int size = 30}) { if (avatarUrl != null && avatarUrl.startsWith('http')) { return RemoteAvatar( diff --git a/lib/page/component/chat/file_upload.dart b/lib/page/component/chat/file_upload.dart index 96f60250..709c8dd3 100644 --- a/lib/page/component/chat/file_upload.dart +++ b/lib/page/component/chat/file_upload.dart @@ -33,11 +33,10 @@ class FileUploadPreview extends StatelessWidget { } if (e.startsWith('data:')) { - return ClipRRect( + return ImageProviderPreviewer( borderRadius: BorderRadius.circular(5), - child: Image.memory( + imageProvider: MemoryImage( const Base64Decoder().convert(e.split(',')[1]), - fit: BoxFit.cover, ), ); } @@ -49,6 +48,19 @@ class FileUploadPreview extends StatelessWidget { )) .toList(); if (children.length > 1) { + if (children.length % 2 == 1) { + return Column( + children: [ + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + children: children.sublist(0, children.length - 1), + ), + children.last, + ], + ); + } + return GridView.count( crossAxisCount: 2, shrinkWrap: true, diff --git a/lib/page/component/image_preview.dart b/lib/page/component/image_preview.dart index 6f60b77a..89872adb 100644 --- a/lib/page/component/image_preview.dart +++ b/lib/page/component/image_preview.dart @@ -268,10 +268,12 @@ void openImagePreviewDialog( BuildContext context, CustomColors customColors, { required ImageProvider imageProvider, - required String imageUrl, + String? imageUrl, String? originalURL, String? description, }) { + final downloadUrl = originalURL ?? imageUrl; + Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, @@ -285,90 +287,127 @@ void openImagePreviewDialog( }, ), actions: [ - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - fullscreenDialog: true, - builder: (context) => GalleryItemShareScreen( - images: [imageUrl], + if (imageUrl != null) + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => GalleryItemShareScreen( + images: [imageUrl], + ), ), - ), - ); - }, - icon: Icon( - Icons.share, - size: 16, - color: customColors.weakLinkColor, + ); + }, + icon: Icon( + Icons.share, + size: 16, + color: customColors.weakLinkColor, + ), ), - ), - IconButton( - onPressed: () async { - final cancel = BotToast.showCustomLoading( - toastBuilder: (cancel) { - return const LoadingIndicator( - message: '下载中,请稍候...', - ); - }, - allowClick: false, - duration: const Duration(seconds: 120), - ); + if (downloadUrl != null) + IconButton( + onPressed: () async { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: '下载中,请稍候...', + ); + }, + allowClick: false, + duration: const Duration(seconds: 120), + ); + + try { + final saveFile = + await DefaultCacheManager().getSingleFile(downloadUrl); + + if (PlatformTool.isIOS() || PlatformTool.isAndroid()) { + await ImageGallerySaver.saveImage( + saveFile.readAsBytesSync(), + quality: 100, + ); - try { - final saveFile = await DefaultCacheManager() - .getSingleFile(originalURL ?? imageUrl); + showSuccessMessage('图片保存成功'); + } else { + var ext = saveFile.path.toLowerCase().split('.').last; + MimeType mimeType; + switch (ext) { + case 'jpg': + case 'jpeg': + mimeType = MimeType.jpeg; + break; + case 'png': + mimeType = MimeType.png; + break; + case 'gif': + mimeType = MimeType.gif; + break; + default: + mimeType = MimeType.other; + } - if (PlatformTool.isIOS() || PlatformTool.isAndroid()) { + FileSaver.instance + .saveFile( + name: filenameWithoutExt(saveFile.path.split('/').last), + filePath: saveFile.path, + ext: ext, + mimeType: mimeType, + ) + .then((value) { + showSuccessMessage('文件保存成功'); + }); + } + } catch (e) { + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, '图片保存失败,请稍后再试'); + Logger.instance.e('下载图片原图失败', error: e); + } finally { + cancel(); + } + }, + icon: Icon( + Icons.download_sharp, + size: 16, + color: customColors.weakLinkColor, + ), + ), + if (downloadUrl == null && + (PlatformTool.isIOS() || PlatformTool.isAndroid())) + IconButton( + onPressed: () async { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: '下载中,请稍候...', + ); + }, + allowClick: false, + duration: const Duration(seconds: 120), + ); + + try { await ImageGallerySaver.saveImage( - saveFile.readAsBytesSync(), + (imageProvider as MemoryImage).bytes, quality: 100, ); showSuccessMessage('图片保存成功'); - } else { - var ext = saveFile.path.toLowerCase().split('.').last; - MimeType mimeType; - switch (ext) { - case 'jpg': - case 'jpeg': - mimeType = MimeType.jpeg; - break; - case 'png': - mimeType = MimeType.png; - break; - case 'gif': - mimeType = MimeType.gif; - break; - default: - mimeType = MimeType.other; - } - - FileSaver.instance - .saveFile( - name: filenameWithoutExt(saveFile.path.split('/').last), - filePath: saveFile.path, - ext: ext, - mimeType: mimeType, - ) - .then((value) { - showSuccessMessage('文件保存成功'); - }); + } catch (e) { + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, '图片保存失败,请稍后再试'); + Logger.instance.e('下载图片原图失败', error: e); + } finally { + cancel(); } - } catch (e) { - // ignore: use_build_context_synchronously - showErrorMessageEnhanced(context, '图片保存失败,请稍后再试'); - Logger.instance.e('下载图片原图失败', error: e); - } finally { - cancel(); - } - }, - icon: Icon( - Icons.download_sharp, - size: 16, - color: customColors.weakLinkColor, - ), - ), + }, + icon: Icon( + Icons.download_sharp, + size: 16, + color: customColors.weakLinkColor, + ), + ) ], ), backgroundColor: customColors.backgroundContainerColor, @@ -383,3 +422,32 @@ void openImagePreviewDialog( ), ); } + +class ImageProviderPreviewer extends StatelessWidget { + final ImageProvider imageProvider; + final BorderRadius? borderRadius; + const ImageProviderPreviewer({ + super.key, + required this.imageProvider, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + return ClipRRect( + borderRadius: borderRadius ?? BorderRadius.circular(8), + child: InkWell( + borderRadius: borderRadius ?? BorderRadius.circular(8), + child: Image(image: imageProvider, fit: BoxFit.cover), + onTap: () { + openImagePreviewDialog( + context, + customColors, + imageProvider: imageProvider, + ); + }, + ), + ); + } +} diff --git a/lib/page/creative_island/draw/components/creative_item.dart b/lib/page/creative_island/draw/components/creative_item.dart index 6c45ecfe..93f79141 100644 --- a/lib/page/creative_island/draw/components/creative_item.dart +++ b/lib/page/creative_island/draw/components/creative_item.dart @@ -9,6 +9,7 @@ class CreativeItem extends StatelessWidget { final Color? titleColor; final String? tag; final Function() onTap; + final String size; const CreativeItem({ super.key, required this.imageURL, @@ -16,30 +17,31 @@ class CreativeItem extends StatelessWidget { required this.onTap, this.titleColor, this.tag, + required this.size, }); @override Widget build(BuildContext context) { final customColors = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), - child: Material( + return Material( + borderRadius: BorderRadius.circular(10), + child: InkWell( borderRadius: BorderRadius.circular(10), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onTap, - child: Stack( - children: [ - SizedBox( - width: double.infinity, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImageEnhanced( - imageUrl: imageURL, - fit: BoxFit.cover, - ), + onTap: onTap, + child: Stack( + children: [ + SizedBox( + width: double.infinity, + height: double.infinity, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImageEnhanced( + imageUrl: imageURL, + fit: BoxFit.cover, ), ), + ), + if (size == 'large') Positioned( left: 20, top: 20, @@ -61,9 +63,59 @@ class CreativeItem extends StatelessWidget { ), ], ), + ) + else if (size == 'medium') + Positioned( + left: 20, + top: 25, + child: Row( + children: [ + Text( + title, + style: TextStyle( + color: titleColor ?? Colors.white, + fontSize: 20, + ), + ), + const SizedBox(width: 10), + if (tag != null && tag != '') + ScaleTransition( + scale: const AlwaysStoppedAnimation(0.5), + child: Tag( + name: tag!, + backgroundColor: customColors.linkColor, + fontsize: 10, + ), + ), + ], + ), + ) + else + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (tag != null && tag != '') const SizedBox(width: 20), + Text( + title, + style: TextStyle( + color: titleColor ?? Colors.white, + fontSize: 20, + ), + ), + if (tag != null && tag != '') + ScaleTransition( + scale: const AlwaysStoppedAnimation(0.5), + child: Tag( + name: tag!, + backgroundColor: customColors.linkColor, + fontsize: 10, + ), + ), + ], + ), ), - ], - ), + ], ), ), ); diff --git a/lib/page/creative_island/draw/draw_list.dart b/lib/page/creative_island/draw/draw_list.dart index d9c68466..c88556fe 100644 --- a/lib/page/creative_island/draw/draw_list.dart +++ b/lib/page/creative_island/draw/draw_list.dart @@ -87,33 +87,83 @@ class _DrawListScreenState extends State { current is CreativeIslandItemsV2Loaded, builder: (context, state) { if (state is CreativeIslandItemsV2Loaded) { - return GridView.count( - padding: const EdgeInsets.only(top: 0), - crossAxisCount: _calCrossAxisCount(context), - childAspectRatio: 2, - children: state.items - .map((e) => CreativeItem( - imageURL: e.previewImage, - title: e.title, - titleColor: stringToColor(e.titleColor), - tag: e.tag, - onTap: () { - if (userSignedIn) { - var uri = Uri.tryParse(e.routeUri); - if (e.note != null && e.note != '') { - uri = uri!.replace( - queryParameters: { - 'note': e.note!, - }..addAll(uri.queryParameters)); - } - - context.push(uri.toString()); - } else { - context.push('/login'); + final items = state.items + .map((e) => CreativeItem( + imageURL: e.previewImage, + title: e.title, + titleColor: stringToColor(e.titleColor), + tag: e.tag, + onTap: () { + if (userSignedIn) { + var uri = Uri.tryParse(e.routeUri); + if (e.note != null && e.note != '') { + uri = uri!.replace( + queryParameters: { + 'note': e.note!, + }..addAll(uri.queryParameters)); } - }, - )) - .toList(), + + context.push(uri.toString()); + } else { + context.push('/login'); + } + }, + size: e.size, + )) + .toList(); + final largeItems = items.where((e) => e.size == 'large').toList(); + final mediumItems = items.where((e) => e.size == 'medium').toList(); + final otherItems = items + .where((e) => e.size != 'large' && e.size != 'medium') + .toList(); + + return SingleChildScrollView( + child: Column( + children: [ + GridView.count( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 0, left: 10, right: 10), + crossAxisCount: _calCrossAxisCount(context), + childAspectRatio: 2, + shrinkWrap: true, + children: largeItems + .map((e) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 10), + child: e, + )) + .toList(), + ), + GridView.count( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 5, left: 10, right: 10), + crossAxisCount: _calCrossAxisCount(context) * 2, + childAspectRatio: 1, + shrinkWrap: true, + children: mediumItems + .map((e) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 5), + child: e, + )) + .toList(), + ), + GridView.count( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 5, left: 10, right: 10), + crossAxisCount: _calCrossAxisCount(context) * 2, + childAspectRatio: 2, + shrinkWrap: true, + children: otherItems + .map((e) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 5), + child: e, + )) + .toList(), + ), + ], + ), ); } @@ -131,6 +181,6 @@ class _DrawListScreenState extends State { width = CustomSize.maxWindowSize; } - return (width / 400).round(); + return (width / 400).round() > 2 ? 2 : (width / 400).round(); } } diff --git a/lib/repo/api/creative.dart b/lib/repo/api/creative.dart index d6d219a8..e50a0540 100644 --- a/lib/repo/api/creative.dart +++ b/lib/repo/api/creative.dart @@ -793,6 +793,7 @@ class CreativeIslandItemV2 { String routeUri; String tag; String? note; + String size; CreativeIslandItemV2({ required this.id, @@ -802,6 +803,7 @@ class CreativeIslandItemV2 { required this.routeUri, this.tag = '', this.note, + this.size = 'large', }); toJson() => { @@ -812,6 +814,7 @@ class CreativeIslandItemV2 { 'route_uri': routeUri, 'tag': tag, 'note': note, + 'size': size, }; static CreativeIslandItemV2 fromJson(Map json) { @@ -823,6 +826,7 @@ class CreativeIslandItemV2 { routeUri: json['route_uri'], tag: json['tag'] ?? '', note: json['note'], + size: json['size'] ?? 'large', ); } }