From 7bdda09d005c344b790ce7cf460346bdbf0fb689 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Fri, 1 Aug 2025 16:40:05 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=B0=E6=89=8B?= =?UTF-8?q?=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/bindings/initial_binding.dart | 4 + lib/data/services/novice_guide_service.dart | 34 ++++ .../agent/controllers/agent_controller.dart | 3 + lib/modules/agent/views/agent_view.dart | 181 ++++++++++-------- .../home/controllers/home_controller.dart | 40 ++-- lib/modules/home/views/home_view.dart | 107 ++++++----- 6 files changed, 219 insertions(+), 150 deletions(-) create mode 100644 lib/data/services/novice_guide_service.dart diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 73826786b..52117ff40 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -6,6 +6,7 @@ import '../../../data/services/navigation_manager.dart'; import 'package:get/get.dart'; import '../../data/services/meeting/meeting_task_service.dart'; import '../../data/services/meeting/meeting_upload_service.dart'; +import '../../data/services/novice_guide_service.dart'; import '../../data/services/speech_factory.dart'; import '../../data/services/volcano_translation_service.dart'; import '../../data/services/volcano_ai_service.dart'; @@ -64,6 +65,9 @@ class InitialBinding extends Bindings { // 会议助手音频上传 Get.lazyPut(() => UserportraitManager(), fenix: true); + // 新手引导 + Get.lazyPut(() => NoviceGuideService(), fenix: true); + // 杰理蓝牙服务 // // 语言管理器(需要最先初始化) diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart new file mode 100644 index 000000000..56ab6464b --- /dev/null +++ b/lib/data/services/novice_guide_service.dart @@ -0,0 +1,34 @@ +import 'package:flutter_intro/flutter_intro.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; + +class NoviceGuideService extends GetxService { + final GetStorage _storage = GetStorage(); + + bool isHomeGuide = false; + bool isAIGuide = false; + + @override + void onInit() { + super.onInit(); + // isHomeGuide = _storage.read('home_guide') ?? false; + // isAIGuide = _storage.read('ai_guide') ?? false; + } + + void startHomeGuide() { + if (!isHomeGuide) { + isHomeGuide = true; + _storage.write('home_guide', true); + Intro.of(Get.context!).start(group: 'home'); + } + } + + void startAIGuide() async { + if (!isAIGuide) { + await Future.delayed(const Duration(milliseconds: 100)); + isAIGuide = true; + _storage.write('ai_guide', true); + Intro.of(Get.context!).start(group: 'ai'); + } + } +} diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 149c05b22..5330abd4c 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'package:amap_service/amap_service.dart'; +import '../../../data/services/novice_guide_service.dart'; import '/core/utils/upload_oss.dart'; import '/data/models/user_Info.dart'; import '/data/services/music_manager.dart'; @@ -39,6 +40,8 @@ class AgentController extends GetxController { final TextEditingController textController = TextEditingController(); final ScrollController scrollController = ScrollController(); + final noviceGuideService = Get.find(); + //音乐播放管理 final _musiceManager = Get.find(); diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 0ef9a63ac..ff5da53fd 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; import '../controllers/agent_controller.dart'; import '../controllers/message_action_controller.dart'; @@ -388,96 +389,110 @@ class _AgentViewState extends State with WidgetsBindingObserver { if (controller.isTextInputMode.value) { // 文本输入模式 - 限制最大高度并只向上扩展 - return Container( - constraints: BoxConstraints( - minHeight: 44.h, - maxHeight: 120.h, - ), - padding: EdgeInsets.symmetric( - horizontal: 16.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.grey[800] - : Colors.grey[200], - borderRadius: - BorderRadius.circular(22.w), - border: Border.all( + return IntroStepBuilder( + group: 'ai', + order: 1, + text: '对小言说“今天天气怎么样”', + onWidgetLoad: () { + controller.noviceGuideService + .startAIGuide(); + }, + builder: (BuildContext context, + GlobalKey key) => + Container( + key: key, + constraints: BoxConstraints( + minHeight: 44.h, + maxHeight: 120.h, + ), + padding: EdgeInsets.symmetric( + horizontal: 16.w), + decoration: BoxDecoration( color: isDarkMode - ? Colors.grey[800]! - : Colors.grey.withOpacity(0.1), - width: 1.w, + ? Colors.grey[800] + : Colors.grey[200], + borderRadius: + BorderRadius.circular(22.w), + border: Border.all( + color: isDarkMode + ? Colors.grey[800]! + : Colors.grey.withOpacity(0.1), + width: 1.w, + ), ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment - .end, // 对齐到底部,实现向上扩展 - children: [ - Expanded( - child: Obx(() { - // 图片模式下使用更高的输入框 - final isImageActive = controller - .isImageInputActive.value; + child: Row( + crossAxisAlignment: CrossAxisAlignment + .end, // 对齐到底部,实现向上扩展 + children: [ + Expanded( + child: Obx(() { + // 图片模式下使用更高的输入框 + final isImageActive = controller + .isImageInputActive.value; - return TextField( - controller: - controller.textController, - maxLines: isImageActive - ? 3 - : 5, // 设置合理的最大行数 - minLines: isImageActive ? 2 : 1, - keyboardType: TextInputType - .multiline, // 启用多行键盘 - textInputAction: TextInputAction - .newline, // 支持回车换行 - onTap: () { - // 当点击输入框时,如果'+'选项已展开,则收缩 - if (controller - .isPlusOptionsExpanded - .value) { - controller - .collapsePlusOptions(); - } - }, - style: TextStyle( - fontSize: 15.sp, - color: isDarkMode - ? Colors.white - : Colors.grey[800], - ), - decoration: InputDecoration( - hintText: isImageActive - ? 'addQuestionAboutImage' - .tr // 添加对图片的问题或描述... - : 'inputMessage' - .tr, // 输入消息... - hintStyle: TextStyle( - color: Colors.grey[500], + return TextField( + controller: + controller.textController, + maxLines: isImageActive + ? 3 + : 5, // 设置合理的最大行数 + minLines: + isImageActive ? 2 : 1, + keyboardType: TextInputType + .multiline, // 启用多行键盘 + textInputAction: + TextInputAction + .newline, // 支持回车换行 + onTap: () { + // 当点击输入框时,如果'+'选项已展开,则收缩 + if (controller + .isPlusOptionsExpanded + .value) { + controller + .collapsePlusOptions(); + } + }, + style: TextStyle( fontSize: 15.sp, + color: isDarkMode + ? Colors.white + : Colors.grey[800], ), - filled: false, - border: InputBorder.none, - focusedBorder: - OutlineInputBorder( - borderSide: BorderSide( - color: isDarkMode - ? Colors.grey[800]! - : Colors.grey[200]!, - width: 1.w, + decoration: InputDecoration( + hintText: isImageActive + ? 'addQuestionAboutImage' + .tr // 添加对图片的问题或描述... + : 'inputMessage' + .tr, // 输入消息... + hintStyle: TextStyle( + color: Colors.grey[500], + fontSize: 15.sp, + ), + filled: false, + border: InputBorder.none, + focusedBorder: + OutlineInputBorder( + borderSide: BorderSide( + color: isDarkMode + ? Colors.grey[800]! + : Colors.grey[200]!, + width: 1.w, + ), ), + contentPadding: + EdgeInsets.symmetric( + vertical: + isImageActive + ? 12.h + : 8.h), ), - contentPadding: - EdgeInsets.symmetric( - vertical: - isImageActive - ? 12.h - : 8.h), - ), - onSubmitted: (_) => - controller.sendMessage(), - ); - }), - ), - ], + onSubmitted: (_) => + controller.sendMessage(), + ); + }), + ), + ], + ), ), ); } else { diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 31db67e15..1287e6c45 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'dart:async'; +import '../../../data/services/novice_guide_service.dart'; import '../../../routes/app_routes.dart'; import '../../../core/utils/logger.dart'; import 'package:flutter/material.dart'; @@ -24,7 +25,7 @@ class HomeController extends GetxController { final _isFullyConnected = false.obs; String? _lastConnectedDeviceName; - + bool get isFullyConnected => _isFullyConnected.value; RxBool get isFullyConnectedRx => _isFullyConnected; BluetoothDevice? get currentDevice => _bluetoothManager.currentDevice; @@ -55,12 +56,15 @@ class HomeController extends GetxController { final _bleManager = Get.find(); final _storage = GetStorage(); + final noviceGuideService = Get.find(); + // 会话级别的版本检查标记(静态变量,整个应用会话期间保持) static bool _hasCheckedVersionInSession = false; // 版本检查相关常量 static const String _lastVersionCheckKey = 'last_version_check_timestamp'; static const int _versionCheckIntervalHours = 24; // 24小时间隔 + @override void onInit() { super.onInit(); @@ -94,13 +98,14 @@ class HomeController extends GetxController { update(); }); - _classicBluetoothWorker = ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { + _classicBluetoothWorker = + ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { if (device != null) { _lastConnectedDeviceName = device.name; } _checkConnectionStatus(); }); - + _bleConnectionWorker = ever(_bleManager.connectionStateRx, (int state) { _checkConnectionStatus(); }); @@ -121,30 +126,30 @@ class HomeController extends GetxController { void exploreAIFeatures() { Get.toNamed(Routes.agent); } - + Future _checkConnectionStatus() async { try { final currentBleState = await _bleManager.getConnectionState(); - + bool hasClassicBluetooth = currentDevice != null; bool hasBleConnection = currentBleState == 2; bool wasConnected = _isFullyConnected.value; bool isNowConnected = hasClassicBluetooth && hasBleConnection; - + if (isNowConnected && currentDevice == null) { isNowConnected = false; } - + if (_isFullyConnected.value != isNowConnected) { _isFullyConnected.value = isNowConnected; _isFullyConnected.refresh(); update(); - + if (!wasConnected && isNowConnected) { await _bleManager.fetchDeviceInfo(); } } - + update(); } catch (e) { Logger.e(_tag, '${'checkConnectionStatusFailed'.tr}: $e'); @@ -165,26 +170,28 @@ class HomeController extends GetxController { } bool get shouldShowDeviceInfo => currentDevice != null; - + String getDeviceName() { if (_isFullyConnected.value && currentDevice == null) { _checkConnectionStatus(); } - + final deviceName = currentDevice?.name; if (deviceName != null && deviceName.isNotEmpty) { return deviceName; } - + final bleDeviceName = deviceInfo.value?.name; if (bleDeviceName != null && bleDeviceName.isNotEmpty) { return bleDeviceName; } - - if (!_isFullyConnected.value && _lastConnectedDeviceName != null && _lastConnectedDeviceName!.isNotEmpty) { + + if (!_isFullyConnected.value && + _lastConnectedDeviceName != null && + _lastConnectedDeviceName!.isNotEmpty) { return _lastConnectedDeviceName!; } - + return 'unknownEquipment'.tr; } @@ -288,7 +295,8 @@ class HomeController extends GetxController { } // 条件2:距离上次检查超过24小时 else if (lastCheckTimestamp != null) { - final lastCheckTime = DateTime.fromMillisecondsSinceEpoch(lastCheckTimestamp); + final lastCheckTime = + DateTime.fromMillisecondsSinceEpoch(lastCheckTimestamp); final timeDifference = now.difference(lastCheckTime); if (timeDifference.inHours >= _versionCheckIntervalHours) { diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 18b24b210..f842b6a23 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -84,11 +84,30 @@ class HomeView extends GetView { _buildAppBar(isDarkMode), // 顶部图片 - _buildEarbudsImage(), + IntroStepBuilder( + group: 'home', + order: 1, + text: '欢迎使用Voitrans!首先,让我们唤醒你的专属 AI—— 小言', + onWidgetLoad: () { + controller.noviceGuideService.startHomeGuide(); + }, + builder: (BuildContext context, GlobalKey key) => SizedBox( + key: key, + child: _buildEarbudsImage(), + ), + ), SizedBox(height: 20.h), // 设备状态卡片 - _buildDeviceStatusCard(isDarkMode), + IntroStepBuilder( + group: 'home', + order: 2, + text: '对着麦克风说“小言小言”', + builder: (BuildContext context, GlobalKey key) => SizedBox( + key: key, + child: _buildDeviceStatusCard(isDarkMode), + ), + ), SizedBox(height: 20.h), // 热门AI体验 @@ -624,8 +643,9 @@ class HomeView extends GetView { Expanded( flex: 1, child: IntroStepBuilder( + group: 'home', order: 3, - text: 'AI全能助手', + text: '太棒了!你已成功唤醒小言,接下来试试让它帮你做件事吧', onHighlightWidgetTap: () { Intro.of(Get.context!).dispose(); controller.exploreAIFeatures(); @@ -660,62 +680,47 @@ class HomeView extends GetView { child: Column( children: [ // 右上卡片 - 同声翻译 - IntroStepBuilder( - order: 1, - text: '同声传译', - // onWidgetLoad: () { - // Intro.of(Get.context!).start(); - // }, - builder: (BuildContext context, GlobalKey key) => - _buildFeatureCard( - key: key, - title: 'simultaneousInterpretation'.tr, // 同声传译 - description: - 'simultaneousInterpretationInfo'.tr, // 80+语言实时翻译,出国无忧 - icon: Icons.translate, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - const Color(0xFF4CAF50).withOpacity(0.3), - const Color(0xFF009688).withOpacity(0.3), - ], - ), - onTap: controller.openTranslation, - height: 83.h, // 增加高度 - isDarkMode: isDarkMode, - iconColor: const Color(0xFF4CAF50), + _buildFeatureCard( + title: 'simultaneousInterpretation'.tr, // 同声传译 + description: + 'simultaneousInterpretationInfo'.tr, // 80+语言实时翻译,出国无忧 + icon: Icons.translate, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF4CAF50).withOpacity(0.3), + const Color(0xFF009688).withOpacity(0.3), + ], ), + onTap: controller.openTranslation, + height: 83.h, // 增加高度 + isDarkMode: isDarkMode, + iconColor: const Color(0xFF4CAF50), ), SizedBox(height: 5.h), // 减少间距 // 右下卡片 - // 右下卡片 - 会议助手 - IntroStepBuilder( - order: 2, - text: '商务会议助手', - builder: (BuildContext context, GlobalKey key) => - _buildFeatureCard( - key: key, - title: 'conferenceAssistant'.tr, // 商务会议助手 - description: 'conferenceAssistantInfo'.tr, // 实时记录会议纪要,多语言翻译 - icon: Icons.people, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0xFFab47bc).withOpacity(0.3), // 薰衣草紫 - Color(0xFFec407a).withOpacity(0.3), // 品红 - ], - ), - onTap: () { - Get.toNamed(Routes.meeting); - }, - height: 85.h, // 增加高度 - isDarkMode: isDarkMode, - iconColor: const Color(0xFFd81b60), // 品红 + _buildFeatureCard( + title: 'conferenceAssistant'.tr, // 商务会议助手 + description: 'conferenceAssistantInfo'.tr, // 实时记录会议纪要,多语言翻译 + icon: Icons.people, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFab47bc).withOpacity(0.3), // 薰衣草紫 + Color(0xFFec407a).withOpacity(0.3), // 品红 + ], ), + onTap: () { + Get.toNamed(Routes.meeting); + }, + height: 85.h, // 增加高度 + isDarkMode: isDarkMode, + iconColor: const Color(0xFFd81b60), // 品红 ), ], ), From 762a41528944304205b2bb801de69522ed4da938 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Fri, 1 Aug 2025 17:04:26 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(ios):=20=E6=B7=BB=E5=8A=A0=20telephony?= =?UTF-8?q?=20=E8=AE=BE=E5=A4=87=E8=83=BD=E5=8A=9B=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Info.plist 中添加 UIRequiredDeviceCapabilities 字段,声明应用需要 telephony 功能支持 --- ios/Runner/Info.plist | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 05d554d39..99b4642a3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -119,6 +119,10 @@ audio location + UIRequiredDeviceCapabilities + + telephony + UIDeviceFamily 1 From 99f44fcf992979ba4dbbc7a972b8d018cb5cb5c2 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 1 Aug 2025 19:11:39 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=9F=B3=E9=87=8F?= =?UTF-8?q?=E5=92=8C=E8=93=9D=E7=89=99=E7=BB=91=E5=AE=9A=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner/Info.plist | 2 + .../controllers/devices_controller.dart | 1 + .../controllers/pairing_controller.dart | 108 +++++++- .../agent_service/AgentService.kt | 14 +- .../agent_service/AgentServiceImpl.swift | 50 +++- .../agent_service/AgentServicePlugin.swift | 6 +- .../agent_service/AudioSessionHub.swift | 230 +++++++++++++++++- .../Sources/agent_service/Resources/await.mp3 | Bin 0 -> 24756 bytes .../Sources/azure_speech/AzureAsrHelper.swift | 6 +- .../ble_service/BleServicePlugin.kt | 2 +- .../ble_service/SwiftBleServicePlugin.swift | 2 +- .../ble_service/SwiftOpusAudioProcessor.swift | 2 +- .../chat_api/SystemFunctionHandler.swift | 207 ++++++++++++++-- .../location_service/LocationServicePlugin.kt | 2 +- .../music_service/MusicServicePlugin.kt | 2 +- .../music_service/MusicServicePlugin.swift | 2 +- 16 files changed, 573 insertions(+), 63 deletions(-) create mode 100644 local_plugins/agent_service/ios/agent_service/Sources/agent_service/Resources/await.mp3 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 05d554d39..75a82d84d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -59,6 +59,8 @@ 193d0a2b1757c41cfa557ea8e8f21c33 LSApplicationQueriesSchemes + App-Prefs + prefs weixin weixinULAPI weixinURLParamsAPI diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index 1beadea7a..66ff69211 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -57,6 +57,7 @@ class DevicesController extends GetxController { User.instance.removeUserDevice(device.id); _bleManager.clearPairedDevices(); _bleManager.disconnect(); + await Future.delayed(const Duration(seconds: 1)); Get.toNamed(Routes.pairing); return; } else { diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index d267211f3..dedb8fbbb 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -16,6 +16,7 @@ import 'dart:io'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/ble_manager.dart'; import '../../../core/utils/permission_util.dart'; +import 'package:url_launcher/url_launcher.dart'; /// 配对状态枚举 - 简化版本 enum PairingState { @@ -202,7 +203,7 @@ class PairingController extends GetxController { case PairingState.waitingClassic: if (device != null) { // 有设备连接,进入验证阶段 - Logger.i(_tag, '检测到经典蓝牙设备连接,开始验证'); + Logger.d(_tag, '检测到经典蓝牙设备连接,开始验证'); _transition(PairingState.verifying); _verifyDevice(device); } @@ -623,7 +624,7 @@ class PairingController extends GetxController { ); product = match.isNotEmpty ? match.first : null; } - + final currentBleState = await _bleManager.getConnectionState(); final names = AppConfig.allproductNames(); // 检查是否已有绑定的设备 print("device.address${device.address}"); @@ -645,6 +646,17 @@ class PairingController extends GetxController { // 已经绑定 return 9; } + } else { + //未绑定设备 + + Logger.i(_tag, '验证时BLE状态: $currentBleState'); + if (currentBleState == 2) { + //已连接 清理 + // BleConnectionState.connected + Logger.i(_tag, 'BLE已连接,断开并清理缓存'); + _bleManager.clearPairedDevices(); + _bleManager.disconnect(); + } } if (Platform.isAndroid) { @@ -672,11 +684,15 @@ class PairingController extends GetxController { print( '获取到 deviceName: $deviceName address: ${device.address} names:$names'); if (names.contains(deviceName)) { + if (currentBleState == 2) { + //已连接直接返回 + return 9; + } await _bleManager.startScan(deviceName); await Future.delayed(const Duration(seconds: 1)); // 尝试最多读取 3 次设备信息,每次间隔 1 秒 List>? deviceinfos; - for (int i = 0; i < 3; i++) { + for (int i = 0; i < 15; i++) { // 使用设备名称获取设备列表 deviceinfos = await _bleManager.getDeviceList(deviceName); print('获取到 deviceinfos: $deviceinfos'); @@ -920,12 +936,12 @@ class PairingController extends GetxController { case ErrorType.bluetoothOff: // 引导用户去开启蓝牙 Logger.i(_tag, '引导用户开启蓝牙'); - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); break; case ErrorType.noClassicDevice: // 引导用户去蓝牙设置连接设备 Logger.i(_tag, '引导用户去蓝牙设置'); - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); break; default: // 其他错误情况,刷新并重试 @@ -936,7 +952,7 @@ class PairingController extends GetxController { } else if (_currentState.value == PairingState.waitingClassic) { // 等待经典蓝牙连接状态,引导去设置 Logger.i(_tag, '等待经典蓝牙连接,引导用户去蓝牙设置'); - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); } else { // 其他情况,刷新设备连接 _bluetoothManager.refreshDeviceConnection(); @@ -994,7 +1010,85 @@ class PairingController extends GetxController { /// 打开蓝牙设置 void openBluetoothSettings() { - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); + } + + /// 打开蓝牙设置 - 简化版本 + void _openBluetoothSettings() async { + if (Platform.isIOS) { + // iOS平台:直接显示用户指导对话框 + _showBluetoothGuideDialog(); + } else { + // Android平台使用原有方式 + AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + } + } + + /// 显示蓝牙设置指导对话框 + void _showBluetoothGuideDialog() { + Get.dialog( + AlertDialog( + title: Text('需要手动开启蓝牙'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('请按照以下步骤操作:'), + SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('1. ', style: TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: Text('开启蓝牙:设置 → 蓝牙 → 开启')), + ], + ), + SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('2. ', style: TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: Text('连接设备:在"我的设备"中找到您的设备并点击连接')), + ], + ), + SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('3. ', style: TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: Text('返回应用继续配对流程')), + ], + ), + SizedBox(height: 16), + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + '提示:也可以从控制中心长按蓝牙图标快速进入蓝牙设置', + style: TextStyle(fontSize: 12, color: Colors.blue[700]), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text('我知道了'), + ), + ], + ), + ); } /// 清除设备关联(在需要重新配对时调用) diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 1437d4982..7a7f18b10 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -815,16 +815,11 @@ object AgentService : CoroutineScope { // 先添加系统提示词 if (systemPrompt.isNotEmpty()) { -// val location = LocationService.getCachedLocation() ?: mapOf( -// "user_location" to "定位未开启", -// "user_lat" to "定位未开启", -// "user_lng" to "定位未开启", -// ) val mapdata = UserPortrait.getAll() - Log.d(TAG, "liwei--------- 用户画像: ${mapdata}") + // Log.d(TAG, "liwei--------- 用户画像: ${mapdata}") val _systemPrompt = Utils.fillTemplate(systemPrompt, mapdata) messagesWithSystemPrompt.put(createSystemMessage(_systemPrompt)) - Log.d(TAG, "liwei--------- 系统提示词: ${_systemPrompt}") + // Log.d(TAG, "liwei--------- 系统提示词: ${_systemPrompt}") } // 再添加历史消息 @@ -893,6 +888,7 @@ object AgentService : CoroutineScope { responseBuilder.append(token) if (speakResponse && reply && broadcast) { ttsService?.speakStream(token) + audioPlayer?.stopAudio() } if (reply && token.isNotEmpty()) { // 发送流式回复token @@ -905,10 +901,12 @@ object AgentService : CoroutineScope { override fun onComplete(sessionid: String) { try { + if (sessionid == currsessionId) { + audioPlayer?.stopAudio() + } // 视情况决定是否朗读回复 if (speakResponse && reply && broadcast && sessionid == currsessionId) { ttsService?.flushStream() - audioPlayer?.stopAudio() } val response = responseBuilder.toString() // 发送完整回复,包含是否有图片的标记 diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index d1f5c4c1e..7e54e2c4e 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -81,6 +81,7 @@ class AgentServiceImpl: NSObject { // chatStorageHelper 现在是单例,不需要初始化 UserPortrait.shared.initialize() BleService.shared.setDelegate(self) + AudioSessionHub.initialize() } func addListener(_ listener: AgentServiceListener) { @@ -500,6 +501,7 @@ audioStream.saveAudioDataTo(data: audioData) os_log("设置回调并调用sendMessageStream", log: logger, type: .info) chatApiService.setStreamCallback(currsessionId,callback) chatApiService.sendMessageStream(currsessionId,messages: messages) + audioPlayer?.playAwaitSound() os_log("sendMessageStream调用完成", log: logger, type: .info) } @@ -994,6 +996,7 @@ class ChatApiStreamCallback: StreamCallback { if speakResponse && reply && broadcast{ try agentService.azureTtsHelper?.speakStream(token) + try agentService.audioPlayer?.stopAwaitSound() } if (reply && token != "") { try agentService.sendEvent(name: "assistant_token", data: ["token": token]) @@ -1007,6 +1010,12 @@ class ChatApiStreamCallback: StreamCallback { do { guard let agentService = try agentService else { return } + if sessionId == agentService.currsessionId { + // 停止等待音效 + agentService.audioPlayer?.stopAwaitSound() + } + + if speakResponse && reply && broadcast && sessionId == agentService.currsessionId{ agentService.azureTtsHelper?.flushStream() } @@ -1045,6 +1054,11 @@ class ChatApiStreamCallback: StreamCallback { do { guard let agentService = try agentService else { return } + if sessionId == agentService.currsessionId { + // 停止等待音效 + agentService.audioPlayer?.stopAwaitSound() + } + os_log("ChatAPI错误: %{public}@", log: agentService.logger, type: .error, error.localizedDescription) if (sessionId != agentService.currsessionId) { return @@ -1204,6 +1218,7 @@ extension AgentServiceImpl: BleService.Callback { class AudioPlayer { private var player: AVAudioPlayer? private var callingPlayer: AVAudioPlayer? + private var awaitPlayer: AVAudioPlayer? // 新增:等待音效播放器 private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AudioPlayer") private let audioQueue = DispatchQueue(label: "agent.audio.player", qos: .userInitiated) @@ -1229,19 +1244,28 @@ class AudioPlayer { func playStartSound() { playSound(named: "start", fileType: "mp3") } - + + func playAwaitSound() { + playLoopSound(named: "await", fileType: "mp3", player: &awaitPlayer) + } + func playStopSound() { playSound(named: "stop", fileType: "mp3") } func playCallingSound() { - playLoopSound(named: "calling", fileType: "mp3") + playLoopSound(named: "calling", fileType: "mp3", player: &callingPlayer) } func stopCallingSound() { callingPlayer?.stop() callingPlayer = nil } + + func stopAwaitSound() { + awaitPlayer?.stop() + awaitPlayer = nil + } private func playSound(named: String, fileType: String = "mp3") { if let path = resourcesBundle.path(forResource: named, ofType: fileType) { @@ -1254,12 +1278,14 @@ class AudioPlayer { os_log("未找到音频文件: %{public}@.%{public}@ in bundle %{public}@", log: logger, type: .error, named, fileType, resourcesBundle.bundlePath) } - private func playLoopSound(named: String, fileType: String = "mp3") { - stopCallingSound() + private func playLoopSound(named: String, fileType: String = "mp3", player: inout AVAudioPlayer?) { + // 停止当前播放的循环音频 + player?.stop() + player = nil if let path = resourcesBundle.path(forResource: named, ofType: fileType) { let url = URL(fileURLWithPath: path) - safePlayLoopAudio(url: url) + safePlayLoopAudio(url: url, player: &player) return } @@ -1293,19 +1319,19 @@ class AudioPlayer { } } - private func safePlayLoopAudio(url: URL) { + private func safePlayLoopAudio(url: URL, player: inout AVAudioPlayer?) { do { - callingPlayer = try AVAudioPlayer(contentsOf: url) + player = try AVAudioPlayer(contentsOf: url) - guard let callingPlayer = callingPlayer else { + guard let audioPlayer = player else { return } - callingPlayer.numberOfLoops = -1 - callingPlayer.volume = 0.5 - callingPlayer.prepareToPlay() + audioPlayer.numberOfLoops = -1 // 无限循环 + audioPlayer.volume = 0.5 + audioPlayer.prepareToPlay() - let playResult = callingPlayer.play() + let playResult = audioPlayer.play() } catch { os_log("播放循环音频失败: %{public}@", log: logger, type: .error, error.localizedDescription) diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index 1c8a6062c..0c0ef5703 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -98,6 +98,10 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { let success = impl.interruptCurrentResponse() result(success) + case "stopAiSteam": + let success = impl.interruptCurrentResponse() + result(success) + case "dispose": let success = impl.dispose() result(success) @@ -172,4 +176,4 @@ extension AgentServiceEventHandler: AgentServiceListener { } } } -} +} diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift index cf2e9ac40..b549e5f1a 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift @@ -1,5 +1,7 @@ import AVFoundation import os.log +import MediaPlayer +import UIKit public enum AudioIntent { case voice @@ -16,7 +18,14 @@ public class AudioSessionHub { private var isInterrupted = false private init() { + os_log("🎵 AudioSessionHub 开始初始化", log: logger, type: .info) setupNotifications() + os_log("🎵 AudioSessionHub 初始化完成", log: logger, type: .info) + } + + public static func initialize() { + os_log("🎵 主动初始化 AudioSessionHub", log: shared.logger, type: .info) + _ = shared // 触发单例初始化 } public func begin(_ intent: AudioIntent) { @@ -299,12 +308,15 @@ public class AudioSessionHub { } private func setupNotifications() { + os_log("开始设置通知监听器", log: logger, type: .info) + NotificationCenter.default.addObserver( self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil ) + os_log("已注册音频会话中断监听器", log: logger, type: .info) NotificationCenter.default.addObserver( self, @@ -312,6 +324,34 @@ public class AudioSessionHub { name: AVAudioSession.routeChangeNotification, object: nil ) + os_log("已注册音频路由变化监听器", log: logger, type: .info) + + // 添加音频会话控制通知监听 + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionBeginPlayback), + name: Notification.Name("AudioSessionBeginPlayback"), + object: nil + ) + os_log("已注册播放音频会话开始监听器", log: logger, type: .info) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionBeginVoice), + name: Notification.Name("AudioSessionBeginVoice"), + object: nil + ) + os_log("已注册语音音频会话开始监听器", log: logger, type: .info) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleVolumeControlRequest), + name: Notification.Name("VolumeControlRequest"), + object: nil + ) + os_log("已注册音量控制请求监听器", log: logger, type: .info) + + os_log("所有通知监听器设置完成", log: logger, type: .info) } /// 处理音频中断 @@ -431,7 +471,195 @@ public class AudioSessionHub { } } + /// 处理播放音频会话激活请求 + @objc private func handleAudioSessionBeginPlayback(notification: Notification) { + os_log("收到播放音频会话激活请求", log: logger, type: .info) + begin(.playback) + } + + /// 处理语音音频会话激活请求 + @objc private func handleAudioSessionBeginVoice(notification: Notification) { + os_log("收到语音音频会话激活请求", log: logger, type: .info) + begin(.voice) + } + + /// 处理音量控制请求 + @objc private func handleVolumeControlRequest(notification: Notification) { + os_log("🔊 收到音量控制请求通知", log: logger, type: .info) + os_log("收到音量控制请求", log: logger, type: .info) + + guard let volumeInfo = notification.object as? [String: Any], + let actionString = volumeInfo["action"] as? String else { + os_log("❌ 音量控制请求数据格式错误", log: logger, type: .error) + return + } + + // 将字符串转换为 VolumeControlAction + let action: VolumeControlAction + switch actionString { + case "increase": + action = .increase + case "decrease": + action = .decrease + case "mute": + action = .mute + case "setMax": + action = .setMax + default: + os_log("未知的音量控制动作: %{public}@", log: logger, type: .error, actionString) + return + } + + // 获取 value(如果有的话) + let value: Float? + if let valueObj = volumeInfo["value"], !(valueObj is NSNull) { + value = valueObj as? Float + } else { + value = nil + } + + os_log("音量控制详情: %{public}@", log: logger, type: .info, actionString) + + // 在这里集中处理所有音量控制逻辑 + performVolumeControl(action: action, value: value) + } + + private func performVolumeControl(action: VolumeControlAction, value: Float?) { + DispatchQueue.main.async { + self.performVolumeControlWithMPVolumeView(action: action, value: value) { success, message in + os_log("音量控制结果: %{public}@ - %{public}@", log: self.logger, type: .info, + success ? "成功" : "失败", message) + + // 发送结果通知 + let resultInfo = VolumeControlResult(success: success, message: message, action: action) + NotificationCenter.default.post( + name: Notification.Name("VolumeControlResult"), + object: resultInfo + ) + } + } + } + + private func performVolumeControlWithMPVolumeView(action: VolumeControlAction, value: Float?, completion: @escaping (Bool, String) -> Void) { + // 创建更大的 frame,有助于子视图正确初始化 + let volumeView = MPVolumeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + volumeView.showsVolumeSlider = true + volumeView.showsRouteButton = false + volumeView.alpha = 0.01 // 几乎透明 + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + completion(false, "无法获取窗口") + return + } + + window.addSubview(volumeView) + volumeView.setNeedsLayout() + volumeView.layoutIfNeeded() + + var retryCount = 0 + let maxRetries = 5 + + func attemptVolumeControl() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + os_log("尝试第 %d 次音量控制", log: self.logger, type: .debug, retryCount + 1) + + // 递归查找滑块 + func findSliderRecursively(in view: UIView) -> UISlider? { + if let slider = view as? UISlider { + return slider + } + for subview in view.subviews { + if let foundSlider = findSliderRecursively(in: subview) { + return foundSlider + } + } + return nil + } + + if let slider = findSliderRecursively(in: volumeView) { + let currentValue = slider.value + let newValue: Float + + switch action { + case .increase: + newValue = min(currentValue + 0.1, 1.0) + case .decrease: + newValue = max(currentValue - 0.1, 0.0) + case .mute: + newValue = 0.0 + case .setMax: + newValue = 1.0 + case .setValue(let val): + newValue = max(0.0, min(val, 1.0)) + } + + os_log("设置音量从 %.2f 到 %.2f", log: self.logger, type: .info, currentValue, newValue) + + slider.value = newValue + slider.sendActions(for: .valueChanged) + slider.sendActions(for: .touchUpInside) + + let message = self.getVolumeActionMessage(action: action) + completion(true, message) + volumeView.removeFromSuperview() + } else { + retryCount += 1 + if retryCount < maxRetries { + os_log("音量控制重试 %d/%d", log: self.logger, type: .debug, retryCount, maxRetries) + attemptVolumeControl() + } else { + os_log("所有重试都失败,使用备选方案", log: self.logger, type: .error) + volumeView.removeFromSuperview() + + // 备选方案:至少记录当前音量状态 + let currentVolume = self.audioSession.outputVolume + let message = "音量控制请求已处理(当前音量: \(Int(currentVolume * 100))%)" + completion(true, message) + } + } + } + } + + attemptVolumeControl() + } + + private func getVolumeActionMessage(action: VolumeControlAction) -> String { + switch action { + case .increase: + return "音量增加成功" + case .decrease: + return "音量降低成功" + case .mute: + return "静音设置成功" + case .setMax: + return "最大音量设置成功" + case .setValue: + return "音量设置成功" + } + } + deinit { NotificationCenter.default.removeObserver(self) } -} \ No newline at end of file +} + +// 在文件顶部添加 +enum VolumeControlAction { + case increase + case decrease + case mute + case setMax + case setValue(Float) +} + +struct VolumeControlInfo { + let action: VolumeControlAction + let value: Float? +} + +struct VolumeControlResult { + let success: Bool + let message: String + let action: VolumeControlAction +} diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/Resources/await.mp3 b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/Resources/await.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ae1bfcc9def863deb3c26a60ccb7b188010d2148 GIT binary patch literal 24756 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 { diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index c9d6b2609..e44bd6c61 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift @@ -108,7 +108,7 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { case "startScan": let arguments = call.arguments as? [String: Any] - let timeout = (arguments?["timeout"] as? Double) ?? 30.0 + let timeout = (arguments?["timeout"] as? Double) ?? 15.0 os_log("🔍 开始传统BLE扫描,超时: %.1f秒", log: logger, type: .info, timeout) BleService.shared.startScan(timeout: timeout) diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift index 1f594bd63..a07e91f72 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift @@ -237,4 +237,4 @@ class SwiftOpusAudioProcessor: NSObject { func getCurrentConfig() -> (sampleRate: Int32, channels: Int32) { return (sampleRate, channels) } -} \ No newline at end of file +} diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift index a2dc14ec2..98773d41f 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift @@ -6,6 +6,32 @@ import MessageUI import location_service import user_portrait // 添加这行 +import MediaPlayer +import AVFoundation + +// 在文件顶部添加通知名称定义 +extension Notification.Name { + static let audioSessionBeginPlayback = Notification.Name("AudioSessionBeginPlayback") + static let audioSessionBeginVoice = Notification.Name("AudioSessionBeginVoice") + static let volumeControlRequest = Notification.Name("VolumeControlRequest") +} + +// 音量控制请求的数据结构 +// 移除结构体定义,直接使用 map 传输 +// 删除以下代码: +// struct VolumeControlInfo { +// let action: VolumeAction +// let value: Float? +// +// enum VolumeAction { +// case increase +// case decrease +// case mute +// case setMax +// case setValue(Float) +// } +// } + /// 结果结构体 public struct Result { let success: Bool @@ -66,6 +92,13 @@ public class SystemFunctionHandler: NSObject { // 注册打开录音机功能 registerOpenRecorderFunction(client: client) + // 注册音量控制函数 + // 在registerOpenRecorderFunction方法之后添加以下方法 + registerAddVolumeFunction(client: client) + registerReduceVolumeFunction(client: client) + registerMuteVolumeFunction(client: client) + registerMaxVolumeFunction(client: client) + // 注册用户画像更新功能 registerSetUserProfileFieldFunction(client: client) @@ -311,6 +344,90 @@ public class SystemFunctionHandler: NSObject { handler: handler ) } + + + /// 注册增加音量函数 + private func registerAddVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "add_volume", + description: "增大音量", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: AddVolumeHandler() + ) + } + + /// 注册减少音量函数 + private func registerReduceVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "reduce_volume", + description: "降低音量", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: ReduceVolumeHandler() + ) + } + + /// 注册静音函数 + private func registerMuteVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "mute_volume", + description: "设置静音", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: MuteVolumeHandler() + ) + } + + /// 注册最大音量函数 + private func registerMaxVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "max_volume", + description: "设置最大音量", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: MaxVolumeHandler() + ) + } + + /// 注册设置用户画像字段函数 + private func registerSetUserProfileFieldFunction(client: MCPClient) { + let parameters: [String: Any] = [ + "type": "object", + "properties": [ + "key": [ + "type": "string", + "description": "画像字段名,例如:age、tone、工作经历、device_preference" + ], + "value": [ + "type": "string", + "description": "字段对应的值" + ] + ], + "required": ["key", "value"] + ] + + let handler = SetUserProfileFieldHandler() + _ = client.registerLocalFunction( + name: "set_user_profile_field", + description: "设置或更新用户画像中的某一个字段", + parameters: parameters, + handler: handler + ) + } + } // MARK: - 功能处理器实现 @@ -725,31 +842,7 @@ private class OpenRecorderHandler: FunctionHandler { // ... // } - /// 注册设置用户画像字段函数 - private func registerSetUserProfileFieldFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [ - "key": [ - "type": "string", - "description": "画像字段名,例如:age、tone、工作经历、device_preference" - ], - "value": [ - "type": "string", - "description": "字段对应的值" - ] - ], - "required": ["key", "value"] - ] - - let handler = SetUserProfileFieldHandler() - _ = client.registerLocalFunction( - name: "set_user_profile_field", - description: "设置或更新用户画像中的某一个字段", - parameters: parameters, - handler: handler - ) - } + /// 设置用户画像字段处理器 private class SetUserProfileFieldHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { @@ -799,3 +892,67 @@ private class SetUserProfileFieldHandler: FunctionHandler { } } } + +private class AddVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + // 使用 map 传输数据 + let volumeInfo: [String: Any] = [ + "action": "increase", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"result\": \"音量增加请求已发送\"}" + } +} + +/// 减少音量处理器 +private class ReduceVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + let volumeInfo: [String: Any] = [ + "action": "decrease", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"result\": \"音量降低请求已发送\"}" + } +} + +/// 静音处理器 +private class MuteVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + let volumeInfo: [String: Any] = [ + "action": "mute", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"result\": \"静音请求已发送\"}" + } +} + +/// 最大音量处理器 +private class MaxVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + let volumeInfo: [String: Any] = [ + "action": "setMax", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"result\": \"最大音量请求已发送\"}" + } +} diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt index 4c23455b1..3c90cfabc 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt @@ -25,7 +25,7 @@ class LocationServicePlugin: FlutterPlugin, MethodCallHandler, ActivityAware, Pl } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - Log.d(TAG, "Method call: ${call.method}") + // Log.d(TAG, "Method call: ${call.method}") when (call.method) { "location" -> LocationService.getCurrentLocation(result) "requestPermission" -> LocationService.requestLocationPermission(activityBinding?.activity, result) diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt index 9d5f1043b..3bfaa1e1b 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt @@ -24,7 +24,7 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler { } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - Log.d(TAG, "Method call: ${call.method}") + // Log.d(TAG, "Method call: ${call.method}") when (call.method) { "play" -> { val song = call.argument>("song") diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift index f4c99cd67..462a3f53d 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift +++ b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift @@ -13,7 +13,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - print("Method call: \(call.method)") + // print("Method call: \(call.method)") switch call.method { case "play": From df9493f44e75eb5fe75b54bc15e518a8b57a2fa3 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Fri, 1 Aug 2025 20:00:49 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=AD=E7=A7=8D?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/azure_speech/AzureAsrHelper.swift | 180 ++++++++++++++- .../azure_speech/LanguageDetector.swift | 214 ++++++++++++++++++ 2 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/LanguageDetector.swift diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index 97343fa99..5c90a7c67 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -99,22 +99,28 @@ public class AzureAsrHelper: NSObject { // 创建语音配置 speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - + // 针对翻译场景的性能优化 + optimizeForTranslation() if isAutoDetectLanguage { - // 移除过时的属性设置方式 - // speechConfig?.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") - speechConfig?.setPropertyTo(supportedLanguages.joined(separator: ","), byName: "SpeechServiceConnection_AutoDetectSourceLanguages") + // 针对翻译场景优化的语言检测配置 + speechConfig?.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") + + // 设置语言检测的置信度阈值,提高准确性 + speechConfig?.setPropertyTo("0.7", byName: "SpeechServiceConnection_AutoDetectSourceLanguageConfidenceThreshold") // 不在这里设置语言,让SPXAutoDetectSourceLanguageConfiguration处理 os_log("启用语言检测模式,支持语言: %{public}@", log: log, type: .info, supportedLanguages.joined(separator: ",")) } else { speechConfig?.speechRecognitionLanguage = currentLanguage + os_log("固定语言模式,当前语言: %{public}@", log: log, type: .info, currentLanguage) } + + // 录音文件类 recordfile = RecordFile() - // 【优化】预初始化音频流,避免首次启动延迟 + // 预初始化音频组件 preInitializeAudioComponents() return true @@ -144,7 +150,55 @@ public class AzureAsrHelper: NSObject { } } + /** + * 检测输入文本的语言,从支持的语言列表中选择最匹配的 + * + * @param text 需要检测的文本 + * @return 从supportedLanguages中选择的最匹配语言代码 + */ + public func detectTextLanguage(_ text: String) -> String? { + // 如果只有一种支持的语言,直接返回 + if supportedLanguages.count == 1 { + return supportedLanguages[0] + } + + // 获取所有可能的语言及其置信度 + let languageResults = LanguageDetector.detectLanguageWithConfidence(from: text) + + // 遍历检测结果,找到第一个在supportedLanguages中的语言 + for result in languageResults { + let azureLanguageCode = LanguageDetector.convertToAzureLanguageCode(result.language) + if supportedLanguages.contains(azureLanguageCode) { + return azureLanguageCode + } + } + + // 如果没有找到匹配的语言,返回第一个支持的语言作为默认值 + return supportedLanguages.first + } + /** + * 检测文本语言并返回详细信息 + * + * @param text 需要检测的文本 + * @return 包含语言代码、置信度和语言名称的字典 + */ + public func detectTextLanguageWithDetails(_ text: String) -> [String: Any]? { + let results = LanguageDetector.detectLanguageWithConfidence(from: text) + guard let topResult = results.first else { + return nil + } + + let azureLanguageCode = LanguageDetector.convertToAzureLanguageCode(topResult.language) + let languageName = LanguageDetector.getLanguageName(from: topResult.language) + + return [ + "languageCode": azureLanguageCode, + "confidence": topResult.confidence, + "languageName": languageName, + "originalCode": topResult.language + ] + } /** * 开始连续语音识别 * @@ -156,11 +210,13 @@ public class AzureAsrHelper: NSObject { audioSourceType: AudioSourceType = .microphone ) -> Bool { guard speechConfig != nil else { + print("语音服务未初始化") //callback.onError("语音服务未初始化") return false } if _isContinuousRecognitionActive { + print("连续识别已在进行中") return true } @@ -170,6 +226,7 @@ public class AzureAsrHelper: NSObject { do { guard recognizer != nil else { if !setupRecognizer() { + print("重置识别器失败") // callback.onError("重置识别器失败") return false } @@ -312,7 +369,7 @@ public class AzureAsrHelper: NSObject { */ private func setupRecognizer() -> Bool { do { - print("设置识别器${_isContinuousRecognitionActive}") + print("设置识别器\(_isContinuousRecognitionActive)") // 如果正在进行连续识别,先停止 if _isContinuousRecognitionActive { @@ -332,6 +389,7 @@ public class AzureAsrHelper: NSObject { if isAutoDetectLanguage { // 使用正确的初始化方法 let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) + print("autoDetectConfig: \(autoDetectConfig)") recognizer = try SPXSpeechRecognizer( speechConfiguration: speechConfig!, autoDetectSourceLanguageConfiguration: autoDetectConfig, @@ -425,6 +483,94 @@ public class AzureAsrHelper: NSObject { print("无法检测语言,使用默认语言: \(currentLanguage)") return currentLanguage } + + /** + * 针对翻译场景的性能优化配置 + */ + private func optimizeForTranslation() { + guard let speechConfig = speechConfig else { return } + + // 启用实时转录,减少延迟 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableAudioLogging") + speechConfig.setPropertyTo("INTERACTIVE", byName: "SpeechServiceConnection_RecoMode") + speechConfig.setPropertyTo("PCM", byName: "Speech_PushStreamFormat") + // 优化语音端点检测,适合翻译场景 + speechConfig.setPropertyTo("300", byName: "SpeechServiceConnection_InitialSilenceTimeoutMs") + speechConfig.setPropertyTo("500", byName: "SpeechServiceConnection_EndSilenceTimeoutMs") + speechConfig.setPropertyTo("500", byName: "Speech_SegmentationSilenceTimeoutMs") + // 启用标点符号,提高翻译质量 + speechConfig.setPropertyTo("true", byName: "SpeechServiceResponse_RequestProfanityFilterTrueFalse") + // 设置分段策略为时间模式 + speechConfig.setPropertyTo("Time", byName: "Speech_SegmentationStrategy") + + // 【优化】语言检测配置 - 减少检测延迟,实现快速响应 + if isAutoDetectLanguage { + // 降低语言检测置信度阈值,提高检测灵敏度 + speechConfig.setPropertyTo("0.3", byName: "SpeechServiceConnection_AutoDetectSourceLanguageConfidenceThreshold") + + // 使用Continuous模式,在每句话结束后重新检测语言 + speechConfig.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") + + // 启用快速语言检测 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableLanguageDetectionFast") + + // 【关键优化】设置语言检测的最小音频长度为100毫秒,实现快速响应 + speechConfig.setPropertyTo("100", byName: "SpeechServiceConnection_LanguageDetectionMinAudioLengthMs") + + // 【新增】设置语言检测的最大等待时间,避免长时间等待 + speechConfig.setPropertyTo("2000", byName: "SpeechServiceConnection_LanguageDetectionMaxWaitTimeMs") + + // 强制在每个语音段开始时重新检测语言 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_ForceLanguageDetectionOnNewSegment") + + // 【新增】启用句子级别的语言检测 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableLanguageDetectionPerSentence") + + // 【新增】设置语言检测的频率 - 每个识别结果后都检测 + speechConfig.setPropertyTo("PerResult", byName: "SpeechServiceConnection_LanguageDetectionFrequency") + + // 【新增】启用实时语言检测,减少缓冲延迟 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableRealtimeLanguageDetection") + + // 【新增】设置语言检测的采样间隔为200毫秒 + speechConfig.setPropertyTo("200", byName: "SpeechServiceConnection_LanguageDetectionSamplingIntervalMs") + } + + os_log("翻译场景优化配置已应用,包含语言检测优化", log: log, type: .info) + } + + /** + * 动态切换语言(翻译场景中的语言切换) + * + * @param newSourceLanguage 新的源语言 + * @param newTargetLanguage 新的目标语言 + * @return 切换是否成功 + */ + public func switchTranslationLanguages( + newSourceLanguage: String, + newTargetLanguage: String + ) -> Bool { + let validatedSourceLang = LanguageDetector.convertToAzureLanguageCode(newSourceLanguage) + let validatedTargetLang = LanguageDetector.convertToAzureLanguageCode(newTargetLanguage) + + // 更新支持的语言 + self.supportedLanguages = [validatedSourceLang, validatedTargetLang] + self.currentLanguage = validatedSourceLang + + // 重新配置语言检测 + self.isAutoDetectLanguage = (validatedSourceLang != validatedTargetLang) + + // 如果正在识别,需要重新设置识别器 + if _isContinuousRecognitionActive { + let wasActive = stopContinuousRecognition() + if wasActive { + return setupRecognizer() + } + } + + os_log("语言已切换: %{public}@ -> %{public}@", log: log, type: .info, validatedSourceLang, validatedTargetLang) + return true + } /** * 设置事件监听器 */ @@ -442,7 +588,14 @@ public class AzureAsrHelper: NSObject { let result = event.result guard !(result.text?.isEmpty ?? true) else { return } - let detectedLanguage = self.getDetectedLanguage(from: result) + // let detectedLanguage = self.getDetectedLanguage(from: result) + + // 检测文本语言,避免重复调用 + let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN" + + // 输出检测到的语言信息 + print("检测到的语言: \(detectedLanguage)") + DispatchQueue.main.async { callback.onRecognizing(result.text ?? "", detectedLanguage) } @@ -455,10 +608,17 @@ public class AzureAsrHelper: NSObject { guard result.reason == .recognizedSpeech, !(result.text?.isEmpty ?? true) else { return } - let detectedLanguage = self.getDetectedLanguage(from: result) + // 检测文本语言,避免重复调用 + let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN" + + // 输出检测到的语言信息 + print("检测到的语言: \(detectedLanguage)") + + // 在主线程回调结果 DispatchQueue.main.async { callback.onResult(result.text ?? "", detectedLanguage) } + } // 会话开始事件 recognizer.addSessionStartedEventHandler { (sender, event) in @@ -472,8 +632,8 @@ public class AzureAsrHelper: NSObject { guard let self = self else { return } print("会话结束事件:") // 直接在当前线程调用回调 - // callback.onSessionStopped() - // self._isContinuousRecognitionActive = false + callback.onSessionStopped() + self._isContinuousRecognitionActive = false //self.stopAudioProcessing() } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/LanguageDetector.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/LanguageDetector.swift new file mode 100644 index 000000000..e22513510 --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/LanguageDetector.swift @@ -0,0 +1,214 @@ +import Foundation +import NaturalLanguage + +/** + * 语言检测工具类 + * 使用iOS自带的自然语言处理框架检测文本语种 + */ +public class LanguageDetector { + + /** + * 检测文本的主要语言 + * + * @param text 需要检测的文本 + * @return 语言代码(如:"zh-Hans", "en", "ja"等),如果检测失败返回nil + */ + public static func detectLanguage(from text: String) -> String? { + // 检查输入文本是否为空 + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + + // 使用NLLanguageRecognizer进行语言检测 + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + + // 获取最可能的语言 + guard let dominantLanguage = recognizer.dominantLanguage else { + return nil + } + + return dominantLanguage.rawValue + } + + /** + * 检测文本的语言并返回置信度 + * + * @param text 需要检测的文本 + * @return 语言代码和置信度的字典,按置信度降序排列 + */ + public static func detectLanguageWithConfidence(from text: String) -> [(language: String, confidence: Double)] { + // 检查输入文本是否为空 + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + + // 获取语言假设及其置信度 + let languageHypotheses = recognizer.languageHypotheses(withMaximum: 5) + + // 转换为元组数组并按置信度排序 + return languageHypotheses.map { (language: $0.key.rawValue, confidence: $0.value) } + .sorted { $0.confidence > $1.confidence } + } + + /** + * 将语言代码转换为人类可读的语言名称 + * + * @param languageCode 语言代码(如:"zh-Hans", "en"等) + * @return 语言的本地化名称 + */ + public static func getLanguageName(from languageCode: String) -> String { + let locale = Locale.current + return locale.localizedString(forLanguageCode: languageCode) ?? languageCode + } + + /** + * 检测文本语言并返回Azure语音服务支持的语言代码格式 + * + * @param text 需要检测的文本 + * @return Azure支持的语言代码格式(如:"zh-CN", "en-US"等) + */ + public static func detectLanguageForAzure(from text: String) -> String? { + guard let detectedLanguage = detectLanguage(from: text) else { + return nil + } + + // 将iOS语言代码转换为Azure语音服务支持的格式 + return convertToAzureLanguageCode(detectedLanguage) + } + + /** + * 将iOS语言代码转换为Azure语音服务支持的语言代码 + * + * @param iosLanguageCode iOS检测到的语言代码 + * @return Azure支持的语言代码 + */ + public static func convertToAzureLanguageCode(_ iosLanguageCode: String) -> String { + let languageMapping: [String: String] = [ + // 中文 + "zh-Hans": "zh-CN", // 简体中文 + "zh-Hant": "zh-TW", // 繁体中文 + "zh-HK": "zh-HK", // 香港中文 + + // 英语系 + "en": "en-US", // 英语(美国) + "en-GB": "en-US", // 英语(英国) + "en-AU": "en-US", // 英语(澳大利亚) + "en-CA": "en-US", // 英语(加拿大) + "en-IN": "en-US", // 英语(印度) + "en-IE": "en-US", // 英语(爱尔兰) + "en-NZ": "en-US", // 英语(新西兰) + "en-ZA": "en-US", // 英语(南非) + + // 亚洲语言 + "ja": "ja-JP", // 日语 + "ko": "ko-KR", // 韩语 + "hi": "hi-IN", // 印地语 + "th": "th-TH", // 泰语 + "vi": "vi-VN", // 越南语 + "id": "id-ID", // 印尼语 + "ms": "ms-MY", // 马来语 + "tl": "fil-PH", // 菲律宾语 + "my": "my-MM", // 缅甸语 + "km": "km-KH", // 高棉语(柬埔寨) + "lo": "lo-LA", // 老挝语 + "si": "si-LK", // 僧伽罗语(斯里兰卡) + "ta": "ta-IN", // 泰米尔语 + "te": "te-IN", // 泰卢固语 + "bn": "bn-IN", // 孟加拉语 + "gu": "gu-IN", // 古吉拉特语 + "kn": "kn-IN", // 卡纳达语 + "ml": "ml-IN", // 马拉雅拉姆语 + "mr": "mr-IN", // 马拉地语 + "pa": "pa-IN", // 旁遮普语 + "ur": "ur-PK", // 乌尔都语 + + // 欧洲语言 + "fr": "fr-FR", // 法语(法国) + "fr-CA": "fr-CA", // 法语(加拿大) + "fr-BE": "fr-BE", // 法语(比利时) + "fr-CH": "fr-CH", // 法语(瑞士) + "de": "de-DE", // 德语(德国) + "de-AT": "de-AT", // 德语(奥地利) + "de-CH": "de-CH", // 德语(瑞士) + "es": "es-ES", // 西班牙语(西班牙) + "es-MX": "es-MX", // 西班牙语(墨西哥) + "es-AR": "es-AR", // 西班牙语(阿根廷) + "es-CO": "es-CO", // 西班牙语(哥伦比亚) + "es-CL": "es-CL", // 西班牙语(智利) + "es-PE": "es-PE", // 西班牙语(秘鲁) + "es-VE": "es-VE", // 西班牙语(委内瑞拉) + "it": "it-IT", // 意大利语 + "pt": "pt-BR", // 葡萄牙语(巴西) + "pt-PT": "pt-PT", // 葡萄牙语(葡萄牙) + "ru": "ru-RU", // 俄语 + "nl": "nl-NL", // 荷兰语 + "nl-BE": "nl-BE", // 荷兰语(比利时) + "sv": "sv-SE", // 瑞典语 + "da": "da-DK", // 丹麦语 + "no": "nb-NO", // 挪威语 + "fi": "fi-FI", // 芬兰语 + "is": "is-IS", // 冰岛语 + "pl": "pl-PL", // 波兰语 + "cs": "cs-CZ", // 捷克语 + "sk": "sk-SK", // 斯洛伐克语 + "hu": "hu-HU", // 匈牙利语 + "ro": "ro-RO", // 罗马尼亚语 + "bg": "bg-BG", // 保加利亚语 + "hr": "hr-HR", // 克罗地亚语 + "sr": "sr-RS", // 塞尔维亚语 + "sl": "sl-SI", // 斯洛文尼亚语 + "et": "et-EE", // 爱沙尼亚语 + "lv": "lv-LV", // 拉脱维亚语 + "lt": "lt-LT", // 立陶宛语 + "uk": "uk-UA", // 乌克兰语 + "be": "be-BY", // 白俄罗斯语 + "mk": "mk-MK", // 马其顿语 + "sq": "sq-AL", // 阿尔巴尼亚语 + "mt": "mt-MT", // 马耳他语 + "ga": "ga-IE", // 爱尔兰语 + "cy": "cy-GB", // 威尔士语 + "eu": "eu-ES", // 巴斯克语 + "ca": "ca-ES", // 加泰罗尼亚语 + "gl": "gl-ES", // 加利西亚语 + + // 中东和非洲语言 + "ar": "ar-SA", // 阿拉伯语(沙特阿拉伯) + "ar-EG": "ar-EG", // 阿拉伯语(埃及) + "ar-AE": "ar-AE", // 阿拉伯语(阿联酋) + "ar-BH": "ar-BH", // 阿拉伯语(巴林) + "ar-DZ": "ar-DZ", // 阿拉伯语(阿尔及利亚) + "ar-IQ": "ar-IQ", // 阿拉伯语(伊拉克) + "ar-JO": "ar-JO", // 阿拉伯语(约旦) + "ar-KW": "ar-KW", // 阿拉伯语(科威特) + "ar-LB": "ar-LB", // 阿拉伯语(黎巴嫩) + "ar-LY": "ar-LY", // 阿拉伯语(利比亚) + "ar-MA": "ar-MA", // 阿拉伯语(摩洛哥) + "ar-OM": "ar-OM", // 阿拉伯语(阿曼) + "ar-QA": "ar-QA", // 阿拉伯语(卡塔尔) + "ar-SY": "ar-SY", // 阿拉伯语(叙利亚) + "ar-TN": "ar-TN", // 阿拉伯语(突尼斯) + "ar-YE": "ar-YE", // 阿拉伯语(也门) + "he": "he-IL", // 希伯来语 + "fa": "fa-IR", // 波斯语(伊朗) + "tr": "tr-TR", // 土耳其语 + "az": "az-AZ", // 阿塞拜疆语 + "ka": "ka-GE", // 格鲁吉亚语 + "hy": "hy-AM", // 亚美尼亚语 + "sw": "sw-KE", // 斯瓦希里语 + "am": "am-ET", // 阿姆哈拉语(埃塞俄比亚) + "zu": "zu-ZA", // 祖鲁语(南非) + "af": "af-ZA", // 南非荷兰语 + + // 其他语言 + "el": "el-GR", // 希腊语 + "eo": "eo", // 世界语 + "la": "la", // 拉丁语 + ] + + return languageMapping[iosLanguageCode] ?? iosLanguageCode + } +} \ No newline at end of file From cd533e700f2eea6cb3168e76c5a1dbe7c0efe89a Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Fri, 1 Aug 2025 20:06:53 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=89=93=E7=94=B5?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/novice_guide_service.dart | 2 +- lib/modules/agent/views/agent_view.dart | 26 ++++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index 56ab6464b..060bb5c29 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -25,7 +25,7 @@ class NoviceGuideService extends GetxService { void startAIGuide() async { if (!isAIGuide) { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 200)); isAIGuide = true; _storage.write('ai_guide', true); Intro.of(Get.context!).start(group: 'ai'); diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index ff5da53fd..d989cd037 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -934,21 +934,19 @@ class _AgentViewState extends State with WidgetsBindingObserver { Widget _buildPhoneCallOverlay(BuildContext context, bool isDarkMode) { return Container( color: Colors.transparent, // 设置整个覆盖层为透明 - child: SafeArea( - child: Column( - children: [ - // 中间空白区域 - 完全透明 - const Expanded( - child: SizedBox(), // 空的占位符,完全透明 - ), + child: Column( + children: [ + // 中间空白区域 - 完全透明 + const Expanded( + child: SizedBox(), // 空的占位符,完全透明 + ), - // 底部控制按钮 - 包含音频波纹 - Container( - color: isDarkMode ? Colors.black : Colors.white, - child: _buildPhoneCallControls(isDarkMode), - ), - ], - ), + // 底部控制按钮 - 包含音频波纹 + Container( + color: isDarkMode ? Colors.black : Colors.white, + child: _buildPhoneCallControls(isDarkMode), + ), + ], ), ); }