import 'dart:async'; import 'package:agent_service/agent_service.dart'; import 'package:chat_storage/chat_storage.dart'; import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../data/models/appconfig.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/permission_util.dart'; enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error } class MobileElfMessage { final bool isUser; final String text; final DateTime time; MobileElfMessage( {required this.isUser, required this.text, required this.time}); } class MobileElfController extends GetxController { static const _tag = 'MobileElfCtrl'; // ── 可观察状态 ───────────────────────────────────────────────────────────── final status = MobileElfStatus.idle.obs; final messages = [].obs; final realtimeText = ''.obs; final errorMsg = ''.obs; final inCall = false.obs; final callSeconds = 0.obs; final recordingSeconds = 0.obs; final micRms = 0.0.obs; final micAlive = false.obs; final pttRecording = false.obs; final pttSeconds = 0.obs; final ttsEnabled = true.obs; // 是否播报 AI 回复 final ScrollController scrollController = ScrollController(); // 凭证状态(原生层负责,Dart 层始终无错) final credentialReady = true.obs; final credentialError = ''.obs; // 设备认证失败(STS credential_failed 事件触发) final credentialFailed = false.obs; final credentialFailedReason = ''.obs; // ── 内部 ─────────────────────────────────────────────────────────────────── StreamSubscription? _eventSub; Timer? _callTimer; Timer? _pttTimer; Timer? _pttSafetyTimer; bool _inCallMode = false; bool _callStarting = false; // 防止并发 startCall // PTT 松开后仍有异步事件待处理(recognition_result / assistant_response / tts) bool _pttResultPending = false; // PTT 正在异步启动(await 权限期间用户已松手) bool _pttStarting = false; // BLE/AI 唤醒触发的会话(非通话、非PTT模式下的唤醒) bool _bleWakeupActive = false; // PTT 被中止(轻点松手),用于忽略原生层的残留事件 bool _pttAborted = false; final _storage = GetStorage(); // 滚动:layout 变化前是否已在底部 bool _atBottom = true; // ═══════════════════════════════════════════════════════════════════════════ // 生命周期 // ═══════════════════════════════════════════════════════════════════════════ String get _agentId => AppConfig.env('MOBILE_ELF_AGENT_ID') ?? 'mobile_elf'; @override void onInit() { super.onInit(); _subscribeEvents(); _loadChatHistory(); // 同步播报开关到原生层 ever(ttsEnabled, (bool enabled) { AgentService.setTtsMuted(!enabled); }); // 主动触发认证检查(页面打开时,确保 credential_failed 事件不被错过) AgentService.fetchCredentials(); } Future _loadChatHistory() async { try { final result = await ChatStorage.getMessages( agentId: _agentId, page: 1, pageSize: 50, ); if (result.messages.isNotEmpty) { final history = result.messages.map((m) => MobileElfMessage( isUser: m.sender == 'user', text: m.message, time: DateTime.tryParse(m.timestamp) ?? DateTime.now(), )).toList(); // 按时间正序排列 history.sort((a, b) => a.time.compareTo(b.time)); messages.addAll(history); _scrollToBottom(); } } catch (e) { Logger.error('$_tag 加载聊天记录失败: $e'); } } @override void onClose() { _eventSub?.cancel(); _callTimer?.cancel(); _pttTimer?.cancel(); _pttSafetyTimer?.cancel(); AgentService.stopConversation(); scrollController.dispose(); super.onClose(); } // ═══════════════════════════════════════════════════════════════════════════ // 事件订阅(来自原生 StsAgent) // ═══════════════════════════════════════════════════════════════════════════ void _subscribeEvents() { _eventSub = AgentService.events.listen(_onAgentEvent); } void _onAgentEvent(AgentServiceEvent event) { Logger.info('$_tag event: ${event.type} data: ${event.data}'); // PTT 松开后 pttRecording=false,但 fin_result/assistant_response/tts 仍异步到达 // 用 _pttResultPending 保持会话激活,直到 ttsCompleted/error 才关闭 final sessionActive = _inCallMode || pttRecording.value || _pttResultPending || _bleWakeupActive; switch (event.type) { case AgentServiceEventType.recognitionStarted: // 忽略刚刚中止的 PTT 会话的残留事件,避免误触发 BLE 唤醒 if (_pttAborted && !sessionActive) { _pttAborted = false; break; } // BLE/AI 唤醒:即使没有主动会话也激活 if (!sessionActive) _bleWakeupActive = true; status.value = MobileElfStatus.recording; realtimeText.value = ''; micAlive.value = true; break; case AgentServiceEventType.recognizing: if (!sessionActive) break; realtimeText.value = event.data['text']?.toString() ?? ''; status.value = MobileElfStatus.recording; break; case AgentServiceEventType.recognitionResult: if (!sessionActive) break; final text = event.data['text']?.toString() ?? ''; realtimeText.value = ''; micAlive.value = false; if (text.isNotEmpty) _addMessage(isUser: true, text: text); status.value = MobileElfStatus.thinking; break; case AgentServiceEventType.recognitionStopped: case AgentServiceEventType.recognitionCanceled: if (!sessionActive) break; realtimeText.value = ''; micAlive.value = false; break; case AgentServiceEventType.assistantResponse: if (!sessionActive) break; final text = event.data['response']?.toString() ?? ''; if (text.isNotEmpty) _updateOrAddAssistantMessage(text); if (!ttsEnabled.value) { status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; micAlive.value = _inCallMode; } break; case AgentServiceEventType.ttsStarted: if (!sessionActive) break; status.value = MobileElfStatus.speaking; break; case AgentServiceEventType.ttsCompleted: if (!sessionActive) break; _pttResultPending = false; _pttSafetyTimer?.cancel(); _endBleWakeup(); // BLE 唤醒一轮结束,清理 native PTT 状态 if (_inCallMode) { status.value = MobileElfStatus.recording; micAlive.value = true; } else { status.value = MobileElfStatus.idle; micAlive.value = false; } break; case AgentServiceEventType.ttsCanceled: case AgentServiceEventType.ttsStopped: if (!sessionActive) break; _pttResultPending = false; _pttSafetyTimer?.cancel(); _endBleWakeup(); status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; micAlive.value = _inCallMode; break; case AgentServiceEventType.error: if (!sessionActive) break; _pttResultPending = false; _pttSafetyTimer?.cancel(); _endBleWakeup(); final msg = event.data['message']?.toString() ?? '未知错误'; status.value = MobileElfStatus.error; errorMsg.value = msg; Logger.error('$_tag error: $msg'); break; case AgentServiceEventType.micRms: final rms = (event.data['rms'] as num?)?.toDouble() ?? 0.0; micRms.value = rms; break; case AgentServiceEventType.heartbeatReported: // 记录心跳上报时间 final t = (event.data['time'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch; _storage.write('sts_heartbeat_time', t); Logger.info('$_tag 心跳上报成功,时间: $t'); break; case AgentServiceEventType.credentialFailed: final reason = event.data['reason']?.toString() ?? '设备未认证'; Logger.error('$_tag 设备认证失败: $reason'); credentialFailed.value = true; credentialFailedReason.value = reason; // 确保停止任何进行中的会话 if (_inCallMode || pttRecording.value) { disconnect(); } break; default: break; } _scrollToBottom(); } // ═══════════════════════════════════════════════════════════════════════════ // 公共接口(View 调用) // ═══════════════════════════════════════════════════════════════════════════ Future startCall() async { if (inCall.value || _callStarting) return; _callStarting = true; _pttAborted = false; _checkHeartbeat(); try { // 若有正在播报的 PTT/BLE 唤醒会话,先强制中断 TTS/AI 流, // 使原生层立刻进入可停止状态,避免 stopConversation 长时间等待 if (_pttResultPending || _bleWakeupActive) { _pttResultPending = false; _bleWakeupActive = false; await AgentService.interruptCurrentResponse(); } // 再确保原生层完全停止 await AgentService.stopConversation(); final granted = await PermissionUtil.instance.requestPermission( permissionType: Permission.microphone, permissionName: '麦克风', explanationText: '移动精灵需要麦克风权限才能进行语音对话', permanentDenialText: '请在设置中开启麦克风权限以使用语音功能', ); if (!granted || inCall.value) return; // 权限等待期间可能已被挂断或重复调用 _inCallMode = true; inCall.value = true; callSeconds.value = 0; micAlive.value = true; status.value = MobileElfStatus.connecting; _callTimer?.cancel(); _callTimer = Timer.periodic( const Duration(seconds: 1), (_) => callSeconds.value++); await AgentService.startConversation(mode: 'phone_call'); // startCall 返回即表示麦克风已打开、VAD 已在监听,直接显示聆听状态 // (call 模式下 recognition_started 只在 VAD 检测到语音时才发出,不能等它来转状态) if (_inCallMode) status.value = MobileElfStatus.recording; } finally { _callStarting = false; } } Future disconnect() async { _callStarting = false; _inCallMode = false; _pttStarting = false; _pttResultPending = false; // 强制结束所有等待 _pttSafetyTimer?.cancel(); _bleWakeupActive = false; inCall.value = false; _callTimer?.cancel(); _callTimer = null; callSeconds.value = 0; pttRecording.value = false; _pttTimer?.cancel(); _pttTimer = null; status.value = MobileElfStatus.idle; micAlive.value = false; realtimeText.value = ''; await AgentService.stopConversation(); } /// 超过1小时未上报心跳则补报一次 void _checkHeartbeat() { final last = _storage.read('sts_heartbeat_time') ?? 0; if (DateTime.now().millisecondsSinceEpoch - last > 3600000) { AgentService.reportHeartbeat().catchError((_) {}); } } Future startPtt() async { if (pttRecording.value || _pttStarting) return; _pttStarting = true; _pttAborted = false; _checkHeartbeat(); // BLE 唤醒/PTT 等待结果期间用户按下 PTT,先中断当前会话(同 startCall 逻辑) if (_bleWakeupActive || _pttResultPending) { _pttResultPending = false; _bleWakeupActive = false; await AgentService.interruptCurrentResponse(); await AgentService.stopConversation(); } final granted = await PermissionUtil.instance.requestPermission( permissionType: Permission.microphone, permissionName: '麦克风', explanationText: '移动精灵需要麦克风权限才能进行语音对话', permanentDenialText: '请在设置中开启麦克风权限以使用语音功能', ); // 权限等待期间用户已松手(stopPtt 将 _pttStarting 置 false),直接放弃 if (!granted || !_pttStarting) { _pttStarting = false; return; } _pttStarting = false; pttRecording.value = true; pttSeconds.value = 0; status.value = MobileElfStatus.connecting; _pttTimer?.cancel(); _pttTimer = Timer.periodic( const Duration(seconds: 1), (_) => pttSeconds.value++); AgentService.startConversation(mode: 'push_to_talk'); } void stopPtt() { // 若仍在等待权限授予,取消本次启动 if (_pttStarting) { _pttStarting = false; return; } if (!pttRecording.value) return; pttRecording.value = false; _pttTimer?.cancel(); _pttTimer = null; // 如果还在连接中(还未开始识别),说明用户轻点了一下就松手, // 直接恢复空闲,不需要等待异步事件回来 if (status.value == MobileElfStatus.connecting) { _pttAborted = true; // 标记中止,忽略原生层的残留事件 _pttResultPending = false; status.value = MobileElfStatus.idle; micAlive.value = false; AgentService.stopConversation(); return; } _pttResultPending = true; // 等待 fin_result/assistant_response/tts 异步回来 AgentService.stopConversation(); // 安全超时:防止原生层未返回事件导致状态永久卡住 _pttSafetyTimer?.cancel(); _pttSafetyTimer = Timer(const Duration(seconds: 8), () { if (_pttResultPending && !_inCallMode) { Logger.info('$_tag PTT safety timeout — force reset to idle'); _pttResultPending = false; status.value = MobileElfStatus.idle; micAlive.value = false; } }); } void interruptSpeaking() { AgentService.interruptCurrentResponse(); status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; micAlive.value = _inCallMode; } /// 原生层已自动获取凭证,此处保留方法供 View 调用不报错 Future retryCredentials() async { credentialError.value = ''; } /// 清空聊天记录 Future clearMessages() async { messages.clear(); try { await AgentService.clearChatHistory(); } catch (e) { Logger.error('$_tag 清空聊天记录失败: $e'); } } // ═══════════════════════════════════════════════════════════════════════════ // 工具 // ═══════════════════════════════════════════════════════════════════════════ void _addMessage({required bool isUser, required String text}) { messages.add(MobileElfMessage( isUser: isUser, text: text, time: DateTime.now())); _scrollToBottom(); } /// 更新最后一条 AI 消息(如果存在且更短),否则添加新消息 void _updateOrAddAssistantMessage(String text) { final lastIdx = messages.length - 1; if (lastIdx >= 0 && !messages[lastIdx].isUser) { // 用更完整的文本替换 messages[lastIdx] = MobileElfMessage( isUser: false, text: text, time: messages[lastIdx].time); messages.refresh(); } else { _addMessage(isUser: false, text: text); } _scrollToBottom(); } /// BLE 唤醒会话标志清除 /// Native 状态(pttRecording 等)由 StsAgent.startPtt() 的重入逻辑懒清理, /// 不在此调用 stopConversation(),避免与新会话产生竞态。 void _endBleWakeup() { _bleWakeupActive = false; } void _scrollToBottom() { // layout 变化前捕获是否在底部,避免新内容撑开后差值变大误判为"已滚离底部" if (scrollController.hasClients) { final pos = scrollController.position; _atBottom = pos.maxScrollExtent - pos.pixels <= 80; } WidgetsBinding.instance.addPostFrameCallback((_) { if (scrollController.hasClients && _atBottom) { scrollController.animateTo( scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } }