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.
376 lines
14 KiB
376 lines
14 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 '../../../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 = <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;
|
|
bool _inCallMode = false;
|
|
bool _callStarting = false; // 防止并发 startCall
|
|
// PTT 松开后仍有异步事件待处理(recognition_result / assistant_response / tts)
|
|
bool _pttResultPending = false;
|
|
// BLE/AI 唤醒触发的会话(非通话、非PTT模式下的唤醒)
|
|
bool _bleWakeupActive = false;
|
|
final _storage = GetStorage();
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 生命周期
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
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<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();
|
|
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:
|
|
// 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; // PTT 一轮结束
|
|
_bleWakeupActive = false; // BLE 唤醒一轮结束
|
|
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; // TTS 被打断也视为结束
|
|
_bleWakeupActive = false;
|
|
status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle;
|
|
micAlive.value = _inCallMode;
|
|
break;
|
|
|
|
case AgentServiceEventType.error:
|
|
if (!sessionActive) break;
|
|
_pttResultPending = false; // 出错时结束等待
|
|
_bleWakeupActive = false;
|
|
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<void> startCall() async {
|
|
if (inCall.value || _callStarting) return;
|
|
_callStarting = true;
|
|
_checkHeartbeat();
|
|
try {
|
|
// 先确保原生层完全停止(处理 onClose 未 await 导致的残留状态)
|
|
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');
|
|
} finally {
|
|
_callStarting = false;
|
|
}
|
|
}
|
|
|
|
Future<void> disconnect() async {
|
|
_callStarting = false;
|
|
_inCallMode = false;
|
|
_pttResultPending = false; // 强制结束所有等待
|
|
_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) return;
|
|
_checkHeartbeat();
|
|
final granted = await PermissionUtil.instance.requestPermission(
|
|
permissionType: Permission.microphone,
|
|
permissionName: '麦克风',
|
|
explanationText: '移动精灵需要麦克风权限才能进行语音对话',
|
|
permanentDenialText: '请在设置中开启麦克风权限以使用语音功能',
|
|
);
|
|
if (!granted) return;
|
|
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 (!pttRecording.value) return;
|
|
pttRecording.value = false;
|
|
_pttResultPending = true; // 等待 fin_result/assistant_response/tts 异步回来
|
|
_pttTimer?.cancel();
|
|
_pttTimer = null;
|
|
AgentService.stopConversation();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (scrollController.hasClients) {
|
|
scrollController.animateTo(
|
|
scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|