import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; import '../../../core/utils/logger.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/user_Info.dart' as user_info; import '../../../data/services/assistant_directive_service.dart'; import '../../../data/services/bailian_multimodal_service.dart'; import '../../../data/models/emai_message.dart'; import '../../../data/services/bes_ai_session_service.dart'; import '../../../data/services/emai_chat_store.dart'; import '../../../data/utils/mic_pcm_streamer.dart'; import '../../../data/services/mcp_token_service.dart'; import '../../../data/utils/pcm_stream_player.dart'; /// 会话链路状态(跟 [BailianDialogState] 是两回事:这个说的是"连上了没", /// 那个说的是"连上之后现在在听/在想/在答") enum EmaiLinkState { idle, connecting, ready, failed, unconfigured, /// 耳机按键唤醒了 AI,会话让给耳机了;耳机那边结束后本页自动接回来 onEarphone, } /// EMAI 助手控制器。 /// /// 整条链路只有一个外部依赖——[BailianMultimodalService](阿里百炼多模态交互协议), /// ASR / LLM / TTS 全在服务端完成。客户端要做的三件事: /// 1. 上行:麦克风 PCM([MicPcmStreamer])按 push2talk 的 SendSpeech/StopSpeech 划段推上去 /// 2. 下行:服务端推回的 PCM 交给 [PcmStreamPlayer] 边收边播 /// 3. 文本:SpeechContent / RespondingContent 流式拼进消息列表 class EmaiController extends GetxController with WidgetsBindingObserver implements EmaiSessionOwner { static const String _tag = 'EmaiController'; static EmaiController get to => Get.find(); final BailianMultimodalService _svc = Get.find(); final MicPcmStreamer _mic = MicPcmStreamer(sampleRate: BailianMultimodalService.upstreamSampleRate); final PcmStreamPlayer _player = PcmStreamPlayer( sampleRate: BailianMultimodalService.downstreamSampleRate); // ---------------- UI 状态 ---------------- /// 对话记录来自共享 store:耳机按键唤醒的那些轮次也在里面, /// 页面没打开时发生的对话,打开后照样看得到 final EmaiChatStore _store = Get.find(); RxList get messages => _store.messages; final linkState = EmaiLinkState.idle.obs; /// 服务端对话状态:聆听 / 思考 / 回答 Rx get dialogState => _svc.state; /// 是否处于"按住说话"中 final isTalking = false.obs; /// 手指是否已滑出按钮区域(松手将取消这段语音) final isCancelPending = false.obs; /// 音浪振幅(0~1):说话时取麦克风,回答时取下行音频 final level = 0.0.obs; /// 连续(长期)对话开关。 /// /// 关:`push2talk` —— 按住说话,客户端用 beginSpeech/endSpeech 自己划段。 /// 开:`duplex` —— 麦克风一直开着,切句交给服务端 VAD,用户可以一直说下去。 /// /// ⚠️ 切换必须重连会话:模式是 `start()` 时定死的会话级参数,中途改不了。 /// /// ⚠️ duplex 在手机上能成立的前提是 AEC —— 外放的播报会被自己的麦收回去, /// 服务端 VAD 会当成用户插话,每次 AI 一开口就被自己打断。 /// [MicPcmStreamer] 已经开了 echoCancel/noiseSuppress,所以不需要像耳机那条 /// 链路那样做「播报期间门控上行」的半双工妥协(见 BesAiSessionService 的注释: /// 耳机没把下行接进 AEC 参考路径,只能门控)。 /// /// **默认关**:进页面只建立会话,不开麦。连续对话必须由用户点一下中间按钮 /// 显式开始,再点一下结束,可以反复开关;退出页面时 [onClose] 一并收掉。 /// /// ⚠️ 别再改回默认开。进页面即开麦意味着用户还没表达意图,麦克风就已经在 /// 录音并往服务端推流了——既是隐私问题,也会让「不小心点进来」变成一次 /// 计费的对话。耳机唤醒那条链路不走这里,它有自己的会话生命周期 /// (见 BesAiSessionService)。 final isContinuous = false.obs; /// 文字输入模式 / 语音输入模式 final isTextMode = false.obs; final hasInputText = false.obs; final TextEditingController textController = TextEditingController(); final ScrollController scrollController = ScrollController(); /// 用户是不是手动往上翻历史去了。 /// 翻上去之后就别再把他拽回底部,直到他自己滑回底部附近。 final isPinnedToBottom = true.obs; /// 距底部多少像素以内算"还贴着底" static const double _bottomSlack = 80; bool get isResponding => dialogState.value == BailianDialogState.responding || dialogState.value == BailianDialogState.thinking; bool get canTalk => linkState.value == EmaiLinkState.ready; // ---------------- 内部 ---------------- final _subs = []; Worker? _storeWorker; /// Start 发出去之后等 Started 事件的看门狗。 /// 服务端偶尔会既不回 Started 也不回 task-failed(鉴权被网关拦掉时), /// 没有这个定时器 UI 会永远停在"正在连接",连重连入口都点不到。 Timer? _handshakeTimer; static const Duration _handshakeTimeout = Duration(seconds: 15); /// 连续对话的空闲上限:这么久没有任何对话往来就自动结束,别让麦克风一直开着。 static const Duration _continuousIdleTimeout = Duration(seconds: 10); /// 空闲计时器。只在连续对话期间跑。 /// /// ⚠️ 计时的依据必须是**对话事件**,不能是上行音频包——连续模式下麦克风 /// 即使没人说话也在稳定出包,拿它续命等于这个超时永远不会触发。 Timer? _idleTimer; /// 连上之后被动断线时自动重连一次;再断就交给用户手动点 bool _autoRetried = false; /// 一次"按住说话"的代际号。 /// /// [startTalk] 里要 await 麦克风启动,用户完全可能在这期间就松手了; /// 没有这个号的话,await 回来后仍会补发 SendSpeech —— 排在 StopSpeech 之后, /// 服务端的语音段就错位了,而且麦克风开着再没人去关。 int _talkGen = 0; /// SendSpeech 已发出、还没 StopSpeech/CancelSpeech 收尾。 /// 没开过段就不该发收尾指令,否则服务端会收到一条无主的 StopSpeech。 bool _speechOpen = false; /// 当前正在流式接收的两条消息(收完就置空) EmaiMessage? _streamingUser; EmaiMessage? _streamingAssistant; @override void onInit() { super.onInit(); WidgetsBinding.instance.addObserver(this); textController.addListener(() { hasInputText.value = textController.text.trim().isNotEmpty; }); scrollController.addListener(_onScroll); // 耳机会话是往同一个 store 里写的(页面开着时耳机也可能在聊), // 那条路径不经过本控制器的事件处理,得单独盯着 store 才能跟到底 _storeWorker = ever(_store.messages, (_) => _scrollToBottom()); // 让 BesAiSessionService 能在耳机唤醒时找到本页并要求让出会话, // 同时避免 data 层反向 import modules 层。 // 用普通引用而不是 Get.put(this):后者会让 GetX // 再跑一次 onStart→onInit,而我们就在 onInit 里,直接递归爆栈。 if (Get.isRegistered()) { BesAiSessionService.to.attachPhonePage(this); } _subs.add(_svc.events.listen(_onEvent)); _subs.add(_svc.audioOut.listen(_player.feed)); _subs.add(_mic.packets.listen(_svc.pushAudio)); // ⚠️ 音浪的门控不能只看 isTalking:连续对话下它恒为 false, // 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。 _subs.add(_mic.level.listen((v) { if (isTalking.value || isContinuous.value) level.value = v; })); _subs.add(_player.level.listen((v) { if (!isTalking.value) level.value = v; })); _subs.add(_player.onCompleted.listen((_) { // 告诉服务端本地播报结束,它才会把对话状态推回 Listening/Idle _svc.notifyLocalRespondingEnded(); level.value = 0; })); _jumpToBottomOnEnter(); connect(); } @override void onClose() { WidgetsBinding.instance.removeObserver(this); if (Get.isRegistered()) { BesAiSessionService.to.detachPhonePage(this); } _handshakeTimer?.cancel(); _cancelIdle(); for (final s in _subs) { s.cancel(); } _subs.clear(); _mic.dispose(); _player.dispose(); _svc.stop(); _storeWorker?.dispose(); textController.dispose(); scrollController.removeListener(_onScroll); scrollController.dispose(); super.onClose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // 退到后台时松开麦克风:iOS 后台录音要额外能力,Android 上也没必要占着 if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { if (isTalking.value) _abortTalk(); // 连续模式下麦克风常开,切后台必须松开:iOS 后台持麦既费电,也可能被系统 // 直接掐掉会话。 // // ⚠️ 这里只停麦、**不动 isContinuous**,也不重连。连续是手机端的默认模式, // 切一次后台就把它降级成「按住说话」的话,用户回来会发现说话没反应, // 还得自己再点一下——而且重连要一秒多,纯属白费。 if (_micRunning) { _micRunning = false; _mic.stop(); level.value = 0; } // 后台不计空闲:回来时再重新开始数,否则挂后台十秒就被判定结束了 _cancelIdle(); } else if (state == AppLifecycleState.resumed) { // 回前台把麦接回来。会话还在(duplex 会话本身没断),只是麦停了。 if (isContinuous.value) { unawaited(_ensureContinuousMic()); _touchIdle(); } } } // ---------------- 连接 ---------------- Future connect() async { if (linkState.value == EmaiLinkState.connecting) return; // 耳机会话正在跑:本页不去抢,等它结束后 resumeFromEarphone 会接回来 if (linkState.value == EmaiLinkState.onEarphone) return; if (Get.isRegistered() && BesAiSessionService.to.isActive.value) { linkState.value = EmaiLinkState.onEarphone; return; } if (!AppConfig.isInitialized() || !_svc.isConfigured) { linkState.value = EmaiLinkState.unconfigured; Logger.w(_tag, 'EMAI 助手未配置:${_svc.configHint}'); return; } linkState.value = EmaiLinkState.connecting; // MCP 令牌要在 Start 之前拿到:biz_params 是握手时定死的, // 会话建起来之后再补就没地方放了。 final mcpToken = await McpTokenService.get(); final ok = await _svc.start( mode: isContinuous.value ? 'duplex' : 'push2talk', userId: _currentUserId(), mcpToken: mcpToken, ); if (!ok) { linkState.value = EmaiLinkState.failed; return; } // ok == true 只代表 Start 发出去了,真正就绪要等 Started 事件回来 _handshakeTimer?.cancel(); _handshakeTimer = Timer(_handshakeTimeout, () { if (linkState.value != EmaiLinkState.connecting) return; Logger.e(_tag, '握手超时:${_handshakeTimeout.inSeconds}s 内没有收到 Started'); linkState.value = EmaiLinkState.failed; _pushError('emaiConnectFailed'.tr); }); } Future reconnect() async { _handshakeTimer?.cancel(); _autoRetried = false; await _abortTalk(); await _svc.stop(); linkState.value = EmaiLinkState.idle; await connect(); } // ---------------- 与耳机 AI 会话互斥 ---------------- @override Future yieldToEarphone() async { _handshakeTimer?.cancel(); _handshakeTimer = null; _autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的 await _abortTalk(); await _player.stopNow(); await _svc.stop(); linkState.value = EmaiLinkState.onEarphone; } /// 手机页面主动把控制权要回来:让耳机退出 AI 模式。 /// /// 收尾是异步的——[BesAiSessionService.requestStop] 发完 `AA 65` 要等耳机回 /// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来, /// 所以这里不用自己 connect(),否则会和耳机会话抢。 Future takeBackFromEarphone() async { if (!Get.isRegistered()) { // 服务没注册(理论上不会),至少别让页面卡在 onEarphone linkState.value = EmaiLinkState.idle; await connect(); return; } linkState.value = EmaiLinkState.connecting; await BesAiSessionService.to.requestStop(); } @override Future resumeFromEarphone() async { // onEarphone = 耳机自己结束了;connecting = 本页刚点了"收回控制"。 // 其它状态说明页面已经在正常跑,不要打断。 if (linkState.value != EmaiLinkState.onEarphone && linkState.value != EmaiLinkState.connecting) { return; } linkState.value = EmaiLinkState.idle; _autoRetried = false; await connect(); } String _currentUserId() { try { return user_info.User.isLoggedIn() ? user_info.User.instance.uid : 'eaimar-guest'; } catch (_) { return 'eaimar-guest'; } } // ---------------- 语音交互(push2talk) ---------------- /// 切换连续对话。 /// /// 走的是「停会话 → 换模式重连 → 开麦」这条路,因为模式是会话级参数。 /// 重连期间界面会短暂回到「正在连接」,这是预期的。 Future toggleContinuous() async { if (isContinuous.value) { await _exitContinuous(); } else { await _enterContinuous(); } } Future _enterContinuous() async { if (!canTalk) return; // 手上还有一段按住说话没结束的,按取消收掉——它属于旧模式 await _abortTalk(); isContinuous.value = true; _buzz(HapticsType.light); await reconnect(); if (!isContinuous.value) return; // 重连期间用户又关掉了 // ⚠️ 必须等 Started 事件,不能在 reconnect() 一返回就判断状态。 // reconnect() 只负责把 Start 指令发出去,`ready` 要等服务端回 Started 才置位, // 那是一次网络往返。原来这里直接判 `!= ready`,几乎每次都命中,于是刚打开的 // 连续对话被当场关掉——按钮闪一下就退回「点击开始连续对话」,而 Started 稍后 // 到达时又会把麦克风开起来,留下一个界面上看不见的常开麦克风。 if (!await _waitLinkReady(_handshakeTimeout)) { // 没连上就别开麦,否则麦克风白开着还没人收 isContinuous.value = false; // Started 可能已经抢先把麦开起来了,一并收掉,别留孤儿 if (_micRunning) { _micRunning = false; await _mic.stop(); level.value = 0; } return; } await _ensureContinuousMic(); _touchIdle(); } /// 有对话往来就把空闲计时重新拨到头 void _touchIdle() { _idleTimer?.cancel(); if (!isContinuous.value) return; _idleTimer = Timer(_continuousIdleTimeout, _onIdleTimeout); } void _cancelIdle() { _idleTimer?.cancel(); _idleTimer = null; } void _onIdleTimeout() { if (!isContinuous.value) return; // 助手还在想/还在说(或者本地还在播它的音频)不算空闲—— // 一段长回复中间可能十几秒没有任何新事件,这时结束会把话掐断。 if (isResponding || _player.isPlaying) { _touchIdle(); return; } Logger.i(_tag, '连续对话空闲 ${_continuousIdleTimeout.inSeconds}s,自动结束'); unawaited(_exitContinuous()); } /// 等握手落地。ready 返回 true;失败/超时返回 false。 Future _waitLinkReady(Duration timeout) async { if (linkState.value == EmaiLinkState.ready) return true; final done = Completer(); final worker = ever(linkState, (s) { if (done.isCompleted) return; if (s == EmaiLinkState.ready) { done.complete(true); } else if (s == EmaiLinkState.failed || s == EmaiLinkState.unconfigured || s == EmaiLinkState.onEarphone) { done.complete(false); } }); try { return await done.future.timeout(timeout, onTimeout: () => false); } finally { worker.dispose(); } } /// 连续模式下把麦克风拉起来(幂等,重复调用无副作用)。 /// /// ⚠️ duplex 下**不发** beginSpeech/endSpeech:切句由服务端 VAD 决定, /// 客户端再去划段会和它打架(见 BailianMultimodalService.start 的说明)。 Future _ensureContinuousMic() async { if (!isContinuous.value) return; if (linkState.value != EmaiLinkState.ready) return; if (_micRunning) return; if (!await _mic.hasPermission()) { isContinuous.value = false; _pushError('emaiMicPermission'.tr); return; } if (!isContinuous.value) return; // 等权限期间用户关掉了 // ⚠️ 必须在 await 之前同步置位。`Started` 事件和 `_enterContinuous` 会几乎 // 同时调进来,都在这里 await 的话两边都会看到 false、都去开麦, // 同一个 recorder 被 startStream 两遍,第二次顶掉第一次那条流。 // (MicPcmStreamer 内部也做了一层去重,两层都留着——这里少一次无谓的往返。) _micRunning = true; if (!await _mic.start()) { _micRunning = false; isContinuous.value = false; _pushError('emaiMicPermission'.tr); } } /// 连续模式下麦克风是否已经开着。[MicPcmStreamer] 自己不暴露状态, /// 重复 start 会重开一路采集,必须在这里记账。 bool _micRunning = false; Future _exitContinuous() async { isContinuous.value = false; _cancelIdle(); _buzz(HapticsType.light); await _mic.stop(); _micRunning = false; level.value = 0; await reconnect(); } Future startTalk() async { if (!canTalk || isTalking.value) return; // 连续模式下麦克风本来就开着,再走按住说话那套会重复开麦 if (isContinuous.value) return; // isTalking 必须在任何 await 之前同步置位。 // 否则快按快放时 endTalk() 会看到 false 直接 return,而这边 await 回来后 // 照样把麦克风开起来 —— 按钮永远停在"松开发送",麦克风再没人关得掉。 final gen = ++_talkGen; isTalking.value = true; isCancelPending.value = false; _talkPressAt = DateTime.now(); _buzz(HapticsType.light); // 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权 if (isResponding) { await _player.stopNow(); _svc.interrupt(); } if (gen != _talkGen) return; if (!await _mic.hasPermission()) { if (gen == _talkGen) { isTalking.value = false; _pushError('emaiMicPermission'.tr); } return; } if (gen != _talkGen) return; // 先把麦克风拉起来再通知服务端开始收音:反过来的话, // SendSpeech 到首包音频之间的空档会被服务端 VAD 当成静音 final started = await _mic.start(); // 启动期间已经松手:把刚拉起来的麦克风关掉就完事, // 这一段语音当没发生过,不要再向服务端开段 if (gen != _talkGen) { await _mic.stop(); return; } if (!started) { isTalking.value = false; _pushError('emaiMicPermission'.tr); return; } _svc.beginSpeech(); _speechOpen = true; } /// 手指滑出/滑回按钮区域 void updateCancelPending(bool pending) { if (!isTalking.value) return; if (isCancelPending.value == pending) return; isCancelPending.value = pending; // 越过取消线时给一下反馈,用户不用低头看也知道松手会取消 _buzz(HapticsType.warning); } /// 按下到松开短于这个时长,算「点一下」而不是「按住说话」。 /// /// 250ms 是取舍出来的:再短容易把用户想按住说话时手抖的一下误判成点击, /// 再长会让真正的点击有明显延迟。反正短于 250ms 的语音本来也没有内容。 static const Duration _tapThreshold = Duration(milliseconds: 250); DateTime? _talkPressAt; Future endTalk() async { if (!isTalking.value) return; final pressed = _talkPressAt; final isTap = !isCancelPending.value && pressed != null && DateTime.now().difference(pressed) < _tapThreshold; if (isTap) { // 点一下 = 切换连续对话。刚才 onPointerDown 已经开了一小段语音, // 按取消收掉——这半秒不到的音频没有任何内容,发上去只会多一轮空回复。 await _finishTalk(cancel: true); await toggleContinuous(); return; } final cancelled = isCancelPending.value; _buzz(cancelled ? HapticsType.warning : HapticsType.light); await _finishTalk(cancel: cancelled); } /// 异常路径下的收手(切后台、断线、页面销毁):一律当取消处理 Future _abortTalk() { if (!isTalking.value) return Future.value(); return _finishTalk(cancel: true); } Future _finishTalk({required bool cancel}) async { // 先推进代际:还卡在 _mic.start() 里的那次 startTalk 会因此作废 _talkGen++; isTalking.value = false; isCancelPending.value = false; level.value = 0; // 先停麦克风,它会把不足一包的尾音补发出去,再发收尾指令, // 保证最后半个字也进了服务端 await _mic.stop(); _micRunning = false; if (_speechOpen) { _speechOpen = false; if (cancel) { _svc.cancelSpeech(); } else { _svc.endSpeech(); } } } // ---------------- 文字交互 ---------------- void sendTextMessage() { final text = textController.text.trim(); if (text.isEmpty || !canTalk) return; textController.clear(); hasInputText.value = false; if (isResponding) { _player.stopNow(); _svc.interrupt(); } // 服务端会把这条 prompt 当成"用户说的话"再回一遍 SpeechContent。 // 把本地这条挂到 _streamingUser 上,回显就并进同一个气泡, // 否则同一句话会在列表里出现两次。 final bubble = EmaiMessage(isUser: true, text: text, isFinal: true); _store.append(bubble); _scrollToBottom(force: true); // 自己发的消息一定要看到 _streamingUser = bubble; _svc.sendText(text); } /// 打断当前回复 Future interrupt() async { if (!isResponding) return; await _player.stopNow(); _svc.interrupt(); _streamingAssistant?.isFinal = true; _streamingAssistant = null; messages.refresh(); } void toggleInputMode() { isTextMode.value = !isTextMode.value; if (!isTextMode.value) { FocusManager.instance.primaryFocus?.unfocus(); } } void clearMessages() { _store.clear(); _streamingUser = null; _streamingAssistant = null; } // ---------------- 事件处理 ---------------- void _onEvent(BailianEvent e) { // 任何一条对话事件都算「还在聊」,把空闲计时拨回去。 // 到这里的事件都是真实往来(SpeechXxx / RespondingXxx / 状态变化), // 心跳在协议层就被吞掉了,不会误续命。 _touchIdle(); switch (e.type) { case BailianEventType.started: _handshakeTimer?.cancel(); _handshakeTimer = null; _autoRetried = false; // 新会话,上一条连接里没收尾的语音段跟着作废 _speechOpen = false; linkState.value = EmaiLinkState.ready; // duplex 会话就绪 = 该开麦了。服务端此刻开始做 VAD,麦克风不开的话 // 它永远收不到音频,界面上就是「已就绪但说什么都没反应」。 if (isContinuous.value) unawaited(_ensureContinuousMic()); break; case BailianEventType.speechStarted: // 这里**不**预先建气泡:ASR 一句都没识别出来时,服务端回的是 // Error("No speech recognized from audio!")而不是 SpeechEnded, // 预建的空气泡就没人清理,会永久留在列表里。 // 真正的气泡等第一个 SpeechContent 到了再懒建(见下面的分支)。 _streamingUser = null; break; case BailianEventType.speechContent: _streamingUser ??= () { final m = EmaiMessage(isUser: true, text: ''); _append(m); return m; }(); _mergeInto(_streamingUser!, e.text); if (e.isFinal) { _streamingUser!.isFinal = true; _streamingUser = null; } messages.refresh(); break; case BailianEventType.speechEnded: final m = _streamingUser; if (m != null) { if (e.text.isNotEmpty) _mergeInto(m, e.text); // ASR 一个字都没出来(误触/纯噪声):把空气泡撤掉,别留一条空消息 _store.finalize(m); _streamingUser = null; } break; case BailianEventType.respondingStarted: _streamingAssistant = EmaiMessage(isUser: false, text: ''); _append(_streamingAssistant!); break; case BailianEventType.respondingContent: _dispatchDirectives(e); // finish_reason=command_calls 的纯指令帧 text 是空的,而且这一轮 // 不会再来 RespondingStarted/Ended。照常建气泡的话会在列表里留一个 // 永远填不上的空泡(就是聊天记录里那几个孤零零的 “• • •”)。 if (e.text.isEmpty && e.finishReason == 'command_calls') break; _streamingAssistant ??= () { final m = EmaiMessage(isUser: false, text: ''); _append(m); return m; }(); _mergeInto(_streamingAssistant!, e.text); messages.refresh(); _scrollToBottom(); break; case BailianEventType.respondingEnded: final m = _streamingAssistant; if (m != null) { if (e.text.isNotEmpty) _mergeInto(m, e.text); _store.finalize(m); _streamingAssistant = null; } // 文本收完不代表音频收完,但下行音频到此为止,让播放器把缓冲播完 _player.end(); break; case BailianEventType.stateChanged: // dialogState 直接绑的是 service 里的 Rx,这里只需要驱动一次滚动 _scrollToBottom(); break; case BailianEventType.error: Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); _abortTalk(); if (e.fatal) { // 会话真的废了(task-failed / 连不上 / 没配置):停掉握手看门狗, // 状态条转成可重连的错误态 _handshakeTimer?.cancel(); _handshakeTimer = null; linkState.value = e.code == 'NOT_CONFIGURED' ? EmaiLinkState.unconfigured : EmaiLinkState.failed; } else { // 轮次级错误(音色不对、没识别到语音、模型超时之类):连接还在, // 把这一轮的流式气泡收干净就行,不要把整页判成"连接失败" _player.stopNow(); _dropIfEmpty(_streamingUser); _streamingUser = null; _dropIfEmpty(_streamingAssistant); _streamingAssistant?.isFinal = true; _streamingAssistant = null; messages.refresh(); } // 只有致命错误才在对话里留一条红色提示。 // // 轮次级错误("No speech recognized from audio!"、音色不对、模型超时之类) // 属于正常噪声:按住说话没出声、误触都会触发,连接还好好的。上面那段已经 // 把这一轮的空气泡收干净了,再塞一条红色英文报错只会吓到用户, // 而且它会永久留在聊天记录里。这类错误只记日志(本分支开头已记)。 if (e.fatal) { _pushError(e.text.isEmpty ? 'emaiConnectFailed'.tr : e.text); } break; case BailianEventType.closed: _handshakeTimer?.cancel(); _handshakeTimer = null; // 断线时麦克风还开着的话必须放掉,否则会一直占用录音设备 _abortTalk(); _player.stopNow(); if (linkState.value == EmaiLinkState.ready) { linkState.value = EmaiLinkState.idle; // 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点 if (!_autoRetried) { _autoRetried = true; Logger.i(_tag, '连接断开,自动重连一次'); connect(); } } break; case BailianEventType.requestAccepted: break; } } /// 把流式文本写进气泡。 /// /// 一律整体替换,不做任何拼接。会话是按 `incremental_response=false` 起的 /// (见 [BailianMultimodalService.start]),每一帧回的都是"到目前为止的全文"。 /// /// 之前这里用「新文本以旧文本开头就替换,否则追加」来两种都兼容, /// 结果在 ASR 上翻车了:识别中间结果是会**回改**的, /// "That's right" 下一帧可能变成 "Thats right",不构成前缀就被当增量拼上去, /// 真机上拼出了 "ThatThat's rightThatThat's right" 这种东西。 /// 把本帧携带的端侧指令交给 [AssistantDirectiveService] 落地。 /// /// 只做转发:解析在协议层、分发与落库在 service,控制器不碰业务。 void _dispatchDirectives(BailianEvent e) { if (e.directives.isEmpty) return; if (!Get.isRegistered()) { Logger.w(_tag, '指令服务未注册,丢弃 ${e.directives.length} 条指令'); return; } AssistantDirectiveService.to.handle(e.directives); } void _mergeInto(EmaiMessage m, String incoming) => _store.applyText(m, incoming); void _dropIfEmpty(EmaiMessage? m) => _store.dropIfEmpty(m); void _append(EmaiMessage m) { _store.append(m); _scrollToBottom(); } void _pushError(String text) { _append( EmaiMessage(isUser: false, text: text, isFinal: true, isError: true)); } /// 流式回复每来一帧就滚一次,用 animateTo 会不停打断上一个动画、 /// 看起来一顿一顿的。这里合并到下一帧再跳,并且只在用户本来就贴着底部时跳—— /// 他往上翻看历史的时候不该被硬拽回来。 bool _scrollScheduled = false; void _onScroll() { if (!scrollController.hasClients) return; final pos = scrollController.position; // 滑回底部附近就重新跟随;往上翻就停住 isPinnedToBottom.value = pos.maxScrollExtent - pos.pixels <= _bottomSlack; } /// 滚到底部。 /// /// [force] = true 用于进入页面和自己发消息这两种情况——那时必须落到最新一条, /// 不管之前停在哪。其余情况(流式刷新)只在用户还贴着底部时才跟随, /// 他正在翻历史就别打扰。 void _scrollToBottom({bool force = false}) { if (!force && !isPinnedToBottom.value) return; if (_scrollScheduled) return; _scrollScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _scrollScheduled = false; if (!scrollController.hasClients) return; final pos = scrollController.position; if (!force && !isPinnedToBottom.value) return; scrollController.jumpTo(pos.maxScrollExtent); if (force) isPinnedToBottom.value = true; }); } /// 「回到最新」按钮 void jumpToLatest() => _scrollToBottom(force: true); /// 进入页面时直接落到最底部。 /// /// 历史记录可能有上百条,列表默认停在第一行,用户得一路拉到底才看得到 /// 最新内容——这里在首帧之后直接跳到底,往上翻才是去看历史。 void _jumpToBottomOnEnter() { if (messages.isEmpty) return; // 首帧布局完成前 maxScrollExtent 还不准,连跳两帧兜住图片/Markdown // 撑开高度带来的二次布局 for (final delay in const [Duration.zero, Duration(milliseconds: 120)]) { Future.delayed(delay, () { if (!scrollController.hasClients) return; scrollController.jumpTo(scrollController.position.maxScrollExtent); isPinnedToBottom.value = true; }); } } void _buzz(HapticsType type) { // 震动失败(设备不支持/被系统禁用)不该影响对话 Haptics.vibrate(type).catchError((_) {}); } }