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.
865 lines
33 KiB
865 lines
33 KiB
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<BailianMultimodalService>();
|
|
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,所以不需要像耳机那条
|
|
/// 链路那样做「播报期间门控上行」的半双工妥协(见 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 = <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());
|
|
|
|
// 让 BesAiSessionService 能在耳机唤醒时找到本页并要求让出会话,
|
|
// 同时避免 data 层反向 import modules 层。
|
|
// 用普通引用而不是 Get.put<EmaiSessionOwner>(this):后者会让 GetX
|
|
// 再跑一次 onStart→onInit,而我们就在 onInit 里,直接递归爆栈。
|
|
if (Get.isRegistered<BesAiSessionService>()) {
|
|
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>()) {
|
|
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<void> connect() async {
|
|
if (linkState.value == EmaiLinkState.connecting) return;
|
|
// 耳机会话正在跑:本页不去抢,等它结束后 resumeFromEarphone 会接回来
|
|
if (linkState.value == EmaiLinkState.onEarphone) return;
|
|
if (Get.isRegistered<BesAiSessionService>() &&
|
|
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<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 模式。
|
|
///
|
|
/// 收尾是异步的——[BesAiSessionService.requestStop] 发完 `AA 65` 要等耳机回
|
|
/// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来,
|
|
/// 所以这里不用自己 connect(),否则会和耳机会话抢。
|
|
Future<void> takeBackFromEarphone() async {
|
|
if (!Get.isRegistered<BesAiSessionService>()) {
|
|
// 服务没注册(理论上不会),至少别让页面卡在 onEarphone
|
|
linkState.value = EmaiLinkState.idle;
|
|
await connect();
|
|
return;
|
|
}
|
|
linkState.value = EmaiLinkState.connecting;
|
|
await BesAiSessionService.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);
|
|
}
|
|
|
|
/// 打断当前回复
|
|
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;
|
|
}
|
|
// 文本收完不代表音频收完,但下行音频到此为止,让播放器把缓冲播完
|
|
_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((_) {});
|
|
}
|
|
}
|
|
|