You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

459 lines
17 KiB

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 '../../../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 = <MobileElfMessage>[].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<AgentServiceEvent>? _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 => 'mobile_elf';
@override
void onInit() {
super.onInit();
_subscribeEvents();
_loadChatHistory();
// 同步播报开关到原生层
ever(ttsEnabled, (bool enabled) {
AgentService.setTtsMuted(!enabled);
});
// 主动触发认证检查(页面打开时,确保 credential_failed 事件不被错过)
AgentService.fetchCredentials();
}
Future<void> _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() ?? 'unknownError'.tr;
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() ?? 'deviceNotAuth'.tr;
Logger.error('$_tag 设备认证失败: $reason');
credentialFailed.value = true;
credentialFailedReason.value = reason;
// 确保停止任何进行中的会话
if (_inCallMode || pttRecording.value) {
disconnect();
}
break;
default:
break;
}
_scrollToBottom();
}
// ═══════════════════════════════════════════════════════════════════════════
// 公共接口(View 调用)
// ═══════════════════════════════════════════════════════════════════════════
Future<void> 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: 'microphoneName'.tr,
explanationText: 'mobileElfMicPermission'.tr,
permanentDenialText: 'micPermissionSettings'.tr,
);
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<void> 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<int>('sts_heartbeat_time') ?? 0;
if (DateTime.now().millisecondsSinceEpoch - last > 3600000) {
AgentService.reportHeartbeat().catchError((_) {});
}
}
Future<void> 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: 'microphoneName'.tr,
explanationText: 'mobileElfMicPermission'.tr,
permanentDenialText: 'micPermissionSettings'.tr,
);
// 权限等待期间用户已松手(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<void> retryCredentials() async {
credentialError.value = '';
}
/// 清空聊天记录
Future<void> 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,
);
}
});
}
}