From dd532a568a4712942660289f5ada7065653b24d5 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 9 May 2025 10:01:55 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/models/appconfig_model.dart | 37 ++++++++++++++++++- lib/data/models/appconfig_model.g.dart | 33 +++++++++++++++++ .../login/controllers/login_controller.dart | 1 + .../splash/controllers/splash_controller.dart | 1 + 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/lib/data/models/appconfig_model.dart b/lib/data/models/appconfig_model.dart index 9ecb31714..54edc6f86 100644 --- a/lib/data/models/appconfig_model.dart +++ b/lib/data/models/appconfig_model.dart @@ -5,14 +5,49 @@ part 'appconfig_model.g.dart'; // 生成的文件名 @JsonSerializable() class UserGetAppConfigResp { final Map env; + final List agents; final Map mcps; - UserGetAppConfigResp({required this.env, required this.mcps}); + UserGetAppConfigResp( + {required this.env, required this.agents, required this.mcps}); factory UserGetAppConfigResp.fromJson(Map json) => _$UserGetAppConfigRespFromJson(json); Map toJson() => _$UserGetAppConfigRespToJson(this); } +@JsonSerializable() +class DBAgent { + final String id; + final String name; + final String avatarUrl; + final String description; + final String tag; + final bool isOnline; + final String voice; + final String? welcomeMessage; + final String? voiceWelcomeMessage; + final String? systemPrompt; + final List? tools; + + DBAgent({ + required this.id, + required this.name, + required this.avatarUrl, + required this.description, + required this.tag, + required this.isOnline, + required this.voice, + required this.welcomeMessage, + required this.voiceWelcomeMessage, + required this.systemPrompt, + required this.tools, + }); + + factory DBAgent.fromJson(Map json) => + _$DBAgentFromJson(json); + Map toJson() => _$DBAgentToJson(this); +} + @JsonSerializable() class DBMCPServer { final String servername; diff --git a/lib/data/models/appconfig_model.g.dart b/lib/data/models/appconfig_model.g.dart index 89da944bf..6b89333fa 100644 --- a/lib/data/models/appconfig_model.g.dart +++ b/lib/data/models/appconfig_model.g.dart @@ -10,6 +10,9 @@ UserGetAppConfigResp _$UserGetAppConfigRespFromJson( Map json) => UserGetAppConfigResp( env: Map.from(json['env'] as Map), + agents: (json['agents'] as List) + .map((e) => DBAgent.fromJson(e as Map)) + .toList(), mcps: (json['mcps'] as Map).map( (k, e) => MapEntry(k, DBMCPServer.fromJson(e as Map)), ), @@ -19,9 +22,39 @@ Map _$UserGetAppConfigRespToJson( UserGetAppConfigResp instance) => { 'env': instance.env, + 'agents': instance.agents, 'mcps': instance.mcps, }; +DBAgent _$DBAgentFromJson(Map json) => DBAgent( + id: json['id'] as String, + name: json['name'] as String, + avatarUrl: json['avatarUrl'] as String, + description: json['description'] as String, + tag: json['tag'] as String, + isOnline: json['isOnline'] as bool, + voice: json['voice'] as String, + welcomeMessage: json['welcomeMessage'] as String?, + voiceWelcomeMessage: json['voiceWelcomeMessage'] as String?, + systemPrompt: json['systemPrompt'] as String?, + tools: + (json['tools'] as List?)?.map((e) => e as String).toList(), + ); + +Map _$DBAgentToJson(DBAgent instance) => { + 'id': instance.id, + 'name': instance.name, + 'avatarUrl': instance.avatarUrl, + 'description': instance.description, + 'tag': instance.tag, + 'isOnline': instance.isOnline, + 'voice': instance.voice, + 'welcomeMessage': instance.welcomeMessage, + 'voiceWelcomeMessage': instance.voiceWelcomeMessage, + 'systemPrompt': instance.systemPrompt, + 'tools': instance.tools, + }; + DBMCPServer _$DBMCPServerFromJson(Map json) => DBMCPServer( servername: json['servername'] as String, url: json['url'] as String, diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 780e94272..02ec0467a 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -740,6 +740,7 @@ class LoginController extends GetxController { final resp = UserGetAppConfigResp.fromJson(response); // 存储用户信息(示例) _storage.write('ENV', resp.env); + _storage.write('AGENTS', resp.agents); _storage.write('MCPS', resp.mcps); return true; } catch (e) { diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index cec9fbff7..2af4890ea 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -133,6 +133,7 @@ class SplashController extends GetxController { print('getAppConfig:进来了'); // 存储用户信息(示例) _storage.write('ENV', resp.env); + _storage.write('AGENTS', resp.agents); _storage.write('MCPS', resp.mcps); return true; From 98ca98c26f862824bd86ddb422185b3537b015a8 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Fri, 9 May 2025 16:14:15 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E9=80=9A=E8=AF=9D?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=EF=BC=8C=E9=9F=B3=E8=A7=86=E9=A2=91=E7=BF=BB?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/translations/language/zh_cn.dart | 2 + lib/data/services/asr_service.dart | 5 - .../speech_impl/azure_asr_service.dart | 60 +++----- .../speech_impl/flutter_asr_service.dart | 3 - .../speech_impl/volcano_asr_api_service.dart | 5 - .../speech_impl/volcano_asr_service.dart | 133 +++++++++--------- .../speech_impl/xunfei_asr_service.dart | 6 - .../controllers/translation_controller.dart | 38 +++-- .../translation/views/translation_view.dart | 68 +++++++-- .../agent_service/AgentService.kt | 11 +- .../azure_speech/AzureSpeechPlugin.kt | 13 -- .../yunqiinnovation/ble_service/BleConst.kt | 4 +- .../yunqiinnovation/ble_service/BleService.kt | 4 +- 13 files changed, 180 insertions(+), 172 deletions(-) diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index 1488135c4..c5d7c6635 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -117,6 +117,8 @@ const Map zhCN = { "allRoundAssistant": "全能助理", "allRoundAssistantInfo": "帮你解决各类问题", "simultaneousTranslation": "同声翻译", + 'audioVideoTranslation': '音视频翻译', + 'callTranslation': '通话翻译', "simultaneousTranslationInfo": "实时翻译80+种语言", "aiGirlfriend": "亲子陪伴", "aiGirlfriendInfo": "智能陪伴,随时聊天", diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 474b16408..fee73821c 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -16,11 +16,6 @@ abstract class AsrService { /// 返回识别的最终结果 Future recognizeOnce(); - /// 开始连续语音识别 - /// - /// 推送音频数据到连续识别中 - Future pushAudioData(Uint8List data); - /// 开始连续语音识别 /// /// 返回一个包含识别事件的流 diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 2d8c96396..7cfcf6d2a 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -8,8 +8,8 @@ import '../asr_service.dart'; /// 音频源类型 enum AudioSourceType { - microphone, // 使用设备麦克风 - external // 使用外部提供的音频数据 + microphone, // 使用设备麦克风 + external // 使用外部提供的音频数据 } /// Azure 语音识别服务 @@ -18,7 +18,8 @@ enum AudioSourceType { class AzureAsrService extends GetxService implements AsrService { static final AzureAsrService to = Get.put(AzureAsrService()); static const MethodChannel _channel = MethodChannel('azure_speech/asr'); - static const EventChannel _eventChannel = EventChannel('azure_speech/asr_events'); + static const EventChannel _eventChannel = + EventChannel('azure_speech/asr_events'); final GetStorage _storage = GetStorage(); bool _isInitialized = false; late final String _subscriptionKey; @@ -77,7 +78,8 @@ class AzureAsrService extends GetxService implements AsrService { bool useEchoCancellation = false, }) async { try { - final List languages = supportedLanguages ?? _defaultSupportedLanguages; + final List languages = + supportedLanguages ?? _defaultSupportedLanguages; // 检查是否需要重新初始化 if (_isInitialized) { @@ -85,7 +87,9 @@ class AzureAsrService extends GetxService implements AsrService { } // 设置音频源类型 - _audioSourceType = useExternalAudio ? AudioSourceType.external : AudioSourceType.microphone; + _audioSourceType = useExternalAudio + ? AudioSourceType.external + : AudioSourceType.microphone; final bool result = await _channel.invokeMethod('initialize', { 'subscriptionKey': _subscriptionKey, @@ -94,7 +98,7 @@ class AzureAsrService extends GetxService implements AsrService { 'audioSourceType': _audioSourceType.toString().split('.').last, 'useEchoCancellation': useEchoCancellation, }); - + _setupEventChannel(); _isInitialized = result; @@ -120,8 +124,9 @@ class AzureAsrService extends GetxService implements AsrService { if (result is Map) { // 处理新格式返回 final String text = result['text'] as String? ?? ''; - final String detectedLanguage = result['detectedLanguage'] as String? ?? ''; - + final String detectedLanguage = + result['detectedLanguage'] as String? ?? ''; + // 更新最新识别结果 _latestRecognizedText = text; _latestDetectedLanguage = detectedLanguage; @@ -157,7 +162,8 @@ class AzureAsrService extends GetxService implements AsrService { _eventStreamController = StreamController.broadcast(); // 开始连续识别 - final bool result = await _channel.invokeMethod('startContinuousRecognition'); + final bool result = + await _channel.invokeMethod('startContinuousRecognition'); if (!result) { _cleanupEventStream(); @@ -180,7 +186,8 @@ class AzureAsrService extends GetxService implements AsrService { } try { - final bool result = await _channel.invokeMethod('stopContinuousRecognition'); + final bool result = + await _channel.invokeMethod('stopContinuousRecognition'); _cleanupEventStream(); return result; } catch (e) { @@ -208,7 +215,8 @@ class AzureAsrService extends GetxService implements AsrService { switch (eventType) { case 'result': final String text = eventMap['text'] as String? ?? ''; - final String detectedLanguage = eventMap['detectedLanguage'] as String? ?? ''; + final String detectedLanguage = + eventMap['detectedLanguage'] as String? ?? ''; _latestRecognizedText = text; _latestDetectedLanguage = detectedLanguage; _eventStreamController?.add(RecognitionEvent( @@ -220,7 +228,8 @@ class AzureAsrService extends GetxService implements AsrService { case 'recognizing': final String text = eventMap['text'] as String? ?? ''; - final String detectedLanguage = eventMap['detectedLanguage'] as String? ?? ''; + final String detectedLanguage = + eventMap['detectedLanguage'] as String? ?? ''; _eventStreamController?.add(RecognitionEvent( type: RecognitionEventType.intermediateResult, text: text, @@ -303,31 +312,4 @@ class AzureAsrService extends GetxService implements AsrService { _isInitialized = false; } } - - @override - Future pushAudioData(Uint8List data) async { - if (!_isInitialized) { - await initialize(useExternalAudio: true); - } - - if (_audioSourceType != AudioSourceType.external) { - Logger.error('当前非外部音频模式,不能推送音频数据'); - return false; - } - - try { - final bool result = await _channel.invokeMethod('pushAudioData', { - 'audioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer - }); - - if (!result) { - throw Exception('推送音频数据失败'); - } - - return true; - } catch (e) { - Logger.error('推送音频数据失败: $e'); - return false; - } - } } diff --git a/lib/data/services/speech_impl/flutter_asr_service.dart b/lib/data/services/speech_impl/flutter_asr_service.dart index 7cbf9b88f..d1e7fb6d8 100644 --- a/lib/data/services/speech_impl/flutter_asr_service.dart +++ b/lib/data/services/speech_impl/flutter_asr_service.dart @@ -474,7 +474,4 @@ class FlutterAsrService extends GetxService implements AsrService { Logger.error('释放语音识别资源失败: $e'); } } - - @override - Future pushAudioData(Uint8List data) async {} } diff --git a/lib/data/services/speech_impl/volcano_asr_api_service.dart b/lib/data/services/speech_impl/volcano_asr_api_service.dart index d2868d887..bab3790ef 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -809,9 +809,4 @@ class VolcanoAsrApiService implements AsrService { _isContinuousRecognitionActive = false; _isRecognitionEndRequested = false; } - - @override - Future pushAudioData(Uint8List data) { - throw UnimplementedError(); - } } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart index 7018af3c5..636c2d834 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -8,35 +8,36 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../asr_service.dart'; /// 火山语音识别服务实现 -class VolcanoAsrService extends GetxService implements AsrService { +class VolcanoAsrService extends GetxService implements AsrService { /// 方法通道 static const MethodChannel _channel = MethodChannel('volcano_speech/asr'); - + /// 事件通道 - static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events'); - + static const EventChannel _eventChannel = + EventChannel('volcano_speech/asr_events'); + /// 事件流控制器 - final StreamController _eventStreamController = + final StreamController _eventStreamController = StreamController.broadcast(); - + /// 事件订阅 StreamSubscription? _eventSubscription; - + /// 是否初始化完成 bool _isInitialized = false; - + /// 是否正在连续识别 bool _isContinuousRecognitionActive = false; - + /// 当前语言 String _currentLanguage = 'zh-CN'; - + /// 支持的语言列表 final List _supportedLanguages = ['zh-CN', 'en-US']; - + @override List get supportedLanguages => _supportedLanguages; - + /// 构造函数 VolcanoAsrService() { _setupEventListener(); @@ -48,39 +49,42 @@ class VolcanoAsrService extends GetxService implements AsrService { List? supportedLanguages, }) async { if (_isInitialized) return true; - + try { // 如果提供了支持的语言列表,则替换默认列表 if (supportedLanguages != null && supportedLanguages.isNotEmpty) { _supportedLanguages.clear(); _supportedLanguages.addAll(supportedLanguages); } - + // 从.env文件读取配置 final appId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? ''; final appToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? ''; - final resourceId = dotenv.env['VOLCANO_ASR_RESOURCE_ID'] ?? 'volc.bigasr.sauc.duration'; - + final resourceId = + dotenv.env['VOLCANO_ASR_RESOURCE_ID'] ?? 'volc.bigasr.sauc.duration'; + if (appId.isEmpty || appToken.isEmpty) { - debugPrint('火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量'); + debugPrint( + '火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量'); return false; } - + // 初始化火山语音ASR final initResult = await _channel.invokeMethod('initialize', { - 'appId': appId, - 'appToken': appToken, - 'resourceId': resourceId, - }) ?? false; - + 'appId': appId, + 'appToken': appToken, + 'resourceId': resourceId, + }) ?? + false; + if (!initResult) { debugPrint('火山语音ASR初始化失败'); return false; } - + // 设置默认语言、VAD参数和启用音量返回 await _configureAsr(); - + _isInitialized = true; return true; } catch (e) { @@ -88,14 +92,13 @@ class VolcanoAsrService extends GetxService implements AsrService { return false; } } - + /// 配置ASR参数 Future _configureAsr() async { // 设置默认语言 await _channel.invokeMethod('setLanguage', { 'language': _currentLanguage, }); - } /// 设置事件监听 @@ -118,25 +121,25 @@ class VolcanoAsrService extends GetxService implements AsrService { /// 处理ASR事件 void _handleAsrEvent(Map event) { final eventType = event['type'] as String?; - + switch (eventType) { case 'sessionStarted': _eventStreamController.add(RecognitionEvent( type: RecognitionEventType.sessionStarted, )); break; - + case 'sessionStopped': _eventStreamController.add(RecognitionEvent( type: RecognitionEventType.sessionStopped, )); _isContinuousRecognitionActive = false; break; - + case 'recognizing': final text = event['text'] as String?; final language = event['language'] as String?; - + if (text != null) { debugPrint('接收到中间识别结果: $text'); _eventStreamController.add(RecognitionEvent( @@ -146,11 +149,11 @@ class VolcanoAsrService extends GetxService implements AsrService { )); } break; - + case 'result': final text = event['text'] as String?; final language = event['language'] as String?; - + if (text != null) { debugPrint('接收到最终识别结果: $text'); _eventStreamController.add(RecognitionEvent( @@ -160,10 +163,10 @@ class VolcanoAsrService extends GetxService implements AsrService { )); } break; - + case 'error': final errorMessage = event['message'] as String?; - + debugPrint('接收到识别错误: ${errorMessage ?? "未知错误"}'); _eventStreamController.add(RecognitionEvent( type: RecognitionEventType.error, @@ -171,11 +174,12 @@ class VolcanoAsrService extends GetxService implements AsrService { )); _isContinuousRecognitionActive = false; break; - + case 'volumeChanged': // 音量变化事件仅在日志中输出,不发送到事件流 final volume = event['volume'] as int?; - if (volume != null && volume > 5) { // 仅记录有意义的音量变化 + if (volume != null && volume > 5) { + // 仅记录有意义的音量变化 debugPrint('音量变化: $volume'); } break; @@ -199,11 +203,11 @@ class VolcanoAsrService extends GetxService implements AsrService { error: '语音识别服务未初始化', ); } - + try { // 创建一个Completer用于等待最终结果 final completer = Completer(); - + // 创建一次性事件监听 StreamSubscription? subscription; subscription = _eventStreamController.stream.listen((event) { @@ -218,10 +222,11 @@ class VolcanoAsrService extends GetxService implements AsrService { subscription?.cancel(); } }); - + // 启动一次性识别 - final started = await _channel.invokeMethod('recognizeOnce') ?? false; - + final started = + await _channel.invokeMethod('recognizeOnce') ?? false; + if (!started) { subscription.cancel(); return RecognitionEvent( @@ -229,7 +234,7 @@ class VolcanoAsrService extends GetxService implements AsrService { error: '启动一次性识别失败', ); } - + // 设置超时,防止永久等待 Future.delayed(const Duration(seconds: 30), () { if (!completer.isCompleted) { @@ -242,7 +247,7 @@ class VolcanoAsrService extends GetxService implements AsrService { _channel.invokeMethod('stopRecognize'); } }); - + // 等待结果 return await completer.future; } catch (e) { @@ -250,7 +255,7 @@ class VolcanoAsrService extends GetxService implements AsrService { try { await _channel.invokeMethod('stopRecognize'); } catch (_) {} - + return RecognitionEvent( type: RecognitionEventType.error, error: e.toString(), @@ -268,10 +273,12 @@ class VolcanoAsrService extends GetxService implements AsrService { )); return _eventStreamController.stream; } - + try { - final success = await _channel.invokeMethod('startContinuousRecognition') ?? false; - + final success = + await _channel.invokeMethod('startContinuousRecognition') ?? + false; + if (success) { _isContinuousRecognitionActive = true; } else { @@ -286,7 +293,7 @@ class VolcanoAsrService extends GetxService implements AsrService { error: '启动连续识别异常:$e', )); } - + return _eventStreamController.stream; } @@ -296,10 +303,12 @@ class VolcanoAsrService extends GetxService implements AsrService { if (!_isInitialized || !_isContinuousRecognitionActive) { return false; } - + try { - final success = await _channel.invokeMethod('stopContinuousRecognition') ?? false; - + final success = + await _channel.invokeMethod('stopContinuousRecognition') ?? + false; + if (success) { _isContinuousRecognitionActive = false; } @@ -310,18 +319,6 @@ class VolcanoAsrService extends GetxService implements AsrService { } } - /// 推送音频数据到连续识别中 - @override - Future pushAudioData(Uint8List data) async { - if (!_isInitialized || !_isContinuousRecognitionActive) { - return; - } - - // 注意:此功能需要在引擎初始化时设置音频输入类型为stream - // 由于当前实现默认使用麦克风输入,此方法暂时不会实际处理数据 - debugPrint('警告:当前火山语音ASR使用麦克风输入,pushAudioData方法不会起作用'); - } - /// 检查连续识别是否活跃 @override bool isContinuousRecognitionActive() { @@ -334,17 +331,17 @@ class VolcanoAsrService extends GetxService implements AsrService { if (_isContinuousRecognitionActive) { await stopContinuousRecognition(); } - + await _eventSubscription?.cancel(); _eventSubscription = null; - + if (!_eventStreamController.isClosed) { await _eventStreamController.close(); } - + if (_isInitialized) { await _channel.invokeMethod('release'); _isInitialized = false; } } -} \ No newline at end of file +} diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index cadb48b2f..1201f7c66 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -269,10 +269,4 @@ class XunfeiAsrService extends GetxService implements AsrService { _isInitialized = false; } } - - @override - Future pushAudioData(Uint8List data) { - // TODO: implement pushAudioData - throw UnimplementedError(); - } } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index c40c8c786..fc97056c5 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -57,10 +57,29 @@ class TranslationController extends GetxController { final targetLanguageCode = 'en-US'.obs; var detectedLanguageCode = ''; + final currentModeTitle = 'simultaneousTranslation'.obs; + final currentMode = 'simultaneous'.obs; // 翻译历史和当前项 final translationHistory = [].obs; String? currentSessionId; + void changeTranslationMode(String mode) { + currentMode.value = mode; + currentModeTitle.value = _getModeTitle(mode); + // 这里添加具体模式切换逻辑 + } + + String _getModeTitle(String mode) { + switch (mode) { + case 'audioVideo': + return 'audioVideoTranslation'; + case 'call': + return 'callTranslation'; + default: + return 'simultaneousTranslation'; + } + } + // 兼容旧版视图的计算属性 Rx get currentItem { if (translationHistory.isNotEmpty && @@ -103,7 +122,6 @@ class TranslationController extends GetxController { targetLanguageCode.value = targetAsrCode; } - // 加载历史记录 loadTranslationHistory(); @@ -116,7 +134,6 @@ class TranslationController extends GetxController { }); } - // 加载保存的语言设置 void _loadSavedLanguages() { final savedSourceLanguage = _storage.read(_sourceLanguageKey); @@ -193,9 +210,7 @@ class TranslationController extends GetxController { Logger.info('开始语音识别'); if (isRecognizing.value) return; - try { - Logger.info('开始语音识别1'); // 每次开始识别前检查 ASR 服务是否已配置为当前语言对 final List currentLanguages = [ sourceLanguageCode.value, @@ -225,15 +240,18 @@ class TranslationController extends GetxController { // // 添加到历史记录 // translationHistory.add(newItem); + if (currentMode.value == "audioVideo") { + // 发送ble音乐或者通话远端声音 + + Logger.info('发送ble音乐或者通话远端声音'); + } else if (currentMode.value == "call") { + // 发送ble系统mic和dac(音乐或者通话远端)声音 + + Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); + } // 开始连续语音识别 final recognitionStream = await _asrService.startContinuousRecognition(); - Logger.info('翻译开启,开始监'); - // 监听音频数据 - _audioSubscription = jieli.audioDataStream.listen((data) async { - // Logger.info('翻译开启,开始监听解码后的数据=${data}'); - final pushAudioDataStream = await _asrService.pushAudioData(data); - }); isRecognizing.value = true; diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 4689249d2..5908b0691 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -21,7 +21,20 @@ class TranslationView extends GetView { ); } - PreferredSizeWidget _buildAppBar(bool isDarkMode) { + PreferredSizeWidget _buildAppBar(bool isDarkMode) { + // 在同一个类中添加辅助方法 + PopupMenuItem _buildPopupMenuItem(String value, String text) { + return PopupMenuItem( + value: value, + child: Text( + text, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ); + } + return AppBar( backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, elevation: 1, @@ -37,14 +50,37 @@ class TranslationView extends GetView { Get.back(); }, ), - title: Text( - 'simultaneousTranslation'.tr,// 实时翻译 - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), + title: Obx(() => PopupMenuButton( + onSelected: (value) { + controller.changeTranslationMode(value); + }, + color: isDarkMode ? AppColors.cardBackground : Colors.white, + itemBuilder: (BuildContext context) => [ + _buildPopupMenuItem('simultaneous', 'simultaneousTranslation'.tr), + _buildPopupMenuItem('audioVideo', 'audioVideoTranslation'.tr), + _buildPopupMenuItem('call', 'callTranslation'.tr), + ], + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + controller.currentModeTitle.value.tr, // 添加 .value 访问实际值 + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + Icon( + Icons.arrow_drop_down, + color: isDarkMode ? Colors.white : Colors.black, + size: 20.sp, + ), + ], + ), + ), + )), centerTitle: true, actions: [ // TTS toggle button @@ -93,7 +129,8 @@ class TranslationView extends GetView { child: GestureDetector( onTap: () => _showLanguageSelectionDialog(true, isDarkMode), child: Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + padding: + EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), decoration: BoxDecoration( color: isDarkMode ? Colors.white.withOpacity(0.1) @@ -101,8 +138,8 @@ class TranslationView extends GetView { borderRadius: BorderRadius.circular(16.r), ), child: Obx(() => Text( - '${controller.sourceLanguage.value - .split(' ')[0]}'.tr, // Just show the Chinese part + '${controller.sourceLanguage.value.split(' ')[0]}' + .tr, // Just show the Chinese part style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w600, @@ -140,7 +177,8 @@ class TranslationView extends GetView { child: GestureDetector( onTap: () => _showLanguageSelectionDialog(false, isDarkMode), child: Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + padding: + EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), decoration: BoxDecoration( color: isDarkMode ? Colors.white.withOpacity(0.1) @@ -148,8 +186,8 @@ class TranslationView extends GetView { borderRadius: BorderRadius.circular(16.r), ), child: Obx(() => Text( - '${controller.targetLanguage.value - .split(' ')[0]}'.tr, // Just show the English part + '${controller.targetLanguage.value.split(' ')[0]}' + .tr, // Just show the English part style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w600, 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 36817caf0..2d3e4676d 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 @@ -287,11 +287,12 @@ object AgentService : CoroutineScope { if (isRecognitionActive) return true // 检查录音权限 - // if (!checkRecordAudioPermission()) { - // FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") - // sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) - // return false - // } + + if (!isExternalActive||!checkRecordAudioPermission()) { + FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") + sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) + return false + } isRecognitionActive = true hasSpeechDetected = false diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 662d627ad..9a97dce58 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -143,19 +143,6 @@ class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { result.error("INITIALIZATION_ERROR", e.message, null) } } - "pushAudioData" -> { - val audioData = call.argument("audioData") - try { - if (audioData != null) { - azureAsrHelper.pushAudioData(audioData) - result.success(true) - } else { - result.error("INVALID_ARGUMENTS", "音频数据不能为空", null) - } - } catch (e: Exception) { - result.error("PUSH_AUDIO_ERROR", e.message, null) - } - } "recognizeOnce" -> { val useExternalAudio = call.argument("useExternalAudio") ?: false try { diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index 74f8c35d0..0da045011 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -82,9 +82,9 @@ object BleConst { // ================== 编解码控制值 ================== /** 关闭编解码指令 */ const val CODEC_CONTROL_CLOSE = 0x00 - /** 打开解码指令 */ + /** 音乐或者通话远端声音 */ const val CODEC_CONTROL_DECODE_ON = 0xA1 - /** 通过A2DP播放 */ + /** mic和dac(音乐或者通话远端)声音 */ const val CODEC_CONTROL_A2DP_PLAY = 0xA2 /** 打开编码指令 */ const val CODEC_CONTROL_ENCODE_ON = 0xB1 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index e029783b1..a6d81d279 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -907,7 +907,9 @@ object BleService { FileLogger.i(TAG, "关闭编解码...") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), - byteArrayOf(BleConst.CODEC_CONTROL_CLOSE.toByte()) + byteArrayOf(BleConst.CODEC_CONTROL_CLOSE.toByte(), + + BleConst.AUDIO_CHANNEL_RIGHT.toByte()) ) } From 0258fbafa87cc55cb9ecafa42119470c9afca88a Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 9 May 2025 16:18:30 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/models/appconfig.dart | 28 +++++++ lib/data/services/ble_manager.dart | 30 +++++--- .../services/open_ai_service_adapter.dart | 9 ++- .../speech_impl/azure_asr_service.dart | 36 ++++++++- .../speech_impl/azure_tts_service.dart | 74 ++++++++++--------- lib/data/services/spotify_service.dart | 9 ++- .../services/volcano_translation_service.dart | 14 ++-- .../login/controllers/login_controller.dart | 8 +- .../splash/controllers/splash_controller.dart | 9 ++- .../open_ai_service/MCPClient.kt | 2 +- .../open_ai_service/OpenAIService.kt | 4 +- 11 files changed, 154 insertions(+), 69 deletions(-) create mode 100644 lib/data/models/appconfig.dart diff --git a/lib/data/models/appconfig.dart b/lib/data/models/appconfig.dart new file mode 100644 index 000000000..1c04272a6 --- /dev/null +++ b/lib/data/models/appconfig.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:deep_voice/data/models/appconfig_model.dart'; + +class AppConfig { + static late UserGetAppConfigResp config; + + // 可选:提供初始化方法 + static void initialize(UserGetAppConfigResp resp) { + config = resp; + } + + //环境变量 + static String? env(String key) { + return config.env[key]; + } + + //mcp配置 + static dynamic mcpConfig() { + return {"mcpServers": config.mcps}; + } + + //智能体配置 + static String agent(String agentId) { + final foundAgent = config.agents.firstWhere((agent) => agent.id == agentId); + return jsonEncode((foundAgent)); + } +} diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 399569d60..56f2841ac 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:get/get.dart'; import 'package:ble_service/ble_service.dart'; import 'package:deep_voice/core/utils/logger.dart'; @@ -383,17 +384,28 @@ class BleManager extends GetxService { Future startBleService() async { try { // 从.env文件直接读取环境变量 - final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; - final azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? 'eastasia'; - final openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; + // final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; + // final azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? 'eastasia'; + // final openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; + // final openaiBaseUrl = + // dotenv.env['OPENAI_BASE_URL'] ?? 'https://api.openai.com/v1'; + // final openaiModel = dotenv.env['OPENAI_MODEL'] ?? 'gpt-3.5-turbo'; + // 从Flutter资源包中加载.mcp.json + // final mcpConfig = await rootBundle.loadString('.mcp.json'); + + // 验证JSON格式 + // final jsonData = jsonDecode(mcpConfig); + + final azureSpeechKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; + final azureSpeechRegion = + AppConfig.env('AZURE_SPEECH_REGION') ?? 'eastasia'; + final openaiApiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; final openaiBaseUrl = - dotenv.env['OPENAI_BASE_URL'] ?? 'https://api.openai.com/v1'; - final openaiModel = dotenv.env['OPENAI_MODEL'] ?? 'gpt-3.5-turbo'; - // 从Flutter资源包中加载.mcp.json - final mcpConfig = await rootBundle.loadString('.mcp.json'); + AppConfig.env('OPENAI_BASE_URL') ?? 'https://api.openai.com/v1'; + final openaiModel = AppConfig.env('OPENAI_MODEL') ?? 'gpt-3.5-turbo'; + // 从Flutter资源包中加载.mcp.json + final jsonData = AppConfig.mcpConfig(); - // 验证JSON格式 - final jsonData = jsonDecode(mcpConfig); final config = { // 语音代理配置参数 'azureSpeechKey': azureSpeechKey, diff --git a/lib/data/services/open_ai_service_adapter.dart b/lib/data/services/open_ai_service_adapter.dart index cd3adca7d..2a88ac09e 100644 --- a/lib/data/services/open_ai_service_adapter.dart +++ b/lib/data/services/open_ai_service_adapter.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:convert'; +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:deep_voice/data/models/appconfig_model.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_storage/get_storage.dart'; @@ -103,10 +104,10 @@ class OpenAIServiceAdapter implements AiService { // final apiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; // final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; // final model = dotenv.env['OPENAI_MODEL'] ?? ''; - final _env = _storage.read("ENV") as Map; - final apiKey = _env['OPENAI_API_KEY'] ?? ''; - final baseUrl = _env['OPENAI_BASE_URL'] ?? ''; - final model = _env['OPENAI_MODEL'] ?? ''; + // final _env = _storage.read("ENV") as Map; + final apiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; + final baseUrl = AppConfig.env('OPENAI_BASE_URL') ?? ''; + final model = AppConfig.env('OPENAI_MODEL') ?? ''; printInfo(info: '从.env读取OpenAI配置'); printInfo(info: '基础URL: $baseUrl'); diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 7cfcf6d2a..d15038a97 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:flutter/services.dart'; import 'package:get_storage/get_storage.dart'; import '../../../core/utils/logger.dart'; @@ -20,7 +21,7 @@ class AzureAsrService extends GetxService implements AsrService { static const MethodChannel _channel = MethodChannel('azure_speech/asr'); static const EventChannel _eventChannel = EventChannel('azure_speech/asr_events'); - final GetStorage _storage = GetStorage(); + // final GetStorage _storage = GetStorage(); bool _isInitialized = false; late final String _subscriptionKey; late final String _serviceRegion; @@ -51,9 +52,9 @@ class AzureAsrService extends GetxService implements AsrService { /// 从环境变量加载配置 void _loadConfig() { - final _env = _storage.read("ENV") as Map; - _subscriptionKey = _env['AZURE_SPEECH_KEY'] ?? ''; - _serviceRegion = _env['AZURE_SPEECH_REGION'] ?? ''; + // final _env = _storage.read("ENV") as Map; + _subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; + _serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { throw Exception( @@ -312,4 +313,31 @@ class AzureAsrService extends GetxService implements AsrService { _isInitialized = false; } } + + @override + Future pushAudioData(Uint8List data) async { + if (!_isInitialized) { + await initialize(useExternalAudio: true); + } + + if (_audioSourceType != AudioSourceType.external) { + Logger.error('当前非外部音频模式,不能推送音频数据'); + return false; + } + + try { + final bool result = await _channel.invokeMethod('pushAudioData', { + 'audioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer + }); + + if (!result) { + throw Exception('推送音频数据失败'); + } + + return true; + } catch (e) { + Logger.error('推送音频数据失败: $e'); + return false; + } + } } diff --git a/lib/data/services/speech_impl/azure_tts_service.dart b/lib/data/services/speech_impl/azure_tts_service.dart index cf6c98bf7..2e24a90b8 100644 --- a/lib/data/services/speech_impl/azure_tts_service.dart +++ b/lib/data/services/speech_impl/azure_tts_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; @@ -13,22 +14,24 @@ import '../tts_service.dart'; class AzureTtsService extends GetxService implements TtsService { static final AzureTtsService to = Get.put(AzureTtsService()); static const MethodChannel _channel = MethodChannel('azure_speech/tts'); - static const EventChannel _eventChannel = EventChannel('azure_speech/tts_events'); - - final GetStorage _storage = GetStorage(); + static const EventChannel _eventChannel = + EventChannel('azure_speech/tts_events'); + + // final GetStorage _storage = GetStorage(); bool _isInitialized = false; late final String _subscriptionKey; late final String _serviceRegion; // 当前设置 String _currentVoice = 'zh-CN-XiaoxiaoNeural'; - + // 语音合成队列 final List _textQueue = []; bool _isProcessingQueue = false; // 事件流 - final StreamController _eventStreamController = StreamController.broadcast(); + final StreamController _eventStreamController = + StreamController.broadcast(); StreamSubscription? _eventSubscription; // 可观察状态 @@ -53,9 +56,9 @@ class AzureTtsService extends GetxService implements TtsService { /// 从环境变量加载配置 void _loadConfig() { - final _env = _storage.read("ENV") as Map; - _subscriptionKey = _env['AZURE_SPEECH_KEY'] ?? ''; - _serviceRegion = _env['AZURE_SPEECH_REGION'] ?? ''; + // final _env = _storage.read("ENV") as Map; + _subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; + _serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; } /// 设置事件通道 @@ -74,50 +77,50 @@ class AzureTtsService extends GetxService implements TtsService { /// 处理TTS事件 void _handleTtsEvent(Map event) { final String eventType = event['type'] as String? ?? ''; - + // Logger.debug('收到TTS事件: $eventType, 数据: $event'); - + switch (eventType) { case 'synthesis_started': _isSpeaking.value = true; _eventStreamController.add(TtsEvent(type: TtsEventType.started)); break; - + case 'synthesizing': // _isSpeaking.value = true; // // 合成中事件暂时映射到started类型,因为TtsEventType中没有synthesizing类型 // _eventStreamController.add(TtsEvent(type: TtsEventType.started)); break; - + case 'synthesis_completed': _isSpeaking.value = false; _eventStreamController.add(TtsEvent(type: TtsEventType.completed)); - + // 如果有队列在处理中,继续处理下一项 if (_isProcessingQueue) { _processNextInQueue(); } break; - + case 'synthesis_canceled': _isSpeaking.value = false; final String reason = event['reason'] as String? ?? ''; - + _eventStreamController.add(TtsEvent.error('合成取消: $reason')); - + // 如果有队列在处理中,继续处理下一项(即使出错) if (_isProcessingQueue) { _processNextInQueue(); } break; - + case 'error': _isSpeaking.value = false; final String message = event['message'] as String? ?? '未知错误'; - + Logger.error('TTS错误: $message'); _eventStreamController.add(TtsEvent.error(message)); - + // 如果有队列在处理中,继续处理下一项(即使出错) if (_isProcessingQueue) { _processNextInQueue(); @@ -189,7 +192,7 @@ class AzureTtsService extends GetxService implements TtsService { final result = await _channel.invokeMethod('speakText', { 'text': text, }); - + return result == true; } catch (e) { Logger.error('语音合成失败: $e'); @@ -277,21 +280,24 @@ class AzureTtsService extends GetxService implements TtsService { // 等待队列处理完成 final completer = Completer(); - + // 监听事件流检查队列是否完成 final subscription = onEvent.listen((event) { // 当队列为空且不在处理中,且当前非说话状态,完成操作 - if (_textQueue.isEmpty && !_isProcessingQueue && !_isSpeaking.value && !completer.isCompleted) { + if (_textQueue.isEmpty && + !_isProcessingQueue && + !_isSpeaking.value && + !completer.isCompleted) { completer.complete(true); } }); - + // 如果已经处于空闲状态,直接完成 if (_textQueue.isEmpty && !_isProcessingQueue && !_isSpeaking.value) { subscription.cancel(); return true; } - + // 设置超时 final timeout = Timer(const Duration(seconds: 60), () { if (!completer.isCompleted) { @@ -299,14 +305,14 @@ class AzureTtsService extends GetxService implements TtsService { completer.complete(false); } }); - + // 等待完成 final result = await completer.future; - + // 清理 subscription.cancel(); timeout.cancel(); - + Logger.info('TTS队列播放完成'); return result; } catch (e) { @@ -320,11 +326,11 @@ class AzureTtsService extends GetxService implements TtsService { if (_textQueue.isEmpty || _isProcessingQueue) return; _isProcessingQueue = true; - + // 处理队列中的第一项 await _processNextInQueue(); } - + /// 处理队列中的下一项 Future _processNextInQueue() async { // 检查队列是否为空或已禁用 @@ -333,14 +339,14 @@ class AzureTtsService extends GetxService implements TtsService { _isProcessingQueue = false; return; } - + try { // 获取当前项但暂不从队列中移除 final text = _textQueue.first; - + // 开始合成 final success = await speakOnce(text); - + // 仅当合成操作失败时才从队列移除并继续处理下一项 // 成功的话会等待合成完成事件后自动处理下一项 if (!success) { @@ -353,12 +359,12 @@ class AzureTtsService extends GetxService implements TtsService { } } catch (e) { Logger.error('处理语音队列出错: $e'); - + // 出错时移除当前项并尝试继续 if (_textQueue.isNotEmpty) { _textQueue.removeAt(0); } - + // 如果队列中还有项目,继续处理 if (_textQueue.isNotEmpty) { await _processNextInQueue(); diff --git a/lib/data/services/spotify_service.dart b/lib/data/services/spotify_service.dart index 30adbfe97..f5f25d7fb 100644 --- a/lib/data/services/spotify_service.dart +++ b/lib/data/services/spotify_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:convert'; +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_storage/get_storage.dart'; import 'package:spotify_sdk/spotify_sdk.dart'; @@ -12,7 +13,7 @@ import 'package:path_provider/path_provider.dart'; /// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 class SpotifService { bool isInitialized = false; - final GetStorage _storage = GetStorage(); + // final GetStorage _storage = GetStorage(); late String SPOTIFY_CLIENT_ID; late String SPOTIFY_REDIRECT_URL; @@ -20,9 +21,9 @@ class SpotifService { /// 初始化OpenAI服务 Future initialize() async { if (!isInitialized) { - final _env = _storage.read("ENV") as Map; - SPOTIFY_CLIENT_ID = _env['SPOTIFY_CLIENT_ID'] ?? ''; - SPOTIFY_REDIRECT_URL = _env['SPOTIFY_REDIRECT_URL'] ?? ''; + // final _env = _storage.read("ENV") as Map; + SPOTIFY_CLIENT_ID = AppConfig.env('SPOTIFY_CLIENT_ID') ?? ''; + SPOTIFY_REDIRECT_URL = AppConfig.env('SPOTIFY_REDIRECT_URL') ?? ''; printInfo( info: "SpotifService----------- initialize SPOTIFY_CLIENT_ID:${SPOTIFY_CLIENT_ID} SPOTIFY_REDIRECT_URL:${SPOTIFY_REDIRECT_URL}"); diff --git a/lib/data/services/volcano_translation_service.dart b/lib/data/services/volcano_translation_service.dart index 11e19bb1c..0cbee7c01 100644 --- a/lib/data/services/volcano_translation_service.dart +++ b/lib/data/services/volcano_translation_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:math'; +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart' as http; import 'package:crypto/crypto.dart'; @@ -22,7 +23,7 @@ class VolcanoTranslationService extends GetxService { // 语言管理器 final LanguageManager _languageManager = Get.find(); - final GetStorage _storage = GetStorage(); + // final GetStorage _storage = GetStorage(); // 构造函数 VolcanoTranslationService(); @@ -33,10 +34,13 @@ class VolcanoTranslationService extends GetxService { String? region, }) async { try { - final _env = _storage.read("ENV") as Map; - _accessKey = accessKey ?? _env['VOLCANO_TRANSLATION_ACCESS_KEY'] ?? ''; - _secretKey = secretKey ?? _env['VOLCANO_TRANSLATION_SECRET_KEY'] ?? ''; - _region = region ?? _env['VOLCANO_TRANSLATION_REGION'] ?? 'cn-north-1'; + // final _env = _storage.read("ENV") as Map; + _accessKey = + accessKey ?? AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY') ?? ''; + _secretKey = + secretKey ?? AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY') ?? ''; + _region = + region ?? AppConfig.env('VOLCANO_TRANSLATION_REGION') ?? 'cn-north-1'; _service = 'translate'; _baseUrl = 'https://translate.volcengineapi.com'; diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 02ec0467a..cc39b10f1 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -1,3 +1,4 @@ +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:deep_voice/data/models/appconfig_model.dart'; import 'package:get/get.dart'; import 'package:flutter/material.dart'; @@ -739,9 +740,10 @@ class LoginController extends GetxController { // 根据API设计调整 final resp = UserGetAppConfigResp.fromJson(response); // 存储用户信息(示例) - _storage.write('ENV', resp.env); - _storage.write('AGENTS', resp.agents); - _storage.write('MCPS', resp.mcps); + // _storage.write('ENV', resp.env); + // _storage.write('AGENTS', resp.agents); + // _storage.write('MCPS', resp.mcps); + AppConfig.initialize(resp); return true; } catch (e) { print('getAppConfig 取失败: $e'); diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index 2af4890ea..e5fd78c59 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:amap_flutter/amap_flutter.dart'; +import 'package:deep_voice/data/models/appconfig.dart'; import 'package:deep_voice/data/models/appconfig_model.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -132,10 +133,10 @@ class SplashController extends GetxController { final resp = UserGetAppConfigResp.fromJson(response); print('getAppConfig:进来了'); // 存储用户信息(示例) - _storage.write('ENV', resp.env); - _storage.write('AGENTS', resp.agents); - _storage.write('MCPS', resp.mcps); - + // _storage.write('ENV', resp.env); + // _storage.write('AGENTS', resp.agents); + // _storage.write('MCPS', resp.mcps); + AppConfig.initialize(resp); return true; } catch (e) { print('getAppConfig 取失败: $e'); diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt index d3b2efa9a..dce107f70 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -313,7 +313,7 @@ class MCPSubClient( "function" to functionMap )) - Log.d(TAG, "成功转换工具 ${tool.name()} 到OpenAI函数格式") + Log.d(TAG, "成功转换工具 ${tool.name()} 到OpenAI函数格式 $functionMap") } catch (e: Exception) { Log.e(TAG, "转换工具 ${tool.name()} 失败: ${e.message}") } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index 304771e21..43a9efcfc 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -337,6 +337,8 @@ class OpenAIService(private val context: Context? = null) { // 获取参数 val argumentsJson = functionCall.getString("arguments") + + Log.e(TAG, "自动处理MCP工具调用: name:${name} argumentsJson:${argumentsJson}") val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf() // 调用工具(统一通过MCPClient处理) @@ -607,7 +609,7 @@ class OpenAIService(private val context: Context? = null) { for (i in 0 until toolCalls.length()) { val toolCall = toolCalls.getJSONObject(i) val index = toolCall.getInt("index") - + Log.e(TAG, "解析道AI需要调用工具: $toolCall") // 创建或获取现有的工具调用信息 val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() } From e4ee5a18dc50a2f5353ad413e0e779a48ac3fbf5 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 9 May 2025 16:47:49 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20mcp=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E8=A7=A3=E6=9E=90bug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../open_ai_service/MCPClient.kt | 99 ++++++++++--------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt index dce107f70..6460b09d6 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -199,88 +199,69 @@ class MCPSubClient( */ private fun updateToolMaps() { toolMaps.clear() - + try { - // 转换MCP工具为OpenAI函数格式 tools.forEach { toolUnion -> val tool = toolUnion.tool().getOrNull() ?: return@forEach - + try { - // 获取工具的输入模式 val inputSchema = tool.inputSchema() ?: return@forEach - - // 使用toString()获取完整JSON字符串 val schemaStr = inputSchema.toString() Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr") - - // 解析properties和required + val propertiesMap = HashMap() val requiredList = ArrayList() - + try { - // 匹配properties内部的内容 - val propertiesPattern = "properties=\\{(.*?)\\}(?:,|\\s|$)".toRegex(RegexOption.DOT_MATCHES_ALL) - val propertiesMatch = propertiesPattern.find(schemaStr) - - if (propertiesMatch != null) { - val propertiesContent = propertiesMatch.groupValues[1] - - // 匹配每个属性字段,例如 query={description=搜索信息, type=string} + // ✅ 替换原来的正则方式,使用括号匹配方式提取 properties 内容 + val propertiesContent = extractPropertiesContent(schemaStr) + if (propertiesContent != null) { val fieldPattern = "([a-zA-Z0-9_]+)=\\{([^{}]+?)\\}".toRegex() val fieldMatches = fieldPattern.findAll(propertiesContent) - + fieldMatches.forEach { fieldMatch -> val fieldName = fieldMatch.groupValues[1] val fieldContent = fieldMatch.groupValues[2] - - // 解析字段属性 + val fieldMap = HashMap() - - // 解析如 description=搜索信息, type=string 格式的键值对 - // 同时处理带引号和不带引号的情况 val keyValuePattern = "([a-zA-Z0-9_]+)=(\"[^\"]*\"|'[^']*'|[^,}\\s]+)".toRegex() val keyValueMatches = keyValuePattern.findAll(fieldContent) - + keyValueMatches.forEach { kvMatch -> val key = kvMatch.groupValues[1] var value = kvMatch.groupValues[2].trim() - - // 去除引号(如果有) - if ((value.startsWith("\"") && value.endsWith("\"")) || + + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { value = value.substring(1, value.length - 1) } - - // 处理不同类型的值 + val processedValue: Any = when { value.equals("true", ignoreCase = true) -> true value.equals("false", ignoreCase = true) -> false value.matches("\\d+".toRegex()) -> value.toIntOrNull() ?: value value.matches("\\d+\\.\\d+".toRegex()) -> value.toDoubleOrNull() ?: value - else -> value // 保持为字符串 + else -> value } - + fieldMap[key] = processedValue } - - // 添加到属性映射 + propertiesMap[fieldName] = fieldMap } - + Log.d(TAG, "成功解析属性: $propertiesMap") } - - // 解析required + + // ✅ required 部分保持不变 val requiredPattern = "required=\\[(.*?)\\]".toRegex() val requiredMatch = requiredPattern.find(schemaStr) - if (requiredMatch != null) { val requiredStr = requiredMatch.groupValues[1] if (requiredStr.isNotEmpty()) { - // 处理可能带引号的字段名 - val fieldNames = requiredStr.split(",").map { + val fieldNames = requiredStr.split(",").map { val trimmed = it.trim() - if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { trimmed.substring(1, trimmed.length - 1) } else { @@ -295,8 +276,7 @@ class MCPSubClient( Log.e(TAG, "解析Schema字符串失败: ${e.message}", e) Log.e(TAG, "原始Schema: $schemaStr") } - - // 创建OpenAI函数格式 + val functionMap = mapOf( "name" to tool.name(), "description" to (tool.description() ?: ""), @@ -306,13 +286,12 @@ class MCPSubClient( "required" to requiredList ) ) - - // 添加到工具列表 + toolMaps.add(mapOf( "type" to "function", "function" to functionMap )) - + Log.d(TAG, "成功转换工具 ${tool.name()} 到OpenAI函数格式 $functionMap") } catch (e: Exception) { Log.e(TAG, "转换工具 ${tool.name()} 失败: ${e.message}") @@ -321,10 +300,36 @@ class MCPSubClient( } catch (e: Exception) { Log.e(TAG, "更新工具映射失败: ${e.message}") } - + Log.d(TAG, "更新工具映射完成,共 ${toolMaps.size} 个工具") } - + + private fun extractPropertiesContent(schemaStr: String): String? { + val startKey = "properties={" + val startIndex = schemaStr.indexOf(startKey) + if (startIndex == -1) return null + + var braceCount = 0 + var endIndex = -1 + val contentStart = startIndex + startKey.length + + for (i in contentStart until schemaStr.length) { + when (schemaStr[i]) { + '{' -> braceCount++ + '}' -> { + if (braceCount == 0) { + endIndex = i + break + } else { + braceCount-- + } + } + } + } + + return if (endIndex != -1) schemaStr.substring(contentStart, endIndex) else null + } + /** * 将JsonNode转换为Map */ From 1e5592db9f05de46fbb79a7aae9da129b85f23dc Mon Sep 17 00:00:00 2001 From: wolfplus Date: Fri, 9 May 2025 14:12:37 +0100 Subject: [PATCH 5/7] add --- .../yunqiinnovation/deepsound/MainActivity.kt | 1 - lib/data/services/ble_manager.dart | 9 +- .../controllers/pairing_controller.dart | 11 +- .../agent_service/android/build.gradle.kts | 13 +- .../android/src/main/AndroidManifest.xml | 37 + .../agent_service/AgentService.kt | 3 +- .../agent_service/AgentServicePlugin.kt | 47 +- .../agent_service/BleAgent.kt} | 9 +- .../agent_service}/BleMediaService.kt | 85 +- .../agent_service}/BleWakeReceiver.kt | 18 +- .../agent_service}/BootReceiver.kt | 2 +- .../yunqiinnovation/agent_service}/utils.kt | 2 +- .../ios/Classes/AgentService.swift | 994 ++++++++++++++++++ .../ios/Classes/AgentServiceListener.swift | 11 + .../ios/Classes/AgentServicePlugin.h | 4 + .../ios/Classes/AgentServicePlugin.m | 12 + .../ios/Classes/SwiftAgentServicePlugin.swift | 259 +++++ .../agent_service/lib/agent_service.dart | 36 + .../ios/Classes/AzureAsrHelper.swift | 873 +++++++++------ .../ios/Classes/AzureTtsHelper.swift | 454 +++++--- .../ble_service/android/build.gradle.kts | 6 - .../android/src/main/AndroidManifest.xml | 36 +- .../yunqiinnovation/ble_service/BleService.kt | 72 +- .../ble_service/BleServicePlugin.kt | 87 +- .../ios/Assets/ble_service-Info.plist | 15 + .../ble_service/ios/Classes/BleConst.swift | 90 ++ .../ble_service/ios/Classes/BleDelegate.swift | 25 + .../ble_service/ios/Classes/BleService.swift | 963 +++++++++++++++++ .../ios/Classes/BleServicePlugin.h | 4 + .../ios/Classes/BleServicePlugin.m | 12 + .../ios/Classes/SwiftBleServicePlugin.swift | 203 ++++ .../ble_service/ios/ble_service.podspec | 23 + .../ios/Classes/ChatStorageHelper.swift | 410 ++++++++ .../ios/Classes/ChatStoragePlugin.h | 4 + .../ios/Classes/ChatStoragePlugin.m | 12 + .../ios/Classes/ChatStoragePlugin.swift | 182 ++++ .../ios/Classes/SwiftChatStoragePlugin.swift | 11 + local_plugins/chat_storage/ios/README.md | 64 ++ .../chat_storage/ios/chat_storage.podspec | 11 +- .../ios/Classes/ClassicBluetoothHelper.swift | 197 ++++ .../ios/Classes/ClassicBluetoothPlugin.h | 4 + .../ios/Classes/ClassicBluetoothPlugin.m | 12 + .../ios/Classes/ClassicBluetoothPlugin.swift | 108 ++ .../ios/Classes/FileLogger.swift | 136 +++ .../Classes/SwiftClassicBluetoothPlugin.swift | 11 + local_plugins/classic_bluetooth/ios/README.md | 44 + .../ios/classic_bluetooth.podspec | 22 + local_plugins/classic_bluetooth/pubspec.yaml | 2 + .../ios/Classes/DeepvoiceUtilsPlugin.h | 4 + .../ios/Classes/DeepvoiceUtilsPlugin.m | 12 + .../ios/Classes/DeepvoiceUtilsPlugin.swift | 30 + .../ios/Classes/FileLogger.swift | 286 +++++ .../Classes/SwiftDeepvoiceUtilsPlugin.swift | 11 + local_plugins/deepvoice_utils/ios/README.md | 54 + .../ios/deepvoice_utils.podspec | 19 + .../ios/Classes/MCPClient.swift | 249 +++++ .../ios/Classes/OpenAIService.swift | 411 ++++++-- .../ios/Classes/OpenAIServicePlugin.swift | 146 ++- .../ios/Classes/SystemFunctionHandler.swift | 514 +++++++++ 59 files changed, 6578 insertions(+), 804 deletions(-) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt} (95%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/BleMediaService.kt (86%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/BleWakeReceiver.kt (82%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/BootReceiver.kt (93%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/utils.kt (99%) create mode 100644 local_plugins/agent_service/ios/Classes/AgentService.swift create mode 100644 local_plugins/agent_service/ios/Classes/AgentServiceListener.swift create mode 100644 local_plugins/agent_service/ios/Classes/AgentServicePlugin.h create mode 100644 local_plugins/agent_service/ios/Classes/AgentServicePlugin.m create mode 100644 local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift create mode 100644 local_plugins/ble_service/ios/Assets/ble_service-Info.plist create mode 100644 local_plugins/ble_service/ios/Classes/BleConst.swift create mode 100644 local_plugins/ble_service/ios/Classes/BleDelegate.swift create mode 100644 local_plugins/ble_service/ios/Classes/BleService.swift create mode 100644 local_plugins/ble_service/ios/Classes/BleServicePlugin.h create mode 100644 local_plugins/ble_service/ios/Classes/BleServicePlugin.m create mode 100644 local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift create mode 100644 local_plugins/ble_service/ios/ble_service.podspec create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift create mode 100644 local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift create mode 100644 local_plugins/chat_storage/ios/README.md create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift create mode 100644 local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift create mode 100644 local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift create mode 100644 local_plugins/classic_bluetooth/ios/README.md create mode 100644 local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec create mode 100644 local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h create mode 100644 local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m create mode 100644 local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift create mode 100644 local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift create mode 100644 local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift create mode 100644 local_plugins/deepvoice_utils/ios/README.md create mode 100644 local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec create mode 100644 local_plugins/open_ai_service/ios/Classes/MCPClient.swift create mode 100644 local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt index 27f8d8498..bf9d92455 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -13,7 +13,6 @@ import android.content.Context import android.os.Handler import android.os.Looper import com.yunqiinnovation.deepvoice_utils.FileLogger -import com.yunqiinnovation.ble_service.BleMediaService class MainActivity: FlutterActivity() { private val TAG = "MainActivity" diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 56f2841ac..ce8302c92 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -8,6 +8,7 @@ import 'package:get_storage/get_storage.dart'; import 'package:deep_voice/data/models/appconfig_model.dart'; import 'dart:convert'; import 'package:flutter/services.dart' show rootBundle; +import 'package:agent_service/agent_service.dart'; /// 命令类型常量(与原生层保持一致) class BleCommands { @@ -381,7 +382,7 @@ class BleManager extends GetxService { } /// 启动BLE服务 - Future startBleService() async { + Future startAgentService() async { try { // 从.env文件直接读取环境变量 // final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; @@ -431,7 +432,7 @@ class BleManager extends GetxService { } } - final result = await _bleService.startBleService(config); + final result = await AgentService.startAgentService(config); if (result) { Logger.info('BLE服务启动成功'); @@ -463,9 +464,9 @@ class BleManager extends GetxService { } /// 停止BLE服务 - Future stopBleService() async { + Future stopAgentService() async { try { - final result = await _bleService.stopBleService(); + final result = await AgentService.stopAgentService(); if (result) { Logger.info('BLE服务停止成功'); diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 26c52885c..99f9f55fc 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -8,6 +8,7 @@ import 'dart:io'; import '../../../data/services/bluetooth_manager.dart'; import 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice; import '../../../data/services/ble_manager.dart'; +import 'package:agent_service/agent_service.dart'; /// 配对控制器,负责检查蓝牙权限、状态,并处理配对流程 class PairingController extends GetxController { static PairingController get to => Get.find(); @@ -87,7 +88,7 @@ class PairingController extends GetxController { statusDescription.value = 'searchingDevice'.tr; // 设备连接时启动BLE后台服务 - _startBleService(); + _startAgentService(); await refreshConnectedDevices(); } catch (e) { @@ -241,7 +242,7 @@ class PairingController extends GetxController { } /// 启动BLE后台服务 - Future _startBleService() async { + Future _startAgentService() async { try { // if (!isDeviceConnected) return; @@ -256,7 +257,7 @@ class PairingController extends GetxController { } // 启动BLE服务,无需传递配置参数,将从.env读取 - final result = await BleManager.to.startBleService(); + final result = await BleManager.to.startAgentService(); if (result) { Logger.info('BLE后台服务启动成功'); @@ -269,9 +270,9 @@ class PairingController extends GetxController { } /// 停止BLE后台服务 - Future _stopBleService() async { + Future _stopAgentService() async { try { - final result = await BleManager.to.stopBleService(); + final result = await BleManager.to.stopAgentService(); if (result) { Logger.info('BLE后台服务停止成功'); diff --git a/local_plugins/agent_service/android/build.gradle.kts b/local_plugins/agent_service/android/build.gradle.kts index eb6ba06cd..5a0a26504 100644 --- a/local_plugins/agent_service/android/build.gradle.kts +++ b/local_plugins/agent_service/android/build.gradle.kts @@ -34,13 +34,18 @@ android { } dependencies { - add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10") - add("implementation", "androidx.core:core-ktx:1.8.0") - add("implementation", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10") + implementation("androidx.core:core-ktx:1.8.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") + implementation("androidx.work:work-runtime-ktx:2.8.1") + implementation("androidx.media:media:1.6.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("androidx.security:security-crypto:1.1.0-alpha03") // 依赖本地其他插件 add("compileOnly", project(":azure_speech")) add("compileOnly", project(":open_ai_service")) add("compileOnly", project(":deepvoice_utils")) add("compileOnly", project(":chat_storage")) + add("compileOnly", project(":ble_service")) } \ No newline at end of file diff --git a/local_plugins/agent_service/android/src/main/AndroidManifest.xml b/local_plugins/agent_service/android/src/main/AndroidManifest.xml index 08d048432..ced0df1dc 100644 --- a/local_plugins/agent_service/android/src/main/AndroidManifest.xml +++ b/local_plugins/agent_service/android/src/main/AndroidManifest.xml @@ -2,4 +2,41 @@ package="com.yunqiinnovation.agent_service"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 2d3e4676d..60cdfdca7 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 @@ -12,7 +12,7 @@ import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.* import java.util.Collections import kotlin.coroutines.CoroutineContext - +import com.yunqiinnovation.ble_service.BleService /** * 代理服务事件监听接口 */ @@ -401,6 +401,7 @@ object AgentService : CoroutineScope { FileLogger.d(TAG, "停止语音识别") try { + BleService.closeCodec() azureAsrHelper?.stopContinuousRecognition() isRecognitionActive = false stopIdleCheck() diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index 2505a6318..588e66ac5 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt @@ -12,6 +12,8 @@ import com.yunqiinnovation.deepvoice_utils.FileLogger import org.json.JSONObject import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import android.content.Intent +import android.os.Build class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler, AgentServiceListener { private val TAG = "AgentServicePlugin" @@ -36,7 +38,50 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { when (call.method) { - + "startAgentService" -> { + try { + val config = call.argument>("config") + if (config == null) { + result.error("INVALID_ARGUMENTS", "配置参数不能为空", null) + return + } + + FileLogger.d(TAG, "启动BleMediaService,配置参数: $config") + + // 创建Intent启动BleMediaService + val intent = Intent(context, BleMediaService::class.java) + + // 将参数添加到Intent + config["azureSpeechKey"]?.toString()?.let { intent.putExtra("azureSpeechKey", it) } + config["azureSpeechRegion"]?.toString()?.let { intent.putExtra("azureSpeechRegion", it) } + config["openaiApiKey"]?.toString()?.let { intent.putExtra("openaiApiKey", it) } + config["openaiBaseUrl"]?.toString()?.let { intent.putExtra("openaiBaseUrl", it) } + config["openaiModel"]?.toString()?.let { intent.putExtra("openaiModel", it) } + config["mcpServer"]?.toString()?.let { intent.putExtra("mcpServer", it) } + + // 启动前台服务 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + + result.success(true) + } catch (e: Exception) { + FileLogger.e(TAG, "启动AgentService失败", e) + result.error("SERVICE_START_ERROR", "启动AgentService失败: ${e.message}", null) + } + } + "stopAgentService" -> { + try { + val intent = Intent(context, BleMediaService::class.java) + context.stopService(intent) + result.success(true) + } catch (e: Exception) { + FileLogger.e(TAG, "停止AgentService失败", e) + result.error("SERVICE_STOP_ERROR", "停止AgentService失败: ${e.message}", null) + } + } "startConversation" -> { try { val isStarted = AgentService.startRecognition(false) diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt similarity index 95% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 4fcca2ac6..368dbe967 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -1,17 +1,18 @@ -package com.yunqiinnovation.ble_service +package com.yunqiinnovation.agent_service import android.content.Context import com.yunqiinnovation.agent_service.AgentService import com.yunqiinnovation.agent_service.AgentServiceListener import com.yunqiinnovation.deepvoice_utils.FileLogger - +import com.yunqiinnovation.ble_service.BleService +import com.yunqiinnovation.ble_service.BleConst /** * 蓝牙语音交互代理 * 连接BleService和AgentService,响应蓝牙设备唤醒信号,启动语音识别和交互 */ -object ChatAgent : BleService.Callback, AgentServiceListener { - private const val TAG = "ChatAgent" +object BleAgent : BleService.Callback, AgentServiceListener { + private const val TAG = "BleAgent" // 是否已初始化 private var isInitialized = false diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt similarity index 86% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt index b8674ed3b..b82ec1291 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt @@ -1,10 +1,13 @@ -package com.yunqiinnovation.ble_service +package com.yunqiinnovation.agent_service import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanSettings import android.content.ComponentName import android.content.Context import android.content.Intent @@ -28,6 +31,8 @@ import android.view.KeyEvent import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import com.yunqiinnovation.ble_service.BleConst +import com.yunqiinnovation.ble_service.BleService /** * 语音助手媒体服务 * 职责: @@ -149,18 +154,7 @@ class BleMediaService : Service(), CoroutineScope { return START_STICKY } - /** - * 注册后台蓝牙扫描 - */ - private fun registerBackgroundScan() { - try { - BleService.registerBackgroundScan() - FileLogger.i(TAG, "后台蓝牙扫描已注册") - } catch (e: Exception) { - FileLogger.e(TAG, "注册后台蓝牙扫描失败", e) - } - } - + /** * 初始化语音交互代理 */ @@ -194,7 +188,7 @@ class BleMediaService : Service(), CoroutineScope { if(config.isNotEmpty()) { // 初始化ChatAgent FileLogger.i(TAG, "开始初始化ChatAgent") - val success = ChatAgent.initialize(applicationContext, config) + val success = BleAgent.initialize(applicationContext, config) if (success) { isChatAgentInitialized = true FileLogger.i(TAG, "语音交互代理初始化成功") @@ -364,7 +358,7 @@ class BleMediaService : Service(), CoroutineScope { val currentTime = System.currentTimeMillis() if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) { lastActionTime = currentTime - ChatAgent.onWakeupSignalReceived() + BleAgent.onWakeupSignalReceived() } else { FileLogger.i(TAG, "媒体操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复请求") } @@ -386,7 +380,66 @@ class BleMediaService : Service(), CoroutineScope { FileLogger.e(TAG, "连接设备失败: ${e.message}", e) } } - + // ====================================================================================================== + // 后台扫描与唤醒 + // ====================================================================================================== + /** + * 注册后台持久扫描,APP被杀死后也能唤醒 + */ + fun registerBackgroundScan() { + val scanner = (getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.bluetoothLeScanner ?: return + try { + val filter = ScanFilter.Builder() + .setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01)) + .build() + + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .build() + + // 创建Intent并设置前台接收器标志 + val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT) + .setClass(this, BleWakeReceiver::class.java) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + + val pi = PendingIntent.getBroadcast( + this, 0, scanIntent, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + ) + scanner.startScan(listOf(filter), settings, pi) + FileLogger.i(TAG, "后台持久扫描已注册") + } catch (e: Exception) { + FileLogger.e(TAG, "注册后台扫描失败: ${e.message}", e) + } + } + + /** + * 注销后台持久扫描 + */ + fun unregisterBackgroundScan() { + // 获取一个局部引用,避免并发访问问题 + val scanner = (getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.bluetoothLeScanner ?: return + try { + // 创建Intent并设置前台接收器标志 + val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT) + .setClass(this, BleWakeReceiver::class.java) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + + val pi = PendingIntent.getBroadcast( + this, 0, scanIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + scanner.stopScan(pi) + FileLogger.i(TAG, "后台持久扫描已注销") + } catch (e: Exception) { + FileLogger.e(TAG, "注销后台扫描失败: ${e.message}", e) + } + } /** * 更新通知内容 */ diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt similarity index 82% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt index 0214d8e35..f979a1c03 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt @@ -1,4 +1,4 @@ -package com.yunqiinnovation.ble_service +package com.yunqiinnovation.agent_service import android.content.* import android.bluetooth.le.* @@ -7,6 +7,7 @@ import android.view.KeyEvent import android.media.AudioManager import com.yunqiinnovation.deepvoice_utils.FileLogger import com.yunqiinnovation.ble_service.BleService +import com.yunqiinnovation.ble_service.BleConst /** * BLE唤醒接收器 * 当耳机发送含特定UUID广播时,系统会触发此接收器 @@ -18,6 +19,7 @@ class BleWakeReceiver : BroadcastReceiver() { } override fun onReceive(ctx: Context, intent: Intent) { + FileLogger.d(TAG, "onReceive, intent: $intent, ${BleConst.ACTION_SCAN_RESULT}") FileLogger.init(ctx) try { @@ -26,15 +28,21 @@ class BleWakeReceiver : BroadcastReceiver() { } val list = intent.getParcelableArrayListExtra( BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT - ) ?: return + ) + // FileLogger.d(TAG, "list: $list") - if (list.isEmpty()) { + if (list?.isEmpty() == true || list == null) { FileLogger.d(TAG, "扫描结果为空") return } - val mac = list.first().device.address - // FileLogger.d(TAG, "被系统唤醒,发现设备 $mac") + val mac = list.firstOrNull()?.device?.address + if (mac == null) { + FileLogger.d(TAG, "没有获取到设备MAC地址") + return + } + + FileLogger.d(TAG, "被系统唤醒,发现设备 $mac") if(!BleMediaService.isServiceRunning()) { val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BootReceiver.kt similarity index 93% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BootReceiver.kt index 4b1bb5905..4f490ae88 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BootReceiver.kt @@ -1,4 +1,4 @@ -package com.yunqiinnovation.ble_service +package com.yunqiinnovation.agent_service import android.content.* import android.os.Build diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/utils.kt similarity index 99% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/utils.kt index 339aed64e..dacd6e2a7 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/utils.kt @@ -1,4 +1,4 @@ -package com.yunqiinnovation.ble_service +package com.yunqiinnovation.agent_service import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences diff --git a/local_plugins/agent_service/ios/Classes/AgentService.swift b/local_plugins/agent_service/ios/Classes/AgentService.swift new file mode 100644 index 000000000..7fdf89e62 --- /dev/null +++ b/local_plugins/agent_service/ios/Classes/AgentService.swift @@ -0,0 +1,994 @@ +import Foundation +import MicrosoftCognitiveServicesSpeech +import open_ai_service +import chat_storage + +/// 代理服务 - 处理语音识别、TTS和AI对话相关逻辑 +/// +/// 负责集成Azure语音服务、OpenAI服务和本地存储服务, +/// 提供语音识别、语音合成、AI对话等功能 +class AgentService { + // 常量定义 + private let TAG = "AgentService" + + // 上下文和监听器 + private var listeners = NSHashTable.weakObjects() + + // 配置参数 + private var azureSpeechKey: String = "" + private var azureSpeechRegion: String = "" + private var openaiApiKey: String = "" + private var openaiBaseUrl: String = "" + private var openaiModel: String = "gpt-3.5-turbo" + private var mcpServer: String = "" + + // Azure服务 + private var azureAsrHelper: AzureAsrHelper? + private var azureTtsHelper: AzureTtsHelper? + + // OpenAI服务 + private var openAIService: OpenAIServiceBridge? + + // 聊天存储服务 + private var chatStorageHelper: ChatStorageHelperBridge? + + // 会话ID,用于区分不同聊天上下文 + private let sessionId = "default_agent" + + // 历史聊天消息缓存 + private var historyMessages = [[String: Any]]() + + // 系统提示词 + private var systemPrompt = "" + + // 状态 + private var isInitialized = false + private var isRecognitionActive = false + private var isTtsSpeaking = false + private var hasSpeechDetected = false + private var isAiStreaming = false + + // AI流生成相关 + private var currentAiTask: DispatchWorkItem? + + // 空闲检测相关 + private var idleCheckTimer: Timer? + private let maxIdleSeconds: TimeInterval = 10 // 最大空闲秒数 + + /// 初始化 + init() { + // 创建OpenAI服务桥接器 + openAIService = OpenAIServiceBridge() + + // 创建聊天存储桥接器 + chatStorageHelper = ChatStorageHelperBridge() + } + + /** + * 初始化系统提示词 + */ + private func initSystemPrompt() { + systemPrompt = """ + 你是一个友好、专业的语音助手,名叫"小语"。你的目标是通过对话为用户提供帮助、解答问题和完成任务。 + 遵循以下指导原则: + 1. 保持简短精炼的回答,因为用户是通过语音与你交流 + 2. 优先使用中文回复,除非用户明确要求使用其他语言 + 3. 当用户问题不明确时,礼貌地请求更多信息 + 4. 避免过长的列表,尽量将信息分成小段 + 5. 不要使用需要视觉展示的元素(如表格、图表或代码块) + 6. 记住用户之前的对话内容,保持对话连贯 + 7. 如果用户发送了图片,请根据图片内容和文字要求回答问题 + 你不仅可以回答知识性问题,还可以帮助用户设置提醒、提供建议,或进行轻松愉快的对话。 + 无论遇到什么问题,都要尽力以温暖、贴心的语气提供最佳帮助。 + """ + } + + /** + * 初始化 + * + * - Parameter config: 配置参数,包含所需的所有API密钥和设置 + * - Returns: 是否初始化成功 + */ + func initialize(config: [String: Any]) -> Bool { + if isInitialized { return true } + + do { + // 从配置中获取参数 + if let azureKey = config["azureSpeechKey"] as? String { + azureSpeechKey = azureKey + } + + if let azureRegion = config["azureSpeechRegion"] as? String { + azureSpeechRegion = azureRegion + } + + if let openaiKey = config["openaiApiKey"] as? String { + openaiApiKey = openaiKey + } + + if let baseUrl = config["openaiBaseUrl"] as? String { + openaiBaseUrl = baseUrl + } + + if let model = config["openaiModel"] as? String { + openaiModel = model + } + + if let server = config["mcpServer"] as? String { + mcpServer = server + } + + // 自定义系统提示词 + if let customSystemPrompt = config["systemPrompt"] as? String, !customSystemPrompt.isEmpty { + systemPrompt = customSystemPrompt + } else { + // 使用默认系统提示词 + initSystemPrompt() + } + + // 检查必要参数 + if azureSpeechKey.isEmpty || azureSpeechRegion.isEmpty || openaiApiKey.isEmpty { + print("\(TAG): 初始化失败:关键配置参数缺失") + return false + } + + // 初始化OpenAI服务 + openAIService?.initialize(apiKey: openaiApiKey, baseUrl: openaiBaseUrl, model: openaiModel, mcpServer: mcpServer) + + // 初始化Azure ASR + azureAsrHelper = AzureAsrHelper(self) + let asrInitResult = azureAsrHelper?.initialize( + subscriptionKey: azureSpeechKey, + region: azureSpeechRegion, + audioSourceType: .microphone + ) ?? false + + if !asrInitResult { + print("\(TAG): Azure ASR初始化失败") + return false + } + + // 初始化Azure TTS + azureTtsHelper = AzureTtsHelper(self) + let ttsInitResult = azureTtsHelper?.initialize( + subscriptionKey: azureSpeechKey, + region: azureSpeechRegion, + callback: self + ) ?? false + + if !ttsInitResult { + print("\(TAG): Azure TTS初始化失败") + return false + } + + // 加载最近的聊天记录 + loadChatHistory() + + isInitialized = true + print("\(TAG): 代理服务初始化成功") + return true + } catch { + print("\(TAG): 初始化失败:\(error.localizedDescription)") + return false + } + } + + /** + * 添加事件监听器 + * + * - Parameter listener: 要添加的监听器 + */ + func addListener(_ listener: AgentServiceListener) { + listeners.add(listener as AnyObject) + } + + /** + * 设置事件监听器(替换现有监听器) + * + * - Parameter listener: 要设置的监听器 + */ + func setListener(_ listener: AgentServiceListener) { + listeners.removeAllObjects() + listeners.add(listener as AnyObject) + } + + /** + * 移除事件监听器 + * + * - Parameter listener: 要移除的监听器 + */ + func removeListener(_ listener: AgentServiceListener) { + listeners.remove(listener as AnyObject) + } + + /** + * 移除所有事件监听器 + */ + func clearListeners() { + listeners.removeAllObjects() + } + + /** + * 启动空闲检测 + */ + private func startIdleCheck() { + stopIdleCheck() // 先停止现有的检查 + + if !isRecognitionActive { return } + + // 创建定时器 + idleCheckTimer = Timer.scheduledTimer(withTimeInterval: maxIdleSeconds, repeats: false) { [weak self] _ in + guard let self = self else { return } + + // 如果状态仍然是空闲的,则停止识别 + if self.isRecognitionActive && !self.hasSpeechDetected && !self.isTtsSpeaking && !self.isAiStreaming { + self.stopRecognition() + self.sendEvent("auto_stop", ["reason": "idle_timeout", "seconds": self.maxIdleSeconds]) + } + } + } + + /** + * 停止空闲检测 + */ + private func stopIdleCheck() { + idleCheckTimer?.invalidate() + idleCheckTimer = nil + } + + /** + * 重启空闲检测 + * 当状态发生变化时调用 + */ + private func restartIdleCheck() { + if isRecognitionActive { + startIdleCheck() + } + } + + /** + * 开始语音识别 + * + * - Returns: 是否成功开始识别 + */ + func startRecognition() -> Bool { + if !isInitialized { + print("\(TAG): 服务未初始化") + return false + } + + if isRecognitionActive { return true } + + isRecognitionActive = true + hasSpeechDetected = false + + do { + // 创建连续识别回调 + class ContinuousRecognizeCallbackImpl: AzureAsrHelper.ContinuousRecognizeCallback { + private weak var service: AgentService? + + init(_ service: AgentService) { + self.service = service + } + + func onRecognizing(recognizing: String, detectedLanguage: String) { + guard let service = service else { return } + + if !recognizing.isEmpty { + // 检测到语音,更新状态 + let previousHasSpeech = service.hasSpeechDetected + service.hasSpeechDetected = true + + // 状态发生变化时重启空闲检测 + if !previousHasSpeech { + service.restartIdleCheck() + } + + service.sendEvent("recognizing", [ + "text": recognizing, + "language": detectedLanguage + ]) + + // 如果TTS正在播放或AI正在生成,则触发打断 + if service.isTtsSpeaking || service.isAiStreaming { + service.interruptCurrentResponse() + } + } + } + + func onResult(text: String, detectedLanguage: String) { + guard let service = service else { return } + + if !text.isEmpty { + service.sendEvent("recognition_result", [ + "text": text, + "language": detectedLanguage + ]) + + service.processWithOpenAI(text: text) + } + + // 重置状态,继续识别 + let previousHasSpeech = service.hasSpeechDetected + service.hasSpeechDetected = false + + // 状态发生变化时重启空闲检测 + if previousHasSpeech { + service.restartIdleCheck() + } + } + + func onSessionStarted() { + guard let service = service else { return } + + service.sendEvent("recognition_started", ["status": "started"]) + // 启动空闲检测 + service.startIdleCheck() + } + + func onSessionStopped() { + guard let service = service else { return } + + service.isRecognitionActive = false + service.stopIdleCheck() + service.sendEvent("recognition_stopped", ["status": "stopped"]) + } + + func onCanceled(reason: String, errorDetails: String) { + guard let service = service else { return } + + service.isRecognitionActive = false + service.stopIdleCheck() + service.sendEvent("recognition_canceled", [ + "reason": reason, + "details": errorDetails + ]) + } + + func onError(error: String) { + guard let service = service else { return } + + service.isRecognitionActive = false + service.stopIdleCheck() + print("\(service.TAG): 语音识别出错: \(error)") + service.sendEvent("error", [ + "code": "RECOGNITION_ERROR", + "message": error + ]) + } + } + + let callback = ContinuousRecognizeCallbackImpl(self) + let result = azureAsrHelper?.startContinuousRecognition(callback) ?? false + + if !result { + isRecognitionActive = false + print("\(TAG): 启动语音识别失败") + sendEvent("error", [ + "code": "RECOGNITION_START_ERROR", + "message": "启动语音识别失败" + ]) + } + + return result + } catch { + isRecognitionActive = false + print("\(TAG): 启动语音识别失败: \(error.localizedDescription)") + sendEvent("error", [ + "code": "RECOGNITION_START_ERROR", + "message": error.localizedDescription + ]) + return false + } + } + + /** + * 停止语音识别 + */ + func stopRecognition() { + if !isRecognitionActive { return } + + print("\(TAG): 停止语音识别") + + // 停止识别 + let _ = azureAsrHelper?.stopContinuousRecognition() + isRecognitionActive = false + stopIdleCheck() + print("\(TAG): 语音识别已停止") + } + + /** + * 打断当前响应 + * 停止TTS播放和AI流输出 + */ + func interruptCurrentResponse() { + if isAiStreaming || isTtsSpeaking { + // 停止TTS播放 + stopTts() + + // 停止AI流输出 + stopAiStream() + + // 发送打断事件 + sendEvent("response_interrupted", ["status": "interrupted"]) + } + } + + /** + * 停止AI流输出 + */ + private func stopAiStream() { + if isAiStreaming { + // 取消当前AI生成任务 + currentAiTask?.cancel() + currentAiTask = nil + + // 通知OpenAI服务终止当前流式请求 + openAIService?.cancelCurrentStream() + + // 更新状态 + isAiStreaming = false + + // 记录日志 + print("\(TAG): AI流输出已停止") + } + } + + /** + * 处理文本输入 + * 作为语音输入的补充,直接处理文本并通过事件返回结果 + * + * - Parameters: + * - text: 用户输入文本 + * - speakResponse: 是否朗读回复,默认为false + * - Returns: 是否成功开始处理 + */ + func processTextInput(text: String, speakResponse: Bool = false) -> Bool { + if !isInitialized { + print("\(TAG): 服务未初始化") + sendEvent("error", ["code": "NOT_INITIALIZED", "message": "服务未初始化"]) + return false + } + + if text.isEmpty { + print("\(TAG): 文本输入不能为空") + sendEvent("error", ["code": "EMPTY_TEXT", "message": "文本输入不能为空"]) + return false + } + + // 使用OpenAI处理文本 + processWithOpenAI(text: text, speakResponse: speakResponse) + return true + } + + /** + * 使用OpenAI处理语音识别结果 + * + * - Parameter text: 用户输入文本 + */ + private func processWithOpenAI(text: String) { + processWithOpenAI(text: text, speakResponse: true) + } + + /** + * 使用OpenAI处理文本消息 + * + * - Parameters: + * - text: 用户输入文本 + * - speakResponse: 是否使用TTS朗读回复 + */ + private func processWithOpenAI(text: String, speakResponse: Bool = true) { + print("\(TAG): 用户问题: \(text)") + + // 创建用户文本消息并处理 + guard let userMessage = openAIService?.createUserMessage(text: text) else { + print("\(TAG): 创建用户消息失败") + return + } + + processWithOpenAIInternal(userMessage: userMessage, displayText: text, speakResponse: speakResponse) + } + + /** + * 使用OpenAI处理图片 + * + * - Parameters: + * - imagePath: 图片文件路径 + * - text: 可选的文本描述或问题 + * - speakResponse: 是否朗读回复 + * - Returns: 是否成功开始处理 + */ + func processImageInput(imagePath: String, text: String = "", speakResponse: Bool = false) -> Bool { + if !isInitialized { + print("\(TAG): 服务未初始化") + sendEvent("error", ["code": "NOT_INITIALIZED", "message": "服务未初始化"]) + return false + } + + if imagePath.isEmpty { + print("\(TAG): 图片路径不能为空") + sendEvent("error", ["code": "EMPTY_IMAGE_PATH", "message": "图片路径不能为空"]) + return false + } + + // 通知开始处理图片 + sendEvent("image_processing", [ + "status": "processing", + "imagePath": imagePath + ]) + + // 异步处理图片 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + // 将图片转换为Base64格式 + guard let imageBase64 = self.openAIService?.fileToBase64(filePath: imagePath) else { + DispatchQueue.main.async { + print("\(self.TAG): 图片转换失败: \(imagePath)") + self.sendEvent("error", [ + "code": "IMAGE_CONVERSION_FAILED", + "message": "图片转换失败" + ]) + } + return + } + + // 通知图片准备完成 + DispatchQueue.main.async { + self.sendEvent("image_ready", [ + "status": "ready", + "imagePath": imagePath + ]) + + // 处理包含图片的消息 + self.processImageWithOpenAI(imageBase64: imageBase64, text: text, speakResponse: speakResponse) + } + } + + return true + } + + /** + * 使用OpenAI处理图片 + * + * - Parameters: + * - imageBase64: Base64编码的图片数据 + * - text: 可选的文本描述或问题 + * - speakResponse: 是否朗读回复 + */ + private func processImageWithOpenAI(imageBase64: String, text: String = "", speakResponse: Bool = false) { + print("\(TAG): 处理图片输入: \(text.isEmpty ? "无附加文本" : "附带文本: \(text)")") + + // 创建带图片的用户消息并处理 + guard let userMessage = openAIService?.createUserMessageWithImage(text: text, imageBase64: imageBase64) else { + print("\(TAG): 创建带图片的用户消息失败") + return + } + + // 图片描述用于存储 + let displayText = text.isEmpty ? "[图片]" : text + processWithOpenAIInternal(userMessage: userMessage, displayText: displayText, speakResponse: speakResponse, hasImage: true) + } + + /** + * 内部方法:通用的OpenAI处理逻辑 + * + * - Parameters: + * - userMessage: 用户消息(可以是文本或图片格式) + * - displayText: 用于显示和存储的文本 + * - speakResponse: 是否朗读回复 + * - hasImage: 是否包含图片 + */ + private func processWithOpenAIInternal(userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false) { + // 如果有正在进行的AI流式输出,先停止它 + stopAiStream() + + // 创建AI任务 + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + // 设置状态为正在流式输出 + self.isAiStreaming = true + + // 使用历史记录作为上下文发送到OpenAI + var responseBuilder = "" + + // 添加系统提示词到历史消息的副本中 + var messagesWithSystemPrompt: [[String: Any]] = [] + + // 先添加系统提示词 + if !self.systemPrompt.isEmpty { + if let systemMessage = self.openAIService?.createSystemMessage(text: self.systemPrompt) { + messagesWithSystemPrompt.append(systemMessage) + } + } + + // 再添加历史消息 + messagesWithSystemPrompt.append(contentsOf: self.historyMessages) + messagesWithSystemPrompt.append(userMessage) + + // 将用户消息添加到历史记录 + self.addToHistoryMessages(userMessage) + + // 流式回调 + class StreamCallbackBridge: NSObject, OpenAIStreamCallback { + private weak var service: AgentService? + private var responseBuilder: String + private let speakResponse: Bool + private let displayText: String + private let hasImage: Bool + + init(_ service: AgentService, responseBuilder: String = "", speakResponse: Bool, displayText: String, hasImage: Bool) { + self.service = service + self.responseBuilder = responseBuilder + self.speakResponse = speakResponse + self.displayText = displayText + self.hasImage = hasImage + super.init() + } + + func onToken(token: String) { + guard let service = service else { return } + + responseBuilder.append(token) + if speakResponse { + _ = service.azureTtsHelper?.speakStream(token) + } + // 发送流式回复token + service.sendEvent("assistant_token", ["token": token]) + } + + func onComplete() { + guard let service = service else { return } + + // 视情况决定是否朗读回复 + if speakResponse { + _ = service.azureTtsHelper?.flushStream() + } + + if !responseBuilder.isEmpty { + // 发送完整回复,包含是否有图片的标记 + var responseData: [String: Any] = [ + "text": responseBuilder, + "userInput": displayText + ] + + if hasImage { + responseData["hasImage"] = true + } + + service.sendEvent("assistant_response", responseData) + + // 添加AI回复到历史记录 + if let assistantMessage = service.openAIService?.createAssistantMessage(text: responseBuilder) { + service.addToHistoryMessages(assistantMessage) + } + + // 保存聊天记录 + service.saveChatMessage(userMessage: displayText, assistantMessage: responseBuilder) + } + + // 标记AI流式输出已完成 + service.isAiStreaming = false + service.currentAiTask = nil + } + + func onError(error: Error) { + guard let service = service else { return } + + print("\(service.TAG): AI处理出错: \(error.localizedDescription)") + service.sendEvent("error", [ + "code": "AI_ERROR", + "message": error.localizedDescription + ]) + + // 标记AI流式输出已完成 + service.isAiStreaming = false + service.currentAiTask = nil + } + + func onFunctionCall(call: [String: Any]) { + guard let service = service else { return } + + if let name = call["name"] as? String { + service.sendEvent("function_call", [ + "name": name, + "arguments": call + ]) + + if name == "exit_interaction" { + service.stopRecognition() + } + } + } + + func onFunctionCallResult(functionCall: [String: Any], functionCallResult: [String: Any]) { + guard let service = service else { return } + + service.sendEvent("function_call_result", [ + "function_call": functionCall, + "result": functionCallResult, + ]) + } + } + + let callback = StreamCallbackBridge(self, responseBuilder: responseBuilder, speakResponse: speakResponse, displayText: displayText, hasImage: hasImage) + + self.openAIService?.sendMessageStream(messages: messagesWithSystemPrompt, callback: callback) + } + + // 保存任务引用并在全局队列中执行 + currentAiTask = workItem + DispatchQueue.global(qos: .userInitiated).async(execute: workItem) + } + + /** + * 加载最近的聊天历史记录 + */ + private func loadChatHistory() { + // 清空当前历史记录 + historyMessages.removeAll() + + // 使用ChatStorageHelper直接获取最近消息 + guard let recentMessages = chatStorageHelper?.getRecentMessages(sessionId: sessionId, count: 10) else { + print("\(TAG): 没有找到历史记录") + return + } + + // 将消息添加到历史记录 + for message in recentMessages { + if let sender = message["sender"] as? String, + let content = message["message"] as? String { + + if sender == "user" { + if let userMessage = openAIService?.createUserMessage(text: content) { + addToHistoryMessages(userMessage) + } + } else if sender == "assistant" { + if let assistantMessage = openAIService?.createAssistantMessage(text: content) { + addToHistoryMessages(assistantMessage) + } + } + } + } + + print("\(TAG): 已加载\(recentMessages.count)条历史记录") + } + + /** + * 添加消息到历史记录,保持最近10条 + */ + private func addToHistoryMessages(_ message: [String: Any]) { + // 添加新消息 + historyMessages.append(message) + + // 如果超过10条,删除最早的消息 + while historyMessages.count > 10 { + historyMessages.removeFirst() + } + } + + /** + * TTS播放函数 + * + * - Parameter text: 要播放的文本 + */ + func speakText(text: String) { + if text.isEmpty { return } + + // 更新状态 + isTtsSpeaking = true + restartIdleCheck() // 状态变化,重启检测 + + // 播放文本 + _ = azureTtsHelper?.speakText(text) + } + + /** + * 停止TTS播放 + */ + func stopTts() { + if isTtsSpeaking { + _ = azureTtsHelper?.stopSpeaking() + isTtsSpeaking = false + restartIdleCheck() // 状态变化,重启检测 + sendEvent("tts_stopped", ["status": "stopped"]) + } + } + + /** + * 保存聊天消息 + */ + private func saveChatMessage(userMessage: String, assistantMessage: String) { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + + // 保存用户消息 + let userMessageId = self.chatStorageHelper?.saveMessage( + sessionId: self.sessionId, + message: userMessage, + sender: "user" + ) ?? -1 + + if userMessageId != -1 { + // 保存AI回复 + let assistantMessageId = self.chatStorageHelper?.saveMessage( + sessionId: self.sessionId, + message: assistantMessage, + sender: "assistant" + ) ?? -1 + + if assistantMessageId == -1 { + print("\(self.TAG): 保存助手消息失败") + } + } else { + print("\(self.TAG): 保存用户消息失败") + } + } + } + + /** + * 清除聊天历史 + */ + func clearChatHistory(completion: @escaping (Bool) -> Void) { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { + DispatchQueue.main.async { + completion(false) + } + return + } + + // 清除指定会话的所有消息 + let success = self.chatStorageHelper?.deleteMessages(sessionId: self.sessionId) ?? false + + if success { + // 清空内存中的历史记录 + self.historyMessages.removeAll() + print("\(self.TAG): 聊天历史已清除") + } else { + print("\(self.TAG): 清除聊天历史失败") + } + + DispatchQueue.main.async { + completion(success) + } + } + } + + /** + * 发送事件 + */ + private func sendEvent(_ eventName: String, _ data: [String: Any]) { + // 向所有监听器发送事件 + let allListeners = listeners.allObjects + for case let listener as AgentServiceListener in allListeners { + listener.onEvent(eventName: eventName, data: data) + } + } + + /** + * 释放资源 + */ + func dispose() { + stopRecognition() + stopTts() + stopAiStream() + stopIdleCheck() + + azureAsrHelper?.dispose() + azureTtsHelper?.dispose() + + // 清除所有监听器 + clearListeners() + + // 重置单例状态,以便下次使用时可以重新初始化 + isInitialized = false + + print("\(TAG): 代理服务资源已释放") + } +} + +// MARK: - AzureTtsHelper.TtsCallback +extension AgentService: AzureTtsHelper.TtsCallback { + func onSynthesisStarted() { + isTtsSpeaking = true + // 状态变化,重置空闲检测 + restartIdleCheck() + sendEvent("tts_started", ["status": "started"]) + } + + func onSynthesizing() { + // 可以在此添加TTS合成中的处理逻辑 + } + + func onSynthesisCompleted() { + isTtsSpeaking = false + // 状态变化,重启空闲检测 + restartIdleCheck() + sendEvent("tts_completed", ["status": "completed"]) + } + + func onSynthesisCanceled() { + isTtsSpeaking = false + // 状态变化,重启空闲检测 + restartIdleCheck() + sendEvent("tts_canceled", ["status": "canceled"]) + } +} + +// MARK: - 外部插件桥接器 + +/// 桥接OpenAIService插件 +class OpenAIServiceBridge { + private let service = OpenAIService() + + /// 初始化服务 + func initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String) { + service.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model, mcpServer: mcpServer) + } + + /// 创建系统消息 + func createSystemMessage(text: String) -> [String: Any] { + return service.createSystemMessage(text) + } + + /// 创建用户消息 + func createUserMessage(text: String) -> [String: Any] { + return service.createUserMessage(text) + } + + /// 创建助手消息 + func createAssistantMessage(text: String) -> [String: Any] { + return service.createAssistantMessage(text) + } + + /// 创建带图片的用户消息 + func createUserMessageWithImage(text: String, imageBase64: String) -> [String: Any]? { + return service.createUserMessageWithImage(text: text, imageBase64: imageBase64) + } + + /// 文件转Base64 + func fileToBase64(filePath: String) -> String? { + return service.fileToBase64(filePath: filePath) + } + + /// 发送流式消息 + func sendMessageStream(messages: [[String: Any]], callback: OpenAIStreamCallback) { + service.sendMessageStream(messages: messages, callback: callback) + } + + /// 取消当前流 + func cancelCurrentStream() { + service.cancelCurrentStream() + } +} + +/// OpenAI流式回调协议 +@objc protocol OpenAIStreamCallback { + func onToken(token: String) + func onComplete() + func onError(error: Error) + func onFunctionCall(call: [String: Any]) + func onFunctionCallResult(functionCall: [String: Any], functionCallResult: [String: Any]) +} + +/// 桥接ChatStorageHelper插件 +class ChatStorageHelperBridge { + private let helper = ChatStorageHelper() + + /// 保存消息 + func saveMessage(sessionId: String, message: String, sender: String) -> Int64 { + return helper.saveMessage(sessionId: sessionId, message: message, sender: sender) + } + + /// 获取最近消息 + func getRecentMessages(sessionId: String, count: Int) -> [[String: Any]]? { + return helper.getRecentMessages(sessionId: sessionId, count: count) + } + + /// 删除消息 + func deleteMessages(sessionId: String) -> Bool { + return helper.deleteMessages(sessionId: sessionId) + } +} \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/AgentServiceListener.swift b/local_plugins/agent_service/ios/Classes/AgentServiceListener.swift new file mode 100644 index 000000000..d7a63e653 --- /dev/null +++ b/local_plugins/agent_service/ios/Classes/AgentServiceListener.swift @@ -0,0 +1,11 @@ +import Foundation + +/// 代理服务事件监听器协议 +/// 用于接收代理服务发送的各类事件 +protocol AgentServiceListener: AnyObject { + /// 事件回调 + /// - Parameters: + /// - eventName: 事件名称 + /// - data: 事件数据 + func onEvent(eventName: String, data: [String: Any]) +} \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/AgentServicePlugin.h b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.h new file mode 100644 index 000000000..a2cb23a03 --- /dev/null +++ b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface AgentServicePlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/AgentServicePlugin.m b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.m new file mode 100644 index 000000000..e79f257b9 --- /dev/null +++ b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.m @@ -0,0 +1,12 @@ +#import "AgentServicePlugin.h" +#if __has_include() +#import +#else +#import "agent_service-Swift.h" +#endif + +@implementation AgentServicePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftAgentServicePlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift b/local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift new file mode 100644 index 000000000..5aa8195a9 --- /dev/null +++ b/local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift @@ -0,0 +1,259 @@ +import Flutter +import UIKit +import open_ai_service +import chat_storage + +/// Swift版Agent Service插件 +/// 处理Flutter与原生层的通信 +public class SwiftAgentServicePlugin: NSObject, FlutterPlugin { + // Flutter方法通道 + private var methodChannel: FlutterMethodChannel? + + // Flutter事件通道 + private var eventChannel: FlutterEventChannel? + + // 事件sink,用于向Flutter发送事件 + private var eventSink: FlutterEventSink? + + // 核心服务实例 + private var agentService: AgentService? + + /// 注册插件 + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = SwiftAgentServicePlugin() + + // 初始化方法通道 + let methodChannel = FlutterMethodChannel(name: "com.yunqiinnovation.agent_service", binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(instance, channel: methodChannel) + instance.methodChannel = methodChannel + + // 初始化事件通道 + let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.agent_service/events", binaryMessenger: registrar.messenger()) + eventChannel.setStreamHandler(instance) + instance.eventChannel = eventChannel + } + + /// 处理方法调用 + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + handleInitialize(call, result) + + case "startConversation": + handleStartConversation(result) + + case "stopConversation": + handleStopConversation(result) + + case "processTextInput": + handleProcessTextInput(call, result) + + case "speakText": + handleSpeakText(call, result) + + case "stopTts": + handleStopTts(result) + + case "clearChatHistory": + handleClearChatHistory(result) + + case "interruptCurrentResponse": + handleInterruptCurrentResponse(result) + + case "processImageInput": + handleProcessImageInput(call, result) + + case "dispose": + handleDispose(result) + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - 方法处理 + + /// 初始化 + private func handleInitialize(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let config = args["config"] as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "缺少配置参数", details: nil)) + return + } + + // 初始化代理服务 + if agentService == nil { + agentService = AgentService() + + // 设置事件监听器 + agentService?.setListener(self) + } + + // 执行初始化 + let success = agentService?.initialize(config: config) ?? false + result(success) + } + + /// 开始对话 + private func handleStartConversation(_ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + let success = service.startRecognition() + result(success) + } + + /// 停止对话 + private func handleStopConversation(_ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + service.stopRecognition() + result(true) + } + + /// 处理文本输入 + private func handleProcessTextInput(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + guard let args = call.arguments as? [String: Any], + let text = args["text"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的文本参数", details: nil)) + return + } + + let speakResponse = (args["speakResponse"] as? Bool) ?? false + let success = service.processTextInput(text: text, speakResponse: speakResponse) + result(success) + } + + /// 朗读文本 + private func handleSpeakText(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + guard let args = call.arguments as? [String: Any], + let text = args["text"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的文本参数", details: nil)) + return + } + + service.speakText(text: text) + result(true) + } + + /// 停止语音合成 + private func handleStopTts(_ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + service.stopTts() + result(true) + } + + /// 清除聊天历史 + private func handleClearChatHistory(_ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + service.clearChatHistory { success in + result(success) + } + } + + /// 中断当前响应 + private func handleInterruptCurrentResponse(_ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + service.interruptCurrentResponse() + result(true) + } + + /// 处理图片输入 + private func handleProcessImageInput(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let service = agentService else { + result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) + return + } + + guard let args = call.arguments as? [String: Any], + let imagePath = args["imagePath"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的图片路径参数", details: nil)) + return + } + + let text = (args["text"] as? String) ?? "" + let speakResponse = (args["speakResponse"] as? Bool) ?? false + + let success = service.processImageInput(imagePath: imagePath, text: text, speakResponse: speakResponse) + result(success) + } + + /// 释放资源 + private func handleDispose(_ result: @escaping FlutterResult) { + guard let service = agentService else { + result(true) // 已经是nil,直接返回成功 + return + } + + service.dispose() + agentService = nil + + result(true) + } +} + +// MARK: - FlutterStreamHandler +extension SwiftAgentServicePlugin: FlutterStreamHandler { + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } +} + +// MARK: - AgentServiceListener +extension SwiftAgentServicePlugin: AgentServiceListener { + func onEvent(eventName: String, data: [String: Any]) { + guard let sink = eventSink else { return } + + do { + // 构建事件JSON + let eventMap: [String: Any] = [ + "event": eventName, + "data": data + ] + + // 将事件转换为JSON字符串 + let jsonData = try JSONSerialization.data(withJSONObject: eventMap, options: []) + if let jsonString = String(data: jsonData, encoding: .utf8) { + // 发送到Flutter + DispatchQueue.main.async { + sink(jsonString) + } + } + } catch { + print("事件序列化失败: \(error.localizedDescription)") + } + } +} \ No newline at end of file diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index f4818bdbe..ffcdd5fbb 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -189,6 +189,42 @@ class AgentService { } } + /// 启动语音助手服务 + /// + /// 启动常驻的语音助手服务,支持后台蓝牙设备唤醒和媒体按钮唤醒 + /// [config] 配置参数,应包含以下参数: + /// - azureSpeechKey: Azure语音服务密钥 + /// - azureSpeechRegion: Azure语音服务区域 + /// - openaiApiKey: OpenAI API密钥 + /// - openaiBaseUrl: (可选) OpenAI API 基础URL + /// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo" + /// - mcpServer: (可选) MCP服务器地址 + /// + /// 返回是否成功启动服务 + static Future startAgentService(Map config) async { + try { + final bool result = await _channel.invokeMethod('startAgentService', { + 'config': config, + }); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '启动语音助手服务失败', e.details); + } + } + + /// 停止语音助手服务 + /// + /// 停止常驻的语音助手服务 + /// 返回是否成功停止服务 + static Future stopAgentService() async { + try { + final bool result = await _channel.invokeMethod('stopAgentService'); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '停止语音助手服务失败', e.details); + } + } + /// 开始对话 /// /// 启动语音识别,开始监听用户语音输入 diff --git a/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift index d3dd8d0b8..7e01c0f8e 100644 --- a/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift @@ -2,85 +2,187 @@ import Foundation import AVFoundation import MicrosoftCognitiveServicesSpeech -/// Azure 语音识别辅助类 -class AzureAsrHelper: NSObject { +/// Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 +class AzureAsrHelper { + private let tag = "AzureAsrHelper" + + // 核心组件 + private var speechConfig: SPXSpeechConfiguration? private var recognizer: SPXSpeechRecognizer? - private var speechConfig: SPXSpeechConfig? - private var audioConfig: SPXAudioConfig? - private var initialized = false + private var audioConfig: SPXAudioConfiguration? + + // 状态管理 private var isContinuousRecognitionActive = false + + // 配置参数 private var currentLanguage = "zh-CN" - private var subscriptionKey = "" - private var serviceRegion = "" + private var supportedLanguages = ["zh-CN"] private var isAutoDetectLanguage = false - private var supportedLanguages = ["zh-CN", "en-US"] + private var subscriptionKey = "" + private var region = "" + + // 音频源配置 + private var audioSourceType = AudioSourceType.microphone - // 音频会话管理 - private let audioSession = AVAudioSession.sharedInstance() + // 音频处理 + private var microphoneStream: MicrophoneStream? + private var externalAudioStream: ExternalAudioPullStream? - // 事件回调 - private var eventHandler: (([String: Any]) -> Void)? + // 连续识别回调 + private var continuousRecognizeCallback: ContinuousRecognizeCallback? - /// 设置事件处理器 - /// - /// - Parameter handler: 事件处理回调 - func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { - self.eventHandler = handler + /// 应用上下文 + private let context: Any + + /// 音频来源类型 + enum AudioSourceType { + /// 使用设备麦克风 + case microphone + + /// 使用外部提供的音频数据 + case external } - /// 初始化语音识别服务 - /// - /// - Parameters: - /// - speechSubscriptionKey: Azure 语音服务订阅密钥 - /// - serviceRegion: Azure 语音服务区域 - /// - supportedLanguages: 支持的语言列表,默认为 ["zh-CN", "en-US"] - /// - Returns: 是否初始化成功 - func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String] = ["zh-CN", "en-US"]) -> Bool { - print("[AzureAsrHelper] 初始化 Azure 语音服务") - - // 检查配置是否为空 - if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureAsrHelper] 错误: Azure 配置信息不完整") - return false - } + /// 一次性识别回调 + protocol RecognizeCallback { + /// 识别结果回调 + /// - Parameters: + /// - text: 识别文本 + /// - detectedLanguage: 检测到的语言 + func onResult(text: String, detectedLanguage: String) - // 释放之前的资源 - dispose() + /// 识别错误回调 + /// - Parameter error: 错误信息 + func onError(error: String) + } + + /// 连续识别回调 + protocol ContinuousRecognizeCallback { + /// 识别中回调 + /// - Parameters: + /// - recognizing: 识别中的文本 + /// - detectedLanguage: 检测到的语言 + func onRecognizing(recognizing: String, detectedLanguage: String) - // 保存配置 - self.subscriptionKey = speechSubscriptionKey - self.serviceRegion = serviceRegion + /// 识别结果回调 + /// - Parameters: + /// - text: 识别文本 + /// - detectedLanguage: 检测到的语言 + func onResult(text: String, detectedLanguage: String) - // 设置语言 - if supportedLanguages.isEmpty { - print("[AzureAsrHelper] 警告: 传入的支持语言列表为空,将使用默认语言") - } else { - self.supportedLanguages = supportedLanguages - } + /// 会话开始回调 + func onSessionStarted() - // 根据支持的语言数量决定是否启用自动语言检测 - self.isAutoDetectLanguage = supportedLanguages.count >= 2 + /// 会话结束回调 + func onSessionStopped() - // 如果只有一种语言,设置为当前语言 - if !isAutoDetectLanguage && !supportedLanguages.isEmpty { - self.currentLanguage = supportedLanguages[0] - } + /// 识别取消回调 + /// - Parameters: + /// - reason: 取消原因 + /// - errorDetails: 错误详情 + func onCanceled(reason: String, errorDetails: String) - // 创建语音配置 + /// 识别错误回调 + /// - Parameter error: 错误信息 + func onError(error: String) + } + + /// 初始化Azure语音识别辅助类 + /// - Parameter context: 应用上下文 + init(_ context: Any) { + self.context = context + } + + /// 初始化Azure语音服务 + /// - Parameters: + /// - subscriptionKey: Azure 订阅密钥 + /// - region: Azure 区域 + /// - supportedLanguages: 支持的语言数组,默认为["zh-CN"] + /// - audioSourceType: 音频源类型,默认为麦克风 + /// - Returns: 初始化是否成功 + func initialize( + subscriptionKey: String, + region: String, + supportedLanguages: [String] = ["zh-CN"], + audioSourceType: AudioSourceType = .microphone + ) -> Bool { do { - speechConfig = try SPXSpeechConfig(subscription: speechSubscriptionKey, region: serviceRegion) + print("\(tag): 初始化 Azure 语音服务, 音频源类型: \(audioSourceType)") + + // 检查配置是否为空 + if subscriptionKey.isEmpty || region.isEmpty { + print("\(tag): Azure 配置信息不完整") + return false + } + + // 释放之前的资源 + dispose() + + // 保存配置 + self.subscriptionKey = subscriptionKey + self.region = region + self.audioSourceType = audioSourceType + + // 设置语言 + if !supportedLanguages.isEmpty { + self.supportedLanguages = supportedLanguages + } + + // 根据支持的语言数量决定是否启用自动语言检测 + self.isAutoDetectLanguage = supportedLanguages.count >= 2 + + // 如果只有一种语言,设置为当前语言 + if !isAutoDetectLanguage && !supportedLanguages.isEmpty { + self.currentLanguage = supportedLanguages[0] + } + + // 创建语音配置 + speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - // 设置语言配置 if isAutoDetectLanguage { - // 设置自动语言检测 - try speechConfig?.setPropertyTo("Continuous", byId: SPXPropertyId.SpeechServiceConnection_LanguageIdMode) + // 启用语言检测模式 + try speechConfig?.setPropertyTo("Continuous", byId: SPXPropertyId.speechServiceConnection_languageIdMode) } else { // 设置指定的识别语言 speechConfig?.speechRecognitionLanguage = currentLanguage } - // 创建音频配置 - 使用默认麦克风 - audioConfig = SPXAudioConfig.default() + // 设置音频配置 + return setupRecognizer() + + } catch { + print("\(tag): 初始化失败: \(error.localizedDescription)") + return false + } + } + + /// 设置识别器 + private func setupRecognizer() -> Bool { + do { + // 清理旧的识别器 + recognizer = nil + + // 设置音频配置 + switch audioSourceType { + case .microphone: + // 检查是否连接了耳机 + let isHeadsetConnected = isHeadphonesConnected() + + if !isHeadsetConnected { + print("\(tag): 未检测到耳机,使用麦克风输入") + // 使用拉流模式进行回音消除 + setupMicrophoneStream() + } else { + print("\(tag): 检测到耳机连接,使用默认麦克风输入") + // 使用默认麦克风输入 + audioConfig = SPXAudioConfiguration() + } + + case .external: + // 创建拉流 + setupExternalAudioStream() + print("\(tag): 使用外部音频源(拉流模式)") + } // 创建识别器 if isAutoDetectLanguage { @@ -90,372 +192,505 @@ class AzureAsrHelper: NSObject { recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) } - // 配置音频会话 - try configureAudioSession() - - initialized = true - print("[AzureAsrHelper] Azure 语音服务初始化成功") + print("\(tag): Azure 语音服务初始化成功") return true } catch { - print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") + print("\(tag): 创建识别器失败: \(error.localizedDescription)") + stopAudioProcessing() return false } } - /// 配置音频会话 - private func configureAudioSession() throws { - print("[AzureAsrHelper] 开始配置音频会话...") - + /// 设置麦克风流 - 使用拉流方式 + private func setupMicrophoneStream() { do { - // 设置音频会话类别和模式 - try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers, .allowBluetooth]) - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + // 创建麦克风流 + microphoneStream = MicrophoneStream() + + // 创建音频配置 + audioConfig = try SPXAudioConfiguration(streamInput: microphoneStream!) + + print("\(tag): 已设置麦克风流(拉流模式)") + } catch { + print("\(tag): 设置麦克风流失败: \(error.localizedDescription)") + } + } + + /// 设置外部音频流 - 使用拉流方式 + private func setupExternalAudioStream() { + do { + // 创建外部音频拉流对象 + externalAudioStream = ExternalAudioPullStream() + + // 创建音频配置 + audioConfig = try SPXAudioConfiguration(streamInput: externalAudioStream!) + + print("\(tag): 已设置外部音频流(拉流模式)") } catch { - print("[AzureAsrHelper] 警告: 通过AudioSessionManager配置音频会话失败") - throw error + print("\(tag): 设置外部音频流失败: \(error.localizedDescription)") } } - /// 一次性语音识别 - /// - /// - Parameter completion: 完成回调,返回是否成功、识别文本、识别语言和可能的错误信息 - func recognizeOnce(completion: @escaping (Bool, String?, String?, String?) -> Void) { - if !initialized { - completion(false, nil, nil, "语音服务未初始化") + /// 向音频流写入音频数据 + /// 仅当音频源设置为external时有效 + /// - Parameter data: 音频数据字节数组 + func pushAudioData(_ data: Data) { + if audioSourceType != .external { + print("\(tag): 当前未使用外部音频源,忽略推送的音频数据") return } - // 重置 recognizer - if !resetRecognizer() { - completion(false, nil, nil, "重置识别器失败") + // 使用拉流模式,将数据推入队列 + externalAudioStream?.pushAudio(data) + } + + /// 执行一次性语音识别 + /// - Parameter callback: 识别结果回调 + func recognizeOnce(callback: RecognizeCallback) { + if speechConfig == nil { + callback.onError(error: "语音服务未初始化") return } + // 确保不在连续识别中 + if isContinuousRecognitionActive { + _ = stopContinuousRecognition() + } + + // 重置识别器 + if !setupRecognizer() { + callback.onError(error: "重置识别器失败") + return + } + + // 启动音频处理 + startAudioProcessing() + + // 执行同步识别 do { - // 激活音频会话 - try audioSession.setActive(true) + let result = try recognizer?.recognizeOnce() - // 添加识别事件处理 - recognizer?.addRecognizedEventHandler { [weak self] _, event in - guard let self = self else { return } - - if event.result.reason == .recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") - completion(true, event.result.text, detectedLanguage, nil) - } - } + // 停止音频处理 + stopAudioProcessing() - recognizer?.addRecognizingEventHandler { [weak self] _, event in - guard let self = self else { return } + if let result = result, result.reason == .recognizedSpeech { + let resultText = result.text ?? "" + var detectedLanguage = supportedLanguages[0] - if event.result.reason == .recognizingSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - // print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") - } - } - - // 添加会话事件处理 - recognizer?.addSessionStartedEventHandler { _, _ in - print("[AzureAsrHelper] 识别会话已开始") - } - - recognizer?.addSessionStoppedEventHandler { _, _ in - print("[AzureAsrHelper] 识别会话已结束") - } - - // 添加取消事件处理 - recognizer?.addCanceledEventHandler { _, event in - if let cancellationDetails = try? SPXCancellationDetails(fromCanceledRecognitionResult: event.result) { - let errorDetails = cancellationDetails.errorDetails ?? "未知错误" - print("[AzureAsrHelper] 识别取消: \(errorDetails)") - completion(false, nil, nil, "识别取消: \(errorDetails)") + // 尝试获取检测到的语言 + if isAutoDetectLanguage, let autoDetectResult = result.properties?.getPropertyByName("SourceLanguage") { + detectedLanguage = autoDetectResult } - } - - // 执行识别 - let result = try recognizer?.recognizeOnceAsync().get() - - if result?.reason != .recognizedSpeech { - completion(false, nil, nil, "未能识别语音") + + callback.onResult(text: resultText, detectedLanguage: detectedLanguage) + } else { + callback.onError(error: "未能识别语音") } } catch { - completion(false, nil, nil, "识别异常: \(error.localizedDescription)") + stopAudioProcessing() + callback.onError(error: "识别异常: \(error.localizedDescription)") } } - /// 重置识别器 - /// - /// - Returns: 是否重置成功 - private func resetRecognizer() -> Bool { - if !initialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") + /// 开始连续语音识别 + /// - Parameter callback: 连续识别结果回调 + /// - Returns: 是否成功开始识别 + func startContinuousRecognition(_ callback: ContinuousRecognizeCallback) -> Bool { + if speechConfig == nil { + callback.onError(error: "语音服务未初始化") return false } - // 检查配置是否为空 - if subscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + if isContinuousRecognitionActive { + return true + } + + // 重置识别器 + if !setupRecognizer() { + callback.onError(error: "重置识别器失败") return false } + // 保存回调 + continuousRecognizeCallback = callback + do { - // 释放之前的 recognizer - recognizer = nil + // 设置各种事件监听 + setupEventListeners(callback) - // 创建音频配置 - 使用默认麦克风 - audioConfig = SPXAudioConfig.default() + // 启动音频处理 + startAudioProcessing() - // 重新创建识别器 - if isAutoDetectLanguage { - let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(sourceLanguages: supportedLanguages) - recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, autoDetectSourceLanguageConfiguration: autoDetectConfig, audioConfiguration: audioConfig!) - } else { - recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) - } + // 开始连续识别 + try recognizer?.startContinuousRecognition() + isContinuousRecognitionActive = true return true } catch { - print("[AzureAsrHelper] 错误: 重置识别器失败: \(error.localizedDescription)") + isContinuousRecognitionActive = false + stopAudioProcessing() + callback.onError(error: "启动连续识别失败: \(error.localizedDescription)") return false } } - /// 获取检测到的语言 - /// - /// - Parameter result: 识别结果 - /// - Returns: 检测到的语言代码 - private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { - if isAutoDetectLanguage { - do { - if let autoDetectResult = try SPXAutoDetectSourceLanguageResult(fromRecognitionResult: result) { - return autoDetectResult.language - } - return "" - } catch { - print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") - return "" - } - } else { - return currentLanguage - } - } - - /// 开始连续语音识别 - /// - /// - Returns: 是否成功启动连续识别 - func startContinuousRecognition() -> Bool { - if !initialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") - return false - } - - // 检查配置是否为空 - if subscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureAsrHelper] 错误: Azure 配置信息不完整") - return false - } - - // 如果已经在进行连续识别,直接返回 - if isContinuousRecognitionActive { - print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") - return true - } + /// 设置事件监听器 + private func setupEventListeners(_ callback: ContinuousRecognizeCallback) { + guard let recognizer = recognizer else { return } - // 重置 recognizer - if !resetRecognizer() { - print("[AzureAsrHelper] 尝试重新创建识别器...") - return false + // 识别中事件 + recognizer.addRecognizing { [weak self] _, event in + guard let self = self else { return } + + var detectedLanguage = "" + if self.isAutoDetectLanguage, let lang = event.result.properties?.getPropertyByName("SourceLanguage") { + detectedLanguage = lang + } + + print("\(self.tag): 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + callback.onRecognizing(recognizing: event.result.text ?? "", detectedLanguage: detectedLanguage) } - do { - // 激活音频会话 - try audioSession.setActive(true) + // 识别完成事件 + recognizer.addRecognized { [weak self] _, event in + guard let self = self else { return } - // 添加识别事件处理 - recognizer?.addRecognizedEventHandler { [weak self] _, event in - guard let self = self else { return } - - if event.result.reason == .recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - let eventData: [String: Any] = [ - "eventType": "finalResult", - "text": event.result.text ?? "", - "language": detectedLanguage - ] - self.eventHandler?(eventData) + if event.result.reason == .recognizedSpeech { + var detectedLanguage = "" + if self.isAutoDetectLanguage, let lang = event.result.properties?.getPropertyByName("SourceLanguage") { + detectedLanguage = lang } - } - - // 识别中事件 - recognizer?.addRecognizingEventHandler { [weak self] _, event in - guard let self = self else { return } - if event.result.reason == .recognizingSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - let eventData: [String: Any] = [ - "eventType": "recognizing", - "text": event.result.text ?? "", - "language": detectedLanguage - ] - self.eventHandler?(eventData) - } + print("\(self.tag): 识别完成: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + callback.onResult(text: event.result.text ?? "", detectedLanguage: detectedLanguage) } + } + + // 会话开始事件 + recognizer.addSessionStarted { [weak self] _, _ in + guard let self = self else { return } - // 会话事件 - recognizer?.addSessionStartedEventHandler { [weak self] _, _ in - guard let self = self else { return } - - let eventData: [String: Any] = [ - "eventType": "sessionStarted" - ] - self.eventHandler?(eventData) - } + print("\(self.tag): 识别会话已开始") + callback.onSessionStarted() + } + + // 会话结束事件 + recognizer.addSessionStopped { [weak self] _, _ in + guard let self = self else { return } - recognizer?.addSessionStoppedEventHandler { [weak self] _, _ in - guard let self = self else { return } - - self.isContinuousRecognitionActive = false - let eventData: [String: Any] = [ - "eventType": "sessionStopped" - ] - self.eventHandler?(eventData) - } + print("\(self.tag): 识别会话已结束") + self.isContinuousRecognitionActive = false + self.stopAudioProcessing() + callback.onSessionStopped() + } + + // 取消事件 + recognizer.addCanceled { [weak self] _, event in + guard let self = self else { return } - // 取消事件 - recognizer?.addCanceledEventHandler { [weak self] _, event in - guard let self = self else { return } - - self.isContinuousRecognitionActive = false - var errorMessage = "未知错误" - - if let cancellationDetails = try? SPXCancellationDetails(fromCanceledRecognitionResult: event.result) { - errorMessage = cancellationDetails.errorDetails ?? "未知错误" - } - - let eventData: [String: Any] = [ - "eventType": "error", - "error": "识别取消: \(errorMessage)" - ] - self.eventHandler?(eventData) - } + let errorDetails = event.errorDetails ?? "未知错误" + let reason = String(describing: event.reason) - // 开始连续识别 - try recognizer?.startContinuousRecognition() - isContinuousRecognitionActive = true - print("[AzureAsrHelper] 连续识别已启动") + print("\(self.tag): 识别取消: \(errorDetails)") + self.isContinuousRecognitionActive = false + self.stopAudioProcessing() - return true - } catch { - print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") - return false + callback.onCanceled(reason: reason, errorDetails: errorDetails) + callback.onError(error: "识别取消: \(errorDetails)") // 兼容旧接口 } } /// 停止连续语音识别 - /// - /// - Returns: 是否成功停止连续识别 + /// - Returns: 是否成功停止 func stopContinuousRecognition() -> Bool { - if !initialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") + if speechConfig == nil { + print("\(tag): 语音服务未初始化") return false } if !isContinuousRecognitionActive { - print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") + print("\(tag): 未进行连续识别,忽略停止请求") return true } do { - print("[AzureAsrHelper] 停止连续语音识别") + print("\(tag): 停止连续语音识别") if recognizer == nil { - if isContinuousRecognitionActive { - print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") - } + print("\(tag): 识别器为空,重置状态") isContinuousRecognitionActive = false - - // 通知停止成功 - let eventData: [String: Any] = [ - "eventType": "success", - "message": "连续识别已停止" - ] - eventHandler?(eventData) return true } // 停止连续识别 try recognizer?.stopContinuousRecognition() - // 延迟一点时间确保处理完成 + // 停止音频处理 + stopAudioProcessing() + + // 等待一段时间以确保资源正确释放 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } - - // 重置状态 - self.isContinuousRecognitionActive = false - - // 恢复音频会话 - do { - try self.audioSession.setActive(false, options: .notifyOthersOnDeactivation) - } catch { - // 忽略错误 - } - - print("[AzureAsrHelper] 连续识别已停止") - - // 通知停止成功 - let eventData: [String: Any] = [ - "eventType": "success", - "message": "连续识别已停止" - ] - self.eventHandler?(eventData) + self?.isContinuousRecognitionActive = false } + print("\(tag): 连续识别停止指令已发送") return true } catch { // 强制重置状态 isContinuousRecognitionActive = false - print("[AzureAsrHelper] 警告: 停止连续识别失败: \(error.localizedDescription)") + print("\(tag): 停止连续识别失败: \(error.localizedDescription)") - // 通知停止失败,但仍然视为处理完成 - let eventData: [String: Any] = [ - "eventType": "success", - "message": "连续识别已停止(但有错误)" - ] - eventHandler?(eventData) + // 停止音频处理 + stopAudioProcessing() + + // 尝试强制关闭识别器 + recognizer = nil return false } } - /// 释放资源 + /// 检查连续识别是否活跃 + /// - Returns: 是否活跃 + func isContinuousRecognitionActive() -> Bool { + return isContinuousRecognitionActive + } + + /// 释放所有资源 func dispose() { // 如果正在进行连续识别,先停止 if isContinuousRecognitionActive { _ = stopContinuousRecognition() } - // 恢复音频会话 - do { - try audioSession.setActive(false, options: .notifyOthersOnDeactivation) - } catch { - // 忽略错误 - } + // 停止音频处理 + stopAudioProcessing() - // 释放资源 + // 清理所有资源 recognizer = nil speechConfig = nil audioConfig = nil + microphoneStream = nil + externalAudioStream = nil - initialized = false + // 重置状态 isContinuousRecognitionActive = false - print("[AzureAsrHelper] 资源已释放") + continuousRecognizeCallback = nil } - /// 检查连续识别是否处于活跃状态 - /// - /// - Returns: 是否正在进行连续识别 - func isContinuousRecognitionActive() -> Bool { - return isContinuousRecognitionActive + /// 启动音频处理 + private func startAudioProcessing() { + switch audioSourceType { + case .microphone: + // 拉流模式不需要额外启动,SDK会自动拉取数据 + break + case .external: + // 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData + break + } + } + + /// 停止音频处理 + private func stopAudioProcessing() { + microphoneStream?.close() + microphoneStream = nil + + externalAudioStream?.close() + externalAudioStream = nil + } + + /// 检查是否连接了耳机 + private func isHeadphonesConnected() -> Bool { + let audioSession = AVAudioSession.sharedInstance() + let outputs = audioSession.currentRoute.outputs + + // 检查是否存在耳机类型的输出 + let headphoneTypes: [AVAudioSession.Port] = [ + .headphones, .bluetoothA2DP, .bluetoothHFP, .bluetoothLE + ] + + for output in outputs { + if headphoneTypes.contains(output.portType) { + return true + } + } + + return false + } + + /// 麦克风流 - 拉流模式 + /// 实现SPXPullAudioInputStreamDelegate,为Azure SDK提供音频数据 + class MicrophoneStream: NSObject, SPXPullAudioInputStreamDelegate { + private let tag = "MicrophoneStream" + private var audioEngine: AVAudioEngine? + private var audioBuffer = Data() + private let bufferLock = NSLock() + + override init() { + super.init() + setupAudioEngine() + } + + private func setupAudioEngine() { + do { + print("\(tag): 初始化音频引擎") + + // 创建音频引擎 + audioEngine = AVAudioEngine() + + // 设置音频会话 + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .default) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + // 获取输入节点 + guard let inputNode = audioEngine?.inputNode else { + print("\(tag): 无法获取输入节点") + return + } + + // 创建16kHz, 16bit, 单声道格式 + let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true) + + // 设置音频处理回调 + let bufferSize = 4096 + inputNode.installTap(onBus: 0, bufferSize: UInt32(bufferSize), format: format) { [weak self] (buffer, time) in + guard let self = self else { return } + + // 将音频数据转换为Data + let audioBuffer = buffer.int16ChannelData?[0] + let audioBufferSize = Int(buffer.frameLength * buffer.format.streamDescription.pointee.mBytesPerFrame) + + if let audioBuffer = audioBuffer { + let data = Data(bytes: audioBuffer, count: audioBufferSize) + self.bufferLock.lock() + self.audioBuffer.append(data) + self.bufferLock.unlock() + } + } + + // 启动音频引擎 + audioEngine?.prepare() + try audioEngine?.start() + + print("\(tag): 音频引擎启动成功") + } catch { + print("\(tag): 设置音频引擎失败: \(error.localizedDescription)") + } + } + + // SPXPullAudioInputStreamDelegate协议方法 + func read(_ buffer: UnsafeMutablePointer, size: UInt32) -> Int32 { + bufferLock.lock() + defer { bufferLock.unlock() } + + let maxSize = Int(size) + + if audioBuffer.count == 0 { + // 没有数据可读 + return 0 + } + + let readSize = min(audioBuffer.count, maxSize) + audioBuffer.copyBytes(to: buffer, from: 0.., size: UInt32) -> Int32 { + bufferLock.lock() + defer { bufferLock.unlock() } + + if isClosed { + return 0 + } + + let maxSize = Int(size) + + if audioBuffer.count == 0 { + // 没有数据可读,返回0表示需要等待 + return 0 + } + + let readSize = min(audioBuffer.count, maxSize) + audioBuffer.copyBytes(to: buffer, from: 0.. Bool { - print("[AzureTtsHelper] 初始化 Azure 语音服务") + func initialize( + subscriptionKey: String, + region: String, + callback: TtsCallback? = nil + ) -> Bool { + print("\(tag): 初始化 Azure 语音服务") // 检查配置是否为空 - if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureTtsHelper] 错误: Azure 配置信息不完整") + if subscriptionKey.isEmpty || region.isEmpty { + print("\(tag): 错误: Azure 配置信息不完整") return false } // 释放之前的资源 dispose() + // 保存全局回调 + ttsCallback = callback + do { // 创建语音配置 - speechConfig = try SPXSpeechConfig(subscription: speechSubscriptionKey, region: serviceRegion) + speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) // 设置语音合成输出格式为高质量音频 speechConfig?.setSpeechSynthesisOutputFormat(.riff24Khz16BitMonoPcm) - // 设置默认语言 - speechConfig?.setSpeechSynthesisLanguage(language) - // 设置默认语音 - speechConfig?.setSpeechSynthesisVoiceName(currentVoiceName) + speechConfig?.setSpeechSynthesisVoiceName(currentVoice) - // 创建音频配置 - 使用默认扬声器 - audioConfig = SPXAudioConfig.default() + // 创建音频配置 + if let customOutput = customAudioOutputStream { + // 使用自定义音频输出流 + audioConfig = try SPXAudioConfiguration(streamOutput: customOutput) + } else { + // 使用默认扬声器 + audioConfig = SPXAudioConfiguration() + } // 创建语音合成器 - synthesizer = try SPXSpeechSynthesizer(speechConfig: speechConfig!, audioConfig: audioConfig!) - - initialized = true + synthesizer = try SPXSpeechSynthesizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) - // 设置默认音频输出类型为自动 - setAudioOutputType(outputType: .auto) + // 设置事件监听 + setupEventListeners() - print("[AzureTtsHelper] TTS 引擎初始化成功") + initialized = true + print("\(tag): TTS 引擎初始化成功") return true } catch { - print("[AzureTtsHelper] TTS 引擎初始化失败: \(error.localizedDescription)") + print("\(tag): TTS 引擎初始化失败: \(error.localizedDescription)") return false } } - /// 设置音频输出设备类型 - /// - /// - Parameter outputType: 音频输出设备类型 - /// - Returns: 是否设置成功 - func setAudioOutputType(outputType: AudioOutputType) -> Bool { - if !initialized { - print("[AzureTtsHelper] TTS 引擎尚未初始化") - return false + /// 设置事件监听器 + private func setupEventListeners() { + guard let synthesizer = synthesizer else { return } + + // 合成开始事件 + synthesizer.addSynthesisStarted { [weak self] _, _ in + guard let self = self else { return } + self.ttsCallback?.onSynthesisStarted() + } + + // 合成中事件 + synthesizer.addSynthesizing { [weak self] _, _ in + guard let self = self else { return } + self.ttsCallback?.onSynthesizing() } + // 合成完成事件 + synthesizer.addSynthesisCompleted { [weak self] _, _ in + guard let self = self else { return } + self.speaking = false + self.ttsCallback?.onSynthesisCompleted() + } + + // 合成取消事件 + synthesizer.addSynthesisCanceled { [weak self] _, _ in + guard let self = self else { return } + print("\(self.tag): 语音合成取消") + self.speaking = false + self.ttsCallback?.onSynthesisCanceled() + } + } + + /// 设置TTS回调 + /// - Parameter callback: TTS回调接口 + func setTtsCallback(_ callback: TtsCallback?) { + ttsCallback = callback + } + + /// 设置自定义音频输出流 + /// - Parameter outputStream: 自定义音频输出流,如果为nil则使用默认音频输出 + /// - Returns: 是否设置成功 + func setCustomAudioOutputStream(_ outputStream: SPXPushAudioOutputStream?) -> Bool { do { - currentAudioOutputType = outputType - - switch outputType { - case .speaker: - // 使用扬声器 - try audioSession.setCategory(.playback, mode: .default) - try audioSession.overrideOutputAudioPort(.speaker) - print("[AzureTtsHelper] 已设置音频输出设备为扬声器") - - case .earpiece: - // 使用听筒 - try audioSession.setCategory(.playback, mode: .voiceChat) - try audioSession.overrideOutputAudioPort(.none) - print("[AzureTtsHelper] 已设置音频输出设备为听筒") - - case .auto: - // 检查是否有耳机连接 - let outputs = audioSession.currentRoute.outputs - let hasHeadphones = outputs.contains { output in - return output.portType == .headphones || output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP - } - - if hasHeadphones { - // 有耳机,使用耳机 - try audioSession.setCategory(.playback, mode: .default) - try audioSession.overrideOutputAudioPort(.none) - print("[AzureTtsHelper] 已设置音频输出设备为耳机") - } else { - // 无耳机,使用听筒 - try audioSession.setCategory(.playback, mode: .voiceChat) - try audioSession.overrideOutputAudioPort(.none) - print("[AzureTtsHelper] 已设置音频输出设备为听筒") - } + // 保存引用 + customAudioOutputStream = outputStream + + // 如果已初始化,需要重新创建合成器以应用新的音频输出流 + if initialized { + recreateSynthesizer() } - try audioSession.setActive(true) return true } catch { - print("[AzureTtsHelper] 设置音频输出设备失败: \(error.localizedDescription)") + print("\(tag): 设置自定义音频输出流失败: \(error.localizedDescription)") return false } } + /// 重新创建合成器 + private func recreateSynthesizer() { + do { + // 关闭现有合成器 + synthesizer = nil + + // 创建音频配置 + if let customOutput = customAudioOutputStream { + // 使用自定义音频输出流 + audioConfig = try SPXAudioConfiguration(streamOutput: customOutput) + } else { + // 使用默认扬声器 + audioConfig = SPXAudioConfiguration() + } + + // 使用新的音频配置创建合成器 + synthesizer = try SPXSpeechSynthesizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) + + // 重新设置事件监听 + setupEventListeners() + } catch { + print("\(tag): 重新创建合成器失败: \(error.localizedDescription)") + } + } + /// 设置语音 - /// /// - Parameter voiceName: 语音名称,例如 "zh-CN-XiaoxiaoNeural" /// - Returns: 是否设置成功 func setVoice(voiceName: String) -> Bool { if !initialized { - print("[AzureTtsHelper] TTS 引擎尚未初始化") + print("\(tag): TTS 引擎尚未初始化") return false } - if voiceName == currentVoiceName { - print("[AzureTtsHelper] 已设置语音: \(voiceName)") + if voiceName == currentVoice { + print("\(tag): 已设置语音: \(voiceName)") return true } do { - currentVoiceName = voiceName + currentVoice = voiceName speechConfig?.setSpeechSynthesisVoiceName(voiceName) // 重新创建合成器 - synthesizer = try SPXSpeechSynthesizer(speechConfig: speechConfig!, audioConfig: audioConfig!) + recreateSynthesizer() - print("[AzureTtsHelper] 已设置语音: \(voiceName)") + print("\(tag): 已设置语音: \(voiceName)") return true } catch { - print("[AzureTtsHelper] 设置语音失败: \(error.localizedDescription)") + print("\(tag): 设置语音失败: \(error.localizedDescription)") return false } } /// 设置语音合成参数 - /// /// - Parameters: /// - rate: 语速,范围 -100 到 100,默认为 0 /// - pitch: 音调,范围 -100 到 100,默认为 0 @@ -171,58 +239,36 @@ class AzureTtsHelper: NSObject { /// - Returns: 是否设置成功 func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { if !initialized { - print("[AzureTtsHelper] TTS 引擎尚未初始化") + print("\(tag): TTS 引擎尚未初始化") return false } - currentSpeechRate = rate - currentPitch = pitch - currentVolume = volume - - print("[AzureTtsHelper] 已设置语音参数: 语速=\(rate), 音调=\(pitch), 音量=\(volume)") - return true - } - - /// 合成文本为语音并播放 - /// - /// - Parameters: - /// - text: 要合成的文本 - /// - completion: 完成回调,返回是否成功和可能的错误信息 - func speakText(text: String, completion: @escaping (Bool, String?) -> Void) { - if !initialized { - print("[AzureTtsHelper] TTS 引擎尚未初始化") - completion(false, "TTS 引擎尚未初始化") - return - } - do { - print("[AzureTtsHelper] 开始合成文本: \(text)") + currentRate = formatPercentage(rate) + currentPitch = formatPercentage(pitch) + currentVolume = "\(min(max(volume, 0), 100))%" - // 生成 SSML - let ssml = generateSsml(text: text) - - // 使用 SSML 合成语音 - speakSsml(ssml: ssml, completion: completion) + print("\(tag): 已设置语音参数: 语速=\(rate), 音调=\(pitch), 音量=\(volume)") + return true } catch { - print("[AzureTtsHelper] 语音合成异常: \(error.localizedDescription)") - completion(false, "语音合成异常: \(error.localizedDescription)") + print("\(tag): 设置语音参数失败: \(error.localizedDescription)") + return false } } + /// 格式化百分比值 + private func formatPercentage(_ value: Int) -> String { + return value >= 0 ? "+\(value)%" : "\(value)%" + } + /// 生成 SSML 文本 - /// /// - Parameter text: 要转换的文本 /// - Returns: SSML 格式的文本 private func generateSsml(text: String) -> String { - // 计算 SSML 参数 - let rateParam = currentSpeechRate == 0 ? "0%" : (currentSpeechRate < 0 ? "\(Int(Double(currentSpeechRate) * 0.9))%" : "\(currentSpeechRate)%") - let pitchParam = currentPitch == 0 ? "0%" : "\(Int(Double(currentPitch) * 0.5))%" - let volumeParam = "\(min(max(currentVolume, 0), 100))%" - return """ - - + + \(text) @@ -230,73 +276,133 @@ class AzureTtsHelper: NSObject { """ } - /// 合成 SSML 为语音并播放 - /// - /// - Parameters: - /// - ssml: SSML 格式的文本 - /// - completion: 完成回调,返回是否成功和可能的错误信息 - private func speakSsml(ssml: String, completion: @escaping (Bool, String?) -> Void) { + /// 合成文本为语音并播放 + /// - Parameter text: 要合成的文本 + /// - Returns: 是否成功开始合成 + func speakText(_ text: String) -> Bool { if !initialized { - print("[AzureTtsHelper] TTS 引擎尚未初始化") - completion(false, "TTS 引擎尚未初始化") - return + print("\(tag): TTS 引擎尚未初始化") + return false } do { - print("[AzureTtsHelper] 开始合成 SSML") + print("\(tag): 开始合成文本: \(text)") + + // 生成 SSML + let ssml = generateSsml(text: text) // 标记为正在播放 speaking = true - // 激活音频会话 - try audioSession.setActive(true) - // 异步合成语音 - let result = try synthesizer!.speakSsml(ssml) - - switch result.reason { - case .synthesizingAudioCompleted: - print("[AzureTtsHelper] 语音合成完成") - speaking = false - completion(true, "语音合成完成") - case .canceled: - if let cancelDetails = try? SPXSpeechSynthesisCancellationDetails(fromResult: result) { - print("[AzureTtsHelper] 语音合成取消: \(cancelDetails.errorDetails ?? "未知错误")") - speaking = false - completion(false, "语音合成取消: \(cancelDetails.errorDetails ?? "未知错误")") - } else { - print("[AzureTtsHelper] 语音合成取消") - speaking = false - completion(false, "语音合成取消") + try synthesizer?.speakSsmlAsync(ssml) + + return true + } catch { + print("\(tag): 语音合成异常: \(error.localizedDescription)") + speaking = false + return false + } + } + + /// 处理流式文本并在遇到标点符号时播放 + /// - Parameter text: 收到的文本流片段 + /// - Returns: 是否成功处理 + func speakStream(_ text: String) -> Bool { + if !initialized || text.isEmpty { + return false + } + + do { + // 添加新文本到缓冲区 + streamBuffer.append(text) + + // 增加500ms防抖逻辑 + let currentTime = Date().timeIntervalSince1970 + if currentTime - lastSpeakTime < 0.5 { + return true + } + lastSpeakTime = currentTime + + let currentText = streamBuffer + + // 定义标点符号列表 + let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":"] + + // 查找最后一个标点符号的位置 + var lastPunctuationIndex = -1 + for i in currentText.indices.reversed() { + if punctuationMarks.contains(currentText[i]) { + lastPunctuationIndex = currentText.distance(from: currentText.startIndex, to: i) + break } - default: - print("[AzureTtsHelper] 语音合成失败: \(result.reason)") - speaking = false - completion(false, "语音合成失败: \(result.reason)") } + + // 如果找到标点符号,则播放到该标点符号 + if lastPunctuationIndex >= 0 { + // 提取要播放的文本(包含标点符号) + let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)) + + // 剩余的文本保存在缓冲区中 + streamBuffer = String(currentText.dropFirst(lastPunctuationIndex + 1)) + + // 播放提取的文本 + return speakText(textToSpeak) + } + + // 如果没有找到标点符号,则等待更多文本 + return true } catch { - print("[AzureTtsHelper] 语音合成异常: \(error.localizedDescription)") - speaking = false - completion(false, "语音合成异常: \(error.localizedDescription)") + print("\(tag): 流式语音合成失败: \(error.localizedDescription)") + return false + } + } + + /// 播放剩余的流式文本 + /// - Returns: 是否成功播放剩余文本 + func flushStream() -> Bool { + if !initialized { + return false + } + + do { + // 获取缓冲区中剩余的文本 + let remainingText = streamBuffer + + // 清空缓冲区 + streamBuffer = "" + + // 如果缓冲区为空,直接返回成功 + if remainingText.isEmpty { + return true + } + + // 播放剩余文本 + return speakText(remainingText) + } catch { + print("\(tag): 刷新流式文本失败: \(error.localizedDescription)") + return false } } /// 停止当前语音合成 - /// /// - Returns: 是否停止成功 func stopSpeaking() -> Bool { if !initialized { - print("[AzureTtsHelper] TTS 引擎尚未初始化") + print("\(tag): TTS 引擎尚未初始化") return false } do { - try synthesizer?.stopSpeaking() + // 清空流缓冲区 + streamBuffer = "" + + try synthesizer?.stopSpeakingAsync() speaking = false - print("[AzureTtsHelper] 已停止语音合成") + print("\(tag): 已停止语音合成") return true } catch { - print("[AzureTtsHelper] 停止语音合成失败: \(error.localizedDescription)") + print("\(tag): 停止语音合成失败: \(error.localizedDescription)") return false } } @@ -309,20 +415,30 @@ class AzureTtsHelper: NSObject { // 恢复音频会话 try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + // 关闭自定义音频输出流 + customAudioOutputStream = nil + + // 清空流缓冲区 + streamBuffer = "" + + // 清理资源 synthesizer = nil speechConfig = nil audioConfig = nil + ttsCallback = nil initialized = false speaking = false - print("[AzureTtsHelper] TTS 引擎已释放") + print("\(tag): TTS 引擎已释放") } catch { - print("[AzureTtsHelper] 释放 TTS 引擎失败: \(error.localizedDescription)") + print("\(tag): 释放 TTS 引擎失败: \(error.localizedDescription)") + // 确保重置状态 + initialized = false + speaking = false } } /// 检查当前是否正在播放语音 - /// /// - Returns: 是否正在播放语音 func isSpeaking() -> Bool { return speaking diff --git a/local_plugins/ble_service/android/build.gradle.kts b/local_plugins/ble_service/android/build.gradle.kts index e6a8a984f..b1b9b4095 100644 --- a/local_plugins/ble_service/android/build.gradle.kts +++ b/local_plugins/ble_service/android/build.gradle.kts @@ -28,13 +28,7 @@ android { dependencies { implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") - implementation("androidx.work:work-runtime-ktx:2.8.1") - implementation("androidx.media:media:1.6.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - implementation("androidx.security:security-crypto:1.1.0-alpha03") implementation(project(":deepvoice_utils")) - implementation(project(":agent_service")) implementation(project(":azure_speech")) implementation(project(":jl_opus")) } \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/AndroidManifest.xml b/local_plugins/ble_service/android/src/main/AndroidManifest.xml index 25fe5c93d..6dba89de3 100644 --- a/local_plugins/ble_service/android/src/main/AndroidManifest.xml +++ b/local_plugins/ble_service/android/src/main/AndroidManifest.xml @@ -18,39 +18,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index a6d81d279..5a1e020d5 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -175,7 +175,6 @@ object BleService { if (isScanning) return false FileLogger.i(TAG, "开始主动扫描BLE设备...") - unregisterBackgroundScan() // 避免双重扫描 scanResults.clear() val filter = ScanFilter.Builder() @@ -223,8 +222,6 @@ object BleService { scanHandler.removeCallbacksAndMessages(null) isScanning = false - // 重新注册后台扫描,保证唤醒能力 - registerBackgroundScan() return true } catch (e: Exception) { @@ -1017,74 +1014,7 @@ object BleService { return bluetoothAdapter } - // ====================================================================================================== - // 后台扫描与唤醒 - // ====================================================================================================== - /** - * 注册后台持久扫描,APP被杀死后也能唤醒 - */ - fun registerBackgroundScan() { - // 获取一个局部引用,避免并发访问问题 - val scanner = bluetoothLeScanner ?: return - try { - val filter = ScanFilter.Builder() - // .setServiceUuid(ParcelUuid(BleConst.PRIMARY_SERVICE_UUID)) - .setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01)) - .build() - - val settings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) - // .setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH) - .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - .build() - - // 创建Intent并设置前台接收器标志 - val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT) - .setClass(context, BleWakeReceiver::class.java) - .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - - - val pi = PendingIntent.getBroadcast( - context, 0, scanIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - ) - scanner.startScan(listOf(filter), settings, pi) - FileLogger.i(TAG, "后台持久扫描已注册") - } catch (e: Exception) { - FileLogger.e(TAG, "注册后台扫描失败: ${e.message}", e) - } - } - - /** - * 注销后台持久扫描 - */ - fun unregisterBackgroundScan() { - // 获取一个局部引用,避免并发访问问题 - val scanner = bluetoothLeScanner ?: return - try { - // 创建Intent并设置前台接收器标志 - val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT) - .setClass(context, BleWakeReceiver::class.java) - .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - - val pi = PendingIntent.getBroadcast( - context, 0, scanIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - ) - scanner.stopScan(pi) - FileLogger.i(TAG, "后台持久扫描已注销") - } catch (e: Exception) { - FileLogger.e(TAG, "注销后台扫描失败: ${e.message}", e) - } - } + // ====================================================================================================== // 资源清理 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index b88f0620a..055d791fb 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -160,48 +160,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { val success = BleService.playA2DP() result.success(success) } - "registerBackgroundScan" -> { - try { - BleService.registerBackgroundScan() - FileLogger.d(TAG, "后台扫描已注册") - result.success(true) - } catch (e: Exception) { - FileLogger.e(TAG, "注册后台扫描失败", e) - result.error("BLE_SERVICE_ERROR", "注册后台扫描失败: ${e.message}", null) - } - } - "unregisterBackgroundScan" -> { - try { - BleService.unregisterBackgroundScan() - FileLogger.d(TAG, "后台扫描已注销") - result.success(true) - } catch (e: Exception) { - FileLogger.e(TAG, "注销后台扫描失败", e) - result.error("BLE_SERVICE_ERROR", "注销后台扫描失败: ${e.message}", null) - } - } - "startBleService" -> { - try { - // 从Flutter获取配置参数 - val configParams = call.arguments as? Map ?: mapOf() - FileLogger.i(TAG, "启动BLE服务,配置参数: $configParams") - // 启动BLE服务并传递配置参数 - startBleServiceWithConfig(configParams) - result.success(true) - } catch (e: Exception) { - FileLogger.e(TAG, "启动BLE服务失败", e) - result.error("BLE_SERVICE_ERROR", "启动BLE服务失败: ${e.message}", null) - } - } - "stopBleService" -> { - try { - stopBleService() - result.success(true) - } catch (e: Exception) { - FileLogger.e(TAG, "停止BLE服务失败", e) - result.error("BLE_SERVICE_ERROR", "停止BLE服务失败: ${e.message}", null) - } - } + else -> { result.notImplemented() } @@ -254,48 +213,4 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { sendEvent(statusEventSink, resultMap, "发送设备信息异常") } - /** - * 使用配置参数启动BLE服务 - */ - private fun startBleServiceWithConfig(configParams: Map) { - try { - - // 启动BLE前台服务 - val serviceIntent = Intent(context, BleMediaService::class.java) - - // 如果有额外配置需要传递给服务,可以通过Intent传递 - configParams.forEach { (key, value) -> - when (value) { - is String -> serviceIntent.putExtra(key, value) - is Boolean -> serviceIntent.putExtra(key, value) - is Int -> serviceIntent.putExtra(key, value) - is Double -> serviceIntent.putExtra(key, value.toFloat()) - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) - } - - FileLogger.d(TAG, "成功启动BLE服务,配置参数: $configParams") - } catch (e: Exception) { - FileLogger.e(TAG, "BLE服务启动失败", e) - throw e - } - } - - /** - * 停止BLE服务 - */ - private fun stopBleService() { - try { - context.stopService(Intent(context, BleMediaService::class.java)) - FileLogger.d(TAG, "成功停止BLE服务") - } catch (e: Exception) { - FileLogger.e(TAG, "停止BLE服务失败", e) - throw e - } - } } \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Assets/ble_service-Info.plist b/local_plugins/ble_service/ios/Assets/ble_service-Info.plist new file mode 100644 index 000000000..9ea29bd4c --- /dev/null +++ b/local_plugins/ble_service/ios/Assets/ble_service-Info.plist @@ -0,0 +1,15 @@ + + + + + NSBluetoothAlwaysUsageDescription + 需要蓝牙权限与设备进行通信和传输音频数据 + NSBluetoothPeripheralUsageDescription + 需要蓝牙权限与设备进行通信和传输音频数据 + UIBackgroundModes + + bluetooth-central + audio + + + \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleConst.swift b/local_plugins/ble_service/ios/Classes/BleConst.swift new file mode 100644 index 000000000..3c551b120 --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/BleConst.swift @@ -0,0 +1,90 @@ +import Foundation +import CoreBluetooth + +/// BLE服务常量 +struct BleConst { + // ================== 协议相关UUID ================== + /// 主服务UUID - 0000ABC0-0000-1111-2222-123456789ABC + static let PRIMARY_SERVICE_UUID = CBUUID(string: "0000abc0-0000-1111-2222-123456789abc") + + /// 音频服务UUID - 00001801-0000-1000-8000-00805f9b34fb + static let AUDIO_SERVICE_UUID = CBUUID(string: "0000ae00-0000-1000-8000-00805f9b34fb") + + /// 接收音频特征UUID - 0000ABC1-0000-1111-2222-123456789ABC + static let RECEIVE_AUDIO_CHAR_UUID = CBUUID(string: "0000ae02-0000-1000-8000-00805f9b34fb") + + /// 写入特征UUID - 0000ABC1-0000-1111-2222-123456789ABC + static let WRITE_CHAR_UUID = CBUUID(string: "0000abc1-0000-1111-2222-123456789abc") + + /// 通知特征UUID - 0000ABC2-0000-1111-2222-123456789ABC + static let NOTIFY_CHAR_UUID = CBUUID(string: "0000abc2-0000-1111-2222-123456789abc") + + /// 客户端特征配置描述符UUID + static let CLIENT_CHAR_CONFIG_UUID = CBUUID(string: "0000fd7b-0000-1000-8000-00805f9b34fb") + + /// BLE扫描Action + static let ACTION_SCAN_RESULT = "com.yunqiinnovation.ble_service.ACTION_BLE_SCAN" + + // ================== 连接状态 ================== + static let STATE_DISCONNECTED = 0 + static let STATE_CONNECTING = 1 + static let STATE_CONNECTED = 2 + static let STATE_DISCONNECTING = 3 + static let STATE_ERROR = 4 + + // ================== 广播厂商信息 ================== + /// 厂商ID - 0xABEF + static let MANUFACTURER_ID: UInt16 = 0xABEF + /// 客户ID - 0xA001 + static let CUSTOMER_ID: UInt16 = 0xA001 + /// 产品ID - 0xA001 + static let PRODUCT_ID: UInt16 = 0xA001 + /// 蓝牙状态-未连接 + static let BT_STATE_DISCONNECTED: UInt8 = 0x00 + /// 蓝牙状态-已连接 + static let BT_STATE_CONNECTED: UInt8 = 0x0C + + // ================== 协议格式 ================== + /// APP请求帧头 + static let APP_REQUEST_HEADER: UInt8 = 0xAA + /// 设备响应帧头 + static let DEVICE_RESPONSE_HEADER: UInt8 = 0xBB + /// 设备主动上报帧头 + static let DEVICE_NOTIFY_HEADER: UInt8 = 0xCC + + // ================== 协议指令 (CMD) ================== + /// 获取耳机版本号 + static let CMD_GET_VERSION: UInt8 = 0x01 + /// 获取耳机产品ID + static let CMD_GET_PRODUCT_ID: UInt8 = 0x02 + /// 获取耳机颜色ID + static let CMD_GET_COLOR_ID: UInt8 = 0x03 + /// 获取耳机电量ID + static let CMD_GET_BATTERY_INFO: UInt8 = 0x04 + /// APP控制编解码指令 + static let CMD_CONTROL_CODEC: UInt8 = 0x05 + /// 音量增大 + static let CMD_VOLUME_UP: UInt8 = 0x06 + /// 音量减小 + static let CMD_VOLUME_DOWN: UInt8 = 0x07 + /// A2DP播放 + static let CMD_PLAY_A2DP: UInt8 = 0x08 + /// 唤醒指令 + static let CMD_WAKE_UP: UInt8 = 0x11 + + // ================== 编解码控制值 ================== + /// 关闭编解码指令 + static let CODEC_CONTROL_CLOSE: UInt8 = 0x00 + /// 打开解码指令 + static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1 + /// 通过A2DP播放 + static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2 + /// 打开编码指令 + static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1 + /// 左声道 + static let AUDIO_CHANNEL_LEFT: UInt8 = 0x01 + /// 右声道 + static let AUDIO_CHANNEL_RIGHT: UInt8 = 0x02 + /// 立体声 + static let AUDIO_CHANNEL_STEREO: UInt8 = 0x03 +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleDelegate.swift b/local_plugins/ble_service/ios/Classes/BleDelegate.swift new file mode 100644 index 000000000..af104cb20 --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/BleDelegate.swift @@ -0,0 +1,25 @@ +import Foundation + +/// BLE服务回调代理协议 +protocol BleDelegate: AnyObject { + /// 扫描结果回调 + /// - Parameter devices: 设备列表,每个设备包含id、name、rssi和uuids等信息 + func onScanResult(devices: [[String: Any]]) + + /// 连接状态改变回调 + /// - Parameter state: 连接状态,参考BleConst中的STATE_*常量 + func onConnectionStateChanged(state: Int) + + /// 音频数据接收回调 + /// - Parameter data: 接收到的音频数据 + func onAudioDataReceived(data: Data) + + /// 唤醒信号接收回调 + func onWakeupSignalReceived() + + /// 设备信息接收回调 + /// - Parameters: + /// - infoType: 信息类型 + /// - infoData: 信息数据 + func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleService.swift b/local_plugins/ble_service/ios/Classes/BleService.swift new file mode 100644 index 000000000..b4c2ae456 --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/BleService.swift @@ -0,0 +1,963 @@ +import Foundation +import CoreBluetooth +import os.log + +/// BLE服务类 - 单例实现 +actor BleService { + // MARK: - 单例实现 + static let shared = BleService() + + // MARK: - 常量 + + /// 用于状态恢复的唯一标识符 + private let restoreIdentifier = "com.yunqiinnovation.ble_service.restore" + + private init() { + // 初始化连接状态 + connectionState = BleConst.STATE_DISCONNECTED + + // 将通知处理移到非actor方法中 + setupNotifications() + } + + deinit { + // 移除通知观察者 + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - 属性 + + /// 中央管理器 + private var centralManager: CBCentralManager? + + /// 当前连接的外设 + private var peripheral: CBPeripheral? + + /// 主服务特征 + private var writeCharacteristic: CBCharacteristic? + private var notifyCharacteristic: CBCharacteristic? + private var audioCharacteristic: CBCharacteristic? + + /// 连接状态 + private(set) var connectionState: Int { + didSet { + if oldValue != connectionState { + Task { await notifyConnectionStateChanged(state: connectionState) } + } + } + } + + /// 是否正在扫描 + private(set) var isScanning = false + + /// 扫描结果缓存 + private var scanResults = [CBPeripheral: [String: Any]]() + + /// 扫描超时任务 + private var scanTimeoutTask: Task? + + /// 是否已初始化 + private var isInitialized = false + + /// 回调代理列表 - 使用actor保证线程安全 + private var delegates = [BleDelegate]() + + /// 日志 + private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService") + + /// 通知观察者 + private var notificationObservers = [NSObjectProtocol]() + + // MARK: - 通知设置 + + /// 设置应用生命周期通知 + private nonisolated func setupNotifications() { + // 使用nonisolated函数以避免actor隔离问题 + let becomeActiveObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { + await self.handleAppDidBecomeActive() + } + } + + let enterBackgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { + await self.handleAppDidEnterBackground() + } + } + + // 存储观察者以便后续移除 + notificationObservers = [becomeActiveObserver, enterBackgroundObserver] + } + + // MARK: - 公共方法 + + /// 初始化BLE服务 + /// - Returns: 是否初始化成功 + func initialize() -> Bool { + if isInitialized { + return true + } + + do { + // 初始化中央管理器 - 配置状态恢复选项 + let options: [String: Any] = [ + CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier, + CBCentralManagerOptionShowPowerAlertKey: true + ] + + centralManager = CBCentralManager( + delegate: CBManagerHandler.shared, + queue: nil, + options: options + ) + + CBManagerHandler.shared.bleService = self + + isInitialized = true + os_log("BLE服务初始化成功", log: logger, type: .info) + return true + } catch { + os_log("BLE服务初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) + return false + } + } + + /// 添加回调代理 + /// - Parameter delegate: 要添加的代理 + /// - Returns: 是否添加成功 + func addDelegate(_ delegate: BleDelegate) -> Bool { + if !delegates.contains(where: { $0 === delegate }) { + delegates.append(delegate) + os_log("已添加BLE回调, 当前回调数: %d", log: logger, type: .debug, delegates.count) + return true + } + return false + } + + /// 移除回调代理 + /// - Parameter delegate: 要移除的代理 + /// - Returns: 是否移除成功 + func removeDelegate(_ delegate: BleDelegate) -> Bool { + if let index = delegates.firstIndex(where: { $0 === delegate }) { + delegates.remove(at: index) + os_log("已移除BLE回调, 当前回调数: %d", log: logger, type: .debug, delegates.count) + return true + } + return false + } + + /// 清除所有回调代理 + func clearDelegates() { + delegates.removeAll() + os_log("已清除所有BLE回调", log: logger, type: .debug) + } + + /// 检查是否已连接 + /// - Returns: 是否已连接 + func isConnected() -> Bool { + return connectionState == BleConst.STATE_CONNECTED + } + + // MARK: - 扫描方法 + + /// 开始主动扫描BLE设备 + /// - Parameter timeout: 扫描超时时间(秒) + /// - Returns: 是否成功开始扫描 + func startScan(timeout: TimeInterval = 15.0) -> Bool { + if !isInitialized { + os_log("BLE服务未初始化", log: logger, type: .error) + return false + } + + if isScanning { + return false + } + + guard let manager = centralManager, manager.state == .poweredOn else { + os_log("蓝牙未开启", log: logger, type: .error) + return false + } + + os_log("开始主动扫描BLE设备...", log: logger, type: .info) + unregisterBackgroundScan() // 避免双重扫描 + scanResults.removeAll() + + // 扫描选项 - 允许重复结果,以获取最新的RSSI值 + let options: [String: Any] = [ + CBCentralManagerScanOptionAllowDuplicatesKey: true + ] + + // 设置过滤服务UUID + let scanFilters = [ + // 如果需要过滤,添加CBUUID + // CBUUID(string: BleConst.PRIMARY_SERVICE_UUID.uuidString) + ] + + // 使用过滤器开始扫描 + manager.scanForPeripherals( + withServices: scanFilters.isEmpty ? nil : scanFilters, + options: options + ) + + isScanning = true + + // 设置扫描超时 + scanTimeoutTask?.cancel() + scanTimeoutTask = Task { [weak self] in + guard let self = self else { return } + + do { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if !Task.isCancelled { + await self.stopScan() + } + } catch { + // 任务被取消,忽略错误 + } + } + + return true + } + + /// 停止主动扫描 + /// - Returns: 是否成功停止扫描 + func stopScan() -> Bool { + if !isScanning { + return false + } + + os_log("停止主动扫描", log: logger, type: .info) + + centralManager?.stopScan() + scanTimeoutTask?.cancel() + scanTimeoutTask = nil + isScanning = false + + // 重新注册后台扫描,保证唤醒能力 + registerBackgroundScan() + + return true + } + + /// 注册后台扫描 + /// - Returns: 是否成功注册 + func registerBackgroundScan() -> Bool { + // iOS中无法直接实现与Android相同的后台扫描唤醒功能 + os_log("iOS不支持直接的后台扫描唤醒,将使用状态保存和恢复功能", log: logger, type: .info) + return true + } + + /// 注销后台扫描 + /// - Returns: 是否成功注销 + func unregisterBackgroundScan() -> Bool { + // iOS没有直接的后台扫描注销机制 + os_log("iOS不支持直接的后台扫描注销", log: logger, type: .info) + return true + } + + // MARK: - 连接方法 + + /// 连接到指定地址的BLE设备 + /// - Parameter address: 设备UUID字符串 + func connect(address: String) async { + if connectionState == BleConst.STATE_CONNECTED { + os_log("已经连接中,忽略连接请求", log: logger, type: .warning) + return + } + + guard let central = centralManager, central.state == .poweredOn else { + updateConnectionState(BleConst.STATE_ERROR) + return + } + + // 关闭现有连接 + disconnectGatt() + + // 查找对应的外设 + if let uuid = UUID(uuidString: address), + let targetPeripheral = scanResults.keys.first(where: { $0.identifier == uuid }) { + // 如果在扫描结果中找到,直接连接 + os_log("从扫描结果中找到目标设备,开始连接...", log: logger, type: .debug) + await connectToPeripheral(targetPeripheral) + } else { + // 如果没有找到,尝试从已知设备中恢复 + os_log("尝试使用UUID恢复设备连接: %{public}@", log: logger, type: .debug, address) + if let uuid = UUID(uuidString: address) { + let peripherals = central.retrievePeripherals(withIdentifiers: [uuid]) + if let targetPeripheral = peripherals.first { + await connectToPeripheral(targetPeripheral) + } else { + os_log("未找到指定UUID的设备: %{public}@", log: logger, type: .error, address) + updateConnectionState(BleConst.STATE_ERROR) + } + } else { + os_log("无效的UUID格式: %{public}@", log: logger, type: .error, address) + updateConnectionState(BleConst.STATE_ERROR) + } + } + } + + /// 连接到指定外设 + /// - Parameter peripheral: 要连接的外设 + private func connectToPeripheral(_ peripheral: CBPeripheral) async { + self.peripheral = peripheral + peripheral.delegate = CBPeripheralHandler.shared + CBPeripheralHandler.shared.bleService = self + updateConnectionState(BleConst.STATE_CONNECTING) + centralManager?.connect(peripheral, options: nil) + } + + /// 断开当前连接 + func disconnect() async { + guard let peripheral = peripheral, let central = centralManager else { + return + } + + updateConnectionState(BleConst.STATE_DISCONNECTING) + central.cancelPeripheralConnection(peripheral) + } + + /// 安全关闭GATT连接 + private func disconnectGatt() { + if let peripheral = peripheral, let central = centralManager { + central.cancelPeripheralConnection(peripheral) + } + + peripheral = nil + writeCharacteristic = nil + notifyCharacteristic = nil + audioCharacteristic = nil + } + + /// 更新连接状态并通知回调 + private func updateConnectionState(_ state: Int) { + connectionState = state + } + + // MARK: - 设备通信方法 + + /// 获取设备版本号 + /// - Returns: 是否成功发送请求 + func getDeviceVersion() -> Bool { + return sendCommand(BleConst.CMD_GET_VERSION) + } + + /// 获取电池信息 + /// - Returns: 是否成功发送请求 + func getBatteryInfo() -> Bool { + return sendCommand(BleConst.CMD_GET_BATTERY_INFO) + } + + /// 打开编码器 + /// - Returns: 是否成功发送请求 + func openEncoder() -> Bool { + return sendCodecControl(BleConst.CODEC_CONTROL_ENCODE_ON) + } + + /// 打开解码器 + /// - Returns: 是否成功发送请求 + func openDecoder() -> Bool { + return sendCodecControl(BleConst.CODEC_CONTROL_DECODE_ON) + } + + /// 关闭编解码器 + /// - Returns: 是否成功发送请求 + func closeCodec() -> Bool { + return sendCodecControl(BleConst.CODEC_CONTROL_CLOSE) + } + + /// 播放A2DP + /// - Returns: 是否成功发送请求 + func playA2DP() -> Bool { + return sendCodecControl(BleConst.CODEC_CONTROL_A2DP_PLAY) + } + + /// 发送编解码控制指令 + /// - Parameter control: 控制值 + /// - Returns: 是否成功发送 + private func sendCodecControl(_ control: UInt8) -> Bool { + let data = Data([BleConst.APP_REQUEST_HEADER, BleConst.CMD_CONTROL_CODEC, control]) + return writeData(data) + } + + /// 发送命令 + /// - Parameter command: 命令字 + /// - Returns: 是否成功发送 + private func sendCommand(_ command: UInt8) -> Bool { + let data = Data([BleConst.APP_REQUEST_HEADER, command]) + return writeData(data) + } + + /// 写入数据 + /// - Parameter data: 要写入的数据 + /// - Returns: 是否成功写入 + private func writeData(_ data: Data) -> Bool { + guard let peripheral = peripheral, + let characteristic = writeCharacteristic, + connectionState == BleConst.STATE_CONNECTED else { + os_log("无法写入数据,设备未连接或特征未找到", log: logger, type: .error) + return false + } + + let hexString = data.map { String(format: "0x%02X", $0) }.joined(separator: " ") + os_log("写入数据: %{public}@", log: logger, type: .debug, hexString) + + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + return true + } + + // MARK: - 数据处理方法 + + /// 处理音频数据 + /// - Parameter data: 音频数据 + func processAudioData(_ data: Data) async { + os_log("收到音频数据: %d字节", log: logger, type: .debug, data.count) + await notifyAudioDataReceived(data: data) + } + + /// 处理设备响应 + /// - Parameter data: 设备响应数据 + func processDeviceResponse(_ data: Data) async { + guard data.count >= 2 else { return } + + let cmd = data[1] + os_log("收到设备响应: CMD=0x%02X", log: logger, type: .debug, cmd) + + switch cmd { + case BleConst.CMD_GET_VERSION: + if data.count >= 3 { + // 解析版本号 + let version = String(format: "%d.%d.%d", data[2], data.count > 3 ? data[3] : 0, data.count > 4 ? data[4] : 0) + let infoData: [String: Any] = ["version": version] + await notifyDeviceInfoReceived(infoType: Int(cmd), infoData: infoData) + } + case BleConst.CMD_GET_BATTERY_INFO: + if data.count >= 3 { + // 解析电池电量 + let batteryLevel = Int(data[2]) + let infoData: [String: Any] = ["level": batteryLevel] + await notifyDeviceInfoReceived(infoType: Int(cmd), infoData: infoData) + } + default: + os_log("未处理的响应命令: 0x%02X", log: logger, type: .debug, cmd) + } + } + + /// 处理设备通知 + /// - Parameter data: 设备通知数据 + func processDeviceNotification(_ data: Data) async { + guard data.count >= 2 else { return } + + let notifyType = data[1] + os_log("收到设备通知: Type=0x%02X", log: logger, type: .debug, notifyType) + + switch notifyType { + case BleConst.CMD_WAKE_UP: + // 设备唤醒信号 + await notifyWakeupSignalReceived() + default: + os_log("未处理的通知类型: 0x%02X", log: logger, type: .debug, notifyType) + } + } + + // MARK: - 应用状态处理 + + /// 处理应用进入活跃状态 + private func handleAppDidBecomeActive() async { + os_log("应用进入活跃状态", log: logger, type: .debug) + // 重新初始化蓝牙管理器,如果需要 + if centralManager == nil { + _ = initialize() + } + } + + /// 处理应用进入后台 + private func handleAppDidEnterBackground() async { + os_log("应用进入后台", log: logger, type: .debug) + // 在iOS中,后台蓝牙操作有一定限制,这里可以做一些清理工作 + } + + // MARK: - 回调通知方法 + + /// 通知扫描结果 + /// - Parameter devices: 设备列表 + func notifyScanResult(devices: [[String: Any]]) async { + let currentDelegates = delegates + + for delegate in currentDelegates { + await MainActor.run { + delegate.onScanResult(devices: devices) + } + } + } + + /// 通知连接状态变化 + /// - Parameter state: 连接状态 + func notifyConnectionStateChanged(state: Int) async { + let currentDelegates = delegates + + for delegate in currentDelegates { + await MainActor.run { + delegate.onConnectionStateChanged(state: state) + } + } + } + + /// 通知音频数据接收 + /// - Parameter data: 音频数据 + func notifyAudioDataReceived(data: Data) async { + let currentDelegates = delegates + + for delegate in currentDelegates { + await MainActor.run { + delegate.onAudioDataReceived(data: data) + } + } + } + + /// 通知唤醒信号接收 + func notifyWakeupSignalReceived() async { + let currentDelegates = delegates + + for delegate in currentDelegates { + await MainActor.run { + delegate.onWakeupSignalReceived() + } + } + } + + /// 通知设备信息接收 + /// - Parameters: + /// - infoType: 信息类型 + /// - infoData: 信息数据 + func notifyDeviceInfoReceived(infoType: Int, infoData: [String: Any]) async { + let currentDelegates = delegates + + for delegate in currentDelegates { + await MainActor.run { + delegate.onDeviceInfoReceived(infoType: infoType, infoData: infoData) + } + } + } + + /// 处理扫描到的设备 + func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) async { + // 打印外设信息 + os_log("扫描到设备: 名称=%{public}@, ID=%{public}@, RSSI=%d", log: logger, type: .debug, + peripheral.name ?? "未知", peripheral.identifier.uuidString, rssi.intValue) + + // 提取服务UUID + var uuids: [String] = [] + if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { + uuids = serviceUUIDs.map { $0.uuidString } + } + + // 创建设备信息字典 + let deviceInfo: [String: Any] = [ + "id": peripheral.identifier.uuidString, + "name": peripheral.name ?? "未知", + "rssi": rssi.intValue, + "uuids": uuids + ] + + // 更新扫描缓存 + scanResults[peripheral] = deviceInfo + + // 通知扫描结果 + await notifyScanResult(devices: [deviceInfo]) + } + + /// 处理连接结果 + func handleConnectionResult(peripheral: CBPeripheral, success: Bool, error: Error?) async { + if success { + os_log("已连接到设备: %{public}@", log: logger, type: .info, peripheral.identifier.uuidString) + updateConnectionState(BleConst.STATE_CONNECTED) + + // 开始服务发现 + peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID]) + } else { + os_log("连接设备失败: %{public}@", log: logger, type: .error, error?.localizedDescription ?? "未知错误") + updateConnectionState(BleConst.STATE_ERROR) + + // 清理资源 + self.peripheral = nil + } + } + + /// 处理断开连接 + func handleDisconnection(peripheral: CBPeripheral, error: Error?) async { + if let error = error { + os_log("设备连接断开(错误): %{public}@", log: logger, type: .error, error.localizedDescription) + updateConnectionState(BleConst.STATE_ERROR) + } else { + os_log("设备连接断开(正常)", log: logger, type: .info) + updateConnectionState(BleConst.STATE_DISCONNECTED) + } + + // 清理资源 + if self.peripheral?.identifier == peripheral.identifier { + self.peripheral = nil + self.writeCharacteristic = nil + self.notifyCharacteristic = nil + self.audioCharacteristic = nil + } + } + + /// 处理发现服务 + func handleDiscoveredServices(peripheral: CBPeripheral, error: Error?) async { + if let error = error { + os_log("发现服务失败: %{public}@", log: logger, type: .error, error.localizedDescription) + updateConnectionState(BleConst.STATE_ERROR) + return + } + + guard let services = peripheral.services else { + os_log("没有发现任何服务", log: logger, type: .error) + updateConnectionState(BleConst.STATE_ERROR) + return + } + + // 发现所有服务的特征 + for service in services { + os_log("发现服务: %{public}@", log: logger, type: .debug, service.uuid.uuidString) + peripheral.discoverCharacteristics(nil, for: service) + } + } + + /// 处理发现的特征 + func handleDiscoveredCharacteristics(for service: CBService, peripheral: CBPeripheral, error: Error?) async { + if let error = error { + os_log("发现特征失败: %{public}@", log: logger, type: .error, error.localizedDescription) + return + } + + guard let characteristics = service.characteristics else { + os_log("服务没有任何特征: %{public}@", log: logger, type: .warning, service.uuid.uuidString) + return + } + + // 根据UUID识别和保存特征 + for characteristic in characteristics { + os_log("发现特征: %{public}@", log: logger, type: .debug, characteristic.uuid.uuidString) + + // 主服务特征 + if service.uuid == BleConst.PRIMARY_SERVICE_UUID { + if characteristic.uuid == BleConst.WRITE_CHAR_UUID { + writeCharacteristic = characteristic + os_log("找到写入特征", log: logger, type: .debug) + } else if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID { + notifyCharacteristic = characteristic + os_log("找到通知特征", log: logger, type: .debug) + // 设置通知 + await setupNotification(for: characteristic, peripheral: peripheral) + } + } + + // 音频服务特征 + if service.uuid == BleConst.AUDIO_SERVICE_UUID { + if characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID { + audioCharacteristic = characteristic + os_log("找到音频特征", log: logger, type: .debug) + // 设置通知 + await setupNotification(for: characteristic, peripheral: peripheral) + } + } + } + + // 检查是否找到所有必要的特征 + if writeCharacteristic == nil || notifyCharacteristic == nil { + os_log("未找到所有必要的特征", log: logger, type: .error) + updateConnectionState(BleConst.STATE_ERROR) + } + } + + /// 设置特征的通知 + /// - Parameters: + /// - characteristic: 特征 + /// - peripheral: 外设 + private func setupNotification(for characteristic: CBCharacteristic, peripheral: CBPeripheral) async { + if characteristic.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: characteristic) + } + } + + /// 处理特征值更新 + func handleValueUpdate(for characteristic: CBCharacteristic, peripheral: CBPeripheral, error: Error?) async { + if let error = error { + os_log("特征值更新失败: %{public}@", log: logger, type: .error, error.localizedDescription) + return + } + + guard let data = characteristic.value else { + os_log("特征没有值", log: logger, type: .warning) + return + } + + let hexString = data.map { String(format: "%02X", $0) }.joined(separator: " ") + os_log("收到特征数据: %{public}@ - %{public}@", log: logger, type: .debug, characteristic.uuid.uuidString, hexString) + + // 根据特征UUID处理不同类型的数据 + if characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID { + // 音频数据处理 + await processAudioData(data) + } else if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID { + // 通知特征处理 + if !data.isEmpty { + switch data[0] { + case BleConst.DEVICE_RESPONSE_HEADER: + // 设备响应 + await processDeviceResponse(data) + case BleConst.DEVICE_NOTIFY_HEADER: + // 设备通知 + await processDeviceNotification(data) + default: + os_log("未知数据类型: %d", log: logger, type: .debug, data[0]) + } + } + } + } + + // MARK: - 状态恢复处理 + + /// 处理蓝牙状态恢复 + func handleStateRestoration(dict: [String: Any]) async { + os_log("正在恢复蓝牙状态...", log: logger, type: .info) + + // 1. 恢复之前连接的外设 + if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { + os_log("恢复 %d 个已连接的外设", log: logger, type: .debug, peripherals.count) + + for peripheral in peripherals { + self.peripheral = peripheral + peripheral.delegate = CBPeripheralHandler.shared + CBPeripheralHandler.shared.bleService = self + + // 根据外设当前状态采取不同操作 + if peripheral.state == .connected { + os_log("外设 %{public}@ 已连接", log: logger, type: .debug, peripheral.identifier.uuidString) + updateConnectionState(BleConst.STATE_CONNECTED) + + // 重新发现服务 + peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID]) + } else if peripheral.state == .connecting { + os_log("外设 %{public}@ 正在连接中", log: logger, type: .debug, peripheral.identifier.uuidString) + updateConnectionState(BleConst.STATE_CONNECTING) + } else { + os_log("外设 %{public}@ 状态异常: %d", log: logger, type: .debug, peripheral.identifier.uuidString, peripheral.state.rawValue) + // 尝试重新连接 + updateConnectionState(BleConst.STATE_CONNECTING) + centralManager?.connect(peripheral, options: nil) + } + } + } + + // 2. 恢复扫描状态 + if let scanServices = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] { + os_log("恢复扫描任务,服务UUID数: %d", log: logger, type: .debug, scanServices.count) + + let scanOptions = dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any] ?? [ + CBCentralManagerScanOptionAllowDuplicatesKey: true + ] + + // 重新启动扫描任务 + centralManager?.scanForPeripherals( + withServices: scanServices.isEmpty ? nil : scanServices, + options: scanOptions + ) + + isScanning = true + + // 设置新的扫描超时(默认15秒) + scanTimeoutTask?.cancel() + scanTimeoutTask = Task { [weak self] in + guard let self = self else { return } + + do { + try await Task.sleep(nanoseconds: UInt64(15.0 * 1_000_000_000)) + if !Task.isCancelled { + await self.stopScan() + } + } catch { + // 任务被取消,忽略错误 + } + } + } + + // 通知外部:BLE状态已恢复 + await notifyStateRestored() + } + + /// 通知状态恢复 + private func notifyStateRestored() async { + let currentDelegates = delegates + + for delegate in currentDelegates { + if let stateDelegate = delegate as? BleStateRestorationDelegate { + await MainActor.run { + stateDelegate.onBleStateRestored() + } + } + } + } +} + +// MARK: - 中央管理器委托处理器 + +class CBManagerHandler: NSObject, CBCentralManagerDelegate { + static let shared = CBManagerHandler() + weak var bleService: BleService? + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + Task { + guard let service = bleService else { return } + + switch central.state { + case .poweredOn: + await MainActor.run { + os_log("蓝牙已开启", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .info) + } + case .poweredOff: + await MainActor.run { + os_log("蓝牙已关闭", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .warning) + } + await service.updateConnectionState(BleConst.STATE_DISCONNECTED) + case .unauthorized, .unsupported: + await MainActor.run { + os_log("蓝牙不可用: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .error, central.state.rawValue) + } + await service.updateConnectionState(BleConst.STATE_ERROR) + default: + await MainActor.run { + os_log("蓝牙状态变化: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, central.state.rawValue) + } + } + } + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + Task { + await bleService?.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: RSSI) + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + Task { + await bleService?.handleConnectionResult(peripheral: peripheral, success: true, error: nil) + } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + Task { + await bleService?.handleConnectionResult(peripheral: peripheral, success: false, error: error) + } + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + Task { + await bleService?.handleDisconnection(peripheral: peripheral, error: error) + } + } + + /// 状态恢复处理 + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { + os_log("蓝牙状态恢复被触发", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .info) + + // 打印恢复信息 + if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { + os_log("恢复外设数量: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, peripherals.count) + for (i, peripheral) in peripherals.enumerated() { + os_log("恢复外设[%d]: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, i, peripheral.identifier.uuidString) + } + } + + if let services = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] { + os_log("恢复扫描服务数量: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, services.count) + } + + // 异步处理状态恢复 + Task { + if let service = bleService { + await service.handleStateRestoration(dict: dict) + } + } + } +} + +// MARK: - 外设委托处理器 + +class CBPeripheralHandler: NSObject, CBPeripheralDelegate { + static let shared = CBPeripheralHandler() + weak var bleService: BleService? + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + Task { + await bleService?.handleDiscoveredServices(peripheral: peripheral, error: error) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + Task { + await bleService?.handleDiscoveredCharacteristics(for: service, peripheral: peripheral, error: error) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + Task { + await bleService?.handleValueUpdate(for: characteristic, peripheral: peripheral, error: error) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + os_log("写入特征值失败: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .error, error.localizedDescription) + } else { + os_log("写入特征值成功", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + os_log("设置通知状态失败: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .error, error.localizedDescription) + } else { + os_log("设置通知状态成功: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, characteristic.uuid.uuidString) + } + } +} + +// MARK: - BLE状态恢复代理 +/// 蓝牙状态恢复代理 +protocol BleStateRestorationDelegate: AnyObject { + /// 当蓝牙状态被系统恢复时调用 + func onBleStateRestored() +} + +// MARK: - BLE代理 +/// 蓝牙事件代理 +protocol BleDelegate: AnyObject { + /// 扫描结果回调 + func onScanResult(devices: [[String: Any]]) + + /// 连接状态变化回调 + func onConnectionStateChanged(state: Int) + + /// 音频数据接收回调 + func onAudioDataReceived(data: Data) + + /// 唤醒信号接收回调 + func onWakeupSignalReceived() + + /// 设备信息接收回调 + func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleServicePlugin.h b/local_plugins/ble_service/ios/Classes/BleServicePlugin.h new file mode 100644 index 000000000..e28dfaa93 --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/BleServicePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface BleServicePlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleServicePlugin.m b/local_plugins/ble_service/ios/Classes/BleServicePlugin.m new file mode 100644 index 000000000..f0a50cd8d --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/BleServicePlugin.m @@ -0,0 +1,12 @@ +#import "BleServicePlugin.h" +#if __has_include() +#import +#else +#import "ble_service-Swift.h" +#endif + +@implementation BleServicePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftBleServicePlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift new file mode 100644 index 000000000..140c0ca7a --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift @@ -0,0 +1,203 @@ +import Flutter +import UIKit + +public class SwiftBleServicePlugin: NSObject, FlutterPlugin, BleDelegate { + private let TAG = "BleServicePlugin" + + // 方法通道 + private var methodChannel: FlutterMethodChannel? + + // 事件通道 + private var dataEventSink: FlutterEventSink? + private var statusEventSink: FlutterEventSink? + + // BLE服务实例 + private let bleService = BleService.shared + + public static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel = FlutterMethodChannel(name: "com.yunqiinnovation.ble_service", binaryMessenger: registrar.messenger()) + + // 创建插件实例 + let instance = SwiftBleServicePlugin() + instance.methodChannel = methodChannel + + // 设置方法通道处理器 + registrar.addMethodCallDelegate(instance, channel: methodChannel) + + // 设置事件通道 + instance.setupEventChannels(with: registrar) + + // 初始化BLE服务 + _ = instance.bleService.initialize() + + // 添加自身作为BLE回调 + _ = instance.bleService.addDelegate(instance) + } + + /// 设置事件通道 + private func setupEventChannels(with registrar: FlutterPluginRegistrar) { + // 数据接收事件通道 + let dataEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/data_events", binaryMessenger: registrar.messenger()) + dataEventChannel.setStreamHandler(StreamHandler { [weak self] sink in + self?.dataEventSink = sink + } onCancel: { [weak self] in + self?.dataEventSink = nil + }) + + // 状态更新事件通道 + let statusEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/status_events", binaryMessenger: registrar.messenger()) + statusEventChannel.setStreamHandler(StreamHandler { [weak self] sink in + self?.statusEventSink = sink + } onCancel: { [weak self] in + self?.statusEventSink = nil + }) + } + + /// 发送事件到Flutter + private func sendEvent(_ eventSink: FlutterEventSink?, _ data: Any) { + DispatchQueue.main.async { + eventSink?(data) + } + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + NSLog("Method call: \(call.method)") + + switch call.method { + case "isConnected": + result(bleService.isConnected()) + + case "getBatteryInfo": + result(bleService.getBatteryInfo()) + + case "getDeviceVersion": + result(bleService.getDeviceVersion()) + + case "openEncoder": + result(bleService.openEncoder()) + + case "openDecoder": + result(bleService.openDecoder()) + + case "closeCodec": + result(bleService.closeCodec()) + + case "playA2DP": + result(bleService.playA2DP()) + + case "registerBackgroundScan": + result(bleService.registerBackgroundScan()) + + case "unregisterBackgroundScan": + result(bleService.unregisterBackgroundScan()) + + case "startBleService": + guard let config = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "配置参数无效", details: nil)) + return + } + + // 处理配置参数 + if let address = config["mac"] as? String { + // 如果提供了设备地址,尝试连接 + bleService.connect(address: address) + } + + // 开始扫描,寻找设备 + let scanSuccess = bleService.startScan() + result(scanSuccess) + + case "stopBleService": + // 停止扫描和断开连接 + _ = bleService.stopScan() + if bleService.isConnected() { + bleService.disconnect() + } + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - BleDelegate Methods + + func onScanResult(devices: [[String: Any]]) { + // 目前Flutter层没有直接使用此事件,可以选择不转发 + } + + func onConnectionStateChanged(state: Int) { + // 将连接状态变化通过状态通道发送 + let stateStr: String + switch state { + case BleConst.STATE_DISCONNECTED: + stateStr = "Disconnected" + case BleConst.STATE_CONNECTING: + stateStr = "Connecting" + case BleConst.STATE_CONNECTED: + stateStr = "Connected" + case BleConst.STATE_DISCONNECTING: + stateStr = "Disconnecting" + case BleConst.STATE_ERROR: + stateStr = "Connection Error" + default: + stateStr = "Unknown State(\(state))" + } + + let stateMap: [String: Any] = [ + "type": "connectionStateChanged", + "state": state, + "stateStr": stateStr + ] + + sendEvent(statusEventSink, stateMap) + } + + func onAudioDataReceived(data: Data) { + // 将音频数据通过数据通道发送 + let dataMap: [String: Any] = [ + "type": "audioData", + "data": FlutterStandardTypedData(bytes: data) + ] + + sendEvent(dataEventSink, dataMap) + } + + func onWakeupSignalReceived() { + // 将唤醒事件通过状态通道发送 + sendEvent(statusEventSink, ["type": "wakeup"]) + } + + func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) { + // 将设备信息通过状态通道发送 + var resultMap = infoData + resultMap["type"] = "deviceInfo" + resultMap["result"] = infoType + + sendEvent(statusEventSink, resultMap) + } +} + +// MARK: - 事件处理器类 + +/// 简化的事件处理器类 +class StreamHandler: NSObject, FlutterStreamHandler { + private let onListen: (FlutterEventSink) -> Void + private let onCancel: () -> Void + + init(onListen: @escaping (FlutterEventSink) -> Void, onCancel: @escaping () -> Void) { + self.onListen = onListen + self.onCancel = onCancel + super.init() + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + onListen(events) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + onCancel() + return nil + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/ble_service.podspec b/local_plugins/ble_service/ios/ble_service.podspec new file mode 100644 index 000000000..72f1802e5 --- /dev/null +++ b/local_plugins/ble_service/ios/ble_service.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint ble_service.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'ble_service' + s.version = '0.0.1' + s.summary = 'BLE服务Flutter插件' + s.description = <<-DESC +BLE服务Flutter插件,提供蓝牙设备扫描、连接和通信功能。 + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '11.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift b/local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift new file mode 100644 index 000000000..d0702e65c --- /dev/null +++ b/local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift @@ -0,0 +1,410 @@ +import Foundation +import SQLite3 + +/// ChatStorageHelper - iOS版本的聊天存储助手 +/// 负责SQLite数据库操作和业务逻辑 +class ChatStorageHelper { + // MARK: - Constants + private let TAG = "ChatStorageHelper" + + // 数据库常量 + private let DATABASE_NAME = "chat_messages.db" + private let DATABASE_VERSION = 1 + + // 表和列名 + private let TABLE_MESSAGES = "messages" + private let COLUMN_ID = "id" + private let COLUMN_SESSION_ID = "session_id" + private let COLUMN_MESSAGE = "message" + private let COLUMN_SENDER = "sender" + private let COLUMN_TIMESTAMP = "timestamp" + private let COLUMN_METADATA = "metadata" + + // MARK: - Properties + private var db: OpaquePointer? + private let dbPath: String + + // MARK: - Initialization + init(_ context: Any) { + // 获取数据库文件路径 + let fileManager = FileManager.default + if let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let databaseDirectory = documentsDirectory.appendingPathComponent("Databases") + + // 确保目录存在 + try? fileManager.createDirectory(at: databaseDirectory, withIntermediateDirectories: true) + + dbPath = databaseDirectory.appendingPathComponent(DATABASE_NAME).path + + // 打开数据库连接 + openDatabase() + } else { + dbPath = "" + print("\(TAG) 无法获取文档目录路径") + } + } + + deinit { + closeDatabase() + } + + // MARK: - Database Operations + + /// 打开数据库连接 + private func openDatabase() { + if sqlite3_open(dbPath, &db) == SQLITE_OK { + createTables() + print("\(TAG) 数据库已成功打开") + } else { + print("\(TAG) 无法打开数据库: \(String(describing: errorMessage))") + } + } + + /// 关闭数据库连接 + private func closeDatabase() { + if db != nil { + sqlite3_close(db) + db = nil + } + } + + /// 创建必要的数据库表 + private func createTables() { + // 创建消息表 + let createTableSQL = """ + CREATE TABLE IF NOT EXISTS \(TABLE_MESSAGES) ( + \(COLUMN_ID) INTEGER PRIMARY KEY AUTOINCREMENT, + \(COLUMN_SESSION_ID) TEXT NOT NULL, + \(COLUMN_MESSAGE) TEXT NOT NULL, + \(COLUMN_SENDER) TEXT NOT NULL, + \(COLUMN_TIMESTAMP) TEXT NOT NULL, + \(COLUMN_METADATA) TEXT + ); + """ + + if executeUpdate(sql: createTableSQL) { + // 创建索引以加快查询速度 + executeUpdate(sql: "CREATE INDEX IF NOT EXISTS idx_session_id ON \(TABLE_MESSAGES) (\(COLUMN_SESSION_ID))") + executeUpdate(sql: "CREATE INDEX IF NOT EXISTS idx_timestamp ON \(TABLE_MESSAGES) (\(COLUMN_TIMESTAMP))") + } + } + + /// 获取SQLite错误消息 + private var errorMessage: String { + if let errorPointer = sqlite3_errmsg(db) { + let errorMessage = String(cString: errorPointer) + return errorMessage + } else { + return "未知错误" + } + } + + /// 执行SQL更新操作(INSERT, UPDATE, DELETE, CREATE等) + private func executeUpdate(sql: String, parameters: [Any]? = nil) -> Bool { + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { + // 绑定参数 + if let params = parameters { + for (index, param) in params.enumerated() { + bindParameter(statement: statement, index: index + 1, value: param) + } + } + + // 执行语句 + let result = sqlite3_step(statement) + sqlite3_finalize(statement) + + return result == SQLITE_DONE + } else { + print("\(TAG) 准备SQL语句失败: \(errorMessage)") + return false + } + } + + /// 绑定SQL参数 + private func bindParameter(statement: OpaquePointer?, index: Int, value: Any) { + if let value = value as? Int { + sqlite3_bind_int(statement, Int32(index), Int32(value)) + } else if let value = value as? Double { + sqlite3_bind_double(statement, Int32(index), value) + } else if let value = value as? String { + sqlite3_bind_text(statement, Int32(index), (value as NSString).utf8String, -1, nil) + } else if value is NSNull { + sqlite3_bind_null(statement, Int32(index)) + } + } + + /// 查询数据并处理结果 + private func executeQuery(sql: String, parameters: [Any]? = nil, handler: (OpaquePointer) -> Void) { + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { + // 绑定参数 + if let params = parameters { + for (index, param) in params.enumerated() { + bindParameter(statement: statement, index: index + 1, value: param) + } + } + + // 处理结果 + while sqlite3_step(statement) == SQLITE_ROW { + handler(statement!) + } + } else { + print("\(TAG) 执行查询失败: \(errorMessage)") + } + + sqlite3_finalize(statement) + } + + // MARK: - Public Methods + + /// 保存消息 + /// - Parameters: + /// - sessionId: 会话ID + /// - message: 消息内容 + /// - sender: 发送者 + /// - metadata: 元数据 + /// - Returns: 成功返回消息ID,失败返回-1 + func saveMessage(sessionId: String, message: String, sender: String, metadata: String? = nil) -> Int64 { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + let timestamp = dateFormatter.string(from: Date()) + + let sql = """ + INSERT INTO \(TABLE_MESSAGES) (\(COLUMN_SESSION_ID), \(COLUMN_MESSAGE), \(COLUMN_SENDER), \(COLUMN_TIMESTAMP), \(COLUMN_METADATA)) + VALUES (?, ?, ?, ?, ?) + """ + + var parameters: [Any] = [sessionId, message, sender, timestamp] + if let metadata = metadata { + parameters.append(metadata) + } else { + parameters.append(NSNull()) + } + + if executeUpdate(sql: sql, parameters: parameters) { + return sqlite3_last_insert_rowid(db) + } else { + print("\(TAG) 保存消息失败") + return -1 + } + } + + /// 获取指定会话的消息 + /// - Parameters: + /// - sessionId: 会话ID + /// - page: 页码 + /// - pageSize: 每页大小 + /// - Returns: JSON字符串,包含消息列表和分页信息 + func getMessages(sessionId: String, page: Int = 1, pageSize: Int = 20) -> [String: Any] { + var messages: [[String: Any]] = [] + let offset = (page - 1) * pageSize + + // 查询消息 + let sql = """ + SELECT * FROM \(TABLE_MESSAGES) + WHERE \(COLUMN_SESSION_ID) = ? + ORDER BY \(COLUMN_TIMESTAMP) DESC + LIMIT ? OFFSET ? + """ + + executeQuery(sql: sql, parameters: [sessionId, pageSize, offset]) { statement in + var message: [String: Any] = [:] + + // 获取ID + message["id"] = Int(sqlite3_column_int(statement, 0)) + + // 获取会话ID + if let sessionId = sqlite3_column_text(statement, 1) { + message["sessionId"] = String(cString: sessionId) + } + + // 获取消息内容 + if let messageText = sqlite3_column_text(statement, 2) { + message["message"] = String(cString: messageText) + } + + // 获取发送者 + if let sender = sqlite3_column_text(statement, 3) { + message["sender"] = String(cString: sender) + } + + // 获取时间戳 + if let timestamp = sqlite3_column_text(statement, 4) { + message["timestamp"] = String(cString: timestamp) + } + + // 获取元数据(如果有) + if sqlite3_column_type(statement, 5) != SQLITE_NULL { + if let metadata = sqlite3_column_text(statement, 5) { + message["metadata"] = String(cString: metadata) + } + } + + messages.append(message) + } + + // 获取总记录数 + var totalCount = 0 + let countSql = "SELECT COUNT(*) FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?" + + executeQuery(sql: countSql, parameters: [sessionId]) { statement in + totalCount = Int(sqlite3_column_int(statement, 0)) + } + + // 计算总页数 + let totalPages = (totalCount + pageSize - 1) / pageSize + + return [ + "messages": messages, + "page": page, + "pageSize": pageSize, + "totalCount": totalCount, + "totalPages": totalPages + ] + } + + /// 获取会话列表 + /// - Returns: 会话列表数据 + func getSessions() -> [[String: Any]] { + var sessions: [[String: Any]] = [] + + // 查询不同的会话ID及其最新消息 + let sql = """ + SELECT s1.\(COLUMN_SESSION_ID), s1.\(COLUMN_MESSAGE), s1.\(COLUMN_SENDER), s1.\(COLUMN_TIMESTAMP) + FROM \(TABLE_MESSAGES) s1 + INNER JOIN ( + SELECT \(COLUMN_SESSION_ID), MAX(\(COLUMN_TIMESTAMP)) as max_timestamp + FROM \(TABLE_MESSAGES) + GROUP BY \(COLUMN_SESSION_ID) + ) s2 ON s1.\(COLUMN_SESSION_ID) = s2.\(COLUMN_SESSION_ID) AND s1.\(COLUMN_TIMESTAMP) = s2.max_timestamp + ORDER BY s1.\(COLUMN_TIMESTAMP) DESC + """ + + executeQuery(sql: sql) { statement in + var session: [String: Any] = [:] + + // 获取会话ID + if let sessionId = sqlite3_column_text(statement, 0) { + let sessionIdStr = String(cString: sessionId) + session["sessionId"] = sessionIdStr + + // 获取该会话的消息数量 + var messageCount = 0 + let countSql = "SELECT COUNT(*) FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?" + + executeQuery(sql: countSql, parameters: [sessionIdStr]) { countStmt in + messageCount = Int(sqlite3_column_int(countStmt, 0)) + } + + session["messageCount"] = messageCount + } + + // 获取最后一条消息 + if let message = sqlite3_column_text(statement, 1) { + session["lastMessage"] = String(cString: message) + } + + // 获取最后发送者 + if let sender = sqlite3_column_text(statement, 2) { + session["lastSender"] = String(cString: sender) + } + + // 获取时间戳 + if let timestamp = sqlite3_column_text(statement, 3) { + session["timestamp"] = String(cString: timestamp) + } + + sessions.append(session) + } + + return sessions + } + + /// 删除消息 + /// - Parameters: + /// - sessionId: 会话ID,如果提供则删除整个会话 + /// - messageIds: 消息ID列表,如果提供则删除指定消息 + /// - Returns: 成功返回true,失败返回false + func deleteMessages(sessionId: String? = nil, messageIds: [Int]? = nil) -> Bool { + if let messageIds = messageIds, !messageIds.isEmpty { + // 删除指定ID的消息 + let placeholders = Array(repeating: "?", count: messageIds.count).joined(separator: ",") + let sql = "DELETE FROM \(TABLE_MESSAGES) WHERE \(COLUMN_ID) IN (\(placeholders))" + + return executeUpdate(sql: sql, parameters: messageIds.map { $0 }) + } else if let sessionId = sessionId, !sessionId.isEmpty { + // 删除会话中的所有消息 + let sql = "DELETE FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?" + return executeUpdate(sql: sql, parameters: [sessionId]) + } + + return false + } + + /// 清空数据库 + /// - Returns: 成功返回true,失败返回false + func clearDatabase() -> Bool { + let sql = "DELETE FROM \(TABLE_MESSAGES)" + return executeUpdate(sql: sql) + } + + /// 获取指定会话的最近消息 + /// - Parameters: + /// - sessionId: 会话ID + /// - limit: 限制数量 + /// - Returns: 消息列表,按时间正序排列 + func getRecentMessages(sessionId: String, limit: Int) -> [[String: Any]] { + var messages: [[String: Any]] = [] + + // 查询语句 - 按时间倒序获取最近的N条,然后在结果中再按时间正序 + let sql = """ + SELECT * FROM ( + SELECT * FROM \(TABLE_MESSAGES) + WHERE \(COLUMN_SESSION_ID) = ? + ORDER BY \(COLUMN_TIMESTAMP) DESC + LIMIT ? + ) tmp ORDER BY \(COLUMN_TIMESTAMP) ASC + """ + + executeQuery(sql: sql, parameters: [sessionId, limit]) { statement in + var message: [String: Any] = [:] + + // 获取ID + message["id"] = Int(sqlite3_column_int(statement, 0)) + + // 获取会话ID + if let sessionId = sqlite3_column_text(statement, 1) { + message["sessionId"] = String(cString: sessionId) + } + + // 获取消息内容 + if let messageText = sqlite3_column_text(statement, 2) { + message["message"] = String(cString: messageText) + } + + // 获取发送者 + if let sender = sqlite3_column_text(statement, 3) { + message["sender"] = String(cString: sender) + } + + // 获取时间戳 + if let timestamp = sqlite3_column_text(statement, 4) { + message["timestamp"] = String(cString: timestamp) + } + + // 获取元数据(如果有) + if sqlite3_column_type(statement, 5) != SQLITE_NULL { + if let metadata = sqlite3_column_text(statement, 5) { + message["metadata"] = String(cString: metadata) + } + } + + messages.append(message) + } + + return messages + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h new file mode 100644 index 000000000..82613da1f --- /dev/null +++ b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface ChatStoragePlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m new file mode 100644 index 000000000..0f111aba2 --- /dev/null +++ b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m @@ -0,0 +1,12 @@ +#import "ChatStoragePlugin.h" +#if __has_include() +#import +#else +#import "chat_storage-Swift.h" +#endif + +@implementation ChatStoragePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftChatStoragePlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift new file mode 100644 index 000000000..031cb370f --- /dev/null +++ b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift @@ -0,0 +1,182 @@ +import Flutter +import UIKit + +public class ChatStoragePlugin: NSObject, FlutterPlugin { + private let TAG = "ChatStoragePlugin" + private var chatStorageHelper: ChatStorageHelper? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "chat_storage", binaryMessenger: registrar.messenger()) + let instance = ChatStoragePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + override init() { + super.init() + chatStorageHelper = ChatStorageHelper(()) + print("\(TAG) 聊天存储插件已加载") + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let helper = chatStorageHelper else { + result(FlutterError(code: "NOT_INITIALIZED", message: "存储助手未初始化", details: nil)) + return + } + + switch call.method { + case "saveMessage": + handleSaveMessage(call, result, helper) + + case "getMessages": + handleGetMessages(call, result, helper) + + case "getSessions": + handleGetSessions(call, result, helper) + + case "deleteMessages": + handleDeleteMessages(call, result, helper) + + case "clearDatabase": + handleClearDatabase(call, result, helper) + + case "getRecentMessages": + handleGetRecentMessages(call, result, helper) + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - Method handlers + + private func handleSaveMessage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { + guard let arguments = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) + return + } + + guard let sessionId = arguments["sessionId"] as? String, + let message = arguments["message"] as? String, + let sender = arguments["sender"] as? String else { + result(FlutterError(code: "MISSING_ARGS", message: "缺少必要参数", details: nil)) + return + } + + let metadata = arguments["metadata"] as? String + + do { + let id = helper.saveMessage(sessionId: sessionId, message: message, sender: sender, metadata: metadata) + + if id != -1 { + result(["success": true, "id": id]) + } else { + result(FlutterError(code: "DB_ERROR", message: "插入消息失败", details: nil)) + } + } catch { + print("\(TAG) 保存消息时发生错误: \(error.localizedDescription)") + result(FlutterError(code: "EXCEPTION", message: "保存消息时发生错误: \(error.localizedDescription)", details: nil)) + } + } + + private func handleGetMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { + guard let arguments = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) + return + } + + guard let sessionId = arguments["sessionId"] as? String else { + result(FlutterError(code: "MISSING_ARGS", message: "缺少会话ID", details: nil)) + return + } + + let page = arguments["page"] as? Int ?? 1 + let pageSize = arguments["pageSize"] as? Int ?? 20 + + do { + let messagesData = helper.getMessages(sessionId: sessionId, page: page, pageSize: pageSize) + + // 将结果转换为JSON字符串 + if let jsonData = try? JSONSerialization.data(withJSONObject: messagesData), + let jsonString = String(data: jsonData, encoding: .utf8) { + result(jsonString) + } else { + result(FlutterError(code: "JSON_ERROR", message: "无法序列化结果", details: nil)) + } + } catch { + print("\(TAG) 获取消息时发生错误: \(error.localizedDescription)") + result(FlutterError(code: "EXCEPTION", message: "获取消息时发生错误: \(error.localizedDescription)", details: nil)) + } + } + + private func handleGetSessions(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { + do { + let sessions = helper.getSessions() + + // 将结果转换为JSON字符串 + if let jsonData = try? JSONSerialization.data(withJSONObject: sessions), + let jsonString = String(data: jsonData, encoding: .utf8) { + result(jsonString) + } else { + result(FlutterError(code: "JSON_ERROR", message: "无法序列化结果", details: nil)) + } + } catch { + print("\(TAG) 获取会话列表时发生错误: \(error.localizedDescription)") + result(FlutterError(code: "EXCEPTION", message: "获取会话列表时发生错误: \(error.localizedDescription)", details: nil)) + } + } + + private func handleDeleteMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { + guard let arguments = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) + return + } + + let sessionId = arguments["sessionId"] as? String + let messageIds = arguments["messageIds"] as? [Int] + + do { + let success = helper.deleteMessages(sessionId: sessionId, messageIds: messageIds) + + if success { + result(["success": true]) + } else { + result(FlutterError(code: "INVALID_ARGS", message: "必须提供sessionId或messageIds", details: nil)) + } + } catch { + print("\(TAG) 删除消息时发生错误: \(error.localizedDescription)") + result(FlutterError(code: "EXCEPTION", message: "删除消息时发生错误: \(error.localizedDescription)", details: nil)) + } + } + + private func handleClearDatabase(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { + do { + let success = helper.clearDatabase() + result(["success": success]) + } catch { + print("\(TAG) 清空数据库时发生错误: \(error.localizedDescription)") + result(FlutterError(code: "EXCEPTION", message: "清空数据库时发生错误: \(error.localizedDescription)", details: nil)) + } + } + + private func handleGetRecentMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) { + guard let arguments = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) + return + } + + guard let sessionId = arguments["sessionId"] as? String else { + result(FlutterError(code: "MISSING_ARGS", message: "缺少会话ID", details: nil)) + return + } + + let limit = arguments["limit"] as? Int ?? 10 + + do { + let messages = helper.getRecentMessages(sessionId: sessionId, limit: limit) + result(messages) + } catch { + print("\(TAG) 获取最近消息时发生错误: \(error.localizedDescription)") + result(FlutterError(code: "EXCEPTION", message: "获取最近消息时发生错误: \(error.localizedDescription)", details: nil)) + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift b/local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift new file mode 100644 index 000000000..bd72a61bc --- /dev/null +++ b/local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift @@ -0,0 +1,11 @@ +import Flutter +import UIKit + +// 这个类是为了满足Flutter插件的命名约定 +// 实际实现在ChatStoragePlugin.swift中 +public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + // 调用实际实现类的注册方法 + ChatStoragePlugin.register(with: registrar) + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/README.md b/local_plugins/chat_storage/ios/README.md new file mode 100644 index 000000000..4bf35eb37 --- /dev/null +++ b/local_plugins/chat_storage/ios/README.md @@ -0,0 +1,64 @@ +# Chat Storage 插件 - iOS 实现 + +## 概述 + +Chat Storage 插件的 iOS 实现,为 Flutter 应用提供本地 SQLite 数据库支持,用于存储和管理聊天记录。 + +## 功能特性 + +- 基于 SQLite 的聊天记录存储 +- 支持按会话分组消息 +- 提供分页查询功能 +- 支持元数据存储 +- 完整的增删改查 API + +## 技术实现 + +本插件使用原生 Swift 实现,通过 SQLite C API 直接操作数据库,不依赖第三方 SQLite 封装库,以保持最小的依赖要求和最佳性能。 + +### 核心组件 + +- `ChatStorageHelper`: 负责 SQLite 数据库操作,提供完整的数据操作 API +- `ChatStoragePlugin`: 插件主类,处理与 Flutter 的通信 + +### 数据库结构 + +数据库包含一个 `messages` 表,结构如下: + +```sql +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + message TEXT NOT NULL, + sender TEXT NOT NULL, + timestamp TEXT NOT NULL, + metadata TEXT +); + +-- 索引 +CREATE INDEX idx_session_id ON messages (session_id); +CREATE INDEX idx_timestamp ON messages (timestamp); +``` + +## 使用注意事项 + +### SQLite 多线程访问 + +本插件所有数据库操作均在主线程执行,以避免 SQLite 多线程访问的复杂性。在处理大量数据时,建议在 Flutter 层面使用 `compute` 函数或 `Isolate` 进行异步处理。 + +### 数据格式 + +- 时间戳使用 `yyyy-MM-dd HH:mm:ss` 格式的字符串 +- JSON 转换遵循 Swift/Objective-C 的类型映射规则 + +## 调试与故障排除 + +- 日志前缀为 `ChatStoragePlugin` 和 `ChatStorageHelper` +- 可通过 Console.app 查看日志输出 +- 数据库文件存储在应用的 Documents/Databases 目录下 + +## 开发者须知 + +如果需要扩展此插件的功能,比如添加新的表或字段,应同时更新 Android 和 iOS 端的实现,并确保在版本升级时提供适当的数据库迁移策略。 + +数据库迁移逻辑目前实现为重建表,在生产环境中应该改为保留用户数据的方式。 \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/chat_storage.podspec b/local_plugins/chat_storage/ios/chat_storage.podspec index b7a2c39b8..c397034ec 100644 --- a/local_plugins/chat_storage/ios/chat_storage.podspec +++ b/local_plugins/chat_storage/ios/chat_storage.podspec @@ -5,13 +5,13 @@ Pod::Spec.new do |s| s.name = 'chat_storage' s.version = '0.0.1' - s.summary = 'Chat Storage plugin' + s.summary = '聊天记录存储插件' s.description = <<-DESC -A Flutter plugin for chat storage services. +用于存储聊天记录的Flutter插件,支持SQLite数据库操作。 DESC - s.homepage = 'http://example.com' + s.homepage = 'http://yunqiinnovation.com' s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } + s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' @@ -20,4 +20,7 @@ A Flutter plugin for chat storage services. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } s.swift_version = '5.0' + + # 添加SQLite库依赖 + s.library = 'sqlite3' end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift new file mode 100644 index 000000000..a9aa9b3b5 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift @@ -0,0 +1,197 @@ +import Foundation +import ExternalAccessory +import CoreBluetooth + +/// 蓝牙事件类型 +enum BluetoothEventType { + case stateChanged + case deviceConnected + case deviceDisconnected +} + +/// 蓝牙状态枚举 +enum BluetoothState: String { + case unknown = "unknown" + case off = "off" + case on = "on" + case turningOn = "turningOn" + case turningOff = "turningOff" +} + +/// 经典蓝牙助手类,负责iOS上的蓝牙设备管理 +class ClassicBluetoothHelper: NSObject { + private let TAG = "ClassicBluetoothHelper" + + // 状态监控相关 + private var isMonitoring = false + private var stateCallback: ((BluetoothEventType, [String: Any]) -> Void)? + + // 用于监控蓝牙系统状态 + private var centralManager: CBCentralManager? + + // 通知观察者 + private var accessoryConnectionObserver: NSObjectProtocol? + private var accessoryDisconnectionObserver: NSObjectProtocol? + + override init() { + super.init() + centralManager = CBCentralManager(delegate: nil, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: false]) + } + + deinit { + stopBluetoothStateMonitoring() + } + + /// 检查蓝牙是否启用 + func isBluetoothEnabled() -> Bool { + guard let centralManager = centralManager else { return false } + return centralManager.state == .poweredOn + } + + /// 获取已连接的蓝牙设备 + /// - Parameter completion: 完成回调 + func getConnectedDevices(completion: @escaping ([[String: String]], Error?) -> Void) { + // 获取所有已连接的外部配件 + let accessoryManager = EAAccessoryManager.shared() + let connectedAccessories = accessoryManager.connectedAccessories + + // 将配件转换为设备信息数组 + let devices = connectedAccessories.map { accessory -> [String: String] in + return [ + "name": accessory.name, + "address": accessory.serialNumber // iOS使用序列号作为唯一标识 + ] + } + + // 返回结果 + completion(devices, nil) + } + + /// 开始监听蓝牙状态变化 + /// - Parameter callback: 状态变化回调 + func startBluetoothStateMonitoring(callback: @escaping (BluetoothEventType, [String: Any]) -> Void) { + self.stateCallback = callback + + if !isMonitoring { + isMonitoring = true + + // 设置蓝牙中央管理器代理 + centralManager?.delegate = self + + // 注册蓝牙配件连接/断开通知 + let notificationCenter = NotificationCenter.default + + // 观察配件连接 + accessoryConnectionObserver = notificationCenter.addObserver( + forName: NSNotification.Name.EAAccessoryDidConnect, + object: nil, + queue: nil + ) { [weak self] notification in + guard let self = self, + let accessory = notification.userInfo?[EAAccessoryKey] as? EAAccessory else { + return + } + + let device: [String: String] = [ + "name": accessory.name, + "address": accessory.serialNumber + ] + + self.stateCallback?(.deviceConnected, ["device": device]) + } + + // 观察配件断开 + accessoryDisconnectionObserver = notificationCenter.addObserver( + forName: NSNotification.Name.EAAccessoryDidDisconnect, + object: nil, + queue: nil + ) { [weak self] notification in + guard let self = self, + let accessory = notification.userInfo?[EAAccessoryKey] as? EAAccessory else { + return + } + + let device: [String: String] = [ + "name": accessory.name, + "address": accessory.serialNumber + ] + + self.stateCallback?(.deviceDisconnected, ["device": device]) + } + + // 开始监视配件变化 + EAAccessoryManager.shared().registerForLocalNotifications() + } + } + + /// 停止监听蓝牙状态变化 + func stopBluetoothStateMonitoring() { + if isMonitoring { + isMonitoring = false + + // 移除蓝牙中央管理器代理 + centralManager?.delegate = nil + + // 注销配件通知观察者 + if let observer = accessoryConnectionObserver { + NotificationCenter.default.removeObserver(observer) + accessoryConnectionObserver = nil + } + + if let observer = accessoryDisconnectionObserver { + NotificationCenter.default.removeObserver(observer) + accessoryDisconnectionObserver = nil + } + + // 停止监视配件变化 + EAAccessoryManager.shared().unregisterForLocalNotifications() + } + + // 清除回调 + stateCallback = nil + } + + /// 报告当前蓝牙状态 + func reportCurrentBluetoothState() { + guard let centralManager = centralManager else { return } + + let state: BluetoothState + + switch centralManager.state { + case .poweredOn: + state = .on + case .poweredOff: + state = .off + case .resetting: + state = .turningOff // 最接近的近似值 + case .unauthorized, .unsupported: + state = .off // 功能上等同于关闭 + default: + state = .unknown + } + + stateCallback?(.stateChanged, ["state": state.rawValue]) + } +} + +// MARK: - CBCentralManagerDelegate +extension ClassicBluetoothHelper: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + let state: BluetoothState + + switch central.state { + case .poweredOn: + state = .on + case .poweredOff: + state = .off + case .resetting: + state = .turningOff + case .unauthorized, .unsupported: + state = .off + default: + state = .unknown + } + + stateCallback?(.stateChanged, ["state": state.rawValue]) + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h new file mode 100644 index 000000000..69a8a8d4e --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface ClassicBluetoothPlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m new file mode 100644 index 000000000..93666d6e1 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m @@ -0,0 +1,12 @@ +#import "ClassicBluetoothPlugin.h" +#if __has_include() +#import +#else +#import "classic_bluetooth-Swift.h" +#endif + +@implementation ClassicBluetoothPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftClassicBluetoothPlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift new file mode 100644 index 000000000..b2cb2d30f --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift @@ -0,0 +1,108 @@ +import Flutter +import UIKit +import ExternalAccessory + +public class ClassicBluetoothPlugin: NSObject, FlutterPlugin { + private let TAG = "ClassicBluetoothPlugin" + private var methodChannel: FlutterMethodChannel? + private var eventChannel: FlutterEventChannel? + private var eventSink: FlutterEventSink? + private let bluetoothHelper = ClassicBluetoothHelper() + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "com.yunqiinnovation.classic_bluetooth/methods", binaryMessenger: registrar.messenger()) + let instance = ClassicBluetoothPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + + // 设置事件通道(用于蓝牙状态监控) + let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.classic_bluetooth/events", binaryMessenger: registrar.messenger()) + eventChannel.setStreamHandler(instance) + + instance.methodChannel = channel + instance.eventChannel = eventChannel + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getConnectedDevices": + getConnectedDevices(result: result) + case "isBluetoothEnabled": + result(bluetoothHelper.isBluetoothEnabled()) + default: + result(FlutterMethodNotImplemented) + } + } + + private func getConnectedDevices(result: @escaping FlutterResult) { + if !bluetoothHelper.isBluetoothEnabled() { + result([]) + return + } + + bluetoothHelper.getConnectedDevices { devices, error in + if let error = error { + result(FlutterError(code: "BLUETOOTH_ERROR", message: error.localizedDescription, details: nil)) + return + } + + result(devices) + } + } + + // 发送事件到Flutter + private func sendEvent(_ event: [String: Any]) { + if let eventSink = eventSink { + eventSink(event) + } + } +} + +// MARK: - FlutterStreamHandler +extension ClassicBluetoothPlugin: FlutterStreamHandler { + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + + // 开始监听蓝牙状态变化 + bluetoothHelper.startBluetoothStateMonitoring { [weak self] eventType, data in + guard let self = self else { return } + + switch eventType { + case .stateChanged: + if let state = data["state"] as? String { + self.sendEvent([ + "type": "stateChanged", + "state": state, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ]) + } + case .deviceConnected: + if let device = data["device"] as? [String: String] { + self.sendEvent([ + "type": "deviceConnected", + "device": device, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ]) + } + case .deviceDisconnected: + if let device = data["device"] as? [String: String] { + self.sendEvent([ + "type": "deviceDisconnected", + "device": device, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ]) + } + } + } + + // 报告当前蓝牙状态 + bluetoothHelper.reportCurrentBluetoothState() + + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + bluetoothHelper.stopBluetoothStateMonitoring() + return nil + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift b/local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift new file mode 100644 index 000000000..7bbc903c9 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift @@ -0,0 +1,136 @@ +import Foundation +import os.log + +/// iOS端的文件日志助手类,提供与Android端FileLogger类似的功能 +class FileLogger { + + private static let logQueue = DispatchQueue(label: "com.yunqiinnovation.classic_bluetooth.logger", qos: .background) + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + return formatter + }() + + /// 日志类型 + enum LogLevel: String { + case debug = "DEBUG" + case info = "INFO" + case warning = "WARNING" + case error = "ERROR" + } + + /// 输出调试日志 + /// - Parameters: + /// - tag: 标签 + /// - message: 日志消息 + static func d(_ tag: String, _ message: String) { + log(level: .debug, tag: tag, message: message) + } + + /// 输出信息日志 + /// - Parameters: + /// - tag: 标签 + /// - message: 日志消息 + static func i(_ tag: String, _ message: String) { + log(level: .info, tag: tag, message: message) + } + + /// 输出警告日志 + /// - Parameters: + /// - tag: 标签 + /// - message: 日志消息 + static func w(_ tag: String, _ message: String) { + log(level: .warning, tag: tag, message: message) + } + + /// 输出错误日志 + /// - Parameters: + /// - tag: 标签 + /// - message: 日志消息 + /// - error: 可选的错误对象 + static func e(_ tag: String, _ message: String, _ error: Error? = nil) { + var fullMessage = message + if let error = error { + fullMessage += " - Error: \(error.localizedDescription)" + } + log(level: .error, tag: tag, message: fullMessage) + } + + /// 输出日志 + /// - Parameters: + /// - level: 日志级别 + /// - tag: 标签 + /// - message: 日志消息 + private static func log(level: LogLevel, tag: String, message: String) { + let timestamp = dateFormatter.string(from: Date()) + let logMessage = "[\(timestamp)] [\(level.rawValue)] [\(tag)]: \(message)" + + // 输出到控制台 + print(logMessage) + + // 异步写入日志文件 + logQueue.async { + writeToLogFile(logMessage) + } + + // 使用系统日志 + if #available(iOS 10.0, *) { + let osLog = OSLog(subsystem: "com.yunqiinnovation.classic_bluetooth", category: tag) + let osLogType: OSLogType + + switch level { + case .debug: + osLogType = .debug + case .info: + osLogType = .info + case .warning: + osLogType = .default + case .error: + osLogType = .error + } + + os_log("%{public}@", log: osLog, type: osLogType, message) + } + } + + /// 将日志写入文件 + /// - Parameter message: 日志消息 + private static func writeToLogFile(_ message: String) { + guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + + let logDirectoryURL = documentsDirectory.appendingPathComponent("Logs") + let dateString = DateFormatter().with { $0.dateFormat = "yyyy-MM-dd" }.string(from: Date()) + let logFileURL = logDirectoryURL.appendingPathComponent("bluetooth_\(dateString).log") + + do { + // 创建日志文件夹 + try FileManager.default.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true) + + // 如果文件不存在,创建文件 + if !FileManager.default.fileExists(atPath: logFileURL.path) { + FileManager.default.createFile(atPath: logFileURL.path, contents: nil) + } + + // 追加日志内容 + if let fileHandle = FileHandle(forWritingAtPath: logFileURL.path) { + fileHandle.seekToEndOfFile() + if let data = "\(message)\n".data(using: .utf8) { + fileHandle.write(data) + } + fileHandle.closeFile() + } + } catch { + print("写入日志文件失败: \(error.localizedDescription)") + } + } +} + +// MARK: - DateFormatter 扩展 +extension DateFormatter { + func with(_ block: (DateFormatter) -> Void) -> DateFormatter { + block(self) + return self + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift b/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift new file mode 100644 index 000000000..9f70212e7 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift @@ -0,0 +1,11 @@ +import Flutter +import UIKit + +// 这个文件是为了满足Flutter插件的命名约定 +// 实际实现在ClassicBluetoothPlugin.swift中 +public class SwiftClassicBluetoothPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + // 调用真正的实现 + ClassicBluetoothPlugin.register(with: registrar) + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/README.md b/local_plugins/classic_bluetooth/ios/README.md new file mode 100644 index 000000000..857396358 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/README.md @@ -0,0 +1,44 @@ +# iOS蓝牙配置说明 + +## 所需权限 + +要在iOS中使用经典蓝牙功能,需要在应用的`Info.plist`中添加以下配置: + +### 1. 外部附件协议支持 + +在Info.plist中添加`UISupportedExternalAccessoryProtocols`数组,并添加你的设备支持的协议。如果不确定,可以先添加常见的协议: + +```xml +UISupportedExternalAccessoryProtocols + + com.example.protocol + + +``` + +### 2. 蓝牙使用说明 + +在iOS 13及以上版本中,需要添加蓝牙使用说明: + +```xml +NSBluetoothAlwaysUsageDescription +我们需要使用蓝牙来连接您的音频设备 +``` + +对于iOS 13以下版本: + +```xml +NSBluetoothPeripheralUsageDescription +我们需要使用蓝牙来连接您的音频设备 +``` + +## 常见问题 + +1. 如果看不到任何设备,请确保: + - 设备已配对并连接到iOS设备 + - 设备是MFi认证的(Made for iPhone/iPad),因为iOS对经典蓝牙有限制 + - 添加了正确的协议字符串 + +2. 蓝牙状态变化不及时: + - iOS系统对蓝牙状态变化的通知可能有延迟 + - 请确保用户已经授权蓝牙权限 \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec b/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec new file mode 100644 index 000000000..8dbc9c504 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# +Pod::Spec.new do |s| + s.name = 'classic_bluetooth' + s.version = '0.0.1' + s.summary = '经典蓝牙插件' + s.description = <<-DESC +一个用于管理经典蓝牙设备的Flutter插件。 + DESC + s.homepage = 'http://yunqiinnovation.com' + s.license = { :file => '../LICENSE' } + s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + + s.ios.deployment_target = '11.0' + s.swift_version = '5.0' + + # 添加CoreBluetooth和ExternalAccessory框架依赖 + s.framework = 'CoreBluetooth', 'ExternalAccessory' +end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/pubspec.yaml b/local_plugins/classic_bluetooth/pubspec.yaml index eca826603..c618200f1 100644 --- a/local_plugins/classic_bluetooth/pubspec.yaml +++ b/local_plugins/classic_bluetooth/pubspec.yaml @@ -17,4 +17,6 @@ flutter: platforms: android: package: com.yunqiinnovation.classic_bluetooth + pluginClass: ClassicBluetoothPlugin + ios: pluginClass: ClassicBluetoothPlugin \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h new file mode 100644 index 000000000..f64c3569a --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface DeepvoiceUtilsPlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m new file mode 100644 index 000000000..af5d121b1 --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m @@ -0,0 +1,12 @@ +#import "DeepvoiceUtilsPlugin.h" +#if __has_include() +#import +#else +#import "deepvoice_utils-Swift.h" +#endif + +@implementation DeepvoiceUtilsPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftDeepvoiceUtilsPlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift new file mode 100644 index 000000000..922bb3c4b --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift @@ -0,0 +1,30 @@ +import Flutter +import UIKit + +public class DeepvoiceUtilsPlugin: NSObject, FlutterPlugin { + private static let TAG = "DeepvoiceUtilsPlugin" + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "deepvoice_utils", binaryMessenger: registrar.messenger()) + let instance = DeepvoiceUtilsPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + + // 初始化文件日志 + FileLogger.initialize() + FileLogger.d(TAG, "DeepvoiceUtilsPlugin 已初始化") + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS \(UIDevice.current.systemVersion)") + default: + result(FlutterMethodNotImplemented) + } + } + + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + // 关闭日志系统 + FileLogger.shutdown() + } +} \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift b/local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift new file mode 100644 index 000000000..f54e040f8 --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift @@ -0,0 +1,286 @@ +import Foundation +import os.log + +/** + * 文件日志工具 + * 将日志同时输出到控制台和文件系统 + */ +@objc public class FileLogger: NSObject { + + private static let TAG = "FileLogger" + private static var instance: FileLogger? + + private let logDir: URL + private var currentLogFile: URL? + private let logQueue = DispatchQueue(label: "com.yunqiinnovation.deepvoice_utils.logger", qos: .utility) + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter + }() + + /** + * 初始化日志系统 + */ + private init(directory: URL) { + self.logDir = directory.appendingPathComponent("logs", isDirectory: true) + super.init() + + // 创建日志目录 + try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true) + + // 创建当天日志文件 + updateLogFile() + + // 记录初始化完成 + self.d(FileLogger.TAG, "文件日志系统已初始化") + } + + /** + * 更新日志文件为当天日期 + */ + private func updateLogFile() { + let today = dateFormatter.string(from: Date()) + let logFile = logDir.appendingPathComponent("log_\(today).txt") + + if currentLogFile == nil || currentLogFile?.path != logFile.path { + currentLogFile = logFile + + // 如果是新文件,添加文件头 + if !FileManager.default.fileExists(atPath: logFile.path) { + let headerDate = DateFormatter() + headerDate.dateFormat = "yyyy-MM-dd HH:mm:ss" + let headerLine = "===== 日志开始: \(headerDate.string(from: Date())) =====\n" + + try? headerLine.write(to: logFile, atomically: true, encoding: .utf8) + } + } + } + + /** + * 将日志写入文件 + */ + private func logToFile(level: String, tag: String, message: String) { + logQueue.async { [weak self] in + guard let self = self else { return } + + do { + // 检查是否需要更新日志文件 + self.updateLogFile() + + guard let logFile = self.currentLogFile, + FileManager.default.fileExists(atPath: logFile.path) else { + return + } + + // 格式化日志内容 + let timestamp = self.timeFormatter.string(from: Date()) + let logLine = "\(timestamp) \(level)/\(tag): \(message)\n" + + // 获取文件句柄并追加内容 + if let fileHandle = try? FileHandle(forWritingTo: logFile) { + fileHandle.seekToEndOfFile() + if let data = logLine.data(using: .utf8) { + fileHandle.write(data) + } + fileHandle.closeFile() + } else { + // 如果无法获取文件句柄,尝试直接写入 + let existingContent = try? String(contentsOf: logFile, encoding: .utf8) + let newContent = (existingContent ?? "") + logLine + try newContent.write(to: logFile, atomically: true, encoding: .utf8) + } + } catch { + print("写入日志文件失败: \(error.localizedDescription)") + } + } + } + + /** + * 调试级别日志 + */ + private func d(_ tag: String, _ message: String) { + // 输出到控制台 + print("D/\(tag): \(message)") + + // 写入文件 + logToFile(level: "D", tag: tag, message: message) + } + + /** + * 信息级别日志 + */ + private func i(_ tag: String, _ message: String) { + // 输出到控制台 + print("I/\(tag): \(message)") + + // 写入文件 + logToFile(level: "I", tag: tag, message: message) + } + + /** + * 警告级别日志 + */ + private func w(_ tag: String, _ message: String) { + // 输出到控制台 + print("W/\(tag): \(message)") + + // 写入文件 + logToFile(level: "W", tag: tag, message: message) + } + + /** + * 错误级别日志 + */ + private func e(_ tag: String, _ message: String, _ error: Error? = nil) { + var fullMessage = message + + // 如果有错误,添加错误信息 + if let error = error { + fullMessage += "\n\(error.localizedDescription)" + } + + // 输出到控制台 + print("E/\(tag): \(fullMessage)") + + // 写入文件 + logToFile(level: "E", tag: tag, message: fullMessage) + } + + /** + * 清理旧日志文件 + */ + private func cleanOldLogs(daysToKeep: Int = 7) { + logQueue.async { + do { + let currentDate = Date() + let fileManager = FileManager.default + + let logFiles = try fileManager.contentsOfDirectory(at: self.logDir, includingPropertiesForKeys: [.creationDateKey], options: []) + + for fileURL in logFiles where fileURL.pathExtension == "txt" { + if let fileAttributes = try? fileManager.attributesOfItem(atPath: fileURL.path), + let creationDate = fileAttributes[.creationDate] as? Date { + + let age = Calendar.current.dateComponents([.day], from: creationDate, to: currentDate) + + if let days = age.day, days > daysToKeep { + try? fileManager.removeItem(at: fileURL) + print("D/\(FileLogger.TAG): 已删除旧日志文件: \(fileURL.lastPathComponent)") + } + } + } + } catch { + print("E/\(FileLogger.TAG): 清理旧日志失败: \(error.localizedDescription)") + } + } + } + + // MARK: - 公共静态方法 + + /** + * 初始化日志系统 + */ + @objc public static func initialize() { + if instance == nil { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + instance = FileLogger(directory: documentsDirectory) + } + } + + /** + * 初始化日志系统(使用自定义目录) + */ + @objc public static func initialize(withDirectory directory: URL) { + if instance == nil { + instance = FileLogger(directory: directory) + } + } + + /** + * 调试日志 + */ + @objc public static func d(_ tag: String, _ message: String) { + // 如果实例不存在,先使用默认目录初始化 + if instance == nil { + initialize() + } + + instance?.d(tag, message) + + // 使用系统日志 + if #available(iOS 10.0, *) { + os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .debug, message) + } + } + + /** + * 信息日志 + */ + @objc public static func i(_ tag: String, _ message: String) { + if instance == nil { + initialize() + } + + instance?.i(tag, message) + + // 使用系统日志 + if #available(iOS 10.0, *) { + os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .info, message) + } + } + + /** + * 警告日志 + */ + @objc public static func w(_ tag: String, _ message: String) { + if instance == nil { + initialize() + } + + instance?.w(tag, message) + + // 使用系统日志 + if #available(iOS 10.0, *) { + os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .default, message) + } + } + + /** + * 错误日志 + */ + @objc public static func e(_ tag: String, _ message: String, _ error: Error? = nil) { + if instance == nil { + initialize() + } + + instance?.e(tag, message, error) + + // 使用系统日志 + if #available(iOS 10.0, *) { + let errorMessage = error != nil ? "\(message): \(error!.localizedDescription)" : message + os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .error, errorMessage) + } + } + + /** + * 清理旧日志 + */ + @objc public static func cleanOldLogs(daysToKeep: Int = 7) { + instance?.cleanOldLogs(daysToKeep: daysToKeep) + } + + /** + * 关闭日志系统 + */ + @objc public static func shutdown() { + instance = nil + } +} \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift b/local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift new file mode 100644 index 000000000..408f07402 --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift @@ -0,0 +1,11 @@ +import Flutter +import UIKit + +// 这个类是为了满足Flutter插件的命名约定 +// 实际实现在DeepvoiceUtilsPlugin.swift中 +public class SwiftDeepvoiceUtilsPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + // 调用真正的实现 + DeepvoiceUtilsPlugin.register(with: registrar) + } +} \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/README.md b/local_plugins/deepvoice_utils/ios/README.md new file mode 100644 index 000000000..7f12f83cb --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/README.md @@ -0,0 +1,54 @@ +# DeepVoice Utils iOS 实现 + +## 简介 + +本模块是 `deepvoice_utils` 插件的 iOS 实现部分,主要提供以下功能: + +- 文件日志记录(FileLogger):将日志记录到文件,方便调试和问题排查 + +## 使用说明 + +### 日志系统 + +该插件自动初始化日志系统,开发者无需手动调用初始化方法。日志文件默认保存在应用的 Documents 目录下的 logs 文件夹中,按日期分文件存储。 + +## 配置要求 + +- iOS 11.0 或更高版本 +- Swift 5.0 +- 确保应用拥有文件写入权限 + +## 开发说明 + +### 文件结构 + +- `DeepvoiceUtilsPlugin.swift`:插件主类,负责与 Flutter 交互 +- `FileLogger.swift`:日志系统实现,提供日志记录功能 +- `SwiftDeepvoiceUtilsPlugin.swift`:命名约定桥接类 +- `DeepvoiceUtilsPlugin.h/.m`:Objective-C 桥接文件 + +### 自定义日志目录 + +如需使用自定义日志目录,可以在应用启动时手动调用初始化方法: + +```swift +import deepvoice_utils + +// 在 AppDelegate 中 +func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // 自定义日志目录 + let customDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("custom_logs") + FileLogger.initialize(withDirectory: customDirectory) + + return true +} +``` + +### 清理旧日志 + +默认情况下,日志系统不会自动清理旧日志。如果需要清理,可以手动调用: + +```swift +// 清理7天前的日志 +FileLogger.cleanOldLogs(daysToKeep: 7) +``` \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec b/local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec new file mode 100644 index 000000000..758fe120c --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec @@ -0,0 +1,19 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# +Pod::Spec.new do |s| + s.name = 'deepvoice_utils' + s.version = '0.0.1' + s.summary = '深语工具库' + s.description = <<-DESC +提供文件日志记录等通用功能的Flutter插件。 + DESC + s.homepage = 'http://yunqiinnovation.com' + s.license = { :file => '../LICENSE' } + s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + + s.ios.deployment_target = '11.0' + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/MCPClient.swift b/local_plugins/open_ai_service/ios/Classes/MCPClient.swift new file mode 100644 index 000000000..99fd86008 --- /dev/null +++ b/local_plugins/open_ai_service/ios/Classes/MCPClient.swift @@ -0,0 +1,249 @@ +import Foundation + +/// 工具类型枚举 +enum ToolType { + case localFunction // 本地函数 + case mcpTool // MCP工具 +} + +/// 函数处理器协议 +protocol FunctionHandler { + /// 处理函数调用 + /// - Parameters: + /// - arguments: 函数参数,Dictionary格式 + /// - Returns: 函数执行结果,字符串 + func handle(arguments: [String: Any]) async throws -> String +} + +/// MCPClient - iOS版本,负责连接到Model Context Protocol服务器和管理本地函数 +public class MCPClient { + private let TAG = "MCPClient" + + // 本地函数Map,函数名 -> 处理器 + private var localFunctions = [String: FunctionHandler]() + + // 工具定义Map + private var toolDefinitions = [[String: Any]]() + + // 是否已连接 + private var isConnected = false + + // 系统功能处理器 + private var systemFunctionHandler: SystemFunctionHandler? + + // SSE会话 + private var sseSession: URLSession? + private var sseTask: URLSessionDataTask? + + /// 初始化MCPClient + /// - Parameter context: 可选的应用上下文 + init(_ context: Any? = nil) { + if let appContext = context { + systemFunctionHandler = SystemFunctionHandler(context: appContext) + + // 初始化语音功能 + initializeVoiceFunctions() + } + } + + /// 初始化语音功能处理器 + private func initializeVoiceFunctions() { + do { + // 使用SystemFunctionHandler注册所有功能 + systemFunctionHandler?.registerAllFunctions(client: self) + + print("\(TAG) 语音功能初始化完成") + } catch { + print("\(TAG) 语音功能初始化失败: \(error.localizedDescription)") + } + } + + /// 注册本地函数 + /// - Parameters: + /// - name: 函数名称 + /// - description: 函数描述 + /// - parameters: 函数参数定义 + /// - handler: 函数处理器 + /// - Returns: 是否注册成功 + func registerLocalFunction(name: String, description: String, parameters: [String: Any], handler: FunctionHandler) -> Bool { + do { + // 存储函数处理器 + localFunctions[name] = handler + + // 创建函数定义 + let functionDef: [String: Any] = [ + "name": name, + "description": description, + "parameters": parameters + ] + + // 添加到工具定义列表 + let toolDef: [String: Any] = [ + "type": "function", + "function": functionDef + ] + + // 更新或添加工具定义 + if let index = toolDefinitions.firstIndex(where: { + guard let function = $0["function"] as? [String: Any], + let functionName = function["name"] as? String else { + return false + } + return functionName == name + }) { + toolDefinitions[index] = toolDef + } else { + toolDefinitions.append(toolDef) + } + + print("\(TAG) 成功注册本地函数: \(name)") + return true + } catch { + print("\(TAG) 注册函数失败: \(error.localizedDescription)") + return false + } + } + + /// 连接到MCP服务器(通过SSE) + /// - Parameter mcpServer: 服务器URL + /// - Returns: 是否连接成功 + func connectToSSE(_ mcpServer: String) -> Bool { + // 关闭现有连接 + closeConnection() + + // 如果URL为空,返回失败 + if mcpServer.isEmpty { + print("\(TAG) MCP服务器URL为空") + return false + } + + // 解析服务器URL + guard let serverURL = URL(string: mcpServer) else { + print("\(TAG) 无效的MCP服务器URL: \(mcpServer)") + return false + } + + print("\(TAG) 正在连接到MCP服务器: \(mcpServer)") + + // 创建会话配置 + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + + // 创建会话 + let session = URLSession(configuration: config) + self.sseSession = session + + // 创建请求 + var request = URLRequest(url: serverURL) + request.httpMethod = "GET" + request.addValue("text/event-stream", forHTTPHeaderField: "Accept") + + // 创建并启动SSE任务 + let task = session.dataTask(with: request) { [weak self] data, response, error in + guard let self = self else { return } + + if let error = error { + print("\(self.TAG) MCP服务器连接失败: \(error.localizedDescription)") + self.isConnected = false + return + } + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + print("\(self.TAG) 成功连接到MCP服务器") + self.isConnected = true + + // 处理服务器响应数据 + if let data = data, let responseString = String(data: data, encoding: .utf8) { + print("\(self.TAG) 收到初始响应: \(responseString)") + + // 在这里解析可用工具列表,暂时模拟 + // 实际情况下,需要实现SSE流解析 + } + } else { + print("\(self.TAG) MCP服务器连接失败,状态码: \(httpResponse.statusCode)") + self.isConnected = false + } + } + } + + task.resume() + self.sseTask = task + + // 在iOS中,连接是异步的,此处返回true表示连接已开始 + // 实际连接状态通过isConnected属性检查 + return true + } + + /// 检查是否已连接 + /// - Returns: 是否已连接 + func isConnected() -> Bool { + return isConnected + } + + /// 关闭连接 + func closeConnection() { + sseTask?.cancel() + sseTask = nil + isConnected = false + } + + /// 获取工具定义列表,用于OpenAI请求 + /// - Returns: 工具定义列表 + func getToolMaps() -> [[String: Any]] { + return toolDefinitions + } + + /// 解析JSON参数 + /// - Parameter argumentsJson: JSON字符串 + /// - Returns: 解析后的参数字典 + func parseJsonArguments(_ argumentsJson: String) -> [String: Any] { + guard !argumentsJson.isEmpty, + let data = argumentsJson.data(using: .utf8), + let parsedArgs = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return parsedArgs + } + + /// 调用工具 + /// - Parameters: + /// - name: 工具名称 + /// - arguments: 工具参数 + /// - Returns: 工具调用结果 + func callTool(name: String, arguments: [String: Any]) async -> [String: Any] { + do { + // 检查是否为本地函数 + if let handler = localFunctions[name] { + let result = try await handler.handle(arguments: arguments) + return [ + "name": name, + "context": result + ] + } + + // 如果不是本地函数,尝试作为MCP工具调用 + if isConnected { + // 这里应该实现真正的MCP工具调用 + // 暂时返回一个模拟结果 + return [ + "name": name, + "context": "模拟MCP工具调用结果: \(name)(\(arguments))" + ] + } + + return ["context": "无法处理工具调用:找不到工具 \(name) 或MCP未连接"] + } catch { + print("\(TAG) 调用工具失败: \(error.localizedDescription)") + return ["context": "工具调用失败: \(error.localizedDescription)"] + } + } + + /// 关闭MCP客户端 + func close() { + closeConnection() + localFunctions.removeAll() + toolDefinitions.removeAll() + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift index 5df99e126..1eb798ef9 100644 --- a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit /// OpenAI服务异常 public class OpenAIError: Error { @@ -20,6 +21,15 @@ private class ToolCallInfo { } } +/// OpenAI服务流回调类型定义 +public typealias StreamCallback = ( + onToken: (String) -> Void, + onComplete: () -> Void, + onError: (Error) -> Void, + onFunctionCall: ([String: Any]) -> Void, + onFunctionCallResult: ([String: Any], [String: Any]) -> Void +) + /// OpenAI服务iOS原生实现 public class OpenAIService { private let TAG = "OpenAIService" @@ -27,6 +37,7 @@ public class OpenAIService { private var apiKey: String = "" private var isInitialized = false private var model: String = "doubao-1-5-lite-32k-250115" // 默认模型 + private var visionModel: String = "doubao-1-5-vision-pro-32k-250115" // 默认视觉模型 // 用于存储注册的函数 private var registeredFunctions: [[String: Any]] = [] @@ -34,6 +45,17 @@ public class OpenAIService { // URL会话 private let session: URLSession + // 当前流式请求任务 + private var currentStreamTask: URLSessionDataTask? + private var isCanceled = false + + // MCP客户端 + private var mcpClient: MCPClient? + private var isMcpInitialized = false + + // 是否自动处理MCP工具调用 + private var autoHandleMcpTools = true + public init() { // 创建URL会话配置 let config = URLSessionConfiguration.default @@ -42,18 +64,181 @@ public class OpenAIService { session = URLSession(configuration: config) } + /// 初始化MCP客户端 + /// - Parameter serverUrl: MCP服务器地址 + /// - Returns: 是否成功开始初始化 + public func initializeMcpClient(_ serverUrl: String) -> Bool { + if mcpClient != nil { + mcpClient?.close() + } + + mcpClient = MCPClient() + + // 开始异步连接 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self, let mcpClient = self.mcpClient else { return } + + let result = mcpClient.connectToSSE(serverUrl) + self.isMcpInitialized = result + print("\(self.TAG) MCP客户端初始化\(result ? "成功" : "失败")") + } + + return true // 立即返回,实际连接在后台进行 + } + + /// 检查MCP客户端是否已初始化 + /// - Returns: 是否已初始化 + public func isMcpInitialized() -> Bool { + return isMcpInitialized && mcpClient?.isConnected() == true + } + + /// 关闭MCP客户端 + /// - Returns: 是否成功关闭 + public func closeMcpClient() -> Bool { + mcpClient?.close() + mcpClient = nil + isMcpInitialized = false + return true + } + + /// 处理MCP工具调用 + /// - Parameter functionCall: 函数调用信息 + /// - Returns: 处理结果 + public func handleMcpToolCall(_ functionCall: [String: Any]) async -> String { + if mcpClient == nil || !isMcpInitialized { + return "MCP客户端未初始化" + } + + do { + // 获取函数名称 + guard let name = functionCall["name"] as? String, + let argumentsJson = functionCall["arguments"] as? String else { + return "函数调用信息不完整" + } + + // 解析参数 + let arguments = mcpClient?.parseJsonArguments(argumentsJson) ?? [:] + + // 调用工具 + let result = await mcpClient?.callTool(name: name, arguments: arguments) + return result?["context"] as? String ?? "处理MCP工具调用失败" + } catch { + print("\(TAG) 处理MCP工具调用失败: \(error.localizedDescription)") + return "处理MCP工具调用失败: \(error.localizedDescription)" + } + } + /// 创建用户消息 public func createUserMessage(content: String) -> [String: Any] { return ["role": "user", "content": content] } + /// 创建系统消息 + public func createSystemMessage(content: String) -> [String: Any] { + return ["role": "system", "content": content] + } + /// 创建助手消息 public func createAssistantMessage(content: String) -> [String: Any] { return ["role": "assistant", "content": content] } + + /// 创建带图片的用户消息 + public func createUserMessageWithImage(text: String, imageBase64: String) -> [String: Any] { + // 创建包含文本和图片的内容数组 + var contentArray: [[String: Any]] = [] + + // 添加文本部分 + if !text.isEmpty { + contentArray.append([ + "type": "text", + "text": text + ]) + } + + // 添加图片部分 + contentArray.append([ + "type": "image_url", + "image_url": [ + "url": "data:image/jpeg;base64,\(imageBase64)" + ] + ]) + + return ["role": "user", "content": contentArray] + } + + /// 将文件转换为Base64字符串 + public func fileToBase64(_ filePath: String, maxSizeKB: Int = 20480) -> String? { + do { + let fileURL = URL(fileURLWithPath: filePath) + + // 检查文件是否存在 + guard FileManager.default.fileExists(atPath: filePath) else { + print("\(TAG) 文件不存在: \(filePath)") + return nil + } + + // 读取图片 + guard let originalImage = UIImage(contentsOfFile: filePath) else { + print("\(TAG) 无法读取图片: \(filePath)") + return nil + } + + // 检查图片尺寸,限制最大为1024*1024 + var processedImage = originalImage + let maxDimension: CGFloat = 1024 + if originalImage.size.width > maxDimension || originalImage.size.height > maxDimension { + print("\(TAG) 图片尺寸超过限制,进行缩放: \(originalImage.size) -> \(maxDimension)") + + // 计算缩放比例,保持纵横比 + let widthRatio = maxDimension / originalImage.size.width + let heightRatio = maxDimension / originalImage.size.height + let ratio = min(widthRatio, heightRatio) + + let newWidth = originalImage.size.width * ratio + let newHeight = originalImage.size.height * ratio + + let newSize = CGSize(width: newWidth, height: newHeight) + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + originalImage.draw(in: CGRect(origin: .zero, size: newSize)) + if let resizedImage = UIGraphicsGetImageFromCurrentImageContext() { + processedImage = resizedImage + } + UIGraphicsEndImageContext() + + print("\(TAG) 缩放后图片尺寸: \(newSize)") + } + + // 压缩图片 + var imageData = processedImage.jpegData(compressionQuality: 0.9) + + // 检查文件大小,如果超出限制,继续压缩 + var compressionQuality: CGFloat = 0.9 + while let data = imageData, data.count > maxSizeKB * 1024 && compressionQuality > 0.1 { + compressionQuality -= 0.1 + imageData = processedImage.jpegData(compressionQuality: compressionQuality) + } + + guard let finalImageData = imageData else { + print("\(TAG) 无法压缩图片") + return nil + } + + // 检查最终大小 + if finalImageData.count > maxSizeKB * 1024 { + print("\(TAG) 压缩后图片仍然超出大小限制: \(finalImageData.count / 1024)KB > \(maxSizeKB)KB") + } + + // 转为Base64 + return finalImageData.base64EncodedString() + } catch { + print("\(TAG) 转换文件到Base64失败: \(error.localizedDescription)") + return nil + } + } /// 初始化OpenAI服务 - public func initialize(apiKey: String, baseUrl: String = "", model: String = "") -> Bool { + public func initialize(apiKey: String, baseUrl: String = "", model: String = "", mcpServer: String = "") -> Bool { self.apiKey = apiKey if !baseUrl.isEmpty { self.baseUrl = baseUrl @@ -61,6 +246,12 @@ public class OpenAIService { if !model.isEmpty { self.model = model } + + // 初始化MCP客户端 + if !mcpServer.isEmpty { + initializeMcpClient(mcpServer) + } + isInitialized = !apiKey.isEmpty return isInitialized } @@ -89,6 +280,76 @@ public class OpenAIService { } } + /// 取消当前流式请求 + public func cancelCurrentStream() { + isCanceled = true + currentStreamTask?.cancel() + currentStreamTask = nil + } + + /// 自动处理MCP工具调用 + private func autoHandleMcpToolCall( + functionCall: [String: Any], + messages: [[String: Any]], + systemPrompt: String, + callback: @escaping StreamCallback + ) { + Task { + do { + // 获取函数名称 + guard let name = functionCall["name"] as? String, + let argumentsJson = functionCall["arguments"] as? String else { + callback.onError(OpenAIError("函数调用信息不完整")) + return + } + + // 解析参数 + let arguments = mcpClient?.parseJsonArguments(argumentsJson) ?? [:] + + // 调用工具 + if let result = await mcpClient?.callTool(name: name, arguments: arguments) { + let functionResult = result["context"] as? String ?? "工具调用失败" + + // 创建结果对象,格式需要与Android匹配 + let functionCallResult: [String: Any] = ["result": functionResult] + + // 发送函数调用结果回调 + callback.onFunctionCallResult(functionCall, functionCallResult) + + // 发送函数调用结果 + sendFunctionCallResult( + messages: messages, + systemPrompt: systemPrompt, + functionCall: functionCall, + functionResult: functionResult, + callback: callback + ) + } else { + callback.onError(OpenAIError("工具调用失败")) + } + } catch { + print("\(TAG) 自动处理工具调用失败: \(error.localizedDescription)") + + // 失败时返回错误给回调函数 + let errorMessage = "工具调用失败: \(error.localizedDescription)" + + // 创建错误结果对象 + let errorResult: [String: Any] = ["error": errorMessage] + + // 发送函数调用结果 + callback.onFunctionCallResult(functionCall, errorResult) + + sendFunctionCallResult( + messages: messages, + systemPrompt: systemPrompt, + functionCall: functionCall, + functionResult: errorMessage, + callback: callback + ) + } + } + } + /// 发送消息(非流式输出) public func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { guard isInitialized, !apiKey.isEmpty else { @@ -239,7 +500,7 @@ public class OpenAIService { /// 发送消息(流式输出) public func sendMessageStream( messages: [[String: Any]], - systemPrompt: String, + systemPrompt: String = "", callback: @escaping StreamCallback ) { guard isInitialized, !apiKey.isEmpty else { @@ -247,34 +508,36 @@ public class OpenAIService { return } + // 重置取消状态 + isCanceled = false + + // 检查最后一条消息内容中是否包含图片,决定使用哪个模型 + var currentModel = model + if let lastMessage = messages.last, + let content = lastMessage["content"] as? [[String: Any]], + content.contains(where: { ($0["type"] as? String) == "image_url" }) { + currentModel = visionModel + } + // 构建请求体 var requestBody: [String: Any] = [ - "model": model, + "model": currentModel, "temperature": 0.7, "max_tokens": 2000, "stream": true ] // 构建完整消息数组,添加系统提示 - var fullMessages: [[String: Any]] = [ - ["role": "system", "content": systemPrompt] - ] + var fullMessages: [[String: Any]] = [] + if !systemPrompt.isEmpty { + fullMessages.append(["role": "system", "content": systemPrompt]) + } fullMessages.append(contentsOf: messages) requestBody["messages"] = fullMessages // 添加工具列表 if let toolMaps = mcpClient?.getToolMaps(), !toolMaps.isEmpty { - var tools: [[String: Any]] = [] - - for toolMap in toolMaps { - if let tool = toolMap as? [String: Any] { - tools.append(tool) - } - } - - if !tools.isEmpty { - requestBody["tools"] = tools - } + requestBody["tools"] = toolMaps } else if !registeredFunctions.isEmpty { var tools: [[String: Any]] = [] @@ -311,9 +574,25 @@ public class OpenAIService { request.httpBody = jsonData // 创建流式会话任务 - let delegate = SSEStreamDelegate(callback: callback) - let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let delegate = SSEStreamDelegate( + callback: callback, + autoHandleMcpTools: autoHandleMcpTools, + autoToolHandler: { [weak self] functionCall in + // 如果需要自动处理工具调用,调用处理方法 + guard let self = self, self.autoHandleMcpTools else { return } + self.autoHandleMcpToolCall( + functionCall: functionCall, + messages: messages, + systemPrompt: systemPrompt, + callback: callback + ) + } + ) + + let sessionConfig = URLSessionConfiguration.default + let session = URLSession(configuration: sessionConfig, delegate: delegate, delegateQueue: nil) let task = session.dataTask(with: request) + currentStreamTask = task task.resume() } @@ -327,10 +606,12 @@ public class OpenAIService { ) { do { // 构建完整消息数组 - var fullMessages: [[String: Any]] = [ - // 添加系统提示 - ["role": "system", "content": systemPrompt] - ] + var fullMessages: [[String: Any]] = [] + + // 添加系统提示 + if !systemPrompt.isEmpty { + fullMessages.append(["role": "system", "content": systemPrompt]) + } // 添加用户消息 fullMessages.append(contentsOf: messages) @@ -347,7 +628,7 @@ public class OpenAIService { // 添加函数调用消息 fullMessages.append([ "role": "assistant", - "content": NSNull(), + "content": nil as Any?, "tool_calls": [ [ "id": id, @@ -378,11 +659,20 @@ public class OpenAIService { /// SSE流委托实现 private class SSEStreamDelegate: NSObject, URLSessionDataDelegate { let callback: StreamCallback + let autoHandleMcpTools: Bool + let autoToolHandler: ([String: Any]) -> Void + private var buffer = Data() private var finalToolCalls: [Int: ToolCallInfo] = [:] - init(callback: @escaping StreamCallback) { + init( + callback: @escaping StreamCallback, + autoHandleMcpTools: Bool = true, + autoToolHandler: @escaping ([String: Any]) -> Void + ) { self.callback = callback + self.autoHandleMcpTools = autoHandleMcpTools + self.autoToolHandler = autoToolHandler super.init() } @@ -471,7 +761,7 @@ public class OpenAIService { } } } catch { - NSLog("解析JSON出错: \(error.localizedDescription)") + print("解析JSON出错: \(error.localizedDescription)") // 忽略解析错误,继续处理其他行 } } @@ -482,50 +772,45 @@ public class OpenAIService { if finalToolCalls.isEmpty { return false } // 只处理第一个工具调用 - guard let firstToolCall = finalToolCalls.values.first, firstToolCall.isValid else { return false } - - // 创建函数调用字典 - let functionCall: [String: Any] = [ - "name": firstToolCall.name, - "arguments": firstToolCall.arguments, - "id": firstToolCall.id - ] + for (_, toolCall) in finalToolCalls { + if toolCall.isValid { + // 创建函数调用字典 + let functionCall: [String: Any] = [ + "name": toolCall.name, + "arguments": toolCall.arguments, + "id": toolCall.id + ] + + // 调用回调 + callback.onFunctionCall(functionCall) + + // 如果需要自动处理工具调用 + if autoHandleMcpTools { + autoToolHandler(functionCall) + } + + return true + } + } - // 回调 - callback.onFunctionCall(functionCall) - return true + return false } // 处理完成 func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { - callback.onError(OpenAIError("请求失败: \(error.localizedDescription)")) + if (error as NSError).code == NSURLErrorCancelled { + // 请求被取消,无需处理 + return + } + + callback.onError(error) + } else { + // 如果没有处理任何工具调用且没有错误,则完成 + if finalToolCalls.isEmpty { + callback.onComplete() + } } } } - - /// 流式输出回调协议 - public typealias StreamCallback = (onToken: (String) -> Void, - onComplete: () -> Void, - onError: (Error) -> Void, - onFunctionCall: ([String: Any]) -> Void) - - /// 处理工具调用(函数调用)并回调 - private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) -> Bool { - if toolCalls.isEmpty { return false } - - // 只处理第一个工具调用 - guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return false } - - // 创建函数调用字典 - let functionCall: [String: Any] = [ - "name": firstToolCall.name, - "arguments": firstToolCall.arguments, - "id": firstToolCall.id - ] - - // 回调 - callback.onFunctionCall(functionCall) - return true - } } \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift index d919e2584..0284055a8 100644 --- a/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift @@ -26,7 +26,8 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler let apiKey = args["apiKey"] as? String { let baseUrl = args["baseUrl"] as? String ?? "" let model = args["model"] as? String ?? "" - let initialized = openAIService.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model) + let mcpServer = args["mcpServer"] as? String ?? "" + let initialized = openAIService.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model, mcpServer: mcpServer) result(initialized) } else { result(FlutterError(code: "INVALID_ARGUMENT", message: "初始化参数无效", details: nil)) @@ -46,12 +47,13 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler case "sendMessage": guard let args = call.arguments as? [String: Any], - let messagesRaw = args["messages"] as? [[String: Any]], - let systemPrompt = args["systemPrompt"] as? String else { + let messagesRaw = args["messages"] as? [[String: Any]] else { result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil)) return } + let systemPrompt = args["systemPrompt"] as? String ?? "" + // 在后台线程执行 DispatchQueue.global(qos: .userInitiated).async { do { @@ -70,12 +72,13 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler case "sendMessageStream": guard let args = call.arguments as? [String: Any], - let messagesRaw = args["messages"] as? [[String: Any]], - let systemPrompt = args["systemPrompt"] as? String else { + let messagesRaw = args["messages"] as? [[String: Any]] else { result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil)) return } + let systemPrompt = args["systemPrompt"] as? String ?? "" + // 检查事件接收器 guard let eventSink = self.eventSink else { result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil)) @@ -106,7 +109,35 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler onFunctionCall: { functionCall in // 发送函数调用事件 DispatchQueue.main.async { - eventSink(["type": "functionCall", "content": functionCall]) + // 转换为字符串 + do { + let data = try JSONSerialization.data(withJSONObject: functionCall) + if let jsonString = String(data: data, encoding: .utf8) { + eventSink(["type": "function_call", "content": jsonString]) + } else { + eventSink(["type": "error", "content": "函数调用数据序列化失败"]) + } + } catch { + eventSink(["type": "error", "content": "函数调用数据序列化失败: \(error.localizedDescription)"]) + } + } + }, + onFunctionCallResult: { functionCall, functionCallResult in + // 发送函数调用结果事件 + DispatchQueue.main.async { + do { + let functionCallData = try JSONSerialization.data(withJSONObject: functionCall) + let resultData = try JSONSerialization.data(withJSONObject: functionCallResult) + + if let functionCallString = String(data: functionCallData, encoding: .utf8), + let resultString = String(data: resultData, encoding: .utf8) { + eventSink(["type": "function_call_result", "function_call": functionCallString, "result": resultString]) + } else { + eventSink(["type": "error", "content": "函数调用结果数据序列化失败"]) + } + } catch { + eventSink(["type": "error", "content": "函数调用结果数据序列化失败: \(error.localizedDescription)"]) + } } } ) @@ -122,13 +153,14 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler case "sendFunctionCallResult": guard let args = call.arguments as? [String: Any], let messagesRaw = args["messages"] as? [[String: Any]], - let systemPrompt = args["systemPrompt"] as? String, let functionCallRaw = args["functionCall"] as? [String: Any], let functionResult = args["functionResult"] as? String else { result(FlutterError(code: "INVALID_ARGUMENT", message: "发送函数调用结果参数无效", details: nil)) return } + let systemPrompt = args["systemPrompt"] as? String ?? "" + // 检查事件接收器 guard let eventSink = self.eventSink else { result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil)) @@ -159,7 +191,35 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler onFunctionCall: { functionCall in // 发送函数调用事件 DispatchQueue.main.async { - eventSink(["type": "functionCall", "content": functionCall]) + // 转换为字符串 + do { + let data = try JSONSerialization.data(withJSONObject: functionCall) + if let jsonString = String(data: data, encoding: .utf8) { + eventSink(["type": "function_call", "content": jsonString]) + } else { + eventSink(["type": "error", "content": "函数调用数据序列化失败"]) + } + } catch { + eventSink(["type": "error", "content": "函数调用数据序列化失败: \(error.localizedDescription)"]) + } + } + }, + onFunctionCallResult: { functionCall, functionCallResult in + // 发送函数调用结果事件 + DispatchQueue.main.async { + do { + let functionCallData = try JSONSerialization.data(withJSONObject: functionCall) + let resultData = try JSONSerialization.data(withJSONObject: functionCallResult) + + if let functionCallString = String(data: functionCallData, encoding: .utf8), + let resultString = String(data: resultData, encoding: .utf8) { + eventSink(["type": "function_call_result", "function_call": functionCallString, "result": resultString]) + } else { + eventSink(["type": "error", "content": "函数调用结果数据序列化失败"]) + } + } catch { + eventSink(["type": "error", "content": "函数调用结果数据序列化失败: \(error.localizedDescription)"]) + } } } ) @@ -196,6 +256,76 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler result(FlutterError(code: "INVALID_ARGUMENT", message: "创建助手消息参数无效", details: nil)) } + case "createUserMessageWithImage": + if let args = call.arguments as? [String: Any], + let text = args["text"] as? String, + let imageBase64 = args["imageBase64"] as? String { + let message = openAIService.createUserMessageWithImage(text: text, imageBase64: imageBase64) + result(message) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "创建图片消息参数无效", details: nil)) + } + + case "fileToBase64": + if let args = call.arguments as? [String: Any], + let filePath = args["filePath"] as? String { + let maxSizeKB = args["maxSizeKB"] as? Int ?? 20480 + let base64 = openAIService.fileToBase64(filePath, maxSizeKB: maxSizeKB) + if let base64 = base64 { + result(base64) + } else { + result(FlutterError(code: "FILE_ERROR", message: "文件转换失败", details: nil)) + } + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "文件转换参数无效", details: nil)) + } + + case "initializeMcpClient": + if let args = call.arguments as? [String: Any], + let serverUrl = args["serverUrl"] as? String { + let success = openAIService.initializeMcpClient(serverUrl) + result(success) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "初始化MCP客户端参数无效", details: nil)) + } + + case "isMcpInitialized": + result(openAIService.isMcpInitialized()) + + case "closeMcpClient": + result(openAIService.closeMcpClient()) + + case "handleMcpToolCall": + if let args = call.arguments as? [String: Any], + let functionCallJson = args["functionCall"] as? String { + + // 解析函数调用JSON + guard let data = functionCallJson.data(using: .utf8), + let functionCall = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "无效的函数调用JSON", details: nil)) + return + } + + // 在后台线程执行 + Task { + do { + let toolResult = await openAIService.handleMcpToolCall(functionCall) + + // 在主线程返回结果 + DispatchQueue.main.async { + result(toolResult) + } + } catch { + // 在主线程返回错误 + DispatchQueue.main.async { + result(FlutterError(code: "MCP_TOOL_ERROR", message: "处理MCP工具调用失败: \(error.localizedDescription)", details: nil)) + } + } + } + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "处理MCP工具调用参数无效", details: nil)) + } + default: result(FlutterMethodNotImplemented) } diff --git a/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift b/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift new file mode 100644 index 000000000..65c8a8add --- /dev/null +++ b/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift @@ -0,0 +1,514 @@ +import Foundation +import UIKit +import CoreLocation +import Contacts +import EventKit + +/// 系统功能处理器 +class SystemFunctionHandler { + private let TAG = "SystemFunctionHandler" + private let context: Any + static let ACTION_EXIT_INTERACTION = "com.yunqiinnovation.deepsound.action.EXIT_INTERACTION" + + /// 初始化系统功能处理器 + /// - Parameter context: 应用上下文 + init(context: Any) { + self.context = context + } + + /// 注册所有系统功能 + /// - Parameter client: MCP客户端 + func registerAllFunctions(client: MCPClient) { + // 注册退出交互函数 + registerExitInteractionFunction(client) + + // 注册翻译模式函数 + registerTranslationModeFunction(client) + + // 注册发送短信函数 + registerSendTextMessageFunction(client) + + // 注册拨打电话函数 + registerMakePhoneCallFunction(client) + + // 注册打开应用函数 + registerOpenAppFunction(client) + + // 注册设置提醒函数 + registerSetReminderFunction(client) + + // 注册获取当前时间函数 + registerGetCurrentTimeFunction(client) + + // 注册获取当前位置函数 + registerGetCurrentLocationFunction(client) + + print("\(TAG) 所有系统功能注册完成") + } + + /// 注册退出交互函数 + private func registerExitInteractionFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [:], + "required": [] + ] + + // 创建函数处理器 + let handler = ExitInteractionHandler() + + // 注册函数 + client.registerLocalFunction( + name: "exit_interaction", + description: "结束当前交互", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册退出交互功能") + } + + /// 注册翻译模式函数 + private func registerTranslationModeFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [:], + "required": [] + ] + + // 创建函数处理器 + let handler = TranslationModeHandler() + + // 注册函数 + client.registerLocalFunction( + name: "enter_translation_mode", + description: "用户请求进入实时翻译模式时,启动实时翻译功能", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册翻译模式功能") + } + + /// 注册发送短信函数 + private func registerSendTextMessageFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [ + "contact": [ + "type": "string", + "description": "联系人姓名或电话号码" + ], + "message": [ + "type": "string", + "description": "短信内容" + ] + ], + "required": ["contact", "message"] + ] + + // 创建函数处理器 + let handler = SendTextMessageHandler() + + // 注册函数 + client.registerLocalFunction( + name: "send_text_message", + description: "发送短信", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册发送短信功能") + } + + /// 注册拨打电话函数 + private func registerMakePhoneCallFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [ + "contact": [ + "type": "string", + "description": "联系人姓名或电话号码" + ] + ], + "required": ["contact"] + ] + + // 创建函数处理器 + let handler = MakePhoneCallHandler() + + // 注册函数 + client.registerLocalFunction( + name: "make_phone_call", + description: "拨打电话", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册拨打电话功能") + } + + /// 注册打开应用函数 + private func registerOpenAppFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [ + "app_name": [ + "type": "string", + "description": "应用名称" + ] + ], + "required": ["app_name"] + ] + + // 创建函数处理器 + let handler = OpenAppHandler() + + // 注册函数 + client.registerLocalFunction( + name: "open_app", + description: "当用户请求打开特定应用时,启动相应应用", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册打开应用功能") + } + + /// 注册设置提醒函数 + private func registerSetReminderFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [ + "title": [ + "type": "string", + "description": "提醒标题" + ], + "content": [ + "type": "string", + "description": "提醒内容" + ], + "time": [ + "type": "string", + "description": "提醒时间,格式为'yyyy-MM-dd HH:mm',如'2023-12-31 14:30'" + ] + ], + "required": ["title", "time"] + ] + + // 创建函数处理器 + let handler = SetReminderHandler() + + // 注册函数 + client.registerLocalFunction( + name: "set_reminder", + description: "设置提醒事项", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册设置提醒功能") + } + + /// 注册获取当前时间函数 + private func registerGetCurrentTimeFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [ + "format": [ + "type": "string", + "description": "时间格式,可选,默认为标准格式" + ] + ], + "required": [] + ] + + // 创建函数处理器 + let handler = GetCurrentTimeHandler() + + // 注册函数 + client.registerLocalFunction( + name: "get_current_time", + description: "获取当前日期和时间", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册获取当前时间功能") + } + + /// 注册获取当前位置函数 + private func registerGetCurrentLocationFunction(_ client: MCPClient) { + // 定义函数参数 + let parameters: [String: Any] = [ + "type": "object", + "properties": [:], + "required": [] + ] + + // 创建函数处理器 + let handler = GetCurrentLocationHandler() + + // 注册函数 + client.registerLocalFunction( + name: "get_current_location", + description: "获取当前地理位置", + parameters: parameters, + handler: handler + ) + print("\(TAG) 已注册获取当前位置功能") + } +} + +/// 退出交互处理器 +class ExitInteractionHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + // 发送通知和广播 + NotificationCenter.default.post( + name: Notification.Name(SystemFunctionHandler.ACTION_EXIT_INTERACTION), + object: nil, + userInfo: ["timestamp": Date().timeIntervalSince1970] + ) + + return "{\"result\": \"已结束当前交互\"}" + } +} + +/// 翻译模式处理器 +class TranslationModeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + // 发送通知 + NotificationCenter.default.post( + name: Notification.Name("com.yunqiinnovation.deepsound.ACTION_ENTER_TRANSLATION_MODE"), + object: nil, + userInfo: ["timestamp": Date().timeIntervalSince1970] + ) + + return "{\"result\": \"已进入实时翻译模式\"}" + } +} + +/// 发送短信处理器 +class SendTextMessageHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + guard let contact = arguments["contact"] as? String, + let message = arguments["message"] as? String else { + return "{\"result\": \"发送短信失败:缺少联系人或消息内容\"}" + } + + // iOS不允许应用直接发送短信,但可以打开短信应用 + if let url = URL(string: "sms:\(contact)&body=\(message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")") { + DispatchQueue.main.async { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + return "{\"result\": \"已打开短信应用,收件人:\(contact)\"}" + } + + return "{\"result\": \"发送短信失败:无法打开短信应用\"}" + } +} + +/// 拨打电话处理器 +class MakePhoneCallHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + guard let contact = arguments["contact"] as? String else { + return "{\"result\": \"拨打电话失败:缺少联系人\"}" + } + + let phoneNumber = contact.replacingOccurrences(of: "[^+0-9]", with: "", options: .regularExpression) + + if let url = URL(string: "tel://\(phoneNumber)") { + DispatchQueue.main.async { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + return "{\"result\": \"正在拨打\(contact)的电话\"}" + } + + return "{\"result\": \"拨打电话失败:无法拨打电话\"}" + } +} + +/// 打开应用处理器 +class OpenAppHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + guard let appName = arguments["app_name"] as? String else { + return "{\"result\": \"打开应用失败:缺少应用名称\"}" + } + + // iOS没有直接打开其他应用的API,只能通过URL Scheme + // 这里只是模拟返回成功,实际上无法在iOS上实现这个功能 + return "{\"result\": \"打开应用功能在iOS上受限,无法直接打开\(appName)\"}" + } +} + +/// 设置提醒处理器 +class SetReminderHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + guard let title = arguments["title"] as? String, + let timeStr = arguments["time"] as? String else { + return "{\"result\": \"设置提醒失败:缺少标题或时间\"}" + } + + let content = arguments["content"] as? String ?? "" + + // 解析时间 + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" + guard let date = dateFormatter.date(from: timeStr) else { + return "{\"result\": \"设置提醒失败:时间格式无效,请使用'yyyy-MM-dd HH:mm'格式\"}" + } + + // 使用EventKit添加提醒 + let eventStore = EKEventStore() + let event = EKEvent(eventStore: eventStore) + event.title = title + event.notes = content + event.startDate = date + event.endDate = date.addingTimeInterval(3600) // 1小时后 + event.calendar = eventStore.defaultCalendarForNewEvents + + do { + try eventStore.save(event, span: .thisEvent) + return "{\"result\": \"已创建提醒: \(title), 时间: \(timeStr)\"}" + } catch { + return "{\"result\": \"设置提醒失败:\(error.localizedDescription)\"}" + } + } +} + +/// 获取当前时间处理器 +class GetCurrentTimeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + let format = arguments["format"] as? String + + let calendar = Calendar.current + let date = Date() + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "zh_CN") + + let timeStr: String + switch format?.lowercased() { + case "short": + dateFormatter.dateFormat = "HH:mm" + timeStr = dateFormatter.string(from: date) + case "date": + dateFormatter.dateFormat = "yyyy年MM月dd日" + timeStr = dateFormatter.string(from: date) + case "time": + dateFormatter.dateFormat = "HH:mm:ss" + timeStr = dateFormatter.string(from: date) + case "full": + dateFormatter.dateFormat = "yyyy年MM月dd日 EEEE HH:mm:ss" + timeStr = dateFormatter.string(from: date) + case "iso": + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + timeStr = dateFormatter.string(from: date) + default: + dateFormatter.dateFormat = "yyyy年MM月dd日 HH:mm" + timeStr = dateFormatter.string(from: date) + } + + // 获取星期几 + let weekDay: String + if format == "full" { + weekDay = "" // 已经包含在dateFormatter里 + } else { + let weekDayNum = calendar.component(.weekday, from: date) + switch weekDayNum { + case 1: weekDay = "星期日" + case 2: weekDay = "星期一" + case 3: weekDay = "星期二" + case 4: weekDay = "星期三" + case 5: weekDay = "星期四" + case 6: weekDay = "星期五" + case 7: weekDay = "星期六" + default: weekDay = "" + } + } + + // 格式化结果 + let resultTime: String + if format == "short" || format == "time" { + resultTime = timeStr + } else if format == "full" { + resultTime = timeStr // already contains weekday + } else { + resultTime = "\(timeStr) \(weekDay)" + } + + return "{\"result\": \"\(resultTime)\", \"time\": \"\(resultTime)\"}" + } +} + +/// 获取当前位置处理器 +class GetCurrentLocationHandler: FunctionHandler { + private let locationManager = CLLocationManager() + private var locationCompletionHandler: ((String, Bool) -> Void)? + + func handle(arguments: [String: Any]) async throws -> String { + return await withCheckedContinuation { continuation in + self.getLocation { message, success in + continuation.resume(returning: "{\"result\": \"\(message)\", \"success\": \(success)}") + } + } + } + + private func getLocation(completion: @escaping (String, Bool) -> Void) { + locationCompletionHandler = completion + + // 检查权限 + let authStatus = CLLocationManager.authorizationStatus() + if authStatus == .denied || authStatus == .restricted { + completion("需要位置权限才能获取当前位置", false) + return + } + + // 检查位置服务是否开启 + if !CLLocationManager.locationServicesEnabled() { + completion("位置服务未启用,请打开GPS或网络定位", false) + return + } + + // 如果有最后的位置,使用它 + if let lastLocation = locationManager.location { + let message = "当前位置: 经度 \(lastLocation.coordinate.longitude), 纬度 \(lastLocation.coordinate.latitude)" + completion(message, true) + return + } + + // 否则请求更新 + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + locationManager.requestWhenInUseAuthorization() + locationManager.startUpdatingLocation() + + // 设置超时 + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in + if let handler = self?.locationCompletionHandler { + self?.locationManager.stopUpdatingLocation() + handler("获取位置超时,请稍后重试", false) + self?.locationCompletionHandler = nil + } + } + } +} + +// 扩展以接收位置更新 +extension GetCurrentLocationHandler: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last, let handler = locationCompletionHandler else { return } + + manager.stopUpdatingLocation() + + let message = "当前位置: 经度 \(location.coordinate.longitude), 纬度 \(location.coordinate.latitude)" + handler(message, true) + locationCompletionHandler = nil + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + manager.stopUpdatingLocation() + locationCompletionHandler?("获取位置失败: \(error.localizedDescription)", false) + locationCompletionHandler = nil + } +} \ No newline at end of file From cc6758699ded48b4dd53f46bf2cf5d9c1ebad242 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Fri, 9 May 2025 19:44:32 +0100 Subject: [PATCH 6/7] add --- .../agent_service/AgentService.kt | 17 +- .../yunqiinnovation/agent_service/BleAgent.kt | 3 +- .../agent_service/BleMediaService.kt | 18 +- .../agent_service/BleWakeReceiver.kt | 2 +- .../azure_speech/AzureAsrHelper.kt | 16 +- .../ble_service/android/build.gradle.kts | 3 +- .../yunqiinnovation/ble_service/BleService.kt | 154 +++++++++++++++--- .../open_ai_service/OpenAIService.kt | 2 +- .../.gradle/8.10/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 +- 11 files changed, 168 insertions(+), 49 deletions(-) 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 60cdfdca7..35ed506ed 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 @@ -50,7 +50,7 @@ object AgentService : CoroutineScope { private var openaiModel: String = "gpt-3.5-turbo" // Azure服务 - var azureAsrHelper: AzureAsrHelper? = null + private var azureAsrHelper: AzureAsrHelper? = null private var azureTtsHelper: AzureTtsHelper? = null // OpenAI服务 @@ -288,11 +288,11 @@ object AgentService : CoroutineScope { // 检查录音权限 - if (!isExternalActive||!checkRecordAudioPermission()) { - FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") - sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) - return false - } + // if (!isExternalActive||!checkRecordAudioPermission()) { + // FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") + // sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) + // return false + // } isRecognitionActive = true hasSpeechDetected = false @@ -392,6 +392,9 @@ object AgentService : CoroutineScope { } } + fun pushAudioData(data: ByteArray) { + azureAsrHelper?.pushAudioData(data) + } /** * 停止语音识别 */ @@ -401,8 +404,8 @@ object AgentService : CoroutineScope { FileLogger.d(TAG, "停止语音识别") try { - BleService.closeCodec() azureAsrHelper?.stopContinuousRecognition() + BleService.closeCodec() isRecognitionActive = false stopIdleCheck() FileLogger.d(TAG, "语音识别已停止") diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 368dbe967..f362b478d 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -128,7 +128,8 @@ object BleAgent : BleService.Callback, AgentServiceListener { } override fun onAudioDataReceived(data: ByteArray) { - AgentService.azureAsrHelper?.pushAudioData(data) + // FileLogger.d(TAG, "onAudioDataReceived, data: ${data.size}") + AgentService.pushAudioData(data) // 可选:处理音频数据 } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt index b82ec1291..21c2cf5c4 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt @@ -353,15 +353,15 @@ class BleMediaService : Service(), CoroutineScope { * 包含防抖逻辑,避免短时间内重复触发 */ private fun handleMediaAction() { - FileLogger.i(TAG, "handleMediaAction") - // 防抖处理逻辑 - val currentTime = System.currentTimeMillis() - if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) { - lastActionTime = currentTime - BleAgent.onWakeupSignalReceived() - } else { - FileLogger.i(TAG, "媒体操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复请求") - } + // FileLogger.i(TAG, "handleMediaAction") + // // 防抖处理逻辑 + // val currentTime = System.currentTimeMillis() + // if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) { + // lastActionTime = currentTime + // BleAgent.onWakeupSignalReceived() + // } else { + // FileLogger.i(TAG, "媒体操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复请求") + // } } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt index f979a1c03..2c8479e29 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt @@ -47,7 +47,7 @@ class BleWakeReceiver : BroadcastReceiver() { if(!BleMediaService.isServiceRunning()) { val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK) am.dispatchMediaKeyEvent(keyEvent) // val mediaIntent = Intent(Intent.ACTION_MEDIA_BUTTON) // mediaIntent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index dbd520dc8..f0b58db68 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -23,7 +23,7 @@ import com.microsoft.cognitiveservices.speech.audio.PullAudioInputStreamCallback import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat import java.util.concurrent.BlockingQueue import java.util.concurrent.LinkedBlockingQueue - +import java.util.concurrent.TimeUnit /** * Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 * @@ -122,7 +122,7 @@ class AzureAsrHelper(private val context: Context) { } // SetProperty(PropertyId.Speech_SegmentationStrategy, "Semantic"); } - + // 创建识别器 return setupRecognizer() } catch (e: Exception) { @@ -308,7 +308,7 @@ class AzureAsrHelper(private val context: Context) { if (isContinuousRecognitionActive) { return true } - this.audioSourceType = audioSourceType + this.audioSourceType = audioSourceType // 重置识别器 if (!setupRecognizer()) { callback.onError("重置识别器失败") @@ -377,11 +377,12 @@ class AzureAsrHelper(private val context: Context) { recognizer?.sessionStopped?.addEventListener( EventHandler { _, _ -> FileLogger.d(tag, "识别会话已结束") + // 直接在当前线程调用回调 + callback.onSessionStopped() isContinuousRecognitionActive = false stopAudioProcessing() - // 直接在当前线程调用回调 - callback.onSessionStopped() + } ) @@ -430,9 +431,12 @@ class AzureAsrHelper(private val context: Context) { isContinuousRecognitionActive = false return true } + if (audioSourceType == AudioSourceType.EXTERNAL) { + pushAudioData(ByteArray(0)) + } // 直接停止连续识别(SDK内部已是异步操作) - recognizer?.stopContinuousRecognitionAsync() + recognizer?.stopContinuousRecognitionAsync()?.get(1000, TimeUnit.MILLISECONDS) // 停止音频处理 stopAudioProcessing() diff --git a/local_plugins/ble_service/android/build.gradle.kts b/local_plugins/ble_service/android/build.gradle.kts index b1b9b4095..72a6745e6 100644 --- a/local_plugins/ble_service/android/build.gradle.kts +++ b/local_plugins/ble_service/android/build.gradle.kts @@ -28,7 +28,8 @@ android { dependencies { implementation("androidx.core:core-ktx:1.13.1") + // 使用从本地Maven仓库引用的AAR依赖 + implementation("com.jieli.audio:jl_audio_decode:2.0.0") implementation(project(":deepvoice_utils")) implementation(project(":azure_speech")) - implementation(project(":jl_opus")) } \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 5a1e020d5..4ec732c33 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -23,7 +23,12 @@ import androidx.core.content.ContextCompat import java.util.concurrent.ConcurrentHashMap import android.content.pm.ServiceInfo import java.util.concurrent.CopyOnWriteArrayList -import com.yunqiinnovation.jl_opus.JlOpusPlugin +// Import Jieli Opus SDK classes +import com.jieli.jl_audio_decode.opus.model.OpusOption +import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback +import com.jieli.jl_audio_decode.callback.OnStateCallback +import com.jieli.jl_audio_decode.opus.OpusManager +import com.jieli.jl_audio_decode.exceptions.OpusException /** * BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能 @@ -86,8 +91,10 @@ object BleService { // 回调集合 - 使用线程安全的集合存储所有回调 private val callbacks = CopyOnWriteArrayList() - // 获取插件实例 - private val opusPlugin = JlOpusPlugin() + // Opus解码器实例 + private var opusManager: OpusManager? = null + private val mainHandler = Handler(Looper.getMainLooper()) + // 初始化状态 private var isInitialized = false @@ -112,6 +119,16 @@ object BleService { // 初始化Handler scanHandler = Handler(Looper.getMainLooper()) + // 初始化OpusManager + try { + opusManager = OpusManager() + FileLogger.d(TAG, "OpusManager初始化成功") + } catch (e: OpusException) { + FileLogger.e(TAG, "OpusManager初始化失败: ${e.message}", e) + // 根据需要决定是否因为Opus初始化失败而返回false + } + + isInitialized = true FileLogger.d(TAG, "BLE服务初始化成功") return true @@ -424,13 +441,7 @@ object BleService { // 设置主服务的通知 setupNotifications(g, notifyChar) //开启解码 - opusPlugin?.startOpusDecodeStream(false, 1, 16000, 40, object : - JlOpusPlugin.CallbackOpus { - override fun onAudioDataReceivedOpus(pcmData: ByteArray) { - // 回调给上层 - notifyAudioDataReceived(pcmData) - } - }) + } override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) { @@ -512,18 +523,21 @@ object BleService { /** * 处理音频数据 - * @param data 音频数据 + * @param data 音频数据 (Opus) */ private fun processAudioData(data: ByteArray) { try { - - opusPlugin?.handleDecodeRequest(data) - + if (opusManager?.isDecodeStream == true) { + opusManager?.writeAudioStream(data) + } else { + // FileLogger.d(TAG, "Opus解码流未启动,忽略音频数据") + } } catch (e: Exception) { FileLogger.e(TAG, "处理音频数据异常: ${e.message}", e) } } + /** * 处理设备响应数据 - 帧头为0xBB */ @@ -841,7 +855,7 @@ object BleService { BleConst.CMD_WAKE_UP -> { FileLogger.i(TAG, "收到唤醒指令") - // 接收到唤醒信号,打开编码器 + // 接收到唤醒信号,打开编码器 (设备侧) openEncoder() notifyWakeupSignalReceived() @@ -862,6 +876,75 @@ object BleService { } } + // ====================================================================================================== + // Opus 解码相关 + // ====================================================================================================== + + private fun startOpusStreamDecoding( + hasHeader: Boolean = false, // 通常BLE传输的Opus没有文件头 + channel: Int = 1, + sampleRate: Int = 16000, // 确认设备端Opus编码采样率 + packetSize: Int = 40 // 确认设备端Opus编码帧长 + ): Boolean { + if (opusManager == null) { + FileLogger.e(TAG, "OpusManager未初始化,无法开始解码") + return false + } + // 如果已经在解码流,先停止 + if (opusManager?.isDecodeStream == true) { + opusManager?.stopDecodeStream() + FileLogger.d(TAG, "已停止正在进行的Opus解码流") + } + + val option = OpusOption() + .setHasHead(hasHeader) + .setChannel(channel) + .setSampleRate(sampleRate) + .setPacketSize(packetSize) + + FileLogger.d(TAG, "准备开始Opus数据流解码, 参数: $option") + + var streamStartedSuccessfully = false + opusManager?.startDecodeStream(option, object : OnDecodeStreamCallback { + override fun onDecodeStream(data: ByteArray?) { + if (data != null) { + // FileLogger.d(TAG, "Opus解码数据: ${data.size} bytes") + mainHandler.post { notifyAudioDataReceived(data) } + } + } + + override fun onStart() { + streamStartedSuccessfully = true + FileLogger.i(TAG, "Opus数据流解码已开始") + // 可以通过回调通知上层解码已开始 + } + + override fun onComplete(outPath: String?) { + FileLogger.i(TAG, "Opus数据流解码完成: $outPath (通常流式解码不会调用此方法)") + } + + override fun onError(code: Int, message: String?) { + FileLogger.e(TAG, "Opus数据流解码错误: [$code] $message") + // 可以通过回调通知上层解码错误 + } + }) + // 注意:startDecodeStream是异步启动的,这里的返回值可能不准确反映是否真的成功启动。 + // onStart回调才是真正启动成功的标志。为了简单,我们暂时认为调用即尝试启动。 + // 后续可以改造为返回Future或通过回调传递启动结果。 + return true // 暂定为调用即成功 + } + + private fun stopOpusStreamDecoding(): Boolean { + if (opusManager?.isDecodeStream == true) { + opusManager?.stopDecodeStream() + FileLogger.i(TAG, "已停止Opus数据流解码") + return true + } + FileLogger.d(TAG, "Opus数据流未在解码或OpusManager未初始化") + return false + } + + // ====================================================================================================== // 公开的命令接口 // ====================================================================================================== @@ -902,11 +985,13 @@ object BleService { */ fun closeCodec(): Boolean { FileLogger.i(TAG, "关闭编解码...") + + stopOpusStreamDecoding() // 停止Opus解码流 return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_CLOSE.toByte(), - BleConst.AUDIO_CHANNEL_RIGHT.toByte()) + BleConst.AUDIO_CHANNEL_RIGHT.toByte()) // 或其他默认声道 ) } @@ -915,10 +1000,7 @@ object BleService { */ fun openDecoder(): Boolean { FileLogger.i(TAG, "打开解码...") - return sendCommand( - BleConst.CMD_CONTROL_CODEC.toByte(), - byteArrayOf(BleConst.CODEC_CONTROL_DECODE_ON.toByte()) - ) + return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_DECODE_ON.toByte())) } /** @@ -933,16 +1015,33 @@ object BleService { * 控制编解码 - 打开编码 */ fun openEncoder(): Boolean { - FileLogger.i(TAG, "打开编码...") + FileLogger.i(TAG, "打开编码 (设备侧)...") + + val opusStarted = startOpusStreamDecoding( + hasHeader = false, // BLE传输的Opus数据通常无文件头 + channel = 1, // 单声道 + sampleRate = 16000,// Opus推荐16kHz或更高 + packetSize = 40 // Opus常用帧长,如20ms, 40ms, 60ms。 (16000Hz * 1channel * 2bytes/sample * 0.04s = 1280 bytes/frame if PCM) + // packetSize for Opus is typically in ms, e.g. 20, 40, 60. + // The library might expect this in a different unit or derive it. + // Given JlOpusPlugin.kt, packetSize refers to Opus encoded frame duration in ms. + ) + + + // 注意:此方法通常是控制设备端开始编码并发送数据。 + // App侧通常是打开解码器(openDecoder)来接收和处理这些数据。 + // 如果App也需要编码(例如发送语音到设备),则需要实现Opus编码逻辑。 + // 目前仅发送命令通知设备开始编码。 return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( BleConst.CODEC_CONTROL_ENCODE_ON.toByte(), - BleConst.AUDIO_CHANNEL_RIGHT.toByte() + BleConst.AUDIO_CHANNEL_RIGHT.toByte() // 或设备期望的声道 ) ) } + /** * 设置声道模式 * @param channelMode 声道模式:AUDIO_CHANNEL_LEFT/AUDIO_CHANNEL_RIGHT/AUDIO_CHANNEL_STEREO @@ -1026,8 +1125,19 @@ object BleService { stopScan() scanHandler.removeCallbacksAndMessages(null) disconnectGatt() + // 释放OpusManager + opusManager?.let { + if (it.isDecodeStream) { + it.stopDecodeStream() + } + it.release() + } + opusManager = null + FileLogger.d(TAG, "OpusManager已释放") + isInitialized = false // 标记为未初始化 } + // ====================================================================================================== // 协议命令实现 // ====================================================================================================== diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index 43a9efcfc..9d3a68ed0 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -609,7 +609,7 @@ class OpenAIService(private val context: Context? = null) { for (i in 0 until toolCalls.length()) { val toolCall = toolCalls.getJSONObject(i) val index = toolCall.getInt("index") - Log.e(TAG, "解析道AI需要调用工具: $toolCall") + // Log.e(TAG, "解析道AI需要调用工具: $toolCall") // 创建或获取现有的工具调用信息 val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() } diff --git a/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock b/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock index 5daa4e92e075dff9f616090dc6d87cf35b3859c7..666b59b98c85050cdf304292b694f6a135bfece4 100644 GIT binary patch literal 17 UcmZQRo$b{-r|q*H0|dwc05?hmf&c&j literal 17 UcmZQRo$b{-r|q*H0|dwb05?bkfB*mh diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock index bbd82acf731ad1e0c071b7874e67f48e088600cf..fafba6f95c378aa6a0e908a1943ef52331b26ead 100644 GIT binary patch literal 17 TcmZQ}Dp$CADo{Lz0Rl7tDJuhL literal 17 TcmZQ}Dp$CADo{Lz0RmJ2DJKJD diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties b/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties index 60e8a4931..06083f70c 100644 --- a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties +++ b/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Fri May 02 14:33:07 IST 2025 +#Fri May 09 15:56:53 IST 2025 gradle.version=8.10 From 98101dd78186bc130000a543e4255df4145e531f Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Sat, 10 May 2025 09:57:59 +0800 Subject: [PATCH 7/7] ble_service --- lib/core/bindings/initial_binding.dart | 9 +- lib/data/services/music_manager.dart | 27 +++ .../agent/controllers/agent_controller.dart | 204 ++++++++++-------- lib/modules/agent/views/agent_view.dart | 104 +++++---- lib/modules/agent/views/message_bubble.dart | 132 ++++++++---- local_plugins/agent_service/pubspec.yaml | 2 + local_plugins/music_service/.gitignore | 33 +++ local_plugins/music_service/.metadata | 33 +++ local_plugins/music_service/CHANGELOG.md | 3 + local_plugins/music_service/LICENSE | 1 + local_plugins/music_service/README.md | 15 ++ .../music_service/analysis_options.yaml | 4 + .../music_service/android/.gitignore | 9 + .../music_service/android/build.gradle | 70 ++++++ .../music_service/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 11 + .../music_service/MusicService.kt | 153 +++++++++++++ .../music_service/MusicServicePlugin.kt | 46 ++++ .../music_service/MusicServiceStarter.kt | 19 ++ local_plugins/music_service/ios/.gitignore | 38 ++++ .../music_service/ios/Assets/.gitkeep | 0 .../ios/Classes/MusicServicePlugin.swift | 19 ++ .../ios/Resources/PrivacyInfo.xcprivacy | 14 ++ .../music_service/ios/music_service.podspec | 29 +++ .../music_service/lib/music_service.dart | 55 +++++ local_plugins/music_service/pubspec.yaml | 72 +++++++ pubspec.yaml | 2 + 27 files changed, 924 insertions(+), 181 deletions(-) create mode 100644 lib/data/services/music_manager.dart create mode 100644 local_plugins/music_service/.gitignore create mode 100644 local_plugins/music_service/.metadata create mode 100644 local_plugins/music_service/CHANGELOG.md create mode 100644 local_plugins/music_service/LICENSE create mode 100644 local_plugins/music_service/README.md create mode 100644 local_plugins/music_service/analysis_options.yaml create mode 100644 local_plugins/music_service/android/.gitignore create mode 100644 local_plugins/music_service/android/build.gradle create mode 100644 local_plugins/music_service/android/settings.gradle create mode 100644 local_plugins/music_service/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt create mode 100644 local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt create mode 100644 local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt create mode 100644 local_plugins/music_service/ios/.gitignore create mode 100644 local_plugins/music_service/ios/Assets/.gitkeep create mode 100644 local_plugins/music_service/ios/Classes/MusicServicePlugin.swift create mode 100644 local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy create mode 100644 local_plugins/music_service/ios/music_service.podspec create mode 100644 local_plugins/music_service/lib/music_service.dart create mode 100644 local_plugins/music_service/pubspec.yaml diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 3356166c3..8921a6fca 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -1,3 +1,4 @@ +import 'package:deep_voice/data/services/music_manager.dart'; import 'package:get/get.dart'; import '../../data/services/speech_factory.dart'; import '../../data/services/chat_history_service.dart'; @@ -8,6 +9,7 @@ import '../../core/utils/logger.dart'; import '../../data/services/open_ai_service_adapter.dart'; import '../../data/services/bluetooth_manager.dart'; import '../../data/services/ble_manager.dart'; + /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @override @@ -34,23 +36,20 @@ class InitialBinding extends Bindings { adapter.initialize(); return adapter; }, fenix: true); - // 蓝牙服务 Get.put(BluetoothManager(), permanent: true); // BleManager Get.put(BleManager(), permanent: true); - + // MusicManager + Get.put(MusicManager(), permanent: true); // 火山AI服务 Get.lazyPut(() => VolcanoAIService(), fenix: true); - // 蓝牙媒体按钮服务 // Get.lazyPut( // () => BluetoothMediaButtonService(), // fenix: true); - - // // 语音交互服务 // Get.lazyPut(() => VoiceInteractionService(), diff --git a/lib/data/services/music_manager.dart b/lib/data/services/music_manager.dart new file mode 100644 index 000000000..5efabeaf1 --- /dev/null +++ b/lib/data/services/music_manager.dart @@ -0,0 +1,27 @@ +import 'package:deep_voice/core/utils/logger.dart'; +import 'package:get/get.dart'; +import 'package:music_service/music_service.dart'; + +//音乐播放管理 +class MusicManager extends GetxService { + static MusicManager get to => Get.find(); + + // 音乐服务实例 + final MusicService _musicService = MusicService(); + + /// 初始化 + @override + void onInit() { + super.onInit(); + Logger.info('BLE管理器初始化'); + } + + /// 释放资源 + @override + void onClose() { + super.onClose(); + } + + //播放 音乐 + Future play(String url) async {} +} diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index b55455446..7428e3b19 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -15,6 +15,7 @@ class Message { final bool isRecognizing; // 是否为语音识别中的临时消息 final bool hasImage; // 是否包含图片 final String? imagePath; // 图片路径 + final Map? meta; //卡片数据 Message({ required this.isUser, @@ -23,50 +24,51 @@ class Message { this.isRecognizing = false, this.hasImage = false, this.imagePath, + this.meta, }) : this.timestamp = timestamp ?? DateTime.now(); } class AgentController extends GetxController { static AgentController get to => Get.find(); - + final logger = Logger(); - + // 聊天消息列表 final messages = [].obs; - + // 控制器 final TextEditingController textController = TextEditingController(); final ScrollController scrollController = ScrollController(); - + // 状态标志 final isInitialized = false.obs; final isListening = false.obs; final isSpeaking = false.obs; final isProcessing = false.obs; - + // 图片处理状态 final isImageProcessing = false.obs; - + // 输入模式控制 final isTextInputMode = true.obs; - + // 当前输入的文本内容 final currentText = ''.obs; - + // 图片输入相关 final isImageInputActive = false.obs; // 是否处于图片输入模式 final selectedImagePath = Rx(null); // 当前选择的图片路径 - + // 流事件订阅 StreamSubscription? _eventSubscription; - + // 用于跟踪AI回复状态 bool _isNewAssistantResponse = true; String _currentResponseId = ''; - + // 会话ID final String sessionId = 'default_agent'; - + // 是否已加载聊天记录 final isHistoryLoaded = false.obs; @@ -86,7 +88,7 @@ class AgentController extends GetxController { _eventSubscription?.cancel(); super.onClose(); } - + // 加载聊天历史记录 Future _loadChatHistory() async { try { @@ -96,19 +98,21 @@ class AgentController extends GetxController { page: 1, pageSize: 20, ); - + if (result.messages.isNotEmpty) { // 转换并添加消息到列表 - final historyMessages = result.messages.map((msg) => Message( - isUser: msg.sender == 'user', - text: msg.message, - timestamp: DateTime.parse(msg.timestamp), - )).toList(); - + final historyMessages = result.messages + .map((msg) => Message( + isUser: msg.sender == 'user', + text: msg.message, + timestamp: DateTime.parse(msg.timestamp), + )) + .toList(); + // 消息按时间排序,最新的消息在底部 historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); messages.assignAll(historyMessages); - + logger.i('成功加载 ${historyMessages.length} 条聊天记录'); } else { // 没有历史记录时添加欢迎消息 @@ -120,16 +124,15 @@ class AgentController extends GetxController { _addWelcomeMessage(); } finally { isHistoryLoaded.value = true; - + messages.refresh(); // 在加载完成后直接定位到底部,无需动画 WidgetsBinding.instance.addPostFrameCallback((_) { scrollController.jumpTo(scrollController.position.maxScrollExtent); }); - } } - + // 添加欢迎消息 void _addWelcomeMessage() { messages.add(Message( @@ -159,15 +162,16 @@ class AgentController extends GetxController { isTextInputMode.value = true; isTextInputMode.refresh(); break; - + case AgentServiceEventType.recognizing: final text = event.data['text'] ?? ''; currentText.value = text; // 保留当前文本,以便其他地方使用 - + if (text.isNotEmpty) { // 查找是否有正在识别中的消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); - + final index = messages + .lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + if (index >= 0) { // 更新已有的识别中消息 messages[index] = Message( @@ -188,13 +192,14 @@ class AgentController extends GetxController { _scrollToBottom(); } break; - + case AgentServiceEventType.recognitionResult: final text = event.data['text'] ?? ''; - + // 查找是否有正在识别中的消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); - + final index = + messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + if (text.isNotEmpty) { if (index >= 0) { // 将识别中的消息转换为最终消息 @@ -208,7 +213,7 @@ class AgentController extends GetxController { // 如果没有找到识别中的消息,添加新消息 messages.add(Message(isUser: true, text: text)); } - + isProcessing.value = true; // 标记为新的AI回复 _isNewAssistantResponse = true; @@ -220,11 +225,11 @@ class AgentController extends GetxController { messages.refresh(); } break; - + case AgentServiceEventType.ttsStarted: isSpeaking.value = true; break; - + case AgentServiceEventType.ttsCompleted: case AgentServiceEventType.ttsStopped: case AgentServiceEventType.ttsCanceled: @@ -238,16 +243,17 @@ class AgentController extends GetxController { case AgentServiceEventType.imageReady: // 图片准备就绪,但AI还没有开始处理,保持processing状态 break; - + case AgentServiceEventType.assistantToken: if (!isProcessing.value) isProcessing.value = true; - + final token = event.data['token'] ?? ''; final responseId = event.data['responseId'] ?? ''; - + if (token.isNotEmpty) { // 如果是新的回复或者响应ID改变,创建新消息 - if (_isNewAssistantResponse || (_currentResponseId != responseId && responseId.isNotEmpty)) { + if (_isNewAssistantResponse || + (_currentResponseId != responseId && responseId.isNotEmpty)) { messages.add(Message(isUser: false, text: token)); _isNewAssistantResponse = false; _currentResponseId = responseId; @@ -267,35 +273,37 @@ class AgentController extends GetxController { _scrollToBottom(); } break; - + case AgentServiceEventType.assistantResponse: isProcessing.value = false; isImageProcessing.value = false; // 标记当前回复完成 _isNewAssistantResponse = true; break; - + case AgentServiceEventType.error: isListening.value = false; isSpeaking.value = false; isProcessing.value = false; isImageProcessing.value = false; - + // 移除临时的识别消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = + messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); if (index >= 0) { messages.removeAt(index); messages.refresh(); } - + logger.e('代理服务错误: ${event.data['message']}'); break; - + case AgentServiceEventType.autoStop: isListening.value = false; - + // 移除临时的识别消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = + messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); if (index >= 0) { // 只有当消息为空或文本太短时才移除 if (messages[index].text.trim().length < 2) { @@ -309,26 +317,51 @@ class AgentController extends GetxController { timestamp: messages[index].timestamp, ); messages.refresh(); - + // 发送此消息到AI处理 isProcessing.value = true; _isNewAssistantResponse = true; - AgentService.processTextInput(messages[index].text, speakResponse: true); + AgentService.processTextInput(messages[index].text, + speakResponse: true); } } break; case AgentServiceEventType.functionCall: - // final functionName = event.data['name'] ?? ''; - // final arguments = event.data['arguments'] ?? ''; - // logger.i('mcp: $functionName, $arguments'); + final functionName = event.data['name'] ?? ''; + final arguments = event.data['arguments'] ?? ''; + logger.i('Fultter 开始调用 mcp: $functionName, $arguments'); break; case AgentServiceEventType.functionCallResult: + final responseId = event.data['responseId'] ?? ''; final result = event.data['result'] ?? ''; final functionCall = event.data['function_call'] ?? ''; + if (!result.isBlank) { + final meta = result["meta"] ?? {}; + //不为空 + // 如果是新的回复或者响应ID改变,创建新消息 + if (_isNewAssistantResponse || + (_currentResponseId != responseId && responseId.isNotEmpty)) { + messages.add(Message(isUser: false, text: "", meta: meta)); + _isNewAssistantResponse = false; + _currentResponseId = responseId; + } else { + // 否则更新最后一条消息 + final lastIndex = messages.length - 1; + + if (lastIndex >= 0 && !messages[lastIndex].isUser) { + messages[lastIndex] = Message( + isUser: false, + text: messages[lastIndex].text, + meta: meta, + timestamp: messages[lastIndex].timestamp, + ); + } + } + } + logger.i('Fultter 结束调用 mcp: $result, $functionCall'); break; - default: break; } @@ -359,22 +392,22 @@ class AgentController extends GetxController { final text = textController.text.trim(); final imagePath = selectedImagePath.value!; // 使用!强制断言非空 await sendImageMessage(imagePath, text: text); - + // 清除图片输入状态 clearImageInput(); return; } - + final text = textController.text.trim(); if (text.isEmpty) return; - + final message = Message(isUser: true, text: text); messages.add(message); textController.clear(); - + // 滚动到底部 _scrollToBottom(); - + try { isProcessing.value = true; // 标记为新的AI回复 @@ -392,31 +425,31 @@ class AgentController extends GetxController { logger.e('图片不存在: $imagePath'); return; } - + // 显示的文本,如果没有提供则使用默认值 final displayText = text?.isNotEmpty == true ? text! : '[图片]'; - + // 添加用户消息 final message = Message( - isUser: true, + isUser: true, text: displayText, hasImage: true, imagePath: imagePath, ); messages.add(message); - + // 清空文本输入 textController.clear(); - + // 滚动到底部 _scrollToBottom(); - + try { isProcessing.value = true; isImageProcessing.value = true; // 标记为新的AI回复 _isNewAssistantResponse = true; - + // 调用Agent Service处理图片 await AgentService.processImageInput( imagePath, @@ -433,7 +466,7 @@ class AgentController extends GetxController { // 开始语音输入 Future startVoiceInput() async { if (isListening.value) return; - + try { await AgentService.startConversation(); } catch (e) { @@ -444,7 +477,7 @@ class AgentController extends GetxController { // 停止语音输入 Future stopVoiceInput() async { if (!isListening.value) return; - + try { await AgentService.stopConversation(); } catch (e) { @@ -456,21 +489,20 @@ class AgentController extends GetxController { Future clearChat() async { try { await AgentService.clearChatHistory(); - // 清空本地消息列表 messages.clear(); - + // 添加欢迎消息 _addWelcomeMessage(); - + // 重置回复状态 _isNewAssistantResponse = true; } catch (e) { logger.e('清除聊天历史失败: $e'); } } - + // 切换输入模式 void toggleInputMode() { // 如果当前在图片输入模式,先清除 @@ -478,9 +510,9 @@ class AgentController extends GetxController { clearImageInput(); return; } - + isTextInputMode.toggle(); - + // 切换到语音模式时,直接开始语音输入 if (!isTextInputMode.value) { startVoiceInput(); @@ -498,10 +530,10 @@ class AgentController extends GetxController { final XFile? image = await picker.pickImage( source: ImageSource.gallery, imageQuality: 80, // 设置图片质量 - maxWidth: 1024, // 限制最大宽度 - maxHeight: 1024, // 限制最大高度 + maxWidth: 1024, // 限制最大宽度 + maxHeight: 1024, // 限制最大高度 ); - + if (image != null) { // 设置已选择的图片 selectedImagePath.value = image.path; @@ -520,10 +552,10 @@ class AgentController extends GetxController { final XFile? photo = await picker.pickImage( source: ImageSource.camera, imageQuality: 80, // 设置图片质量 - maxWidth: 1024, // 限制最大宽度 - maxHeight: 1024, // 限制最大高度 + maxWidth: 1024, // 限制最大宽度 + maxHeight: 1024, // 限制最大高度 ); - + if (photo != null) { // 设置已选择的图片 selectedImagePath.value = photo.path; @@ -534,18 +566,18 @@ class AgentController extends GetxController { logger.e('拍照失败: $e'); } } - + // 清除图片输入状态 void clearImageInput() { selectedImagePath.value = null; isImageInputActive.value = false; } - + // 使用预设提示词处理图片 void useImagePrompt(String prompt) { // 确保selectedImagePath.value不为null if (selectedImagePath.value == null) return; - + // 填充文本框同时直接发送消息 textController.text = prompt; // 立即发送图片和提示词 @@ -554,19 +586,19 @@ class AgentController extends GetxController { // 发送后清除图片输入状态 clearImageInput(); } - + // 预设提示词:总结图片内容 void summarizeImage() { useImagePrompt('请总结这张图片的内容'); } - + // 预设提示词:抽取图像文字 void extractTextFromImage() { useImagePrompt('请提取这张图片中的所有文字'); } - + // 预设提示词:翻译图像文字 void translateImageText() { useImagePrompt('请翻译这张图片中的文字'); } -} \ No newline at end of file +} diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 8189ec0b7..be56a48f9 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -10,7 +10,7 @@ class AgentView extends StatelessWidget { @override Widget build(BuildContext context) { final controller = Get.find(); - + return Scaffold( appBar: AppBar( title: Row( @@ -93,12 +93,13 @@ class AgentView extends StatelessWidget { isRecognizing: message.isRecognizing, hasImage: message.hasImage, imagePath: message.imagePath, + mate: message.meta, ); }, ), ), ), - + // 输入区域 Container( padding: const EdgeInsets.all(16), @@ -117,7 +118,8 @@ class AgentView extends StatelessWidget { children: [ // 图片预览区域 - 仅在图片输入模式下显示 Obx(() { - if (controller.isImageInputActive.value && controller.selectedImagePath.value != null) { + if (controller.isImageInputActive.value && + controller.selectedImagePath.value != null) { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -154,7 +156,7 @@ class AgentView extends StatelessWidget { ), ], ), - + // 预设提示选项 SizedBox(height: 8), SingleChildScrollView( @@ -188,7 +190,7 @@ class AgentView extends StatelessWidget { return SizedBox.shrink(); } }), - + // 输入控件行 Row( children: [ @@ -213,14 +215,14 @@ class AgentView extends StatelessWidget { ), ); }), - + const SizedBox(width: 8), - + // 文本输入框或语音波形图 Expanded( child: Obx(() { final isTextMode = controller.isTextInputMode.value; - + if (isTextMode) { // 文本输入模式 - 注意观察图片模式时的高度变化 return Container( @@ -234,18 +236,23 @@ class AgentView extends StatelessWidget { Expanded( child: Obx(() { // 图片模式下使用更高的输入框 - final isImageActive = controller.isImageInputActive.value; - + final isImageActive = + controller.isImageInputActive.value; + return TextField( controller: controller.textController, maxLines: isImageActive ? 2 : 1, minLines: isImageActive ? 2 : 1, decoration: InputDecoration( - hintText: isImageActive ? '添加对图片的问题或描述...' : '输入消息...', + hintText: isImageActive + ? '添加对图片的问题或描述...' + : '输入消息...', border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: isImageActive ? 12 : 0), + contentPadding: EdgeInsets.symmetric( + vertical: isImageActive ? 12 : 0), ), - onSubmitted: (_) => controller.sendTextMessage(), + onSubmitted: (_) => + controller.sendTextMessage(), ); }), ), @@ -271,7 +278,8 @@ class AgentView extends StatelessWidget { }, child: Container( height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: + const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(24), @@ -281,7 +289,8 @@ class AgentView extends StatelessWidget { // 语音波形图 - 始终显示 Expanded( child: Row( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: + MainAxisAlignment.start, children: [ ...List.generate(10, (index) { return _buildSoundBar(index); @@ -289,14 +298,16 @@ class AgentView extends StatelessWidget { const Spacer(), // "正在聆听"文字 Obx(() { - final isListening = controller.isListening.value; + final isListening = + controller.isListening.value; return Row( children: [ Icon( Icons.mic, size: 14, - color: isListening - ? Theme.of(context).primaryColor + color: isListening + ? Theme.of(context) + .primaryColor : Colors.grey, ), const SizedBox(width: 4), @@ -304,8 +315,9 @@ class AgentView extends StatelessWidget { '正在聆听', style: TextStyle( fontSize: 12, - color: isListening - ? Theme.of(context).primaryColor + color: isListening + ? Theme.of(context) + .primaryColor : Colors.grey, ), ), @@ -322,9 +334,9 @@ class AgentView extends StatelessWidget { } }), ), - + const SizedBox(width: 8), - + // 图片选择按钮 Container( width: 48, @@ -351,7 +363,8 @@ class AgentView extends StatelessWidget { value: 'camera', child: Row( children: [ - Icon(Icons.camera_alt, color: Theme.of(context).primaryColor), + Icon(Icons.camera_alt, + color: Theme.of(context).primaryColor), SizedBox(width: 8), Text('拍照'), ], @@ -361,7 +374,8 @@ class AgentView extends StatelessWidget { value: 'gallery', child: Row( children: [ - Icon(Icons.photo_library, color: Theme.of(context).primaryColor), + Icon(Icons.photo_library, + color: Theme.of(context).primaryColor), SizedBox(width: 8), Text('从相册选择'), ], @@ -379,7 +393,7 @@ class AgentView extends StatelessWidget { ), ); } - + // 构建提示选项芯片 Widget _buildPromptChip({ required BuildContext context, @@ -408,15 +422,15 @@ class AgentView extends StatelessWidget { ), ); } - + // 构建声音柱 Widget _buildSoundBar(int index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: Obx(() => _SoundBar( - delay: index * 0.1, - isActive: Get.find().isListening.value, - )), + delay: index * 0.1, + isActive: Get.find().isListening.value, + )), ); } } @@ -425,41 +439,42 @@ class AgentView extends StatelessWidget { class _SoundBar extends StatefulWidget { final double delay; final bool isActive; - + const _SoundBar({ required this.delay, required this.isActive, }); - + @override _SoundBarState createState() => _SoundBarState(); } -class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixin { +class _SoundBarState extends State<_SoundBar> + with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; - + @override void initState() { super.initState(); - + _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), ); - + _animation = Tween(begin: 3, end: 15).animate( CurvedAnimation( parent: _controller, curve: Curves.easeInOut, ), )..addListener(() { - setState(() {}); - }); - + setState(() {}); + }); + _startOrStopAnimation(); } - + @override void didUpdateWidget(_SoundBar oldWidget) { super.didUpdateWidget(oldWidget); @@ -467,7 +482,7 @@ class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixi _startOrStopAnimation(); } } - + void _startOrStopAnimation() { if (widget.isActive) { Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () { @@ -481,22 +496,23 @@ class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixi _controller.animateTo(0.1, duration: Duration(milliseconds: 300)); } } - + @override void dispose() { _controller.dispose(); super.dispose(); } - + @override Widget build(BuildContext context) { return Container( width: 2, height: widget.isActive ? _animation.value : 3, decoration: BoxDecoration( - color: widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400], + color: + widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400], borderRadius: BorderRadius.circular(1), ), ); } -} \ No newline at end of file +} diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index c7c4b581c..cc539bc18 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -10,6 +10,7 @@ class MessageBubble extends StatelessWidget { final bool isRecognizing; final bool hasImage; final String? imagePath; + final Map? mate; const MessageBubble({ Key? key, @@ -19,31 +20,42 @@ class MessageBubble extends StatelessWidget { this.isRecognizing = false, this.hasImage = false, this.imagePath, + this.mate, }) : super(key: key); @override Widget build(BuildContext context) { final timeFormat = DateFormat('HH:mm'); - + return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( - mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isUser) _buildAvatar(isUser, context), const SizedBox(width: 8), Flexible( child: Column( - crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Container( - padding: hasImage ? (message == '[图片]' ? EdgeInsets.zero : const EdgeInsets.all(12)) : const EdgeInsets.all(12), + padding: hasImage + ? (message == '[图片]' + ? EdgeInsets.zero + : const EdgeInsets.all(12)) + : const EdgeInsets.all(12), decoration: BoxDecoration( color: _getBubbleColor(context), borderRadius: BorderRadius.circular(18).copyWith( - bottomLeft: isUser ? const Radius.circular(18) : const Radius.circular(4), - bottomRight: !isUser ? const Radius.circular(18) : const Radius.circular(4), + bottomLeft: isUser + ? const Radius.circular(18) + : const Radius.circular(4), + bottomRight: !isUser + ? const Radius.circular(18) + : const Radius.circular(4), ), boxShadow: [ BoxShadow( @@ -79,7 +91,7 @@ class MessageBubble extends StatelessWidget { /// 获取气泡颜色 Color _getBubbleColor(BuildContext context) { if (isUser) { - return isRecognizing + return isRecognizing ? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色 : Color(0xFF3B82F6); // 使用更明亮的蓝色 } else { @@ -135,7 +147,7 @@ class MessageBubble extends StatelessWidget { ], ); } - + // 如果是图片消息,显示图片 if (hasImage && imagePath != null) { // 如果消息是默认的[图片]标记,只显示图片 @@ -150,7 +162,7 @@ class MessageBubble extends StatelessWidget { ), ); } - + // 如果消息是自定义文本,显示文本+图片 return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -177,18 +189,34 @@ class MessageBubble extends StatelessWidget { ], ); } - + // 判断消息内容,显示特殊卡片 - if (!isUser) { - if (message.contains('播放列表') || message.contains('音乐')) { + if (!isUser && mate != null) { + if (mate!.keys.contains('card_music')) { + final musicName = mate!['card_music']['name'] as String; + final musicUrl = mate!['card_music']['url'] as String; + final musicSinger = mate!['card_music']['sgener'] as String; + //音乐卡片 + return _buildMusicItem(context, musicName, musicUrl, musicSinger); + } else if (mate!.keys.contains('card_musiclist')) { + //音乐播放列表 return _buildMusicContent(context); - } else if (message.contains('景点') || message.contains('旅游')) { - return _buildTravelContent(context); - } else if (message.contains('天气') || message.contains('气温') || message.contains('预报')) { + } else if (mate!.keys.contains('card_weather')) { + //天气 return _buildWeatherContent(context); } + + // if (message.contains('播放列表') || message.contains('音乐')) { + // return _buildMusicContent(context); + // } else if (message.contains('景点') || message.contains('旅游')) { + // return _buildTravelContent(context); + // } else if (message.contains('天气') || + // message.contains('气温') || + // message.contains('预报')) { + // return _buildWeatherContent(context); + // } } - + return Text( message, style: TextStyle( @@ -272,14 +300,19 @@ class MessageBubble extends StatelessWidget { ], ), const SizedBox(height: 10), - + // 歌曲示例 - _buildMusicItem('雨声与钢琴曲', '纯音乐', context), + _buildMusicItem( + context, + '雨声与钢琴曲', + "", + '纯音乐', + ), Divider(height: 1, thickness: 1, color: Colors.grey[100]), - _buildMusicItem('Rainy Day', 'Coldplay', context), + _buildMusicItem(context, 'Rainy Day', "", 'Coldplay'), Divider(height: 1, thickness: 1, color: Colors.grey[100]), - _buildMusicItem('雨天的爵士咖啡馆', '爵士合集', context), - + _buildMusicItem(context, '雨天的爵士咖啡馆', "", '爵士合集'), + const SizedBox(height: 8), Center( child: TextButton( @@ -313,7 +346,8 @@ class MessageBubble extends StatelessWidget { label: Text('播放全部'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).primaryColor, - side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity(0.5)), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -344,7 +378,7 @@ class MessageBubble extends StatelessWidget { ], ); } - + /// 构建旅游内容卡片 Widget _buildTravelContent(BuildContext context) { return Column( @@ -396,14 +430,14 @@ class MessageBubble extends StatelessWidget { ], ), const SizedBox(height: 10), - + // 景点列表 _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), Divider(height: 1, thickness: 1, color: Colors.grey[100]), _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), Divider(height: 1, thickness: 1, color: Colors.grey[100]), _buildLocationItem('浅草寺', '东京最古老的寺庙', context), - + const SizedBox(height: 8), Center( child: TextButton( @@ -437,7 +471,8 @@ class MessageBubble extends StatelessWidget { label: Text('查看地图'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).primaryColor, - side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity(0.5)), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -470,7 +505,8 @@ class MessageBubble extends StatelessWidget { } /// 构建音乐项目 - Widget _buildMusicItem(String title, String artist, BuildContext context) { + Widget _buildMusicItem( + BuildContext context, String name, String url, String singer) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( @@ -494,7 +530,7 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - title, + name, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -504,7 +540,7 @@ class MessageBubble extends StatelessWidget { ), SizedBox(height: 2), Text( - artist, + singer, style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -516,7 +552,8 @@ class MessageBubble extends StatelessWidget { ), IconButton( onPressed: () {}, - icon: Icon(Icons.play_circle, color: Theme.of(context).primaryColor), + icon: + Icon(Icons.play_circle, color: Theme.of(context).primaryColor), padding: EdgeInsets.zero, constraints: BoxConstraints(), iconSize: 24, @@ -525,9 +562,10 @@ class MessageBubble extends StatelessWidget { ), ); } - + /// 构建地点项目 - Widget _buildLocationItem(String name, String description, BuildContext context) { + Widget _buildLocationItem( + String name, String description, BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( @@ -749,7 +787,8 @@ class MessageBubble extends StatelessWidget { label: Text('更多地区'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).primaryColor, - side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity(0.5)), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -780,7 +819,7 @@ class MessageBubble extends StatelessWidget { ], ); } - + /// 构建单日天气预报 Widget _buildForecastDay(String day, IconData icon, String temp) { return Column( @@ -815,26 +854,27 @@ class MessageBubble extends StatelessWidget { /// 动画点组件 class _AnimatedDot extends StatefulWidget { final int delayMilliseconds; - + const _AnimatedDot({required this.delayMilliseconds}); - + @override _AnimatedDotState createState() => _AnimatedDotState(); } -class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin { +class _AnimatedDotState extends State<_AnimatedDot> + with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; - + @override void initState() { super.initState(); - + _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), ); - + _animation = Tween(begin: 0, end: 6).animate( CurvedAnimation( parent: _controller, @@ -842,22 +882,22 @@ class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderSta reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn), ), )..addListener(() { - setState(() {}); - }); - + setState(() {}); + }); + Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () { if (mounted) { _controller.repeat(); } }); } - + @override void dispose() { _controller.dispose(); super.dispose(); } - + @override Widget build(BuildContext context) { return Container( @@ -876,4 +916,4 @@ class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderSta ), ); } -} \ No newline at end of file +} diff --git a/local_plugins/agent_service/pubspec.yaml b/local_plugins/agent_service/pubspec.yaml index 3c348c619..a12f742b4 100644 --- a/local_plugins/agent_service/pubspec.yaml +++ b/local_plugins/agent_service/pubspec.yaml @@ -19,6 +19,8 @@ dependencies: path: ../deepvoice_utils chat_storage: path: ../chat_storage + music_service: + path: ../music_service get: ^4.6.5 dev_dependencies: diff --git a/local_plugins/music_service/.gitignore b/local_plugins/music_service/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/local_plugins/music_service/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/local_plugins/music_service/.metadata b/local_plugins/music_service/.metadata new file mode 100644 index 000000000..cff4c42e1 --- /dev/null +++ b/local_plugins/music_service/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: android + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: ios + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/local_plugins/music_service/CHANGELOG.md b/local_plugins/music_service/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/local_plugins/music_service/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/local_plugins/music_service/LICENSE b/local_plugins/music_service/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/local_plugins/music_service/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/local_plugins/music_service/README.md b/local_plugins/music_service/README.md new file mode 100644 index 000000000..af4d1a7b1 --- /dev/null +++ b/local_plugins/music_service/README.md @@ -0,0 +1,15 @@ +# music_service + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/local_plugins/music_service/analysis_options.yaml b/local_plugins/music_service/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/local_plugins/music_service/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_plugins/music_service/android/.gitignore b/local_plugins/music_service/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/local_plugins/music_service/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_plugins/music_service/android/build.gradle b/local_plugins/music_service/android/build.gradle new file mode 100644 index 000000000..8c1602312 --- /dev/null +++ b/local_plugins/music_service/android/build.gradle @@ -0,0 +1,70 @@ +group = "com.yunqiinnovation.music_service" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "1.8.22" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.yunqiinnovation.music_service" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} + +dependencies { + implementation project(':deepvoice_utils') +} diff --git a/local_plugins/music_service/android/settings.gradle b/local_plugins/music_service/android/settings.gradle new file mode 100644 index 000000000..48f745bec --- /dev/null +++ b/local_plugins/music_service/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'music_service' diff --git a/local_plugins/music_service/android/src/main/AndroidManifest.xml b/local_plugins/music_service/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7653a17a6 --- /dev/null +++ b/local_plugins/music_service/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt new file mode 100644 index 000000000..2deda573b --- /dev/null +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt @@ -0,0 +1,153 @@ +package com.yunqiinnovation.music_service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.yunqiinnovation.music_service.MusicServiceStarter +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.atomic.AtomicBoolean + +class MusicService : Service() { + + companion object { + private const val TAG = "MusicService" + private const val CHANNEL_ID = "music_media_service_channel" + private const val NOTIFICATION_ID = 1002 + private const val PREF_NAME = "deep_voice_config" + + // 用于跟踪服务是否正在运行 + private val isRunning = AtomicBoolean(false) + + // 获取服务运行状态 + fun isServiceRunning(): Boolean = isRunning.get() + } + private var mediaPlayer: MediaPlayer? = null + private val playlist = mutableListOf() // 可扩展支持播放列表 + private var currentIndex = 0 + + override fun onCreate() { + super.onCreate() + val notification = createNotification() // 创建一个非空通知 + startForeground(1, notification) + } + private var isPlaying = false + /** + * 创建前台服务通知 + */ + private fun createNotification(): Notification { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Android 8.0+ 需要创建通知渠道 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "音乐播放服务", + NotificationManager.IMPORTANCE_LOW + ) + manager.createNotificationChannel(channel) + } + + // 点击通知打开主界面 + val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = if (intent != null) { + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } else { + null + } + + // 构建通知 + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("DeepSound") + .setContentText(if (isPlaying) "正在播放" else "已暂停") + .setSmallIcon(android.R.drawable.ic_media_play) + + // 设置点击意图 + pendingIntent?.let { builder.setContentIntent(it) } + + return builder.build() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val command = intent?.getStringExtra("command") ?: return START_NOT_STICKY + Log.i(TAG, "onStartCommand: $command") + when (command) { + "play" -> { + val url = intent.getStringExtra("url") ?: return START_NOT_STICKY + play(url) + } + "pause" -> mediaPlayer?.pause() + "resume" -> mediaPlayer?.start() + "stop" -> stopSelf() + "next" -> { + currentIndex++ + if (currentIndex < playlist.size) play(playlist[currentIndex]) + } + "previous" -> { + currentIndex-- + if (currentIndex >= 0) play(playlist[currentIndex]) + } + "setVolume" -> { + val volume = intent.getFloatExtra("volume", 1.0f) + mediaPlayer?.setVolume(volume, volume) + } + } + return START_STICKY + } + + private fun play(url: String) { + Log.i(TAG, "开始播放音乐: $url") + mediaPlayer?.release() + mediaPlayer = MediaPlayer().apply { + setDataSource(url) + setAudioStreamType(AudioManager.STREAM_MUSIC) + prepareAsync() + setOnPreparedListener { start() } + setOnCompletionListener { stopSelf() } + } +// startForeground(1, NotificationUtils.createNotification(this, "Playing...")) + } + + override fun onDestroy() { + mediaPlayer?.release() + stopForeground(true) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + /** + * 释放资源 + */ + fun release() { + try { + mediaPlayer?.apply { + if (isPlaying) stop() + release() + } + mediaPlayer = null + } catch (e: Exception) { + // 忽略异常 + } + } + /** + * 是否正在播放 + */ + fun isPlaying() = mediaPlayer?.isPlaying == true +} 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 new file mode 100644 index 000000000..6741e7937 --- /dev/null +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt @@ -0,0 +1,46 @@ +package com.yunqiinnovation.music_service + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import com.yunqiinnovation.deepvoice_utils.FileLogger +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler + + +/** MusicServicePlugin */ +class MusicServicePlugin: FlutterPlugin, MethodCallHandler { + private val TAG = "MusicServicePlugin" + private lateinit var context: Context + private lateinit var channel : MethodChannel + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "music_service") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + FileLogger.d(TAG, "Method call: ${call.method}") + val intent = Intent(context, MusicService::class.java) + when (call.method) { + "play" -> { + val url = call.argument("url") + MusicServiceStarter.startServiceWithCommand(context, "play", url) + } + "pause" -> MusicServiceStarter.startServiceWithCommand(context, "pause") + "resume" -> MusicServiceStarter.startServiceWithCommand(context, "resume") + "stop" -> MusicServiceStarter.startServiceWithCommand(context, "stop") + "setVolume" -> { + val volume = call.argument("volume")?.toFloat() + MusicServiceStarter.startServiceWithCommand(context, "setVolume", volume = volume) + } + else -> result.notImplemented() + } + result.success(null) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} +} diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt new file mode 100644 index 000000000..dbcd05cca --- /dev/null +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt @@ -0,0 +1,19 @@ +package com.yunqiinnovation.music_service + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.ContextCompat + +object MusicServiceStarter { + private val TAG = "MusicServiceStarter" + fun startServiceWithCommand(context: Context, command: String, url: String? = null, volume: Float? = null) { + Log.i(TAG, "startServiceWithCommand: $command $url") + val intent = Intent(context, MusicService::class.java).apply { + putExtra("command", command) + url?.let { putExtra("url", it) } + volume?.let { putExtra("volume", it) } + } + ContextCompat.startForegroundService(context, intent) + } +} \ No newline at end of file diff --git a/local_plugins/music_service/ios/.gitignore b/local_plugins/music_service/ios/.gitignore new file mode 100644 index 000000000..034771fc9 --- /dev/null +++ b/local_plugins/music_service/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/local_plugins/music_service/ios/Assets/.gitkeep b/local_plugins/music_service/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/music_service/ios/Classes/MusicServicePlugin.swift b/local_plugins/music_service/ios/Classes/MusicServicePlugin.swift new file mode 100644 index 000000000..87d033474 --- /dev/null +++ b/local_plugins/music_service/ios/Classes/MusicServicePlugin.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +public class MusicServicePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "music_service", binaryMessenger: registrar.messenger()) + let instance = MusicServicePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy b/local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..a34b7e2e6 --- /dev/null +++ b/local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/local_plugins/music_service/ios/music_service.podspec b/local_plugins/music_service/ios/music_service.podspec new file mode 100644 index 000000000..d9aa1dcb3 --- /dev/null +++ b/local_plugins/music_service/ios/music_service.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint music_service.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'music_service' + s.version = '0.0.1' + s.summary = 'A new Flutter project.' + s.description = <<-DESC +A new Flutter project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'music_service_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/local_plugins/music_service/lib/music_service.dart b/local_plugins/music_service/lib/music_service.dart new file mode 100644 index 000000000..cace4292f --- /dev/null +++ b/local_plugins/music_service/lib/music_service.dart @@ -0,0 +1,55 @@ +import 'package:flutter/services.dart'; + +class MusicService { + static final MusicService _instance = MusicService._internal(); + + /// 获取单例 + factory MusicService() => _instance; + + // 方法通道 + final MethodChannel _methodChannel = const MethodChannel( + 'com.yunqiinnovation.music_service', + ); + + // 事件通道 - 简化为只使用数据和状态通道 + late final EventChannel _dataEventChannel; + late final EventChannel _statusEventChannel; + + // 事件流 + late final Stream _dataStream; + late final Stream _statusStream; + + // 获取事件流 + Stream get onDataReceived => _dataStream; + + // 状态流直接传递原始数据 + Stream get onStatusUpdated => _statusStream; + + // 内部构造函数 + MusicService._internal() { + _dataEventChannel = const EventChannel( + 'com.yunqiinnovation.music_service/data_events', + ); + _statusEventChannel = const EventChannel( + 'com.yunqiinnovation.music_service/status_events', + ); + + // 初始化事件流 + _dataStream = _dataEventChannel.receiveBroadcastStream(); + _statusStream = _statusEventChannel.receiveBroadcastStream(); + } + + Future playMusic(String url) async { + await _methodChannel.invokeMethod('play', {'url': url}); + } + + Future pauseMusic() async => _methodChannel.invokeMethod('pause'); + Future resumeMusic() async => _methodChannel.invokeMethod('resume'); + Future nextTrack() async => _methodChannel.invokeMethod('next'); + Future previousTrack() async => _methodChannel.invokeMethod('previous'); + Future stopMusic() async => _methodChannel.invokeMethod('stop'); + Future setVolume(double volume) async => _methodChannel.invokeMethod( + 'setVolume', + {'volume': volume}, + ); // volume: 0.0~1.0 +} diff --git a/local_plugins/music_service/pubspec.yaml b/local_plugins/music_service/pubspec.yaml new file mode 100644 index 000000000..28633c08c --- /dev/null +++ b/local_plugins/music_service/pubspec.yaml @@ -0,0 +1,72 @@ +name: music_service +description: "A new Flutter project." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.7.2 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.yunqiinnovation.music_service + pluginClass: MusicServicePlugin + ios: + pluginClass: MusicServicePlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/pubspec.yaml b/pubspec.yaml index 2cbc56592..427b3b9c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,8 @@ dependencies: path: local_plugins/classic_bluetooth agent_service: path: local_plugins/agent_service + music_service: + path: local_plugins/music_service dev_dependencies: