Browse Source

车载香薰 AI 对话:唤醒问候、按键插话、自动回连、退出聊天、前后台与状态同步(均只对香薰生效,恒玄耳机流程不变)

- 会话:服务端放弃回复(requestAccepted)时停播解门控,修掉互等 90s 的卡死;会话中再按唤醒键=打断重听;
  唤醒后用应用音色随机回一句「我在」;退出(0x07 03)无应答不再干等 2s;设备上线复位 AI 状态,
  防 App 被杀后设备停在 AI 模式、再唤醒 0 帧上行;Android 会话期间开前台服务,防切后台被冻结断 WS
- 指令:extra_info.commands 为 JSON 字符串时也能解出 quit(新车载应用下发形状)
- 连接:香薰断线/冷启动自动回连(带退避、OTA 与主动断开不回连);回连带上记住的 MAC;
  设备断开后界面显示最后连接的那台(DeviceHub 落盘 lastUsedVendor)
- iOS 经典蓝牙判断按 MAC 首字节±1/设备名匹配,连着就不再弹配对提示
- Android 播放器:车载会话(playbackOnly)回复间垫静音,防 A2DP 流挂起吞掉「我在」
- 聊天页:设备对话中底部按钮可「改用手机」;离开正显示香薰对话的页面时结束会话并通知设备
- 车载助手百炼应用 ID 改为 mm_127ddb82ba1244a99d2feaef0c1f

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 2 weeks ago
parent
commit
4ec8932aef
  1. 19
      apps/client/lib/data/models/assistant_directive.dart
  2. 205
      apps/client/lib/data/services/device_ai_session_service.dart
  3. 7
      apps/client/lib/data/utils/pcm_stream_player.dart
  4. 33
      apps/client/lib/devices/device_hub.dart
  5. 176
      apps/client/lib/devices/eaicar/eaicar_device_plugin.dart
  6. 3
      apps/client/lib/devices/eaicar/eaicar_ota_port.dart
  7. 2
      apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart
  8. 3
      apps/client/lib/modules/eaicar/module.dart
  9. 47
      apps/client/lib/modules/emai/controllers/emai_controller.dart
  10. 28
      apps/client/lib/modules/emai/views/emai_view.dart
  11. 3
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  12. 48
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/PcmSpeakerPlayer.kt
  13. 16
      apps/client/test/assistant_directive_test.dart
  14. 6
      docs/Smartcar车载香薰-接入设计与落地文档.md

19
apps/client/lib/data/models/assistant_directive.dart

@ -94,9 +94,10 @@ class AssistantDirective {
final extra = output['extra_info'];
if (extra is! Map) return const [];
final calls = extra['tool_calls'];
final commands = extra['commands'];
final rawCommands = _decodeIfString(extra['commands']);
final List commands = rawCommands is List ? rawCommands : const [];
final hasCalls = calls is List && calls.isNotEmpty;
final hasCommands = commands is List && commands.isNotEmpty;
final hasCommands = commands.isNotEmpty;
if (!hasCalls && !hasCommands) return const [];
final dialogId = (output['dialog_id'] ?? '').toString();
@ -159,6 +160,20 @@ class AssistantDirective {
return result;
}
/// `commands` 有时是数组,有时是**数组的 JSON 字符串**。
///
/// ⚠️ 2026-09-25 真机:换成车载助手新应用(mm_127ddb82…)后,同样的 quit 指令
/// 下发成了 `"commands":"[{\"intent_info\":…,\"name\":\"quit\"}]"`,
/// 只认 List 就解析出 0 条——用户说「退出聊天」,AI 回「好的」,会话却不结束。
static Object? _decodeIfString(Object? v) {
if (v is! String || v.trim().isEmpty) return v;
try {
return json.decode(v);
} catch (_) {
return v;
}
}
/// 解析 `extra_info.commands` —— **另一种指令形状**。
///
/// ⚠️ 2026-09-11 真机发现:EaiCar 车载助手下发的指令不在 `tool_calls` 里,

205
apps/client/lib/data/services/device_ai_session_service.dart

@ -4,8 +4,10 @@ import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '../../core/utils/audio_foreground_service.dart';
import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import '../../devices/device_vendors.dart';
import '../models/agent_module.dart';
import '../models/emai_message.dart';
import '../models/user_Info.dart' as user_info;
@ -181,6 +183,10 @@ class DeviceAiSessionService extends GetxService {
int _micPackets = 0;
int _downPackets = 0;
/// 本次会话开了 Android 前台服务(结束时要关,别关掉别人开的)
bool _holdsForeground = false;
int _micPeakRms = 0;
EmaiChatStore get _store => EmaiChatStore.to;
EmaiMessage? _streamingUser;
EmaiMessage? _streamingAssistant;
@ -192,6 +198,7 @@ class DeviceAiSessionService extends GetxService {
_wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) {
Logger.e(_tag, '唤醒事件流错误: $e');
});
_onlineWorker = ever<List<DeviceSession>>(hub.sessions, _resetAiStateOnOnline);
_hubSub = hub.sessionEvents.listen((e) {
if (e.type != DeviceSessionEventType.connectionStateChanged) return;
if (!isActive.value || _session?.deviceId != e.deviceId) return;
@ -221,12 +228,37 @@ class DeviceAiSessionService extends GetxService {
@override
void onClose() {
_onlineWorker?.dispose();
_wakeSub?.cancel();
_hubSub?.cancel();
_teardown(notifyDevice: true, playEndTone: false);
super.onClose();
}
// ---------------- 设备上线复位 ----------------
//
// ⚠️ 2026-09-25 真机:对话中 App 被划掉(或被系统杀掉)时来不及通知设备退出 AI,
// 设备一直停在 AI 模式;App 重开回连后再按唤醒键,设备照样报 0xCC 0x11,
// 却一帧麦克风都不发(原生统计 framesIn=0),用户说什么都没反应。
// 所以设备一上线、且此刻没有会话在跑,就先把它的 AI 状态复位成「关闭」。
// 只对车载香薰做:它的退出是单向的 0x07 03(无副作用);恒玄的 AA 65 是另一套流程,不碰。
Worker? _onlineWorker;
final Set<String> _onlineSeen = {};
void _resetAiStateOnOnline(List<DeviceSession> list) {
final ids = list.map((s) => s.deviceId).toSet();
_onlineSeen.removeWhere((id) => !ids.contains(id));
for (final s in list) {
if (!_onlineSeen.add(s.deviceId)) continue;
if (s.vendor != DeviceVendors.eaicar || isActive.value) continue;
Logger.i(_tag, '设备上线,复位它的 AI 状态(防上次会话没收尾)');
s.invokeFeature(DeviceFeatures.aiExit).catchError((Object e) {
Logger.w(_tag, '上线复位 AI 状态失败: $e');
return <String, Object?>{};
});
}
}
// ---------------- 设备事件 ----------------
void _onWake(DeviceWakeEvent e) {
@ -321,8 +353,10 @@ class DeviceAiSessionService extends GetxService {
void _onMicPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return;
_micPackets++;
final rms = rmsOfPcm16(pcm);
if (rms > _micPeakRms) _micPeakRms = rms;
// 用户在说话 = 有活动。只看能量、带节流,静音帧不续命
if (!_uplinkGated && rmsOfPcm16(pcm) >= _micActivityRms) {
if (!_uplinkGated && rms >= _micActivityRms) {
final now = DateTime.now();
if (_lastMicActivityAt == null ||
now.difference(_lastMicActivityAt!) >= _micActivityThrottle) {
@ -331,8 +365,10 @@ class DeviceAiSessionService extends GetxService {
}
}
if (_micPackets == 1 || _micPackets % 50 == 0) {
// 带上这段时间的峰值音量:排查「在说话却没识别」时,先分清是没声音还是服务端没认
Logger.i(_tag,
'设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}');
'设备上行 #$_micPackets (${pcm.length}B/包, 峰值RMS=$_micPeakRms)${_uplinkGated ? ' [门控中]' : ''}');
_micPeakRms = 0;
}
// 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是
// "真的没人说话",而不是一段数据缺口
@ -344,7 +380,15 @@ class DeviceAiSessionService extends GetxService {
Future<void> _handleStart(String deviceId) async {
if (isActive.value) {
// 会话中又按了一次唤醒键 = 用户要插话(「别说了,听我说」)。
// ⚠️ 2026-09-25 以前一律忽略:AI 正在念一段 60 多秒的回答,用户连按两次设备键
// 都没反应,设备以为进了新一轮、App 还在念旧的——两边状态对不上,看着就是卡死。
// 只在 Dart 链路这么做(车载香薰);恒玄原生直连的行为保持不变。
if (_native == null && _isEaiCar && _session?.deviceId == deviceId) {
_bargeIn();
} else {
Logger.w(_tag, '已有会话在跑,忽略重复唤醒');
}
return;
}
final hub = DeviceHub.to;
@ -385,6 +429,9 @@ class DeviceAiSessionService extends GetxService {
// 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持)
_aiStateUnsupported = false;
_quitAfterPlayout = false;
_greetPending = false;
_ownRequestPending = false;
_dropStaleDownlink = false;
_micPackets = 0;
_downPackets = 0;
_uplinkGated = false;
@ -398,6 +445,14 @@ class DeviceAiSessionService extends GetxService {
// ★ 先试原生直连:上行 PCM 与合成音频都不经过 Dart。
// 插件老 / 设备不是恒玄 → 返回 null,整条链路退回下面的 Dart 路径。
_native = await _tryOpenNativeDialog(session);
// Android 的等价物:前台服务把进程钉住。
// ⚠️ 2026-09-25 华为 ALN-AL80 实测:车载香薰对话中按 Home,约 2s 后进程被 Pged-Freezer
// 冻住(上行 PCM 攒了 2s 才一次性冲出来),百炼 WS 随即断开,重连两次又各活 3s,
// 最后放弃——用户看到的就是「切后台就聊不了了」。恒玄耳机走原生链路,不在这里管。
if (_native == null && _isEaiCar) {
unawaited(AudioForegroundService.start());
_holdsForeground = true;
}
try {
if (_useNative) {
_eventSub = _native!.events.listen(_onBailianEvent);
@ -424,6 +479,9 @@ class DeviceAiSessionService extends GetxService {
await _playCue(CueTone.ding(sampleRate: _deviceRate));
_touchActivity();
// 握手一完成就让助手用自己的音色说一句「我在」(见 _greetOnWake)。
// 只在 Dart 链路做:原生直连(恒玄耳机)不经过 _svc,行为保持不变。
_greetPending = _native == null && _isEaiCar;
final ok = await _startDialog();
final elapsed = DateTime.now().difference(ackAt);
if (elapsed > _ackDeadline) {
@ -455,6 +513,33 @@ class DeviceAiSessionService extends GetxService {
break;
}
switch (e.type) {
case BailianEventType.started:
if (_greetPending) {
_greetPending = false;
_greetOnWake();
}
break;
case BailianEventType.requestAccepted:
// 我们自己发的唤醒问候也会回一个 requestAccepted,那不是「服务端放弃回复」
if (_ownRequestPending) {
_ownRequestPending = false;
break;
}
// 服务端受理了一次新请求 = 它已经放弃正在回的那一轮(这一轮不会再有
// respondingEnded)。门若还开着,上行就一直是静音、服务端在 listening
// 里干等,双方互等到 _gateSafety 的 90s 才解开(2026-09-25 车载香薰实测)。
// 所以:丢掉那一轮没播完的音频,立刻关门,把耳朵还给用户。
if (_uplinkGated && _isEaiCar) {
Logger.w(_tag, '服务端放弃了本轮回复(requestAccepted),停播并解除门控');
_spk?.discardPending();
_cancelPlayoutTimer();
_store.finalize(_streamingAssistant);
_streamingAssistant = null;
_closeGate();
}
break;
case BailianEventType.speechStarted:
// 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的
if (!_uplinkGated) {
@ -483,6 +568,7 @@ class DeviceAiSessionService extends GetxService {
break;
case BailianEventType.respondingStarted:
_dropStaleDownlink = false;
_respondingBytes = 0;
_firstAudioAt = null;
_respondingStartedAt = DateTime.now();
@ -576,15 +662,14 @@ class DeviceAiSessionService extends GetxService {
_teardown(notifyDevice: true);
}
break;
default:
break;
}
}
/// 退回 Dart 链路时才会跑(原生直连时 PCM 不经过这里)
void _onDownstreamPcm(Uint8List pcm) {
if (pcm.isEmpty) return;
// 被按键打断的那一轮,服务端可能还在往下发尾巴,丢掉,等下一轮 respondingStarted
if (_dropStaleDownlink) return;
_noteDownstream(pcm.length);
_spk?.write(pcm);
}
@ -612,6 +697,79 @@ class DeviceAiSessionService extends GetxService {
/// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`)
static const String _quitDirective = 'quit';
/// 当前会话是不是车载香薰。2026-09-25 为香薰加的会话行为(唤醒问候、按键插话、
/// 服务端放弃回复时解门控、前台服务、0 包诊断)**一律只对它生效**,
/// 恒玄耳机的会话流程保持原样、一个分支都不走。
bool get _isEaiCar => _session?.vendor == DeviceVendors.eaicar;
/// 按键插话后,丢弃被打断那一轮剩下的下行音频(见 [_bargeIn])。
bool _dropStaleDownlink = false;
/// 会话中再次按唤醒键:停掉正在播的回复、立刻重新聆听,并把状态同步给设备。
void _bargeIn() {
Logger.i(_tag, '会话中再次按键唤醒:打断当前播报,重新聆听');
// respondingEnded 已到 = 服务端发完了,只剩本地在播(_playoutTimer 在等播完)
final serverStillSending =
_svc.state.value == BailianDialogState.responding &&
_playoutTimer == null;
final localPlaying = _uplinkGated || _playoutTimer != null;
_spk?.discardPending();
_cancelPlayoutTimer();
_quitAfterPlayout = false;
if (serverStillSending) {
// 服务端还在生成:请它停下,并丢掉这一轮之后到的尾巴
_dropStaleDownlink = true;
_svc.interrupt();
}
if (serverStillSending || localPlaying) {
// 告诉服务端本地已经播完,它才会回到 Listening
_notifyLocalRespondingEnded();
}
_store.finalize(_streamingAssistant);
_streamingAssistant = null;
_closeGate();
_pushAiState(DeviceAiState.listening);
_touchActivity();
}
// ---------------- 唤醒问候 ----------------
//
// 2026-09-25 用户要求:唤醒后马上随机回一句「我在 / 有什么可以帮你」,
// 让人知道唤醒成功了。走 RequestToRespond(type=transcript):服务端用**应用自己的音色**
// 直接合成这段文字,不经过大模型,所以快、也不会被模型改写。
// 它走的是正常的回复链路(respondingStarted → 下行 → respondingEnded),
// 门控、播完估算、LocalRespondingEnded 都不用另写。
/// 本次会话握手完成后要不要问候(每次唤醒只一次;WS 重连后的 Started 不再问候)。
bool _greetPending = false;
/// 刚发出问候、等它的 requestAccepted——那一条不能当成「服务端放弃了本轮回复」。
bool _ownRequestPending = false;
static const List<String> _greetingsZh = [
'我在',
'我在呢',
'你好呀,有什么可以帮你?',
'在的,请说',
'嗨,我在听',
];
static const List<String> _greetingsEn = [
"I'm here",
'Hi, how can I help?',
"Yes? I'm listening",
];
void _greetOnWake() {
final zh = (Get.locale?.languageCode ?? 'zh') == 'zh';
final list = zh ? _greetingsZh : _greetingsEn;
final text = list[DateTime.now().microsecond % list.length];
Logger.i(_tag, '唤醒问候: $text');
_ownRequestPending = true;
_svc.sendText(text, speakDirectly: true);
// 服务端万一不回 requestAccepted,别让这个标记吞掉之后真正的那一次
Timer(const Duration(seconds: 3), () => _ownRequestPending = false);
}
/// 收到 quit 指令,等这一轮告别语播完就收尾
bool _quitAfterPlayout = false;
@ -781,7 +939,13 @@ class DeviceAiSessionService extends GetxService {
if (!isActive.value) return;
Logger.i(_tag, '请求设备退出 AI 模式');
final s = _session;
if (s != null) await _sendExit(s);
final noAck = s != null && await _sendExit(s);
if (noAck) {
// 车载香薰的「退出」是单向的 0x07 03,设备从不回应答——
// 干等 2s 只会让 App 比设备晚 2s 结束(2026-09-25 日志里每次都是超时收尾)
await _teardown(notifyDevice: false);
return;
}
_stopAckTimer?.cancel();
_stopAckTimer = Timer(_stopAckTimeout, () {
if (!isActive.value) return;
@ -790,12 +954,16 @@ class DeviceAiSessionService extends GetxService {
});
}
Future<void> _sendExit(DeviceSession s) async {
if (s.state != DeviceConnectionState.ready) return;
/// 发 ai.exit。返回 true 表示该设备**不会回结束应答**(插件回了 `noAck`),
/// 调用方可以直接收尾。
Future<bool> _sendExit(DeviceSession s) async {
if (s.state != DeviceConnectionState.ready) return false;
try {
await s.invokeFeature(DeviceFeatures.aiExit);
final r = await s.invokeFeature(DeviceFeatures.aiExit);
return r['noAck'] == true;
} catch (e) {
Logger.w(_tag, '发送 ai.exit 失败: $e');
return false;
}
}
@ -883,9 +1051,21 @@ class DeviceAiSessionService extends GetxService {
await _svc.stop();
final s = _session;
// 整场一帧设备上行都没有(2026-09-25 真机出现过「上行 0 包」):
// 趁会话还在,把原生解码统计打出来,分清是设备没发、BLE 没收到还是没解出来
if (wasActive && _micPackets == 0 && s != null && _isEaiCar) {
try {
final st = await s.invokeFeature('debug.audioStats');
Logger.w(_tag, '本场设备上行 0 包,原生音频统计: $st');
} catch (_) {}
}
if (wasActive && notifyDevice && s != null) await _sendExit(s);
_session = null;
await BackgroundKeepalive.release(_tag);
if (_holdsForeground) {
_holdsForeground = false;
unawaited(AudioForegroundService.stop());
}
if (wasActive) {
Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)');
await _resumePhonePage();
@ -896,6 +1076,13 @@ class DeviceAiSessionService extends GetxService {
void attachPhonePage(EmaiSessionOwner page) => _phonePage = page;
/// [page] 是不是当前登记的那个聊天页(同一时刻可能有新旧两个实例:
/// 新页面 onInit 先登记,旧页面 onClose 后到)。
bool isAttachedPage(EmaiSessionOwner page) => identical(_phonePage, page);
/// 正在进行的是不是车载香薰的会话(聊天页据此决定要不要用香薰那套交互)。
bool get activeIsEaiCar => isActive.value && _isEaiCar;
void detachPhonePage(EmaiSessionOwner page) {
if (identical(_phonePage, page)) _phonePage = null;
}

7
apps/client/lib/data/utils/pcm_stream_player.dart

@ -238,7 +238,12 @@ class PcmStreamPlayer {
try {
_nativeChannel.setMethodCallHandler(_onNativeCall);
final ok =
await _nativeChannel.invokeMethod<bool>('start', {'sampleRate': sampleRate});
await _nativeChannel.invokeMethod<bool>('start', {
'sampleRate': sampleRate,
// 只有车载香薰的设备会话用 playbackOnly(声音走 A2DP)。Android 原生据此在
// 回复之间垫静音,别让 A2DP 流被挂起吞掉下一句的开头;其它调用方不受影响。
'keepAlive': playbackOnly,
});
if (ok != true) {
Logger.e(_tag, '原生播放器启动失败');
return false;

33
apps/client/lib/devices/device_hub.dart

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../core/utils/logger.dart';
@ -114,14 +115,19 @@ class DeviceHub extends GetxService {
/// AI 页的默认编排按厂商取,两者指向不同设备就会出现「图是耳机、
/// 功能却是香薰的」这种自相矛盾的界面。
///
/// ⚠️ 已知口径局限:没有活跃会话时按**插件注册顺序**取第一个连过的,
/// 不是按「最近连接时间」。同时连过耳机和香薰、然后都断开时,
/// 显示的是注册在前的那家(当前是恒玄),而不是真正最近用的那台。
/// 没有活跃会话时,先认**最后一次上线的那家**([lastUsedVendor],落盘,跨冷启动),
/// 它没有记住的设备才退回按插件注册顺序找。
/// ⚠️ 2026-09-25 以前只有后一条:连过耳机又连了香薰、香薰一断,
/// 界面就跳回耳机(恒玄注册在前),与「最后连的是哪台就显示哪台」相反。
({String name, String vendor}) get _lastKnown {
final p = primary;
if (p != null && p.info.name.isNotEmpty) {
return (name: p.info.name, vendor: p.vendor);
}
final last = _plugins[lastUsedVendor]?.lastKnownDevice;
if (last != null && last.name.isNotEmpty) {
return (name: last.name, vendor: lastUsedVendor);
}
for (final plugin in _plugins.values) {
final d = plugin.lastKnownDevice;
if (d != null && d.name.isNotEmpty) {
@ -131,6 +137,26 @@ class DeviceHub extends GetxService {
return (name: '', vendor: '');
}
static const String _lastVendorKey = 'device_hub_last_vendor';
/// 最后一次上线的设备出自哪家插件(落盘)。从没连过为空串。
/// 自动回连也按它决定该回连哪一家,避免冷启动时两家互相顶掉。
String get lastUsedVendor {
try {
return GetStorage().read<String>(_lastVendorKey) ?? '';
} catch (_) {
return '';
}
}
void _rememberLastVendor(String vendor) {
try {
if (lastUsedVendor != vendor) GetStorage().write(_lastVendorKey, vendor);
} catch (e) {
Logger.w(_tag, '记录最后连接的设备厂商失败: $e');
}
}
/// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。
/// 从没连过任何设备返回空串。
String get lastKnownName => _lastKnown.name;
@ -212,6 +238,7 @@ class DeviceHub extends GetxService {
});
}
sessions.insert(0, s);
_rememberLastVendor(s.vendor);
_sessionSubs[s.vendor] = s.eventStream.listen((e) {
_events.add(e);
_onSessionEvent(s, e);

176
apps/client/lib/devices/eaicar/eaicar_device_plugin.dart

@ -10,9 +10,11 @@ import 'package:get_storage/get_storage.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/utils/logger.dart';
import '../../data/services/background_launch_consent.dart';
import '../../data/utils/pcm_stream_player.dart';
import '../../core/utils/permission_util.dart';
import '../device_auth.dart';
import '../device_hub.dart';
import '../device_vendors.dart';
import 'eaicar_device_auth.dart';
import 'eaicar_link.dart';
@ -91,8 +93,14 @@ class EaiCarDevicePlugin implements DevicePlugin {
_connSub?.cancel();
_connSub = link.connected.listen((up) {
if (!up) {
final wasOnline = _session != null;
_session?._markDisconnected();
_session = null;
// 不是用户/切设备主动断的(设备走远、关机、信号抖)→ 自动回连
if (wasOnline && !_userDisconnected) {
_reconnectAttempt = 0;
_scheduleReconnect('设备意外断开');
}
}
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
@ -103,6 +111,112 @@ class EaiCarDevicePlugin implements DevicePlugin {
));
});
_emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady));
// 冷启动回连:等 CoreBluetooth / 蓝牙适配器就绪、DeviceHub 注册完其它插件再动
Timer(const Duration(seconds: 3), _startupReconnect);
}
// ─────────────────────────── 自动回连 ───────────────────────────
//
// 2026-09-25 以前 [tryAutoConnect] 写好了但**全项目没有调用方**(DeviceHub 的
// tryAutoConnectAll 也没人调),于是香薰断了就断了,冷启动也不连。现在两处触发:
// 1. 冷启动:最后一次用的设备是香薰([DeviceHub.lastUsedVendor]),且用户同意过
// 后台自动连接(与恒玄同一个开关);
// 2. 意外断开:不是用户点断开 / 切到别的设备 / 解绑造成的,就按退避重试。
// 两个平台都能按 id 直连已知外设,不需要先扫描(iOS retrievePeripherals、
// Android getRemoteDevice),设备回到范围内就能接上。
// 有别的设备在线时不回连:DeviceHub 是单活动设备,连上香薰会把用户正在用的那台顶掉。
/// 用户(或 DeviceHub 切设备)主动断开 / 解绑后置位,挡住自动回连;下次 connect 清掉。
bool _userDisconnected = false;
/// OTA 进行中([EaiCarOtaPort] 置位、close 时清掉):设备会断开重启,别去回连。
bool _otaActive = false;
set otaActive(bool v) {
_otaActive = v;
if (v) _cancelReconnect();
}
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
static const List<int> _reconnectDelaysSec = [2, 5, 10, 20, 30];
/// 意外断开后最多试这么多次(约 10 分钟),之后等下次冷启动或手动连接。
static const int _reconnectMaxAttempts = 22;
void _startupReconnect() {
if (_disposed || _session != null || link.isConnected) return;
if (rememberedDevices.isEmpty) return;
String last = '';
try {
last = DeviceHub.to.lastUsedVendor;
} catch (_) {}
if (last.isNotEmpty && last != vendorKey) return;
if (!BackgroundLaunchConsent.granted) {
Logger.i(_tag, '用户未开启设备自动连接,跳过香薰冷启动回连');
return;
}
_reconnectAttempt = 0;
_scheduleReconnect('冷启动', immediate: true);
}
bool _otherDeviceOnline() {
try {
return DeviceHub.to.sessions.any((x) => x.vendor != vendorKey);
} catch (_) {
return false;
}
}
void _cancelReconnect() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
}
void _scheduleReconnect(String why, {bool immediate = false}) {
_cancelReconnect();
if (_disposed || _userDisconnected || _otaActive || rememberedDevices.isEmpty) {
return;
}
if (_reconnectAttempt >= _reconnectMaxAttempts) {
Logger.i(_tag, '香薰自动回连已试 $_reconnectAttempt 次,停止(等手动连接或下次启动)');
return;
}
final sec = immediate
? 0
: _reconnectDelaysSec[
math.min(_reconnectAttempt, _reconnectDelaysSec.length - 1)];
_reconnectAttempt++;
Logger.i(_tag, '$why,${sec}s 后第 $_reconnectAttempt 次自动回连');
_reconnectTimer = Timer(Duration(seconds: sec), () async {
_reconnectTimer = null;
if (_disposed ||
_userDisconnected ||
_otaActive ||
_session != null ||
link.isConnected) {
return;
}
if (_otherDeviceOnline()) {
Logger.i(_tag, '已有其它设备在线,停止香薰自动回连');
return;
}
final first = rememberedDevices.isEmpty ? null : rememberedDevices.first;
if (first == null) return;
try {
// 回连不经过扫描,拿不到广播里的 MAC——带上记住的那份,
// 否则确权跳过、经典蓝牙状态也查不了(日志里的「广播里没解出 MAC」)
final mac = first.metadata['mac'];
await connect(first.id,
options: DeviceConnectOptions(
timeout: const Duration(seconds: 45),
extra: {if (mac is String && mac.isNotEmpty) 'mac': mac},
));
Logger.i(_tag, '香薰自动回连成功(第 $_reconnectAttempt 次)');
_reconnectAttempt = 0;
} catch (e) {
_scheduleReconnect('自动回连未成功($e)');
}
});
}
/// 自持 GATT 要的运行时权限(Android 12+)。iOS 由系统在首次用 CoreBluetooth 时弹框。
@ -203,6 +317,8 @@ class EaiCarDevicePlugin implements DevicePlugin {
Future<DeviceSession> connect(String deviceId,
{DeviceConnectOptions? options}) async {
_checkAlive();
_userDisconnected = false;
_cancelReconnect();
await stopScan();
final adv = options?.extra['advertisement'];
@ -311,6 +427,10 @@ class EaiCarDevicePlugin implements DevicePlugin {
@override
Future<void> forgetDevice(String deviceId) async {
if (link.deviceId == deviceId) {
_userDisconnected = true;
_cancelReconnect();
}
final list = _remembered..removeWhere((m) => m['id'] == deviceId);
await _storage.write(_rememberedKey, list);
if (link.deviceId == deviceId) await link.disconnect();
@ -333,6 +453,7 @@ class EaiCarDevicePlugin implements DevicePlugin {
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
_cancelReconnect();
await _scanSub?.cancel();
await _connSub?.cancel();
await _diagSub?.cancel();
@ -881,10 +1002,54 @@ class EaiCarSession implements DeviceSession {
/// 但只有扫描时才能拿到,连上之后就不再更新
Future<bool> isClassicAudioConnected() async {
final mac = classicMacCandidate;
if (Platform.isIOS) return _iosClassicAudioConnected(mac);
if (mac == null || mac.isEmpty) return false;
return ClassicBluetooth.isA2dpConnected(mac);
}
/// iOS:看当前音频输出里有没有这台设备。
///
/// ⚠️ 2026-09-25 真机:系统设置里经典蓝牙明明连着,这里却恒判「未连接」,
/// 于是每次连上都弹「请到 设置 → 蓝牙 里配对」、AI 会话也打「A2DP 未连接」。
/// 原因是只拿广播 MAC 去比,而经典蓝牙地址可能与它差首字节(杰理 BLE/经典
/// 两个地址的约定,见 [classicMacCandidate]),名字也没参与比较。
/// 现在广播 MAC、首字节 ±1、设备名任一对上即算,并把系统看到的输出打出来,
/// 下次真机一眼就能看出经典地址和广播地址的关系。
Future<bool> _iosClassicAudioConnected(String? mac) async {
try {
final outs = await ClassicBluetooth.getConnectedDevices();
final macs = <String>{
for (final m in [mac, _mac, _shiftFirstByte(_mac, -1), _shiftFirstByte(_mac, 1)])
if (m != null && m.isNotEmpty) m.toUpperCase(),
};
final name = _info.name.trim().toUpperCase();
final hit = outs.any((d) {
final a = d.address.toUpperCase();
final n = d.name.toUpperCase();
return macs.contains(a) ||
(name.isNotEmpty && n.contains(name)) ||
n.contains('EAICAR');
});
Logger.i('EaiCarSession',
'iOS 蓝牙音频输出: ${outs.map((d) => '${d.name}(${d.address})').join(', ')}'
' | 广播MAC=$_mac 设备名=${_info.name} → ${hit ? '已连' : '未连'}');
return hit;
} catch (e) {
Logger.w('EaiCarSession', '查询 iOS 音频输出失败: $e');
return false;
}
}
static String? _shiftFirstByte(String? mac, int delta) {
if (mac == null) return null;
final parts = mac.split(':');
if (parts.length != 6) return null;
final first = int.tryParse(parts[0], radix: 16);
if (first == null) return null;
parts[0] = ((first + delta) & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase();
return parts.join(':');
}
EaiCarOtaPort? _otaPort;
/// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。
@ -965,7 +1130,8 @@ class EaiCarSession implements DeviceSession {
case DeviceFeatures.aiExit:
await _setAiState(EaiCarProtocol.aiStateClose);
await _link.audioStop();
return {'ok': true};
// 0x07 是单向命令,设备不回结束应答;告诉会话服务别等了
return {'ok': true, 'noAck': true};
case DeviceFeatures.aiState:
// 通用语义 → C2 的 0x07。**设备的表情动画全靠这条**:不发的话
// 整轮对话里设备只在开头收到一次"聆听中",之后一直是那个表情
@ -1007,6 +1173,8 @@ class EaiCarSession implements DeviceSession {
.map((d) => {'name': d.name, 'address': d.address})
.toList(),
};
case 'debug.audioStats':
return await _link.audioStats();
case 'eaicar.a2dp_connect':
return await connectClassicAudio();
@ -1038,6 +1206,9 @@ class EaiCarSession implements DeviceSession {
/// 去「设置 → 蓝牙」手动配一次。返回 `{'ok': false, 'status': 'unsupported_ios'}`
Future<Map<String, Object?>> connectClassicAudio() async {
final mac = classicMacCandidate;
if (Platform.isIOS && await isClassicAudioConnected()) {
return {'ok': true, 'status': 'already_connected', 'mac': mac};
}
if (mac == null || mac.isEmpty) {
return {'ok': false, 'status': 'no_mac', 'hint': '广播里没解出 MAC'};
}
@ -1086,6 +1257,9 @@ class EaiCarSession implements DeviceSession {
@override
Future<void> disconnect() async {
// 主动断开(用户点断开 / DeviceHub 切到别的设备)不触发自动回连
_plugin._userDisconnected = true;
_plugin._cancelReconnect();
_state = DeviceConnectionState.disconnecting;
await _link.audioStop();
await _link.disconnect();

3
apps/client/lib/devices/eaicar/eaicar_ota_port.dart

@ -55,6 +55,8 @@ class EaiCarOtaPort implements DeviceOtaPort {
_running = true;
_terminal = false;
_succeeded = false;
// 升级中设备会断开/重启进 stage2,这期间插件的断线自动回连必须让路
_plugin.otaActive = true;
_lastPercent = -1;
_sub ??= _link.otaEvents.listen(_onEvent, onError: (Object e) {
_fail(DeviceErrorCode.otaTransferFailed, '$e');
@ -135,6 +137,7 @@ class EaiCarOtaPort implements DeviceOtaPort {
// 升级成功设备会重启,广播回来要几秒;失败/取消时原生已把链路断开,一样回连
final delay = _succeeded ? const Duration(seconds: 8) : const Duration(seconds: 2);
Future.delayed(delay, () async {
_plugin.otaActive = false;
try {
await _plugin.tryAutoConnect();
} catch (e) {

2
apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart

@ -45,4 +45,4 @@ const String kEaiCarTag = 'eaicar';
/// 等运营提出「要后台随时改」时,再改成从 `DBAgent.serviceData` 下发——
/// 那时要给客户端手写镜像补 `source` / `serviceData` 两个字段,且**必须 nullable**,
/// 否则老后端不下发就整个 `fromJson` 抛 TypeError、应用配置全空白。
const String kEaiCarBailianAppId = 'mm_dd1eb7cb06484aafb94a0b6b2dff';
const String kEaiCarBailianAppId = 'mm_127ddb82ba1244a99d2feaef0c1f';

3
apps/client/lib/modules/eaicar/module.dart

@ -51,6 +51,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
translations: const {
'zh_CN': {
'agentEaiCar': 'EaiCar',
'emaiDeviceTalkingTapPhone': '设备对话中 · 点此改用手机',
'agentEaiCarDesc': '您的车载助手',
'eaicarWelcome': '欢迎回来',
'eaicarA2dpTitle': '需要连接蓝牙音频',
@ -73,6 +74,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
},
'zh_TW': {
'agentEaiCar': 'EaiCar',
'emaiDeviceTalkingTapPhone': '裝置對話中 · 點此改用手機',
'agentEaiCarDesc': '您的車載助手',
'eaicarWelcome': '歡迎回來',
'eaicarA2dpTitle': '需要連接藍牙音訊',
@ -117,6 +119,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
},
'en_US': {
'agentEaiCar': 'EaiCar',
'emaiDeviceTalkingTapPhone': 'On device · tap to use phone',
'agentEaiCarDesc': 'Your in-car assistant',
'eaicarWelcome': 'Welcome back',
'eaicarA2dpTitle': 'Bluetooth audio not connected',

47
apps/client/lib/modules/emai/controllers/emai_controller.dart

@ -194,6 +194,10 @@ class EmaiController extends GetxController
}));
_jumpToBottomOnEnter();
if (Get.isRegistered<DeviceAiSessionService>()) {
Logger.i(_tag,
'聊天页打开 (设备对话${DeviceAiSessionService.to.isActive.value ? '进行中,本页只显示、由设备收音' : '未进行'})');
}
connect();
}
@ -201,7 +205,24 @@ class EmaiController extends GetxController
void onClose() {
WidgetsBinding.instance.removeObserver(this);
if (Get.isRegistered<DeviceAiSessionService>()) {
DeviceAiSessionService.to.detachPhonePage(this);
final device = DeviceAiSessionService.to;
// 离开聊天页 = 结束这次对话。设备唤醒的会话此刻正显示在本页上(onEarphone),
// 也一并结束并通知设备退出 AI 模式——否则 App 这边聊天页没了,
// 设备还停在「聆听中」、AI 还在后台说话,两边状态对不上(2026-09-25 反馈)。
//
// ⚠️ 必须是「正在显示这场设备对话的那个页面」才能结束它。只判 isActive 的话,
// 进 EaiCar 页时被顶掉的旧页面实例一 onClose 就把刚唤醒的设备会话掐了——
// 用户在主界面唤醒、点进 EaiCar 页,接着对设备说话就再也没回应(2026-09-25 真机)。
// 只对车载香薰:恒玄耳机的会话离开页面后照旧继续(保持原有行为)
final showingDevice = device.activeIsEaiCar &&
device.isAttachedPage(this) &&
linkState.value == EmaiLinkState.onEarphone;
device.detachPhonePage(this);
Logger.i(_tag,
'聊天页关闭 (设备会话${device.isActive.value ? '进行中' : '未进行'},本页${showingDevice ? '正显示它,一并结束' : '未显示它,不动'})');
if (showingDevice && device.isActive.value) {
unawaited(device.requestStop());
}
}
_handshakeTimer?.cancel();
_cancelIdle();
@ -315,7 +336,26 @@ class EmaiController extends GetxController
/// 收尾是异步的——[DeviceAiSessionService.requestStop] 发完 `AA 65` 要等耳机回
/// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来,
/// 所以这里不用自己 connect(),否则会和耳机会话抢。
/// 设备对话中点了本页的说话按钮:结束设备对话,接回来后直接进连续对话。
///
/// ⚠️ 2026-09-25 以前设备对话中这个按钮是灰的、写着「离线」,用户在主界面唤醒后
/// 点进 EaiCar 页想接着聊,按了没有任何反应。
Future<void> takeBackAndTalk() async {
_talkAfterTakeBack = true;
await takeBackFromEarphone();
}
bool _talkAfterTakeBack = false;
/// 设备对话中底部按钮要不要换成「改用手机」:只对车载香薰。
/// 恒玄耳机保持原样(按钮置灰,顶部状态条上有「收回控制」)。
bool get showDeviceTakeOverButton =>
linkState.value == EmaiLinkState.onEarphone &&
Get.isRegistered<DeviceAiSessionService>() &&
DeviceAiSessionService.to.activeIsEaiCar;
Future<void> takeBackFromEarphone() async {
Logger.i(_tag, '用户点「收回」:结束设备对话,改在手机上聊');
if (!Get.isRegistered<DeviceAiSessionService>()) {
// 服务没注册(理论上不会),至少别让页面卡在 onEarphone
linkState.value = EmaiLinkState.idle;
@ -334,6 +374,11 @@ class EmaiController extends GetxController
linkState.value != EmaiLinkState.connecting) {
return;
}
if (_talkAfterTakeBack) {
_talkAfterTakeBack = false;
// 连续对话:Started 一到就开手机麦(见 _onEvent 的 started 分支)
isContinuous.value = true;
}
linkState.value = EmaiLinkState.idle;
_autoRetried = false;
await connect();

28
apps/client/lib/modules/emai/views/emai_view.dart

@ -430,6 +430,34 @@ class EmaiView extends GetView<EmaiController> {
);
}
// 设备对话中:直接对设备说话就行;点这里 = 改用手机聊(结束设备对话)
if (controller.showDeviceTakeOverButton) {
return GestureDetector(
onTap: controller.takeBackAndTalk,
child: Container(
height: 40.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppPalette.accent,
borderRadius: BorderRadius.circular(20.r),
),
padding: EdgeInsets.symmetric(horizontal: 12.w),
// 按钮夹在两个图标之间,宽度有限:只许一行,放不下就整体缩小,别折行溢出
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'emaiDeviceTalkingTapPhone'.tr,
maxLines: 1,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.white),
),
),
),
);
}
final talking = controller.isTalking.value;
final cancelling = controller.isCancelPending.value;

3
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -233,7 +233,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
when (call.method) {
"start" -> {
val rate = (call.argument<Number>("sampleRate"))?.toInt() ?: 24000
result.success(pcmSpeakerPlayer.start(rate))
val keepAlive = call.argument<Boolean>("keepAlive") ?: false
result.success(pcmSpeakerPlayer.start(rate, keepAlive))
}
"feed" -> {
call.argument<ByteArray>("data")?.let { pcmSpeakerPlayer.feed(it) }

48
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/PcmSpeakerPlayer.kt

@ -50,6 +50,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) {
* 这时也必须报完成,否则服务端会一直停在 Responding。
*/
private const val DRAIN_TIMEOUT_MS = 5_000L
/** 空闲垫静音:缓冲低于这个值才垫(毫秒) */
private const val KEEPALIVE_LOW_MS = 40L
/** 每次垫多长的静音(毫秒) */
private const val KEEPALIVE_CHUNK_MS = 20
}
private val lock = Any()
@ -70,8 +76,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) {
// ---------- 对外接口(与 iOS 逐字对齐)----------
fun start(sampleRate: Int): Boolean {
/** 本轮是否在空闲时垫静音(只有车载香薰的设备会话传 true,见 [keepAlive]) */
@Volatile private var keepAliveEnabled = false
fun start(sampleRate: Int, keepAlive: Boolean = false): Boolean {
synchronized(lock) {
keepAliveEnabled = keepAlive
generation++
val gen = generation
stopWriterLocked()
@ -156,8 +166,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) {
writeFully(chunk, gen)
continue
}
// 队列空了。没收到 end() 就继续等下一块(服务端还在合成)
if (!ended) continue
// 队列空了。没收到 end() 就继续等下一块(服务端还在合成),
// 同时补一小段静音,别让 AudioTrack 欠载停下来(见 [keepAlive])
if (!ended) {
keepAlive(gen)
continue
}
if (awaitDrained(gen)) {
finish(gen)
return
@ -189,6 +203,34 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) {
framesWritten += chunk.size / 2
}
/**
* 空闲时给 AudioTrack 垫静音,保持输出流不断。
*
* ⚠️ 2026-09-25 车载香薰(华为 ALN-AL80)实测:设备会话里 AI 回复之间是空档,
* AudioTrack 一欠载就被系统判停(`PLAYER_STATE_TIMEOUT`),紧接着蓝牙 A2DP 流被挂起;
* 下一句来时要重新 `A2DP START`,这要 0.5~0.7 秒——唤醒问候「我在呢」只有 1.2 秒,
* 前半截被吞,用户听上去就是「没有默认回复」。iOS 靠 BackgroundKeepalive 循环播静音
* 保住了这条流,Android 这里补上等价的做法。
*
* 只在缓冲快见底(< [KEEPALIVE_LOW_MS])时写一块 [KEEPALIVE_CHUNK_MS] 的静音,
* 所以积在真实音频前面的静音最多几十毫秒,不会拖慢开口。
* 调了 end() 之后不再垫(要等播完报完成)。
*/
private fun keepAlive(gen: Int) {
if (!keepAliveEnabled) return
val t = track ?: return
if (trackRate <= 0) return
val head = try {
t.playbackHeadPosition
} catch (e: Exception) {
return
}
val pendingMs = (framesWritten - head).toLong() * 1000 / trackRate
if (pendingMs >= KEEPALIVE_LOW_MS) return
val frames = trackRate * KEEPALIVE_CHUNK_MS / 1000
writeFully(ByteArray(frames * 2), gen)
}
/**
* 等已写入的帧真正播完。返回 false 表示这一轮被 stop() 掀掉了。
*

16
apps/client/test/assistant_directive_test.dart

@ -399,5 +399,21 @@ void main() {
(f['extra_info'] as Map)['commands'] = [];
expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty);
});
// 2026-09-25 真机:车载助手新应用 mm_127ddb82… 把 commands 下发成 JSON 字符串
test('commands 是 JSON 字符串时也解得出 quit', () {
final f = frame();
(f['extra_info'] as Map)['commands'] =
'[{"intent_info":{"domain":"general_command","intent":"quit"},"name":"quit","params":[]}]';
final list = AssistantDirective.parseFromOutput(f, source: 'earphone');
expect(list.map((d) => d.name).toList(), ['quit']);
expect(list.first.args['intent'], 'quit');
});
test('commands 是畸形字符串时不抛、不产生指令', () {
final f = frame();
(f['extra_info'] as Map)['commands'] = '[{oops';
expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty);
});
});
}

6
docs/Smartcar车载香薰-接入设计与落地文档.md

@ -428,7 +428,7 @@ FE DC BA 80 01 00 8E | seq(1) | 04 | opus payload … | 尾字节(1,丢弃)
- **接哪个助手**:`AgentModule.bailianAppId`(放在 **data 层基类**,不是 modules 层的
`AgentModuleDescriptor`——`DeviceAiSessionService` 在 data 层,**data 层不能反向依赖 modules 层**)。
`DeviceAiSessionService` 走「厂商 → 品类 → 默认编排第一个模块 → 该模块的 app_id」,
车载香薰因此接的是 Smartcar 车载助手(`mm_dd1eb7cb06484aafb94a0b6b2dff`)而不是 EMAI。
车载香薰因此接的是 Smartcar 车载助手(`mm_127ddb82ba1244a99d2feaef0c1f`)而不是 EMAI。
耳机/支架没声明就仍用全局默认,**行为不变**。
### 5.11 ✅ 命令表复核(2026-09-11 样机实测)
@ -460,7 +460,7 @@ FE DC BA 80 01 00 8E | seq(1) | 04 | opus payload … | 尾字节(1,丢弃)
## 6. AI 侧:新增 Smartcar agent(按新模块系统重写)
需求:**在广场新增一个 agent**(不复用 EMAI),同一百炼 workspace 下换应用 ID,
例如 `mm_dd1eb7cb06484aafb94a0b6b2dff`;连上设备后 AI 页默认只显示它。主功能是随时开聊。
例如 `mm_127ddb82ba1244a99d2feaef0c1f`;连上设备后 AI 页默认只显示它。主功能是随时开聊。
### 6.1 模块系统已经重构,工作量大幅下降
@ -558,7 +558,7 @@ DeviceCategories.carAroma: [smartcarChat], // ← 加这一行
| 名字 | **Smartcar** |
| 副标题 | **您的车载助手** |
| 欢迎语 | **欢迎回来** |
| 百炼 app_id | `mm_dd1eb7cb06484aafb94a0b6b2dff`(workspace 同 EMAI) |
| 百炼 app_id | `mm_127ddb82ba1244a99d2feaef0c1f`(workspace 同 EMAI) |
⚠️ **图标只能用 Material 图标,不能直接用产品图。** `AgentModule.icon` 的类型是
`IconData`,`module_card.dart` 里是 `Icon(module.icon, ...)` 套一个渐变色块,**不支持图片**。

Loading…
Cancel
Save