Compare commits

...

3 Commits

Author SHA1 Message Date
Rodger-Wang 4ec8932aef 车载香薰 AI 对话:唤醒问候、按键插话、自动回连、退出聊天、前后台与状态同步(均只对香薰生效,恒玄耳机流程不变) 2 weeks ago
Rodger-Wang 6c9e0d48eb 语音纪要:有算力却提示余额不足(客户端按停用的旧会议额度拦截);转写失败按扣费明细原路退还算力 2 weeks ago
Rodger-Wang 16ba488341 client(android):打包修复同步到 along——去掉 azure_speech 的 gRPC 代码生成、钉住 firebase 版本 2 weeks ago
  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. 21
      apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart
  12. 10
      apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart
  13. 60
      apps/client/lib/modules/meeting/views/meeting_home_view.dart
  14. 17
      apps/client/local_plugins/azure_speech/android/build.gradle.kts
  15. 3
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  16. 48
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/PcmSpeakerPlayer.kt
  17. 640
      apps/client/pubspec.lock
  18. 7
      apps/client/pubspec.yaml
  19. 16
      apps/client/test/assistant_directive_test.dart
  20. 3
      apps/proto/echomeet/echomeet_db.proto
  21. 2
      apps/proto/enum.proto
  22. 66
      apps/services/comm/compute.go
  23. 32
      apps/services/comm/compute_refund_test.go
  24. 2
      apps/services/modules/echomeet/CLAUDE.md
  25. 1
      apps/services/modules/echomeet/api_alibackcall.go
  26. 1
      apps/services/modules/echomeet/api_backcall.go
  27. 19
      apps/services/modules/echomeet/api_starttask.go
  28. 92
      apps/services/modules/echomeet/compute_refund.go
  29. 20
      apps/services/modules/echomeet/tasks.go
  30. 23
      apps/services/pb/echomeet_db.pb.go
  31. 9
      apps/services/pb/enum.pb.go
  32. 6
      docs/Smartcar车载香薰-接入设计与落地文档.md

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

@ -94,9 +94,10 @@ class AssistantDirective {
final extra = output['extra_info']; final extra = output['extra_info'];
if (extra is! Map) return const []; if (extra is! Map) return const [];
final calls = extra['tool_calls']; 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 hasCalls = calls is List && calls.isNotEmpty;
final hasCommands = commands is List && commands.isNotEmpty; final hasCommands = commands.isNotEmpty;
if (!hasCalls && !hasCommands) return const []; if (!hasCalls && !hasCommands) return const [];
final dialogId = (output['dialog_id'] ?? '').toString(); final dialogId = (output['dialog_id'] ?? '').toString();
@ -159,6 +160,20 @@ class AssistantDirective {
return result; 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` —— **另一种指令形状**。 /// 解析 `extra_info.commands` —— **另一种指令形状**。
/// ///
/// ⚠️ 2026-09-11 真机发现:EaiCar 车载助手下发的指令不在 `tool_calls` 里, /// ⚠️ 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:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../core/utils/audio_foreground_service.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart'; import '../../devices/device_hub.dart';
import '../../devices/device_vendors.dart';
import '../models/agent_module.dart'; import '../models/agent_module.dart';
import '../models/emai_message.dart'; import '../models/emai_message.dart';
import '../models/user_Info.dart' as user_info; import '../models/user_Info.dart' as user_info;
@ -181,6 +183,10 @@ class DeviceAiSessionService extends GetxService {
int _micPackets = 0; int _micPackets = 0;
int _downPackets = 0; int _downPackets = 0;
/// 本次会话开了 Android 前台服务(结束时要关,别关掉别人开的)
bool _holdsForeground = false;
int _micPeakRms = 0;
EmaiChatStore get _store => EmaiChatStore.to; EmaiChatStore get _store => EmaiChatStore.to;
EmaiMessage? _streamingUser; EmaiMessage? _streamingUser;
EmaiMessage? _streamingAssistant; EmaiMessage? _streamingAssistant;
@ -192,6 +198,7 @@ class DeviceAiSessionService extends GetxService {
_wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) { _wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) {
Logger.e(_tag, '唤醒事件流错误: $e'); Logger.e(_tag, '唤醒事件流错误: $e');
}); });
_onlineWorker = ever<List<DeviceSession>>(hub.sessions, _resetAiStateOnOnline);
_hubSub = hub.sessionEvents.listen((e) { _hubSub = hub.sessionEvents.listen((e) {
if (e.type != DeviceSessionEventType.connectionStateChanged) return; if (e.type != DeviceSessionEventType.connectionStateChanged) return;
if (!isActive.value || _session?.deviceId != e.deviceId) return; if (!isActive.value || _session?.deviceId != e.deviceId) return;
@ -221,12 +228,37 @@ class DeviceAiSessionService extends GetxService {
@override @override
void onClose() { void onClose() {
_onlineWorker?.dispose();
_wakeSub?.cancel(); _wakeSub?.cancel();
_hubSub?.cancel(); _hubSub?.cancel();
_teardown(notifyDevice: true, playEndTone: false); _teardown(notifyDevice: true, playEndTone: false);
super.onClose(); 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) { void _onWake(DeviceWakeEvent e) {
@ -321,8 +353,10 @@ class DeviceAiSessionService extends GetxService {
void _onMicPcm(Uint8List pcm) { void _onMicPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return; if (!isActive.value || pcm.isEmpty) return;
_micPackets++; _micPackets++;
final rms = rmsOfPcm16(pcm);
if (rms > _micPeakRms) _micPeakRms = rms;
// 用户在说话 = 有活动。只看能量、带节流,静音帧不续命 // 用户在说话 = 有活动。只看能量、带节流,静音帧不续命
if (!_uplinkGated && rmsOfPcm16(pcm) >= _micActivityRms) { if (!_uplinkGated && rms >= _micActivityRms) {
final now = DateTime.now(); final now = DateTime.now();
if (_lastMicActivityAt == null || if (_lastMicActivityAt == null ||
now.difference(_lastMicActivityAt!) >= _micActivityThrottle) { now.difference(_lastMicActivityAt!) >= _micActivityThrottle) {
@ -331,8 +365,10 @@ class DeviceAiSessionService extends GetxService {
} }
} }
if (_micPackets == 1 || _micPackets % 50 == 0) { if (_micPackets == 1 || _micPackets % 50 == 0) {
// 带上这段时间的峰值音量:排查「在说话却没识别」时,先分清是没声音还是服务端没认
Logger.i(_tag, Logger.i(_tag,
'设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}'); '设备上行 #$_micPackets (${pcm.length}B/包, 峰值RMS=$_micPeakRms)${_uplinkGated ? ' [门控中]' : ''}');
_micPeakRms = 0;
} }
// 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是 // 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是
// "真的没人说话",而不是一段数据缺口 // "真的没人说话",而不是一段数据缺口
@ -344,7 +380,15 @@ class DeviceAiSessionService extends GetxService {
Future<void> _handleStart(String deviceId) async { Future<void> _handleStart(String deviceId) async {
if (isActive.value) { if (isActive.value) {
// 会话中又按了一次唤醒键 = 用户要插话(「别说了,听我说」)。
// ⚠️ 2026-09-25 以前一律忽略:AI 正在念一段 60 多秒的回答,用户连按两次设备键
// 都没反应,设备以为进了新一轮、App 还在念旧的——两边状态对不上,看着就是卡死。
// 只在 Dart 链路这么做(车载香薰);恒玄原生直连的行为保持不变。
if (_native == null && _isEaiCar && _session?.deviceId == deviceId) {
_bargeIn();
} else {
Logger.w(_tag, '已有会话在跑,忽略重复唤醒'); Logger.w(_tag, '已有会话在跑,忽略重复唤醒');
}
return; return;
} }
final hub = DeviceHub.to; final hub = DeviceHub.to;
@ -385,6 +429,9 @@ class DeviceAiSessionService extends GetxService {
// 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持) // 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持)
_aiStateUnsupported = false; _aiStateUnsupported = false;
_quitAfterPlayout = false; _quitAfterPlayout = false;
_greetPending = false;
_ownRequestPending = false;
_dropStaleDownlink = false;
_micPackets = 0; _micPackets = 0;
_downPackets = 0; _downPackets = 0;
_uplinkGated = false; _uplinkGated = false;
@ -398,6 +445,14 @@ class DeviceAiSessionService extends GetxService {
// ★ 先试原生直连:上行 PCM 与合成音频都不经过 Dart。 // ★ 先试原生直连:上行 PCM 与合成音频都不经过 Dart。
// 插件老 / 设备不是恒玄 → 返回 null,整条链路退回下面的 Dart 路径。 // 插件老 / 设备不是恒玄 → 返回 null,整条链路退回下面的 Dart 路径。
_native = await _tryOpenNativeDialog(session); _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 { try {
if (_useNative) { if (_useNative) {
_eventSub = _native!.events.listen(_onBailianEvent); _eventSub = _native!.events.listen(_onBailianEvent);
@ -424,6 +479,9 @@ class DeviceAiSessionService extends GetxService {
await _playCue(CueTone.ding(sampleRate: _deviceRate)); await _playCue(CueTone.ding(sampleRate: _deviceRate));
_touchActivity(); _touchActivity();
// 握手一完成就让助手用自己的音色说一句「我在」(见 _greetOnWake)。
// 只在 Dart 链路做:原生直连(恒玄耳机)不经过 _svc,行为保持不变。
_greetPending = _native == null && _isEaiCar;
final ok = await _startDialog(); final ok = await _startDialog();
final elapsed = DateTime.now().difference(ackAt); final elapsed = DateTime.now().difference(ackAt);
if (elapsed > _ackDeadline) { if (elapsed > _ackDeadline) {
@ -455,6 +513,33 @@ class DeviceAiSessionService extends GetxService {
break; break;
} }
switch (e.type) { 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: case BailianEventType.speechStarted:
// 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的 // 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的
if (!_uplinkGated) { if (!_uplinkGated) {
@ -483,6 +568,7 @@ class DeviceAiSessionService extends GetxService {
break; break;
case BailianEventType.respondingStarted: case BailianEventType.respondingStarted:
_dropStaleDownlink = false;
_respondingBytes = 0; _respondingBytes = 0;
_firstAudioAt = null; _firstAudioAt = null;
_respondingStartedAt = DateTime.now(); _respondingStartedAt = DateTime.now();
@ -576,15 +662,14 @@ class DeviceAiSessionService extends GetxService {
_teardown(notifyDevice: true); _teardown(notifyDevice: true);
} }
break; break;
default:
break;
} }
} }
/// 退回 Dart 链路时才会跑(原生直连时 PCM 不经过这里) /// 退回 Dart 链路时才会跑(原生直连时 PCM 不经过这里)
void _onDownstreamPcm(Uint8List pcm) { void _onDownstreamPcm(Uint8List pcm) {
if (pcm.isEmpty) return; if (pcm.isEmpty) return;
// 被按键打断的那一轮,服务端可能还在往下发尾巴,丢掉,等下一轮 respondingStarted
if (_dropStaleDownlink) return;
_noteDownstream(pcm.length); _noteDownstream(pcm.length);
_spk?.write(pcm); _spk?.write(pcm);
} }
@ -612,6 +697,79 @@ class DeviceAiSessionService extends GetxService {
/// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`) /// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`)
static const String _quitDirective = 'quit'; 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 指令,等这一轮告别语播完就收尾 /// 收到 quit 指令,等这一轮告别语播完就收尾
bool _quitAfterPlayout = false; bool _quitAfterPlayout = false;
@ -781,7 +939,13 @@ class DeviceAiSessionService extends GetxService {
if (!isActive.value) return; if (!isActive.value) return;
Logger.i(_tag, '请求设备退出 AI 模式'); Logger.i(_tag, '请求设备退出 AI 模式');
final s = _session; 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?.cancel();
_stopAckTimer = Timer(_stopAckTimeout, () { _stopAckTimer = Timer(_stopAckTimeout, () {
if (!isActive.value) return; if (!isActive.value) return;
@ -790,12 +954,16 @@ class DeviceAiSessionService extends GetxService {
}); });
} }
Future<void> _sendExit(DeviceSession s) async { /// 发 ai.exit。返回 true 表示该设备**不会回结束应答**(插件回了 `noAck`),
if (s.state != DeviceConnectionState.ready) return; /// 调用方可以直接收尾。
Future<bool> _sendExit(DeviceSession s) async {
if (s.state != DeviceConnectionState.ready) return false;
try { try {
await s.invokeFeature(DeviceFeatures.aiExit); final r = await s.invokeFeature(DeviceFeatures.aiExit);
return r['noAck'] == true;
} catch (e) { } catch (e) {
Logger.w(_tag, '发送 ai.exit 失败: $e'); Logger.w(_tag, '发送 ai.exit 失败: $e');
return false;
} }
} }
@ -883,9 +1051,21 @@ class DeviceAiSessionService extends GetxService {
await _svc.stop(); await _svc.stop();
final s = _session; 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); if (wasActive && notifyDevice && s != null) await _sendExit(s);
_session = null; _session = null;
await BackgroundKeepalive.release(_tag); await BackgroundKeepalive.release(_tag);
if (_holdsForeground) {
_holdsForeground = false;
unawaited(AudioForegroundService.stop());
}
if (wasActive) { if (wasActive) {
Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)');
await _resumePhonePage(); await _resumePhonePage();
@ -896,6 +1076,13 @@ class DeviceAiSessionService extends GetxService {
void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; 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) { void detachPhonePage(EmaiSessionOwner page) {
if (identical(_phonePage, page)) _phonePage = null; if (identical(_phonePage, page)) _phonePage = null;
} }

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

@ -238,7 +238,12 @@ class PcmStreamPlayer {
try { try {
_nativeChannel.setMethodCallHandler(_onNativeCall); _nativeChannel.setMethodCallHandler(_onNativeCall);
final ok = 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) { if (ok != true) {
Logger.e(_tag, '原生播放器启动失败'); Logger.e(_tag, '原生播放器启动失败');
return false; 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:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../core/utils/logger.dart'; import '../core/utils/logger.dart';
@ -114,14 +115,19 @@ class DeviceHub extends GetxService {
/// AI 页的默认编排按厂商取,两者指向不同设备就会出现「图是耳机、 /// AI 页的默认编排按厂商取,两者指向不同设备就会出现「图是耳机、
/// 功能却是香薰的」这种自相矛盾的界面。 /// 功能却是香薰的」这种自相矛盾的界面。
/// ///
/// ⚠️ 已知口径局限:没有活跃会话时按**插件注册顺序**取第一个连过的, /// 没有活跃会话时,先认**最后一次上线的那家**([lastUsedVendor],落盘,跨冷启动),
/// 不是按「最近连接时间」。同时连过耳机和香薰、然后都断开时, /// 它没有记住的设备才退回按插件注册顺序找。
/// 显示的是注册在前的那家(当前是恒玄),而不是真正最近用的那台。 /// ⚠️ 2026-09-25 以前只有后一条:连过耳机又连了香薰、香薰一断,
/// 界面就跳回耳机(恒玄注册在前),与「最后连的是哪台就显示哪台」相反。
({String name, String vendor}) get _lastKnown { ({String name, String vendor}) get _lastKnown {
final p = primary; final p = primary;
if (p != null && p.info.name.isNotEmpty) { if (p != null && p.info.name.isNotEmpty) {
return (name: p.info.name, vendor: p.vendor); 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) { for (final plugin in _plugins.values) {
final d = plugin.lastKnownDevice; final d = plugin.lastKnownDevice;
if (d != null && d.name.isNotEmpty) { if (d != null && d.name.isNotEmpty) {
@ -131,6 +137,26 @@ class DeviceHub extends GetxService {
return (name: '', vendor: ''); 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; String get lastKnownName => _lastKnown.name;
@ -212,6 +238,7 @@ class DeviceHub extends GetxService {
}); });
} }
sessions.insert(0, s); sessions.insert(0, s);
_rememberLastVendor(s.vendor);
_sessionSubs[s.vendor] = s.eventStream.listen((e) { _sessionSubs[s.vendor] = s.eventStream.listen((e) {
_events.add(e); _events.add(e);
_onSessionEvent(s, 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 'package:permission_handler/permission_handler.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import '../../data/services/background_launch_consent.dart';
import '../../data/utils/pcm_stream_player.dart'; import '../../data/utils/pcm_stream_player.dart';
import '../../core/utils/permission_util.dart'; import '../../core/utils/permission_util.dart';
import '../device_auth.dart'; import '../device_auth.dart';
import '../device_hub.dart';
import '../device_vendors.dart'; import '../device_vendors.dart';
import 'eaicar_device_auth.dart'; import 'eaicar_device_auth.dart';
import 'eaicar_link.dart'; import 'eaicar_link.dart';
@ -91,8 +93,14 @@ class EaiCarDevicePlugin implements DevicePlugin {
_connSub?.cancel(); _connSub?.cancel();
_connSub = link.connected.listen((up) { _connSub = link.connected.listen((up) {
if (!up) { if (!up) {
final wasOnline = _session != null;
_session?._markDisconnected(); _session?._markDisconnected();
_session = null; _session = null;
// 不是用户/切设备主动断的(设备走远、关机、信号抖)→ 自动回连
if (wasOnline && !_userDisconnected) {
_reconnectAttempt = 0;
_scheduleReconnect('设备意外断开');
}
} }
_emit(DevicePluginEvent( _emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged, type: DevicePluginEventType.connectionStateChanged,
@ -103,6 +111,112 @@ class EaiCarDevicePlugin implements DevicePlugin {
)); ));
}); });
_emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); _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 时弹框。 /// 自持 GATT 要的运行时权限(Android 12+)。iOS 由系统在首次用 CoreBluetooth 时弹框。
@ -203,6 +317,8 @@ class EaiCarDevicePlugin implements DevicePlugin {
Future<DeviceSession> connect(String deviceId, Future<DeviceSession> connect(String deviceId,
{DeviceConnectOptions? options}) async { {DeviceConnectOptions? options}) async {
_checkAlive(); _checkAlive();
_userDisconnected = false;
_cancelReconnect();
await stopScan(); await stopScan();
final adv = options?.extra['advertisement']; final adv = options?.extra['advertisement'];
@ -311,6 +427,10 @@ class EaiCarDevicePlugin implements DevicePlugin {
@override @override
Future<void> forgetDevice(String deviceId) async { Future<void> forgetDevice(String deviceId) async {
if (link.deviceId == deviceId) {
_userDisconnected = true;
_cancelReconnect();
}
final list = _remembered..removeWhere((m) => m['id'] == deviceId); final list = _remembered..removeWhere((m) => m['id'] == deviceId);
await _storage.write(_rememberedKey, list); await _storage.write(_rememberedKey, list);
if (link.deviceId == deviceId) await link.disconnect(); if (link.deviceId == deviceId) await link.disconnect();
@ -333,6 +453,7 @@ class EaiCarDevicePlugin implements DevicePlugin {
Future<void> dispose() async { Future<void> dispose() async {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_cancelReconnect();
await _scanSub?.cancel(); await _scanSub?.cancel();
await _connSub?.cancel(); await _connSub?.cancel();
await _diagSub?.cancel(); await _diagSub?.cancel();
@ -881,10 +1002,54 @@ class EaiCarSession implements DeviceSession {
/// 但只有扫描时才能拿到,连上之后就不再更新 /// 但只有扫描时才能拿到,连上之后就不再更新
Future<bool> isClassicAudioConnected() async { Future<bool> isClassicAudioConnected() async {
final mac = classicMacCandidate; final mac = classicMacCandidate;
if (Platform.isIOS) return _iosClassicAudioConnected(mac);
if (mac == null || mac.isEmpty) return false; if (mac == null || mac.isEmpty) return false;
return ClassicBluetooth.isA2dpConnected(mac); 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; EaiCarOtaPort? _otaPort;
/// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。 /// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。
@ -965,7 +1130,8 @@ class EaiCarSession implements DeviceSession {
case DeviceFeatures.aiExit: case DeviceFeatures.aiExit:
await _setAiState(EaiCarProtocol.aiStateClose); await _setAiState(EaiCarProtocol.aiStateClose);
await _link.audioStop(); await _link.audioStop();
return {'ok': true}; // 0x07 是单向命令,设备不回结束应答;告诉会话服务别等了
return {'ok': true, 'noAck': true};
case DeviceFeatures.aiState: case DeviceFeatures.aiState:
// 通用语义 → C2 的 0x07。**设备的表情动画全靠这条**:不发的话 // 通用语义 → C2 的 0x07。**设备的表情动画全靠这条**:不发的话
// 整轮对话里设备只在开头收到一次"聆听中",之后一直是那个表情 // 整轮对话里设备只在开头收到一次"聆听中",之后一直是那个表情
@ -1007,6 +1173,8 @@ class EaiCarSession implements DeviceSession {
.map((d) => {'name': d.name, 'address': d.address}) .map((d) => {'name': d.name, 'address': d.address})
.toList(), .toList(),
}; };
case 'debug.audioStats':
return await _link.audioStats();
case 'eaicar.a2dp_connect': case 'eaicar.a2dp_connect':
return await connectClassicAudio(); return await connectClassicAudio();
@ -1038,6 +1206,9 @@ class EaiCarSession implements DeviceSession {
/// 去「设置 → 蓝牙」手动配一次。返回 `{'ok': false, 'status': 'unsupported_ios'}` /// 去「设置 → 蓝牙」手动配一次。返回 `{'ok': false, 'status': 'unsupported_ios'}`
Future<Map<String, Object?>> connectClassicAudio() async { Future<Map<String, Object?>> connectClassicAudio() async {
final mac = classicMacCandidate; final mac = classicMacCandidate;
if (Platform.isIOS && await isClassicAudioConnected()) {
return {'ok': true, 'status': 'already_connected', 'mac': mac};
}
if (mac == null || mac.isEmpty) { if (mac == null || mac.isEmpty) {
return {'ok': false, 'status': 'no_mac', 'hint': '广播里没解出 MAC'}; return {'ok': false, 'status': 'no_mac', 'hint': '广播里没解出 MAC'};
} }
@ -1086,6 +1257,9 @@ class EaiCarSession implements DeviceSession {
@override @override
Future<void> disconnect() async { Future<void> disconnect() async {
// 主动断开(用户点断开 / DeviceHub 切到别的设备)不触发自动回连
_plugin._userDisconnected = true;
_plugin._cancelReconnect();
_state = DeviceConnectionState.disconnecting; _state = DeviceConnectionState.disconnecting;
await _link.audioStop(); await _link.audioStop();
await _link.disconnect(); await _link.disconnect();

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

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

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

@ -45,4 +45,4 @@ const String kEaiCarTag = 'eaicar';
/// 等运营提出「要后台随时改」时,再改成从 `DBAgent.serviceData` 下发—— /// 等运营提出「要后台随时改」时,再改成从 `DBAgent.serviceData` 下发——
/// 那时要给客户端手写镜像补 `source` / `serviceData` 两个字段,且**必须 nullable**, /// 那时要给客户端手写镜像补 `source` / `serviceData` 两个字段,且**必须 nullable**,
/// 否则老后端不下发就整个 `fromJson` 抛 TypeError、应用配置全空白。 /// 否则老后端不下发就整个 `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 { translations: const {
'zh_CN': { 'zh_CN': {
'agentEaiCar': 'EaiCar', 'agentEaiCar': 'EaiCar',
'emaiDeviceTalkingTapPhone': '设备对话中 · 点此改用手机',
'agentEaiCarDesc': '您的车载助手', 'agentEaiCarDesc': '您的车载助手',
'eaicarWelcome': '欢迎回来', 'eaicarWelcome': '欢迎回来',
'eaicarA2dpTitle': '需要连接蓝牙音频', 'eaicarA2dpTitle': '需要连接蓝牙音频',
@ -73,6 +74,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
}, },
'zh_TW': { 'zh_TW': {
'agentEaiCar': 'EaiCar', 'agentEaiCar': 'EaiCar',
'emaiDeviceTalkingTapPhone': '裝置對話中 · 點此改用手機',
'agentEaiCarDesc': '您的車載助手', 'agentEaiCarDesc': '您的車載助手',
'eaicarWelcome': '歡迎回來', 'eaicarWelcome': '歡迎回來',
'eaicarA2dpTitle': '需要連接藍牙音訊', 'eaicarA2dpTitle': '需要連接藍牙音訊',
@ -117,6 +119,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
}, },
'en_US': { 'en_US': {
'agentEaiCar': 'EaiCar', 'agentEaiCar': 'EaiCar',
'emaiDeviceTalkingTapPhone': 'On device · tap to use phone',
'agentEaiCarDesc': 'Your in-car assistant', 'agentEaiCarDesc': 'Your in-car assistant',
'eaicarWelcome': 'Welcome back', 'eaicarWelcome': 'Welcome back',
'eaicarA2dpTitle': 'Bluetooth audio not connected', 'eaicarA2dpTitle': 'Bluetooth audio not connected',

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

@ -194,6 +194,10 @@ class EmaiController extends GetxController
})); }));
_jumpToBottomOnEnter(); _jumpToBottomOnEnter();
if (Get.isRegistered<DeviceAiSessionService>()) {
Logger.i(_tag,
'聊天页打开 (设备对话${DeviceAiSessionService.to.isActive.value ? '进行中,本页只显示、由设备收音' : '未进行'})');
}
connect(); connect();
} }
@ -201,7 +205,24 @@ class EmaiController extends GetxController
void onClose() { void onClose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
if (Get.isRegistered<DeviceAiSessionService>()) { 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(); _handshakeTimer?.cancel();
_cancelIdle(); _cancelIdle();
@ -315,7 +336,26 @@ class EmaiController extends GetxController
/// 收尾是异步的——[DeviceAiSessionService.requestStop] 发完 `AA 65` 要等耳机回 /// 收尾是异步的——[DeviceAiSessionService.requestStop] 发完 `AA 65` 要等耳机回
/// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来, /// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来,
/// 所以这里不用自己 connect(),否则会和耳机会话抢。 /// 所以这里不用自己 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 { Future<void> takeBackFromEarphone() async {
Logger.i(_tag, '用户点「收回」:结束设备对话,改在手机上聊');
if (!Get.isRegistered<DeviceAiSessionService>()) { if (!Get.isRegistered<DeviceAiSessionService>()) {
// 服务没注册(理论上不会),至少别让页面卡在 onEarphone // 服务没注册(理论上不会),至少别让页面卡在 onEarphone
linkState.value = EmaiLinkState.idle; linkState.value = EmaiLinkState.idle;
@ -334,6 +374,11 @@ class EmaiController extends GetxController
linkState.value != EmaiLinkState.connecting) { linkState.value != EmaiLinkState.connecting) {
return; return;
} }
if (_talkAfterTakeBack) {
_talkAfterTakeBack = false;
// 连续对话:Started 一到就开手机麦(见 _onEvent 的 started 分支)
isContinuous.value = true;
}
linkState.value = EmaiLinkState.idle; linkState.value = EmaiLinkState.idle;
_autoRetried = false; _autoRetried = false;
await connect(); 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 talking = controller.isTalking.value;
final cancelling = controller.isCancelPending.value; final cancelling = controller.isCancelPending.value;

21
apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart

@ -49,6 +49,26 @@ class MeetingHomeController extends GetxController {
// 多选删除模式状态 // 多选删除模式状态
final RxBool isSelectionMode = false.obs; final RxBool isSelectionMode = false.obs;
/// 顶栏右侧的算力摘要(「算力:设备 X · 账户 Y」,user_getcompute)。
///
/// 2026-09-25 以前这里显示的是旧「会议额度」meetintegral 的剩余时长 + 进度条——那个桶已随会员与算力改造停用、
/// 新账号恒为 0,于是永远显示 00:00:00。现在语音纪要按算力计量,就显示算力。拿不到就不显示(null)。
final RxnString computeText = RxnString();
Future<void> refreshCompute() async {
try {
final resp = await Api.getCompute();
if (resp is! Map) return;
final c = resp['compute'];
if (c is! Map) return;
final dev = (c['deviceleft'] as num?)?.toInt() ?? 0;
final usr = (c['userbalance'] as num?)?.toInt() ?? 0;
computeText.value = 'computeSummary'.trParams({'device': '$dev', 'user': '$usr'});
} catch (_) {
// 展示项,失败不影响页面
}
}
final RxList<int> selectedIds = <int>[].obs; final RxList<int> selectedIds = <int>[].obs;
bool get isAllSelected => bool get isAllSelected =>
@ -111,6 +131,7 @@ class MeetingHomeController extends GetxController {
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
refreshCompute();
_requestStoragePermission(); _requestStoragePermission();
_initGetDataList(); _initGetDataList();
addMeetingAudio(); addMeetingAudio();

10
apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart

@ -7,8 +7,6 @@ import 'package:get_storage/get_storage.dart';
import '../../controllers/meeting_details_controller.dart'; import '../../controllers/meeting_details_controller.dart';
import 'record_language_bottom_sheet.dart'; import 'record_language_bottom_sheet.dart';
import '../template/template_view.dart'; import '../template/template_view.dart';
import '../../../../data/models/user_Info.dart';
import '../../../common/widgets/recharge_dialog.dart';
class GenerateBottomSheet extends StatefulWidget { class GenerateBottomSheet extends StatefulWidget {
final bool isSummarizeAgain; final bool isSummarizeAgain;
@ -339,10 +337,10 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
if (User.isLoggedIn() && User.instance.meetintegral <= 0) { // ⚠️ 这里原来判的是 `User.instance.meetintegral <= 0`(旧「会议额度」桶)就弹「余额不足」、
showRechargeDialog('meeting'); // 请求都不发。那个桶已于会员与算力改造时停用:余额一次性折进算力、之后不再发放,
return; // 新账号恒为 0——于是「明明有算力,语音纪要却说没余额」(2026-09-25 修,同翻译那次)。
} // 准入交给服务端:没 VIP 回 VipRequired(拦截器会引导开通),算力不足按后台闸门开关处理。
if (_templateData['id'] != _templateList[0]['id']) { if (_templateData['id'] != _templateList[0]['id']) {
int index = _templateList int index = _templateList
.indexWhere((v) => v['id'] == _templateData['id']); .indexWhere((v) => v['id'] == _templateData['id']);

60
apps/client/lib/modules/meeting/views/meeting_home_view.dart

@ -11,7 +11,6 @@ import '../controllers/meeting_home_controller.dart';
import '../model/meeting_model.dart'; import '../model/meeting_model.dart';
import '../utils/meeting_ui_utils.dart'; import '../utils/meeting_ui_utils.dart';
import 'bottomSheet/edit_title_bottom_sheet.dart'; import 'bottomSheet/edit_title_bottom_sheet.dart';
import '../../../../data/models/user_Info.dart';
///会议主页面,提供会议列表、会议详情、会议设置 ///会议主页面,提供会议列表、会议详情、会议设置
class MeetingHomeView extends StatefulWidget { class MeetingHomeView extends StatefulWidget {
@ -367,57 +366,22 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
), ),
), ),
centerTitle: true, centerTitle: true,
// 顶栏右侧:算力摘要(原来是旧「会议额度」的剩余时长 + 进度条,那个桶已停用、新账号恒为 0)
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: Size.fromHeight(28.w), preferredSize: Size.fromHeight(22.w),
child: Obx(() { child: Obx(() {
final _ = User.refreshTick.value; final text = _controller.computeText.value;
final remain = User.isLoggedIn() ? User.instance.meetintegral : 0; if (text == null) return const SizedBox.shrink();
final total = User.isLoggedIn() ? User.instance.meettotalintegral : 0; return Container(
final double fraction = padding: EdgeInsets.only(right: 12.w, bottom: 6.w),
total > 0 ? (remain / total).clamp(0.0, 1.0).toDouble() : 0.0;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: EdgeInsets.only(right: 12.w, bottom: 4.w),
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text( child: Text(
_formatHms(remain), text,
style: TextStyle( style: TextStyle(
fontSize: 12.sp, fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54, color: isDarkMode ? Colors.white70 : Colors.black54,
), ),
), ),
),
LayoutBuilder(
builder: (context, constraints) {
final barWidth = constraints.maxWidth * fraction;
return Stack(
children: [
Container(
height: 2.w,
decoration: BoxDecoration(
color: isDarkMode ? Colors.white10 : Colors.black12,
borderRadius: BorderRadius.circular(2.w),
),
),
Align(
alignment: Alignment.centerRight,
child: Container(
width: barWidth,
height: 2.w,
decoration: BoxDecoration(
color: MeetingUIUtils.getButtonColor(isDarkMode),
borderRadius: BorderRadius.circular(2.w),
),
),
),
],
);
},
),
],
); );
}), }),
), ),
@ -708,14 +672,4 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
return '${hours}h ${remainingMinutes}m ${remainingSeconds}s'; return '${hours}h ${remainingMinutes}m ${remainingSeconds}s';
} }
} }
String _formatHms(int seconds) {
final sec = seconds < 0 ? 0 : seconds; // 确保秒数非负
final d = Duration(seconds: sec);
final h = d.inHours;
final m = d.inMinutes.remainder(60);
final s = d.inSeconds.remainder(60);
String twoDigits(int n) => n.toString().padLeft(2, '0');
return '${twoDigits(h)}:${twoDigits(m)}:${twoDigits(s)}';
}
} }

17
apps/client/local_plugins/azure_speech/android/build.gradle.kts

@ -78,11 +78,13 @@ protobuf {
protoc { protoc {
artifact = "com.google.protobuf:protoc:3.25.1" artifact = "com.google.protobuf:protoc:3.25.1"
} }
plugins { // ⚠️ 不再跑 protoc-gen-grpc-java(2026-09-24 去掉):
id("grpc") { // 1) 它生成的只有 ASTServiceGrpc 这个 gRPC 客户端桩,全项目零引用——豆包 AST 走的是
artifact = "io.grpc:protoc-gen-grpc-java:1.57.2" // WebSocket(DoubaoE2ETranslateHelper 用 OkHttp 发 protobuf 消息),只用到下面 java lite
} // 生成的消息类(TranslateRequest / TranslateResponse 等),那部分由 protoc 本身产出;
} // 2) grpc-java 官方所有版本(1.57 ~ 1.72 实测)标成 osx-aarch_64 的插件其实都是 x86_64 程序,
// Apple 芯片的 Mac 没装 Rosetta 就执行不了,Android 编译直接失败在 generateDebugProto。
// 要恢复 gRPC 桩就把 plugins { id("grpc") } 与 task.plugins { id("grpc") } 加回来,并确保构建机有 Rosetta。
generateProtoTasks { generateProtoTasks {
all().forEach { task -> all().forEach { task ->
task.builtins { task.builtins {
@ -90,11 +92,6 @@ protobuf {
option("lite") option("lite")
} }
} }
task.plugins {
id("grpc") {
option("lite")
}
}
} }
} }
} }

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) { when (call.method) {
"start" -> { "start" -> {
val rate = (call.argument<Number>("sampleRate"))?.toInt() ?: 24000 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" -> { "feed" -> {
call.argument<ByteArray>("data")?.let { pcmSpeakerPlayer.feed(it) } 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。 * 这时也必须报完成,否则服务端会一直停在 Responding。
*/ */
private const val DRAIN_TIMEOUT_MS = 5_000L 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() private val lock = Any()
@ -70,8 +76,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) {
// ---------- 对外接口(与 iOS 逐字对齐)---------- // ---------- 对外接口(与 iOS 逐字对齐)----------
fun start(sampleRate: Int): Boolean { /** 本轮是否在空闲时垫静音(只有车载香薰的设备会话传 true,见 [keepAlive]) */
@Volatile private var keepAliveEnabled = false
fun start(sampleRate: Int, keepAlive: Boolean = false): Boolean {
synchronized(lock) { synchronized(lock) {
keepAliveEnabled = keepAlive
generation++ generation++
val gen = generation val gen = generation
stopWriterLocked() stopWriterLocked()
@ -156,8 +166,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) {
writeFully(chunk, gen) writeFully(chunk, gen)
continue continue
} }
// 队列空了。没收到 end() 就继续等下一块(服务端还在合成) // 队列空了。没收到 end() 就继续等下一块(服务端还在合成),
if (!ended) continue // 同时补一小段静音,别让 AudioTrack 欠载停下来(见 [keepAlive])
if (!ended) {
keepAlive(gen)
continue
}
if (awaitDrained(gen)) { if (awaitDrained(gen)) {
finish(gen) finish(gen)
return return
@ -189,6 +203,34 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) {
framesWritten += chunk.size / 2 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() 掀掉了。 * 等已写入的帧真正播完。返回 false 表示这一轮被 stop() 掀掉了。
* *

640
apps/client/pubspec.lock

File diff suppressed because it is too large

7
apps/client/pubspec.yaml

@ -89,8 +89,11 @@ dependencies:
url_launcher: ^6.3.1 url_launcher: ^6.3.1
in_app_purchase_android: ^0.5.0 # Play Billing 8.0(Google Play 2026-08-31 起的最低要求) in_app_purchase_android: ^0.5.0 # Play Billing 8.0(Google Play 2026-08-31 起的最低要求)
app_links: app_links:
firebase_core: ^4.0.0 # 2026-09-24 钉死:pub 重解析后 firebase_auth 6.7.0 拉进的 firebase-auth 24.2.0 原生库要求 Kotlin 2.3,
firebase_auth: ^6.0.1 # 工程仍是 2.1.10,Android 编不过("Module was compiled with an incompatible version of Kotlin")。
# 要升 firebase 先把 android/settings.gradle.kts 的 kotlin 版本一起抬上去。
firebase_core: 4.13.0
firebase_auth: 6.5.7
google_sign_in: ^6.2.2 google_sign_in: ^6.2.2
sign_in_with_apple: ^7.0.1 sign_in_with_apple: ^7.0.1

16
apps/client/test/assistant_directive_test.dart

@ -399,5 +399,21 @@ void main() {
(f['extra_info'] as Map)['commands'] = []; (f['extra_info'] as Map)['commands'] = [];
expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty); 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);
});
}); });
} }

3
apps/proto/echomeet/echomeet_db.proto

@ -66,6 +66,9 @@ message DBEchoMeetRecord {
int64 processduration = 26; //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒 int64 processduration = 26; //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒
int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率
string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结)
// 本次开始任务实际扣的算力明细,失败时按它原路退回(退完清零,保证只退一次)。只在服务端用,不下发。
int64 computeuser = 33; //@go_tags(`gorm:"computeuser" json:"-"`) 从用户余额扣的算力
string computedevices = 34; //@go_tags(`gorm:"computedevices" json:"-"`) 从设备赠送额度扣的明细 code:n,code:n
string imageurls = 29; //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结 string imageurls = 29; //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结
} }

2
apps/proto/enum.proto

@ -132,6 +132,8 @@ enum UserLogType {
// @Description 后台调整用户算力余额(加减) // @Description 后台调整用户算力余额(加减)
// @example // @example
AdminAdjust = 5; //后台调整算力 AdminAdjust = 5; //后台调整算力
// 算力退还(语音纪要转写失败 / 开始任务失败时按扣费明细原路退回,2026-09-25)
ComputeRefund = 6; //算力退还
} }
//MCP服务类型 //MCP服务类型

66
apps/services/comm/compute.go

@ -2,6 +2,7 @@ package comm
import ( import (
"errors" "errors"
"fmt"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -488,11 +489,75 @@ type ComputeResult struct {
User int64 // 从用户余额扣的部分 User int64 // 从用户余额扣的部分
Over int64 // 超额(闸门关时余额不足仍放行的量;闸门开时恒为 0) Over int64 // 超额(闸门关时余额不足仍放行的量;闸门开时恒为 0)
DeviceMac string // 扣了哪台设备(多台以逗号分隔) DeviceMac string // 扣了哪台设备(多台以逗号分隔)
Cuts []DeviceCharge // 从每台设备各扣了多少(按 device_mac.code),退还时原路退回用
DeviceLeft int64 // 扣后各绑定设备剩余合计 DeviceLeft int64 // 扣后各绑定设备剩余合计
UserLeft int64 // 扣后用户余额 UserLeft int64 // 扣后用户余额
Rejected bool // 闸门开启且不足,已拒绝、未写库 Rejected bool // 闸门开启且不足,已拒绝、未写库
} }
// DeviceCharge 一次消耗里从某台设备赠送额度扣的量。Code 是 device_mac 的主键。
type DeviceCharge struct {
Code string
N int64
}
// EncodeDeviceCharges / DecodeDeviceCharges:扣费明细存进会议记录的 computedevices 列(code:n,code:n)。
// code 是 MAC / 授权码,里面不会有逗号;解析时按最后一个冒号切,MAC 自带的冒号不受影响。
func EncodeDeviceCharges(cs []DeviceCharge) string {
parts := make([]string, 0, len(cs))
for _, c := range cs {
if c.Code == "" || c.N <= 0 {
continue
}
parts = append(parts, fmt.Sprintf("%s:%d", c.Code, c.N))
}
return strings.Join(parts, ",")
}
func DecodeDeviceCharges(s string) []DeviceCharge {
out := make([]DeviceCharge, 0)
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
i := strings.LastIndex(p, ":")
if i <= 0 {
continue
}
n, err := strconv.ParseInt(p[i+1:], 10, 64)
if err != nil || n <= 0 {
continue
}
out = append(out, DeviceCharge{Code: p[:i], N: n})
}
return out
}
// RefundComputeUsage 按扣费明细原路退回:设备部分把 computeused 减回去(不低于 0),
// 用户部分加回 computebalance。返回实际退回的总量。
//
// ⚠️ 只退「真正扣掉的」——闸门关时余额不足放行的超额部分(ComputeResult.Over)从来没扣过,
// 不在明细里,也就不会被退成白送的算力。
// 幂等由调用方保证(语音纪要是先在记录上原子地「认领」明细再调这里,见 echomeet.refundTaskCompute)。
func RefundComputeUsage(uid string, userPart int64, devices []DeviceCharge) (refunded int64, err error) {
for _, d := range devices {
if d.Code == "" || d.N <= 0 {
continue
}
if err = postgres.Table(TableDeviceMac).Where("code=?", d.Code).
Update("computeused", gorm.Expr("GREATEST(computeused - ?, 0)", d.N)).Error; err != nil {
return
}
refunded += d.N
}
if userPart > 0 {
if err = mysql.Table(TableUser).Where("uid=?", uid).
Update("computebalance", gorm.Expr("computebalance + ?", userPart)).Error; err != nil {
return
}
refunded += userPart
}
return
}
type deviceCut struct { type deviceCut struct {
d *pb.DBAuthCode d *pb.DBAuthCode
n int64 n int64
@ -584,6 +649,7 @@ func ApplyComputeUsage(uid string, kind ComputeUsageKind, raw int64, userlog *pb
} }
c.d.Computeused += c.n c.d.Computeused += c.n
res.Device += c.n res.Device += c.n
res.Cuts = append(res.Cuts, DeviceCharge{Code: c.d.Code, N: c.n})
macs = append(macs, c.d.Devicemac) macs = append(macs, c.d.Devicemac)
} }
if fromUser > 0 { if fromUser > 0 {

32
apps/services/comm/compute_refund_test.go

@ -0,0 +1,32 @@
package comm
import (
"reflect"
"testing"
)
// 扣费明细存进会议记录 computedevices 列(code:n,code:n),转写失败时按它原路退回。
// code 可能是带冒号的 MAC,必须按最后一个冒号切。
func TestDeviceChargesRoundTrip(t *testing.T) {
in := []DeviceCharge{
{Code: "F0:A9:12:34:56:78", N: 30},
{Code: "B0220000000000AB", N: 5},
}
s := EncodeDeviceCharges(in)
if s != "F0:A9:12:34:56:78:30,B0220000000000AB:5" {
t.Fatalf("编码结果 = %q", s)
}
if got := DecodeDeviceCharges(s); !reflect.DeepEqual(got, in) {
t.Fatalf("解码结果 = %+v, want %+v", got, in)
}
// 空码、非正数不进明细;脏数据解析时跳过,不能退出负数
if got := EncodeDeviceCharges([]DeviceCharge{{Code: "", N: 3}, {Code: "X", N: 0}}); got != "" {
t.Fatalf("空明细应编码为空串,实际 %q", got)
}
if got := DecodeDeviceCharges("abc,:5,X:-3,Y:notnum,Z:7"); !reflect.DeepEqual(got, []DeviceCharge{{Code: "Z", N: 7}}) {
t.Fatalf("脏数据解析 = %+v", got)
}
if got := DecodeDeviceCharges(""); len(got) != 0 {
t.Fatalf("空串应解析为空,实际 %+v", got)
}
}

2
apps/services/modules/echomeet/CLAUDE.md

@ -54,7 +54,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **两处返回中间态的坑**:`PollTranscribe` 成功后先把 State 推到 `AwaitSummarizing` 落库,再 `go finishTranscribe(...)` 在后台写 `Translate`(刻意不共用指针,避免与 HTTP 响应序列化产生数据竞争)。于是 getrecord/getrecords 手上那个待序列化的 rec **必然**是「状态说转写完了、Translate 还是空」的中间态——不是偶发竞态,是每次首轮必中。客户端据此 `jsonDecode(translate)` 会抛异常。三个拉取入口统一用 `hideHalfDoneTranscribe` 在响应侧降级回 `Transcribing`(只改响应不落库,且只对本次真正轮询过的记录降级,否则 finishTranscribe 出错时记录会被永久钉住)。 - **两处返回中间态的坑**:`PollTranscribe` 成功后先把 State 推到 `AwaitSummarizing` 落库,再 `go finishTranscribe(...)` 在后台写 `Translate`(刻意不共用指针,避免与 HTTP 响应序列化产生数据竞争)。于是 getrecord/getrecords 手上那个待序列化的 rec **必然**是「状态说转写完了、Translate 还是空」的中间态——不是偶发竞态,是每次首轮必中。客户端据此 `jsonDecode(translate)` 会抛异常。三个拉取入口统一用 `hideHalfDoneTranscribe` 在响应侧降级回 `Transcribing`(只改响应不落库,且只对本次真正轮询过的记录降级,否则 finishTranscribe 出错时记录会被永久钉住)。
- 短音频走字节 flash 同步通道(`ShortAudioProcess`);长音频/其他服务商走异步 submit+query。 - 短音频走字节 flash 同步通道(`ShortAudioProcess`);长音频/其他服务商走异步 submit+query。
状态机见 `DBEchoMeetRecordState`(proto):Unknow→AwaitTranscribing→Transcribing→AwaitSummarizing→Summarizing→Completed→Readed,失败 TranscribeFail/SummarizFail。转写失败会**退还** `Meetintegral` 时长并冲正统计埋点。 状态机见 `DBEchoMeetRecordState`(proto):Unknow→AwaitTranscribing→Transcribing→AwaitSummarizing→Summarizing→Completed→Readed,失败 TranscribeFail/SummarizFail。转写失败(八条失败路径,外加开始任务中途报错)一律按扣费明细**原路退还算力**:开始任务扣费时把明细记在记录的 `computeuser` / `computedevices` 两列,失败时 `refundRecordCompute` 先原子认领明细再退,保证只退一次;超额部分(闸门关时不够也放行的量)没扣过,不退。见 [compute_refund.go](compute_refund.go)。⚠️ 旧会议额度 `Meetintegral` 已停用,别再往里退(2026-09-25 以前就退错了地方)。
## 语言码映射(core.go,易错) ## 语言码映射(core.go,易错)

1
apps/services/modules/echomeet/api_alibackcall.go

@ -37,6 +37,7 @@ func (this *apiComp) AliBackCall(session comm.IUserSession, req *pb.EchomeetAliC
// 21050001 = 成功 // 21050001 = 成功
if req.StatusCode != 21050001 { if req.StatusCode != 21050001 {
model.State = pb.DBEchoMeetRecordState_TranscribeFail model.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(model, "阿里回调转写失败")
this.module.model.saverecord(model) this.module.model.saverecord(model)
resp = &pb.EchomeetAliCallbackResp{} resp = &pb.EchomeetAliCallbackResp{}
return return

1
apps/services/modules/echomeet/api_backcall.go

@ -47,6 +47,7 @@ func (this *apiComp) BackCall(session comm.IUserSession, req *pb.EchomeetCallbac
if req.Code != 20000000 { if req.Code != 20000000 {
model.State = pb.DBEchoMeetRecordState_TranscribeFail model.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(model, "字节回调转写失败")
this.module.model.saverecord(model) this.module.model.saverecord(model)
resp = []byte(utils.ToString(map[string]string{ resp = []byte(utils.ToString(map[string]string{
"code": "SUCCESS", "code": "SUCCESS",

19
apps/services/modules/echomeet/api_starttask.go

@ -84,6 +84,23 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT
} }
return return
} }
// 扣费明细记到记录上:之后转写失败时按它原路退回(compute_refund.go)。
recordCharge(model, res)
// 算力已经扣了,但下面还有模板解析、选路等好几步会直接 return 错误——以前这些情况扣了不退。
// 本次请求只要以错误结束就退:记录已带着明细落库的,走「认领明细再退」(防和别的失败路径重复退);
// 还没落库的,直接按这次的扣费结果退。
chargeSaved := false
defer func() {
if errdata == nil {
return
}
reason := "开始任务失败:" + errdata.Message
if chargeSaved {
this.module.model.refundRecordCompute(model, reason)
} else {
this.module.model.refundCharge(user.Uid, res.User, res.Cuts, int64(model.Seconds), reason, model.Id)
}
}()
// 会议时长消耗埋点 // 会议时长消耗埋点
if this.module.analyze != nil { if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{ this.module.analyze.Report(&comm.StatEvent{
@ -137,6 +154,7 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT
if model.Rtype == "VOICETRANSLAT" { if model.Rtype == "VOICETRANSLAT" {
// 语音翻译:已有转写文本,只走翻译+总结,无需识别选路。 // 语音翻译:已有转写文本,只走翻译+总结,无需识别选路。
this.module.model.saverecord(model) this.module.model.saverecord(model)
chargeSaved = true
this.module.Infof("StartTask id:%d → 语音翻译 mt:%s llm:%s", model.Id, model.MtSvcId, model.LlmSvcId) this.module.Infof("StartTask id:%d → 语音翻译 mt:%s llm:%s", model.Id, model.MtSvcId, model.LlmSvcId)
var contexts []*pb.ContextStruct var contexts []*pb.ContextStruct
if err = json.Unmarshal([]byte(model.Original), &contexts); err != nil { if err = json.Unmarshal([]byte(model.Original), &contexts); err != nil {
@ -167,6 +185,7 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT
} }
model.AsrSvcId = asrSvcId model.AsrSvcId = asrSvcId
this.module.model.saverecord(model) this.module.model.saverecord(model)
chargeSaved = true
this.module.Infof("StartTask id:%d uid:%s formlang:%s tolang:%s seconds:%d size:%d distinguishSpeaker:%v asr:%s mt:%s llm:%s", this.module.Infof("StartTask id:%d uid:%s formlang:%s tolang:%s seconds:%d size:%d distinguishSpeaker:%v asr:%s mt:%s llm:%s",
model.Id, model.Uid, model.Formlanguage, model.Tolanguage, model.Seconds, model.Size, model.Isdistinguishspeaker, model.Id, model.Uid, model.Formlanguage, model.Tolanguage, model.Seconds, model.Size, model.Isdistinguishspeaker,

92
apps/services/modules/echomeet/compute_refund.go

@ -0,0 +1,92 @@
package echomeet
import (
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/mysql"
"yunyan/pb"
)
// 语音纪要的算力退还(2026-09-25)。
//
// 开始任务(api_starttask)按会议时长扣算力:先扣设备赠送、再扣用户余额,闸门关时不够也放行、
// 差额记超额。之后转写可能失败,失败路径有八处(同步/异步提交、短音频处理、轮询、两个回调、卡住兜底……),
// 以前只有短音频那一处在「退」,而且退进了早已停用的旧会议额度 meetintegral——算力其实没回来。
//
// 现在统一走这里:
// - 扣费明细在开始任务时记到记录上(computeuser / computedevices,见 recordCharge);
// - 任何一处判转写失败,都调 refundRecordCompute 按明细原路退回;
// - 退之前先在库里**原子地认领**明细(带条件把两列清零,影响 1 行才算认领到),
// 多条失败路径同时触发也只会退一次;
// - 超额部分从来没扣过,不在明细里,不会被退成白送的算力。
// recordCharge 把本次实际扣费的明细记到会议记录上(调用方随后 saverecord)。
func recordCharge(rec *pb.DBEchoMeetRecord, res comm.ComputeResult) {
rec.Computeuser = res.User
rec.Computedevices = comm.EncodeDeviceCharges(res.Cuts)
}
// refundRecordCompute 转写失败时按记录上的明细退还算力。**必须在 saverecord 之前调**:
// 它会把 rec 上的明细清零,随后那次保存才不会把已退的明细又写回去。
func (this *modelComp) refundRecordCompute(rec *pb.DBEchoMeetRecord, reason string) {
if rec == nil || (rec.Computeuser == 0 && rec.Computedevices == "") {
return
}
userPart, devices := rec.Computeuser, comm.DecodeDeviceCharges(rec.Computedevices)
// 原子认领:只有把非空明细清零的那一次才去退,别的并发路径影响 0 行、直接跳过。
res := mysql.Table(comm.TableEchomeetRecord).
Where("id = ? AND (computeuser <> 0 OR computedevices <> '')", rec.Id).
Updates(map[string]interface{}{"computeuser": 0, "computedevices": ""})
rec.Computeuser, rec.Computedevices = 0, ""
if res.Error != nil {
this.module.Errorf("算力退还 id:%d 认领明细失败 err:%v(本次不退,避免重复退)", rec.Id, res.Error)
return
}
if res.RowsAffected == 0 {
this.module.Infof("算力退还 id:%d 明细已被其它路径认领,跳过", rec.Id)
return
}
this.refundCharge(rec.Uid, userPart, devices, int64(rec.Seconds), reason, rec.Id)
}
// refundCharge 按明细退还并写一条 ComputeRefund 流水。开始任务中途失败(还没落到记录上)时直接用它。
func (this *modelComp) refundCharge(uid string, userPart int64, devices []comm.DeviceCharge, seconds int64, reason string, recId uint64) {
refunded, err := comm.RefundComputeUsage(uid, userPart, devices)
if err != nil {
this.module.Errorf("算力退还 id:%d uid:%s 失败 err:%v(已退 %d)", recId, uid, err, refunded)
}
if refunded == 0 {
return
}
var devicePart int64
codes := make([]string, 0, len(devices))
for _, d := range devices {
devicePart += d.N
codes = append(codes, d.Code)
}
log := &pb.DBUserUseLog{
Uid: uid,
Logtype: pb.UserLogType_ComputeRefund,
Ts: time.Now().Unix(),
Addmeetsecond: seconds,
Extra: reason,
Compute: refunded,
Computedevice: devicePart,
Computeuser: userPart,
Devicemac: truncate(strings.Join(codes, ","), 50), // 列宽 size:50
}
if lerr := this.addUseLog(log); lerr != nil {
this.module.Warnf("算力退还 id:%d 写流水失败 err:%v", recId, lerr)
}
this.module.Infof("算力退还 id:%d uid:%s 退回 %d(设备 %d / 账户 %d)原因:%s",
recId, uid, refunded, devicePart, userPart, reason)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}

20
apps/services/modules/echomeet/tasks.go

@ -186,6 +186,7 @@ func (this *tasksComp) SubmitTranscribeTask(task *pb.DBEchoMeetRecord) (err erro
if err != nil { if err != nil {
this.module.Errorf("SubmitTranscribeTask id:%d 路由失败 err:%v", task.Id, err) this.module.Errorf("SubmitTranscribeTask id:%d 路由失败 err:%v", task.Id, err)
task.State = pb.DBEchoMeetRecordState_TranscribeFail task.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(task, "转写失败")
this.module.model.saverecord(task) this.module.model.saverecord(task)
return err return err
} }
@ -216,6 +217,7 @@ func (this *tasksComp) SubmitTranscribeTask(task *pb.DBEchoMeetRecord) (err erro
if nerr != nil { if nerr != nil {
// 没有别的候选了:这才是真失败 // 没有别的候选了:这才是真失败
task.State = pb.DBEchoMeetRecordState_TranscribeFail task.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(task, "转写失败")
this.module.model.saverecord(task) this.module.model.saverecord(task)
return err return err
} }
@ -270,15 +272,13 @@ func (this *tasksComp) sweepStuckTranscribe() {
// 超时判失败:第三方可能已经把任务弄丢了,不设上限就会每分钟白查一次 // 超时判失败:第三方可能已经把任务弄丢了,不设上限就会每分钟白查一次
// 直到天荒地老。 // 直到天荒地老。
// //
// ⚠️ 这里**不退还** Meetintegral。退还逻辑只写在 ShortAudioProcess 里; // 判失败时按扣费明细退还算力(refundRecordCompute,所有转写失败路径共用一个入口,
// 异步路径(PollTranscribe 查询失败)本来也不退,这里与之保持一致。 // 靠原子认领保证只退一次,见 compute_refund.go)。
// 也就是说:走异步转写的用户,扣掉的时长在失败后拿不回来——这是既有
// 行为不是本次引入的,但确实是个待办。要补的话应当抽一个统一的
// refundOnTranscribeFail,三处失败路径共用,否则容易重复退。
if rec.Starttime > 0 && now-rec.Starttime > StuckTranscribeGiveUp { if rec.Starttime > 0 && now-rec.Starttime > StuckTranscribeGiveUp {
this.module.Warnf("兜底扫描 id:%d 转写已 %ds 未完成,超过上限 %ds,判失败", this.module.Warnf("兜底扫描 id:%d 转写已 %ds 未完成,超过上限 %ds,判失败",
rec.Id, now-rec.Starttime, StuckTranscribeGiveUp) rec.Id, now-rec.Starttime, StuckTranscribeGiveUp)
rec.State = pb.DBEchoMeetRecordState_TranscribeFail rec.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(rec, "转写失败")
this.module.model.saverecord(rec) this.module.model.saverecord(rec)
continue continue
} }
@ -383,11 +383,13 @@ func (this *tasksComp) ShortAudioProcess(task interface{}) {
if err != nil { if err != nil {
this.module.Errorf("ShortAudioProcess id:%d 转写失败 err:%v", rec.Id, err) this.module.Errorf("ShortAudioProcess id:%d 转写失败 err:%v", rec.Id, err)
rec.State = pb.DBEchoMeetRecordState_TranscribeFail rec.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(rec, "转写失败")
this.module.model.saverecord(rec) this.module.model.saverecord(rec)
// 算力已由上面的 refundRecordCompute 按扣费明细退回。这里原来是 user.Meetintegral += 秒数
// 再整行 updateuser——退进了早已停用的旧会议额度(算力没回来),整行写回还可能用旧值覆盖掉
// 并发变动的算力余额,2026-09-25 去掉。
if user, err := this.module.model.getuser(rec.Uid); err == nil { if user, err := this.module.model.getuser(rec.Uid); err == nil {
user.Meetintegral += int64(rec.Seconds) // 转写失败,冲正会议时长埋点
this.module.model.updateuser(user)
// 转写失败退还,冲正会议时长埋点
if this.module.analyze != nil { if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{ this.module.analyze.Report(&comm.StatEvent{
Type: comm.StatEventMeeting, Type: comm.StatEventMeeting,
@ -781,6 +783,7 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) {
if err != nil { if err != nil {
this.module.Errorf("转写轮询 id:%d 查询失败 err:%v,状态置为失败", rec.Id, err) this.module.Errorf("转写轮询 id:%d 查询失败 err:%v,状态置为失败", rec.Id, err)
rec.State = pb.DBEchoMeetRecordState_TranscribeFail rec.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(rec, "转写失败")
this.module.model.saverecord(rec) this.module.model.saverecord(rec)
return return
} }
@ -805,6 +808,7 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) {
default: default:
this.module.Errorf("转写轮询 id:%d 状态异常 status:%s,标记为 TranscribeFail", rec.Id, result.Status) this.module.Errorf("转写轮询 id:%d 状态异常 status:%s,标记为 TranscribeFail", rec.Id, result.Status)
rec.State = pb.DBEchoMeetRecordState_TranscribeFail rec.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.refundRecordCompute(rec, "转写失败")
this.module.model.saverecord(rec) this.module.model.saverecord(rec)
} }
} }

23
apps/services/pb/echomeet_db.pb.go

@ -254,6 +254,9 @@ type DBEchoMeetRecord struct {
Processduration int64 `protobuf:"varint,26,opt,name=processduration,proto3" json:"processduration" gorm:"processduration"` //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒 Processduration int64 `protobuf:"varint,26,opt,name=processduration,proto3" json:"processduration" gorm:"processduration"` //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒
Lastquerytime int64 `protobuf:"varint,27,opt,name=lastquerytime,proto3" json:"lastquerytime" gorm:"lastquerytime"` //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 Lastquerytime int64 `protobuf:"varint,27,opt,name=lastquerytime,proto3" json:"lastquerytime" gorm:"lastquerytime"` //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率
Remark string `protobuf:"bytes,28,opt,name=remark,proto3" json:"remark" gorm:"remark"` //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) Remark string `protobuf:"bytes,28,opt,name=remark,proto3" json:"remark" gorm:"remark"` //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结)
// 本次开始任务实际扣的算力明细,失败时按它原路退回(退完清零,保证只退一次)。只在服务端用,不下发。
Computeuser int64 `protobuf:"varint,33,opt,name=computeuser,proto3" json:"-" gorm:"computeuser"` //@go_tags(`gorm:"computeuser" json:"-"`) 从用户余额扣的算力
Computedevices string `protobuf:"bytes,34,opt,name=computedevices,proto3" json:"-" gorm:"computedevices"` //@go_tags(`gorm:"computedevices" json:"-"`) 从设备赠送额度扣的明细 code:n,code:n
Imageurls string `protobuf:"bytes,29,opt,name=imageurls,proto3" json:"imageurls" gorm:"imageurls"` //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结 Imageurls string `protobuf:"bytes,29,opt,name=imageurls,proto3" json:"imageurls" gorm:"imageurls"` //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@ -499,6 +502,20 @@ func (x *DBEchoMeetRecord) GetRemark() string {
return "" return ""
} }
func (x *DBEchoMeetRecord) GetComputeuser() int64 {
if x != nil {
return x.Computeuser
}
return 0
}
func (x *DBEchoMeetRecord) GetComputedevices() string {
if x != nil {
return x.Computedevices
}
return ""
}
func (x *DBEchoMeetRecord) GetImageurls() string { func (x *DBEchoMeetRecord) GetImageurls() string {
if x != nil { if x != nil {
return x.Imageurls return x.Imageurls
@ -950,7 +967,7 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" +
"\aoutline\x18\n" + "\aoutline\x18\n" +
" \x01(\tR\aoutline\x12\x1a\n" + " \x01(\tR\aoutline\x12\x1a\n" +
"\btemplate\x18\v \x01(\tR\btemplate\x12\x12\n" + "\btemplate\x18\v \x01(\tR\btemplate\x12\x12\n" +
"\x04sort\x18\f \x01(\x05R\x04sort\"\x9e\a\n" + "\x04sort\x18\f \x01(\x05R\x04sort\"\xe8\a\n" +
"\x10DBEchoMeetRecord\x12\x0e\n" + "\x10DBEchoMeetRecord\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" +
"\x03uid\x18\x02 \x01(\tR\x03uid\x12,\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\x12,\n" +
@ -988,7 +1005,9 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" +
"\tstarttime\x18\x19 \x01(\x03R\tstarttime\x12(\n" + "\tstarttime\x18\x19 \x01(\x03R\tstarttime\x12(\n" +
"\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\n" + "\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\n" +
"\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" + "\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" +
"\x06remark\x18\x1c \x01(\tR\x06remark\x12\x1c\n" + "\x06remark\x18\x1c \x01(\tR\x06remark\x12 \n" +
"\vcomputeuser\x18! \x01(\x03R\vcomputeuser\x12&\n" +
"\x0ecomputedevices\x18\" \x01(\tR\x0ecomputedevices\x12\x1c\n" +
"\timageurls\x18\x1d \x01(\tR\timageurls\"\x99\x01\n" + "\timageurls\x18\x1d \x01(\tR\timageurls\"\x99\x01\n" +
"\rContextStruct\x12\x1c\n" + "\rContextStruct\x12\x1c\n" +
"\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" + "\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" +

9
apps/services/pb/enum.pb.go

@ -346,6 +346,8 @@ const (
// @Description 后台调整用户算力余额(加减) // @Description 后台调整用户算力余额(加减)
// @example // @example
UserLogType_AdminAdjust UserLogType = 5 //后台调整算力 UserLogType_AdminAdjust UserLogType = 5 //后台调整算力
// 算力退还(语音纪要转写失败 / 开始任务失败时按扣费明细原路退回,2026-09-25)
UserLogType_ComputeRefund UserLogType = 6 //算力退还
) )
// Enum value maps for UserLogType. // Enum value maps for UserLogType.
@ -357,6 +359,7 @@ var (
3: "AdminGive", 3: "AdminGive",
4: "UserConsume", 4: "UserConsume",
5: "AdminAdjust", 5: "AdminAdjust",
6: "ComputeRefund",
} }
UserLogType_value = map[string]int32{ UserLogType_value = map[string]int32{
"UserLogTypeUnknown": 0, "UserLogTypeUnknown": 0,
@ -365,6 +368,7 @@ var (
"AdminGive": 3, "AdminGive": 3,
"UserConsume": 4, "UserConsume": 4,
"AdminAdjust": 5, "AdminAdjust": 5,
"ComputeRefund": 6,
} }
) )
@ -678,14 +682,15 @@ const file_enum_proto_rawDesc = "" +
"\x04Meet\x10\x02\x12\x06\n" + "\x04Meet\x10\x02\x12\x06\n" +
"\x02AI\x10\x03\x12\a\n" + "\x02AI\x10\x03\x12\a\n" +
"\x03VIP\x10\x04\x12\v\n" + "\x03VIP\x10\x04\x12\v\n" +
"\aCompute\x10\x05*s\n" + "\aCompute\x10\x05*\x86\x01\n" +
"\vUserLogType\x12\x16\n" + "\vUserLogType\x12\x16\n" +
"\x12UserLogTypeUnknown\x10\x00\x12\a\n" + "\x12UserLogTypeUnknown\x10\x00\x12\a\n" +
"\x03Pay\x10\x01\x12\x12\n" + "\x03Pay\x10\x01\x12\x12\n" +
"\x0eActivityReward\x10\x02\x12\r\n" + "\x0eActivityReward\x10\x02\x12\r\n" +
"\tAdminGive\x10\x03\x12\x0f\n" + "\tAdminGive\x10\x03\x12\x0f\n" +
"\vUserConsume\x10\x04\x12\x0f\n" + "\vUserConsume\x10\x04\x12\x0f\n" +
"\vAdminAdjust\x10\x05*\"\n" + "\vAdminAdjust\x10\x05\x12\x11\n" +
"\rComputeRefund\x10\x06*\"\n" +
"\rMCPServerType\x12\b\n" + "\rMCPServerType\x12\b\n" +
"\x04Http\x10\x00\x12\a\n" + "\x04Http\x10\x00\x12\a\n" +
"\x03SEE\x10\x01*m\n" + "\x03SEE\x10\x01*m\n" +

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

Loading…
Cancel
Save