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.
 
 
 
 
 
 

900 lines
34 KiB

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:haptic_feedback/haptic_feedback.dart';
import '../../../data/services/network/api.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/device_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 {
/// [service] 不传就用全局那个(EMAI 的百炼应用),行为与改造前一致。
///
/// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 EaiCar
/// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见
/// [BailianMultimodalService] 的类注释。
EmaiController({BailianMultimodalService? service})
: _svc = service ?? Get.find<BailianMultimodalService>();
static const String _tag = 'EmaiController';
final BailianMultimodalService _svc;
final MicPcmStreamer _mic =
MicPcmStreamer(sampleRate: BailianMultimodalService.upstreamSampleRate);
final PcmStreamPlayer _player = PcmStreamPlayer(
sampleRate: BailianMultimodalService.downstreamSampleRate);
// ---------------- UI 状态 ----------------
/// 对话记录来自共享 store:耳机按键唤醒的那些轮次也在里面,
/// 页面没打开时发生的对话,打开后照样看得到
final EmaiChatStore _store = Get.find<EmaiChatStore>();
RxList<EmaiMessage> get messages => _store.messages;
final linkState = EmaiLinkState.idle.obs;
/// 服务端对话状态:聆听 / 思考 / 回答
Rx<BailianDialogState> 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,所以不需要像耳机那条
/// 链路那样做「播报期间门控上行」的半双工妥协(见 DeviceAiSessionService 的注释:
/// 耳机没把下行接进 AEC 参考路径,只能门控)。
///
/// **默认关**:进页面只建立会话,不开麦。连续对话必须由用户点一下中间按钮
/// 显式开始,再点一下结束,可以反复开关;退出页面时 [onClose] 一并收掉。
///
/// ⚠️ 别再改回默认开。进页面即开麦意味着用户还没表达意图,麦克风就已经在
/// 录音并往服务端推流了——既是隐私问题,也会让「不小心点进来」变成一次
/// 计费的对话。耳机唤醒那条链路不走这里,它有自己的会话生命周期
/// (见 DeviceAiSessionService)。
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 = <StreamSubscription>[];
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());
// 让 DeviceAiSessionService 能在耳机唤醒时找到本页并要求让出会话,
// 同时避免 data 层反向 import modules 层。
// 用普通引用而不是 Get.put<EmaiSessionOwner>(this):后者会让 GetX
// 再跑一次 onStart→onInit,而我们就在 onInit 里,直接递归爆栈。
if (Get.isRegistered<DeviceAiSessionService>()) {
DeviceAiSessionService.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<DeviceAiSessionService>()) {
DeviceAiSessionService.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<void> connect() async {
if (linkState.value == EmaiLinkState.connecting) return;
// 耳机会话正在跑:本页不去抢,等它结束后 resumeFromEarphone 会接回来
if (linkState.value == EmaiLinkState.onEarphone) return;
if (Get.isRegistered<DeviceAiSessionService>() &&
DeviceAiSessionService.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<void> reconnect() async {
_handshakeTimer?.cancel();
_autoRetried = false;
await _abortTalk();
await _svc.stop();
linkState.value = EmaiLinkState.idle;
await connect();
}
// ---------------- 与耳机 AI 会话互斥 ----------------
@override
Future<void> yieldToEarphone() async {
_handshakeTimer?.cancel();
_handshakeTimer = null;
_autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的
await _abortTalk();
await _player.stopNow();
await _svc.stop();
linkState.value = EmaiLinkState.onEarphone;
}
/// 手机页面主动把控制权要回来:让耳机退出 AI 模式。
///
/// 收尾是异步的——[DeviceAiSessionService.requestStop] 发完 `AA 65` 要等耳机回
/// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来,
/// 所以这里不用自己 connect(),否则会和耳机会话抢。
Future<void> takeBackFromEarphone() async {
if (!Get.isRegistered<DeviceAiSessionService>()) {
// 服务没注册(理论上不会),至少别让页面卡在 onEarphone
linkState.value = EmaiLinkState.idle;
await connect();
return;
}
linkState.value = EmaiLinkState.connecting;
await DeviceAiSessionService.to.requestStop();
}
@override
Future<void> 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<void> toggleContinuous() async {
if (isContinuous.value) {
await _exitContinuous();
} else {
await _enterContinuous();
}
}
Future<void> _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<bool> _waitLinkReady(Duration timeout) async {
if (linkState.value == EmaiLinkState.ready) return true;
final done = Completer<bool>();
final worker = ever<EmaiLinkState>(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<void> _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<void> _exitContinuous() async {
isContinuous.value = false;
_cancelIdle();
_buzz(HapticsType.light);
await _mic.stop();
_micRunning = false;
level.value = 0;
await reconnect();
}
Future<void> 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<void> 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<void> _abortTalk() {
if (!isTalking.value) return Future.value();
return _finishTalk(cancel: true);
}
Future<void> _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);
}
/// 一轮对话结束后上报**原始字数**(输入 = 这轮之前最近一条用户消息,输出 = 助手这条回复),
/// 服务端按后台换算系数折成算力记账。客户端**不算算力**——系数可在后台改,客户端算的话
/// 改一次系数就要发一次版。字符数按 Unicode 码点(runes)计,与服务端口径一致。
/// 上报失败只记日志:这是记账不是闸门,不该影响对话。
void _reportAiUsage(EmaiMessage assistant) {
int up = 0;
for (final msg in messages.reversed) {
if (identical(msg, assistant)) continue;
if (msg.isUser) {
up = msg.text.runes.length;
break;
}
}
final down = assistant.text.runes.length;
if (up == 0 && down == 0) return;
unawaited(Api.usagesResPoints({
'usagetype': 3, // UsageType.AI
'usages': <String, int>{},
'upchars': up,
'downchars': down,
}).catchError((e) {
Logger.w(_tag, 'AI 用量上报失败: $e');
return null;
}));
}
/// 打断当前回复
Future<void> 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;
_reportAiUsage(m);
}
// 文本收完不代表音频收完,但下行音频到此为止,让播放器把缓冲播完
_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<AssistantDirectiveService>()) {
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((_) {});
}
}