From 41e3a023a328b210a7f8abd18bfacf565bd2659b Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Wed, 16 Sep 2026 09:49:28 +0800 Subject: [PATCH] =?UTF-8?q?client:=20=E8=93=9D=E7=89=99=E5=90=8D=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=EF=BC=88EaiRec/EaiCar=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E5=8D=B3=E6=8C=89=E8=B4=A6=E5=8F=B7=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E7=BB=91=E5=AE=9A=EF=BC=9BiOS=20=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=9B=E5=BC=B1=E7=BD=91=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设备名闸门:支架只认 EaiRec/EAIMAR/Pad Note,车载香薰只认 EaiCar,固件名对不上直接不连; 一次性清掉此前被归一化名字污染的配对记录;Smartcar 资源与 agent 命名统一改为 EaiCar - 绑定登记:新增 DeviceBindRegistry(按 uid|MAC 去重),支架也进 verifyConnected, 登录态从无到有时补报一次,换账号不再漏切绑定 - iOS 崩溃加固:补 NSPhotoLibraryAddUsageDescription(保存思维导图/统计图必崩); StsAgent 先配音频会话再 installTap 并校验格式;支架录音两处 installTap 加 0Hz 守卫; AzureTtsHelper 播放队列改加锁队列(三线程裸改 Array,release 下崩); MicrophoneCapture 主线程不再用信号量等权限弹窗(看门狗误杀) - 网络:connectionError 给中文文案;实时翻译请求 12s 超时,不再跟 30s 默认值等 - 附:AI 助手中枢设计文档 v0.2(未动代码) Co-Authored-By: Claude Fable 5.1 --- .../ios/Runner.xcodeproj/project.pbxproj | 6 +- apps/client/ios/Runner/Info.plist | 2 + .../lib/core/bindings/initial_binding.dart | 3 + .../lib/data/models/device_category.dart | 8 +- apps/client/lib/data/models/user_Info.dart | 5 + .../client/lib/data/services/network/api.dart | 3 +- .../services/network/auth_interceptor.dart | 23 +- .../data/services/network/dio_manager.dart | 13 +- .../services/server_translation_service.dart | 23 +- .../lib/devices/bes/bes_device_auth.dart | 13 +- apps/client/lib/devices/device_auth.dart | 43 +- .../lib/devices/device_bind_registry.dart | 85 +++ .../devices/holder/holder_device_plugin.dart | 3 +- .../devices/holder/holder_device_service.dart | 119 ++-- .../smartcar/smartcar_device_auth.dart | 9 +- .../smartcar/smartcar_device_plugin.dart | 10 +- .../lib/devices/smartcar/smartcar_link.dart | 31 +- apps/client/lib/modules/smartcar/module.dart | 22 +- .../Sources/agent_service/StsAgent.swift | 31 +- .../Sources/azure_speech/AzureTtsHelper.swift | 31 +- .../Sources/tools/MicrophoneCapture.swift | 9 + .../tool/SimpleAudioReceiver.swift | 19 +- .../recorder_holder/sdk/lib/src/client.dart | 17 +- apps/client/pubspec.yaml | 2 +- .../client/test/devices/holder_name_test.dart | 41 ++ .../test/devices/smartcar_name_test.dart | 30 + docs/AI助手中枢-设计与落地文档.md | 602 ++++++++++++++++++ 27 files changed, 1113 insertions(+), 90 deletions(-) create mode 100644 apps/client/lib/devices/device_bind_registry.dart create mode 100644 apps/client/test/devices/holder_name_test.dart create mode 100644 apps/client/test/devices/smartcar_name_test.dart create mode 100644 docs/AI助手中枢-设计与落地文档.md diff --git a/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/client/ios/Runner.xcodeproj/project.pbxproj index 9a329526..b676f650 100644 --- a/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/client/ios/Runner.xcodeproj/project.pbxproj @@ -641,7 +641,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -835,7 +835,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -869,7 +869,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/apps/client/ios/Runner/Info.plist b/apps/client/ios/Runner/Info.plist index 8d30946a..f4cb5025 100644 --- a/apps/client/ios/Runner/Info.plist +++ b/apps/client/ios/Runner/Info.plist @@ -172,6 +172,8 @@ Our app's voice AI interaction features (such as voice wake-up for AI, issuing commands, and real-time conversations) require microphone access. With this permission, you can interact with AI directly through speech, improving operational efficiency. We strictly protect your voice data—only used for real-time processing of interaction requests, and raw audio will not be stored. NSMotionUsageDescription This app uses motion and fitness data to detect when you're walking or moving, which helps optimize voice recognition accuracy and automatically adjust microphone sensitivity for better audio recording quality during different activities. + NSPhotoLibraryAddUsageDescription + Photo library permission is required to save exported images (mind maps, statistics) to your album NSPhotoLibraryUsageDescription Photo library permission is required to save recording files and import audio files NSRemindersUsageDescription diff --git a/apps/client/lib/core/bindings/initial_binding.dart b/apps/client/lib/core/bindings/initial_binding.dart index 8b32d93f..2e0f27fc 100644 --- a/apps/client/lib/core/bindings/initial_binding.dart +++ b/apps/client/lib/core/bindings/initial_binding.dart @@ -28,6 +28,7 @@ import '../../data/services/device_call_recording_service.dart'; import '../../data/services/device_file_import_service.dart'; import '../../data/services/device_recording_service.dart'; import '../../devices/device_bootstrap.dart'; +import '../../devices/device_auth.dart'; import '../../devices/device_hub.dart'; import '../../modules/module_registry.dart'; import '../services/ai_orb_controller.dart'; @@ -97,6 +98,8 @@ class InitialBinding extends Bindings { installDeviceHub(); // 设备品类首次连上时把默认功能铺到 AI 页 AgentDefaults.bind(DeviceHub.to); + // 登录完成后把「连接时因未登录跳过」的绑定登记补上,见 DeviceAuth.bindLoginRetry + DeviceAuth.bindLoginRetry(); // 设备主麦录音(现场录音)的全局采集/归档:设备端按键在任意页面都生效 Get.put(DeviceRecordingService(), permanent: true); // 通话录音(双声道:左本端 / 右对端),跟随通话状态自动起停 diff --git a/apps/client/lib/data/models/device_category.dart b/apps/client/lib/data/models/device_category.dart index b6e0f6e6..a796a79e 100644 --- a/apps/client/lib/data/models/device_category.dart +++ b/apps/client/lib/data/models/device_category.dart @@ -59,7 +59,7 @@ class DeviceCategories { static const String studyMachine = 'studyMachine'; static const String noteCard = 'noteCard'; - /// AI车载香薰(Smartcar)。2026-09-08 新增。 + /// AI车载香薰(EaiCar,旧名 Smartcar)。2026-09-08 新增。 static const String carAroma = 'carAroma'; // ⚠️ 这里原来有一组写死的图片常量(earphoneImage / earphoneIconImage / @@ -72,7 +72,7 @@ class DeviceCategories { // 2026-09-08 废弃,下发的值恒为 0,再也匹配不到 1/2/3。留着字段只为不动 // 老数据结构,**新品类一律用 nameKeywords 圈定**。圈不到时 productsOf 返回空。 // - // 「选择设备」页当前上架三条:AI耳机(恒玄)、AI手机支架(EaiRec)、AI车载香薰(Smartcar)。 + // 「选择设备」页当前上架三条:AI耳机(恒玄)、AI手机支架(EaiRec)、AI车载香薰(EaiCar)。 // 下架某条 = 把它挪回 [hidden]。 static const List all = [ DeviceCategory( @@ -98,14 +98,14 @@ class DeviceCategories { imageAsset: '${DeviceImages.dir}EaiRec.png', nameKeywords: ['echomeet', 'eairec', 'eaimar', '支架', 'holder', 'stand'], ), - // AI车载香薰 = Smartcar。2026-09-08 新增,蓝牙链路同样未接。 + // AI车载香薰 = EaiCar(广播名,旧名 Smartcar 已于 2026-09-15 停止接纳)。 DeviceCategory( id: carAroma, title: 'catCarAroma', subtitle: 'catCarAromaDesc', icon: Icons.air, gradient: [Color(0xFFFFB74D), Color(0xFFE65100)], - imageAsset: '${DeviceImages.dir}Smartcar.png', + imageAsset: '${DeviceImages.dir}EaiCar.png', nameKeywords: ['smartcar', '车载', '香薰', 'aroma', 'diffuser'], ), ]; diff --git a/apps/client/lib/data/models/user_Info.dart b/apps/client/lib/data/models/user_Info.dart index 8ebdc864..badc6970 100644 --- a/apps/client/lib/data/models/user_Info.dart +++ b/apps/client/lib/data/models/user_Info.dart @@ -143,6 +143,10 @@ class User { [], ); _instance = u; + // 登录完成也是「用户信息变了」,要发信号:设备的绑定登记挂在这上面补报 + // (见 DeviceAuth.bindLoginRetry)。原来只有 updateUserInfo 会 +1, + // 于是「先连设备后登录」这条路上没有任何人知道登录已经完成。 + refreshTick.value++; Logger.i("User", "初始化用户: uid=${u.uid}, name=${u.name}, gender=${u.gender}, viplv=${u.viplv}, vipexptime=${u.vipexptime}, ai聊天=${u.aichatintegral}/${u.aichattotalintegral}, 翻译=${u.tradeintegral}/${u.tradetotalintegral}, 会议=${u.meetintegral}/${u.meettotalintegral}"); return u; @@ -180,6 +184,7 @@ class User { : [], ); _instance = u; + refreshTick.value++; // 同上:token 登录(冷启动恢复会话)也要发信号 Logger.i("User", "初始化用户(token): uid=${u.uid}, name=${u.name}, gender=${u.gender}, viplv=${u.viplv}, vipexptime=${u.vipexptime}, ai聊天=${u.aichatintegral}/${u.aichattotalintegral}, 翻译=${u.tradeintegral}/${u.tradetotalintegral}, 会议=${u.meetintegral}/${u.meettotalintegral}"); return u; diff --git a/apps/client/lib/data/services/network/api.dart b/apps/client/lib/data/services/network/api.dart index 5ed8c798..b7bb7013 100644 --- a/apps/client/lib/data/services/network/api.dart +++ b/apps/client/lib/data/services/network/api.dart @@ -587,12 +587,13 @@ class Api { // // [cancelToken] 供同传/面对面的**中间结果**翻译使用:说话过程中会连着发好几次, // 后一次发出时要把前一次撤掉,别让废弃的预览请求占着链路和服务商配额。 - static userTranslate(params, {CancelToken? cancelToken}) { + static userTranslate(params, {CancelToken? cancelToken, Duration? timeout}) { return DioManager().request( NWMethod.post, '/api/home/user_translate', params: params, cancelToken: cancelToken, + timeout: timeout, ); } } diff --git a/apps/client/lib/data/services/network/auth_interceptor.dart b/apps/client/lib/data/services/network/auth_interceptor.dart index 16be3fe9..33c6cb46 100644 --- a/apps/client/lib/data/services/network/auth_interceptor.dart +++ b/apps/client/lib/data/services/network/auth_interceptor.dart @@ -222,11 +222,32 @@ class AuthInterceptor extends Interceptor { colorText: Colors.red, ); } + // 断网 / DNS 解析失败 / 证书问题:这些都是「连不上」,不是业务错误。 + // ⚠️ 别再落到 default 去弹 err.message —— 那是 Dio 的英文技术文案, + // 用户看到的是整段 + // "The connection errored: Failed host lookup: 'ym-dev.ymaikj.com' + // This indicates an error which most likely cannot be solved by the library." + // 既看不懂、也不知道该做什么(2026-09-15 通话翻译页真机截图)。 + case DioExceptionType.connectionError: + { + Logger.w('API', + '连接失败(${err.requestOptions.path}): ${err.message}'); + Get.snackbar( + 'error'.tr, + 'networkConnectionFailed'.tr, // 网络连接失败,请检查网络设置后重试 + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + } default: { + // 兜底也不要暴露库的英文原文:原因记进日志,界面给一句能看懂的话。 + Logger.w('API', + '请求异常(${err.type} ${err.requestOptions.path}): ${err.message}'); Get.snackbar( 'error'.tr, // 错误 - err.message ?? 'unknownError'.tr, + 'networkErrorRetry'.tr, // 网络错误,请稍后重试 snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, diff --git a/apps/client/lib/data/services/network/dio_manager.dart b/apps/client/lib/data/services/network/dio_manager.dart index 194501a1..742db52b 100644 --- a/apps/client/lib/data/services/network/dio_manager.dart +++ b/apps/client/lib/data/services/network/dio_manager.dart @@ -77,19 +77,30 @@ class DioManager { // method:请求方法,NWMethod.POST等 // path:请求地址 // params:请求参数 + /// [timeout] 覆盖本次请求的收发超时(默认走全局的 30s)。 + /// + /// ⚠️ 实时链路**必须**自己给一个短超时:全局那 30s 是给上传、总结这类慢请求留的, + /// 而同传/面对面是流式的,一句话等 30s 早就没有意义了,只会让后面的句子一起堵住, + /// 表现就是「弱网下卡住不动」。服务端 user_translate 内部的上下文只给 10s, + /// 客户端再等 30s 纯属白等 20s。 Future request( NWMethod method, String path, { Object? params, Map? queryParameters, CancelToken? cancelToken, + Duration? timeout, }) async { Response response = await dio.request( path, data: params, queryParameters: queryParameters, cancelToken: cancelToken, - options: Options(method: nwMethodValues[method]), + options: Options( + method: nwMethodValues[method], + sendTimeout: timeout, + receiveTimeout: timeout, + ), ); return response.data; } diff --git a/apps/client/lib/data/services/server_translation_service.dart b/apps/client/lib/data/services/server_translation_service.dart index b152b6d1..938d9b3c 100644 --- a/apps/client/lib/data/services/server_translation_service.dart +++ b/apps/client/lib/data/services/server_translation_service.dart @@ -40,11 +40,23 @@ class ServerTranslationService extends GetxService { /// [cancelToken] 给同传/面对面的**中间结果**预览翻译用:说话过程中会连发好几次, /// 后一次发出时把前一次撤掉。被取消返回 null,且不打 warning——那是预期行为, /// 不是失败。 + /// 实时链路(同传 / 面对面 / 通话)的翻译超时。 + /// + /// ⚠️ 不能用全局那 30s:那是给上传、会议总结这类慢请求留的。同传是流式的, + /// 一句话等 30s 早就没有意义,而且会把后面的句子一起堵在同一条链路上—— + /// 弱网下的「卡住不动」就是这么来的。 + /// + /// 取 12s 是有依据的:服务端 user_translate 内部的上下文只给服务商 10s + /// (api_translate.go 的 context.WithTimeout),10s 拿不到结果它自己就返回失败了, + /// 客户端再多等就是纯白等。留 2s 给网络往返。 + static const Duration realtimeTimeout = Duration(seconds: 12); + Future translateText({ required String text, required String sourceLanguageCode, required String targetLanguageCode, CancelToken? cancelToken, + Duration? timeout = realtimeTimeout, }) async { final src = text.trim(); if (src.isEmpty) return null; @@ -53,7 +65,7 @@ class ServerTranslationService extends GetxService { 'text': src, 'from': sourceLanguageCode, 'to': targetLanguageCode, - }, cancelToken: cancelToken); + }, cancelToken: cancelToken, timeout: timeout); if (data is! Map) return null; final out = (data['text'] as String?)?.trim() ?? ''; lastProvider.value = (data['provider'] as String?) ?? ''; @@ -69,6 +81,15 @@ class ServerTranslationService extends GetxService { if (e is DioException && e.type == DioExceptionType.cancel) return null; // 服务端错误码见 errorcode.proto 5101-5103: // 5101 未配置 MT 服务 / 5102 参数不合法 / 5103 服务商调用失败 + if (e is DioException && + (e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.connectionTimeout)) { + // 弱网:这一句放弃,不阻塞后面的句子。同传宁可丢一句也不能整条卡住。 + Logger.w(_tag, + '翻译超时($sourceLanguageCode→$targetLanguageCode),跳过这一句'); + return null; + } Logger.w(_tag, '翻译失败($sourceLanguageCode→$targetLanguageCode): $e'); return null; } diff --git a/apps/client/lib/devices/bes/bes_device_auth.dart b/apps/client/lib/devices/bes/bes_device_auth.dart index 332b1520..5df81ae0 100644 --- a/apps/client/lib/devices/bes/bes_device_auth.dart +++ b/apps/client/lib/devices/bes/bes_device_auth.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -5,6 +7,7 @@ import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; import '../../data/models/user_Info.dart'; +import '../device_bind_registry.dart'; import '../../data/services/network/api.dart'; import 'bes_bluetooth_service.dart'; @@ -143,7 +146,15 @@ class BesDeviceAuth { final name = bes.connectedDeviceName.value; // ① 本地白名单——第一次之后就走这条,不打接口、离线也能连 - if (isAuthorizedLocally(mac)) return BesAuthResult.authorized; + if (isAuthorizedLocally(mac)) { + // ⚠️ 放行的只是**确权**,绑定登记不能跟着一起省:白名单不按账号分 + // (确权是设备属性,一台正品永远是正品),换个账号登录同一台设备仍会命中, + // 直接 return 就等于 binddevice 一次都不打 —— 服务端还认为它属于上一个账号, + // 新账号的「设备管理」页永远空着,而且连接/录音/OTA 全都正常,看不出异常。 + // 登记按当前账号判断,异步补,不阻塞握手。见 DeviceBindRegistry。 + unawaited(DeviceBindRegistry.ensureBound(mac: mac, name: name)); + return BesAuthResult.authorized; + } if (_inflight.contains(mac)) return BesAuthResult.unknown; _inflight.add(mac); diff --git a/apps/client/lib/devices/device_auth.dart b/apps/client/lib/devices/device_auth.dart index 3b30707a..02e7a788 100644 --- a/apps/client/lib/devices/device_auth.dart +++ b/apps/client/lib/devices/device_auth.dart @@ -1,8 +1,14 @@ +import 'dart:async'; + import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:get/get.dart'; +import '../data/models/user_Info.dart'; import 'bes/bes_device_auth.dart'; import 'bes/bes_device_plugin.dart'; +import 'device_bind_registry.dart'; import 'device_hub.dart'; +import 'holder/holder_device_plugin.dart'; import 'smartcar/smartcar_device_auth.dart'; import 'smartcar/smartcar_device_plugin.dart'; @@ -33,6 +39,8 @@ class DeviceAuth { static void reset() { BesDeviceAuth.reset(); SmartcarDeviceAuth.reset(); + // 换账号后同一台设备要重新登记到新账号名下(服务端会接管) + DeviceBindRegistry.reset(); } /// 解绑:移出白名单,下次连上重新走服务端确权。 @@ -51,7 +59,13 @@ class DeviceAuth { } /// 补校当前连着的、需要确权的设备(顺带完成绑定登记)。 - /// 「先连耳机后登录」时连接事件里的登记会因未登录跳过,设备管理页进来补一次。 + /// 「先连设备后登录」时连接事件里的登记会因未登录跳过,这里补一次。 + /// + /// ⚠️ **每接一种会绑定的设备都要在这里加一个分支**。漏掉的后果不是「少一层 + /// 保险」,而是那种设备**永远补不上登记**:连接时若还没登录就跳过了, + /// 而此后没有任何别的路径会再触发。支架(holder)原先就漏在这儿 —— + /// 冷启动时它被动回连的速度常常快过登录态恢复,于是设备连着、 + /// 「设备管理」页却一直是空的,且不报任何错。 static Future verifyConnected() async { final hub = DeviceHub.to; for (final s in hub.sessions.toList()) { @@ -59,6 +73,9 @@ class DeviceAuth { final p = hub.plugin(s.vendor); if (p is BesDevicePlugin) { await p.link.auth?.verifyConnected(); + } else if (p is HolderDevicePlugin) { + // 支架没有单独的确权类,登记就在服务自己身上(本进程内已成功的会去重) + await p.link.bindConnectedToServer(); } else if (p is SmartcarDevicePlugin) { // Smartcar 的 MAC 来自广播,两端都拿得到(不像恒玄 iOS 只有 UUID) await SmartcarDeviceAuth.verify( @@ -68,4 +85,28 @@ class DeviceAuth { } } } + + static Worker? _loginWorker; + + /// App 启动时挂一次:登录态一变化就对当前连着的设备补报一次绑定登记。 + /// + /// ⚠️ 不能只靠「连接时登记」+「进设备管理页补一次」这两条:设备是被动回连的, + /// 冷启动时它上线常常早于登录态恢复,那次登记直接被「未登录」跳过; + /// 而用户不一定会去点设备管理页 —— 于是设备连着、服务端却没有绑定记录, + /// 「我的设备」空着还不报错。登录完成后补这一次,把这个时序洞堵上。 + static void bindLoginRetry() { + _loginWorker?.dispose(); + _lastLoggedIn = User.isLoggedIn(); + _loginWorker = ever(User.refreshTick, (_) { + final now = User.isLoggedIn(); + final was = _lastLoggedIn; + _lastLoggedIn = now; + // 只在**登录态从无到有**那一次补报。refreshTick 在用户信息每次刷新时都会 +1, + // 不收敛的话会对「服务端拒绝」的设备反复打 binddevice(成功的那些有 _bound 去重, + // 失败的没有)——那正是还没导入 MAC 的设备最常见的状态。 + if (now && !was) unawaited(verifyConnected()); + }); + } + + static bool _lastLoggedIn = false; } diff --git a/apps/client/lib/devices/device_bind_registry.dart b/apps/client/lib/devices/device_bind_registry.dart new file mode 100644 index 00000000..dfa129ad --- /dev/null +++ b/apps/client/lib/devices/device_bind_registry.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import '../core/utils/logger.dart'; +import '../data/models/user_Info.dart'; +import '../data/services/network/api.dart'; + +/// 「这台设备现在属于哪个账号」的登记。 +/// +/// ⚠️ 与**确权**(这台硬件是不是正品)是两件完全不同的事,必须分开: +/// - 确权是**设备属性**,可以缓存在本地白名单里,离线也认,一台正品永远是正品; +/// - 登记是**账号属性**,同一台设备换个账号登录就得重新报一次。 +/// +/// 把两者混在一条路径上(白名单命中就整段 return)会导致一个不报错的坑: +/// A 账号绑过的设备,换 B 账号登录后连上时 `user_binddevice` **一次都不打**, +/// 服务端仍认为它属于 A —— B 的「设备管理」页永远空着,而连接、录音、OTA 全都正常, +/// 看不出任何异常。设备在同事之间来回借用、或同一台手机切账号测试时必然踩到。 +/// +/// 服务端本来就支持接管:`api_binddevice.go` 里 +/// `model.Status != 0 && model.Uid != session.GetUserId()` 那一支会删掉原账号的 +/// userdevice 行、把绑定统计冲正 -1,再把设备行改挂到新账号名下 +/// (「授权码已被使用」的拒绝早就注释掉了)。**客户端只要把「连上了」这件事告诉它**。 +class DeviceBindRegistry { + DeviceBindRegistry._(); + + static const String _tag = 'DeviceBindRegistry'; + + /// 本进程内已向服务端报告过的 `uid|MAC`,避免同一次登录里反复打接口。 + /// + /// ⚠️ 键**必须带 uid**:只按 MAC 记会把「换账号」这件事连同一起吞掉, + /// 那就等于又回到了白名单短路的老问题上。 + static final Set _reported = {}; + + /// 登出时清掉:换账号后同一台设备要重新登记到新账号名下。 + static void reset() => _reported.clear(); + + /// 确保**当前账号**在服务端登记了这台设备。 + /// + /// 调用方不必 await —— 连接流程不该等这一次网络往返,登记失败也不影响设备可用。 + /// 未登录时直接返回:那条路由 [DeviceAuth.bindLoginRetry] 在登录完成后补。 + static Future ensureBound({ + required String mac, + required String name, + }) async { + final m = mac.trim().toUpperCase(); + if (m.isEmpty) return; + if (!User.isLoggedIn()) return; + + final uid = User.instance.uid; + // 服务端已经知道这台属于当前账号 —— 省一次往返。 + // 这个判据天然按账号走(devices 是当前账号的列表),不需要额外缓存。 + if (User.instance.devices + .any((d) => d.devicemac.trim().toUpperCase() == m)) { + return; + } + + final key = '$uid|$m'; + if (_reported.contains(key)) return; + _reported.add(key); // 先占位挡住并发;失败时移除,下次连上还能重试 + try { + final resp = await Api.binddevice({ + 'code': '', // BLE 设备没有杰理那套广播授权码,服务端按 MAC 全局反查 + 'devicename': name, + 'devicemac': mac, + }).timeout(const Duration(seconds: 10)); + final deviceJson = resp?['device']; + if (deviceJson is Map) { + User.instance.addUserDevice(UserDevice.fromJson(deviceJson)); + Logger.i(_tag, '已登记到当前账号($uid): $m'); + return; + } + // resp == null 是服务端**明确拒绝**(AuthInterceptor 在业务码 != 0 时 + // 把 data 置 null 后 resolve,不抛)。最常见是这台 MAC 还没导入后台的 + // device_mac 表,返回 {"code":2005,"msg":"设备未登记"}。 + _reported.remove(key); + Logger.w( + _tag, + '服务端未返回 device(resp=$resp):这台 MAC 可能没导入 device_mac,' + '设备管理页看不到它'); + } catch (e) { + // 网络问题:不记成功,下次连上重试。登记失败不影响连接与使用。 + _reported.remove(key); + Logger.w(_tag, '登记失败(不影响使用): $e'); + } + } +} diff --git a/apps/client/lib/devices/holder/holder_device_plugin.dart b/apps/client/lib/devices/holder/holder_device_plugin.dart index 97f3ff36..628c3aba 100644 --- a/apps/client/lib/devices/holder/holder_device_plugin.dart +++ b/apps/client/lib/devices/holder/holder_device_plugin.dart @@ -180,7 +180,8 @@ class HolderDevicePlugin implements DevicePlugin { DiscoveredDevice? get lastKnownDevice { final n = link.lastDeviceName; if (n.isEmpty) return null; - return DiscoveredDevice(id: link.connectedAddress, name: n, vendor: vendorKey); + return DiscoveredDevice( + id: link.connectedAddress, name: n, vendor: vendorKey); } @override diff --git a/apps/client/lib/devices/holder/holder_device_service.dart b/apps/client/lib/devices/holder/holder_device_service.dart index 9096bb87..afcdc5c2 100644 --- a/apps/client/lib/devices/holder/holder_device_service.dart +++ b/apps/client/lib/devices/holder/holder_device_service.dart @@ -10,8 +10,8 @@ import 'package:permission_handler/permission_handler.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/permission_util.dart'; import '../../data/models/user_Info.dart'; +import '../device_bind_registry.dart'; import '../../data/services/background_launch_consent.dart'; -import '../../data/services/network/api.dart'; /// EaiRec 录音支架(AI手机支架)的接入层。 /// @@ -24,6 +24,29 @@ import '../../data/services/network/api.dart'; /// 与项目原有的 [BleManager](杰理耳机链路)是两套独立的设备通路,互不影响: /// - 杰理耳机 → BleManager /// - EaiRec 支架 → 本服务 +/// 我们规定的支架广播名(准入白名单)。 +/// +/// ⚠️ 必须与 `EchoMeetDeviceSdk.defaultNameFilters` 保持一致:那边管「扫描时筛谁」, +/// 这边管「连接时放不放行」。SDK 是独立 package,import 不过来,只能两处各维护一份。 +const List kHolderAllowedNames = ['EaiRec', 'EAIMAR', 'Pad Note']; + +/// 固件广播名是不是我们规定的名字。 +/// +/// ⚠️ **名字对不上就不连**,这是准入校验不是容错:那样的固件不是正式产品, +/// 服务端的 MAC 校验同样过不了(device_mac 里没有它),连上也没有意义, +/// 只会让用户以为设备可用、然后在设备管理页找不到它。 +/// +/// 早先这里做的是「`Echomeet` → `EaiRec` 归一」——方向错了:归一等于**接纳** +/// 不合规的固件,把问题往后推到服务端再失败一次。2026-09-15 改为在连接入口拒绝。 +bool isAllowedHolderName(String raw) { + final n = raw.trim().toUpperCase(); + if (n.isEmpty) return false; + for (final allowed in kHolderAllowedNames) { + if (n.startsWith(allowed.toUpperCase())) return true; + } + return false; +} + class HolderDeviceService extends GetxService { static const String _tag = 'HolderDeviceService'; @@ -73,6 +96,27 @@ class HolderDeviceService extends GetxService { /// 本地已绑定设备列表的存储键(服务端绑定失败时也保证设备管理页有记录) static const String _pairedListKey = 'holder_paired_devices'; + /// 一次性清理:广播名白名单收紧(移除 `Echomeet`)之前记下的配对记录不可信。 + /// + /// 两个原因都要清: + /// 1. 那时连的可能就是不合规固件,记录留着会被自动回连直接拉起来,绕过准入; + /// 2. 短暂存在过的"名字归一"会把存储里的广播名改写成合规名, + /// 而自动回连拿不到真实广播名、只能信存储 —— 校验因此形同虚设。 + /// + /// 代价是用户要重新扫一次配对(扫描页点一下)。这一步换来的是「记住的设备」 + /// 与「扫描白名单」口径一致,值得,而且只发生一次。 + void _purgeUntrustedPairsOnce() { + const key = 'holder_pairs_purged_namegate_v1'; + if (_storage.read(key) == true) return; + _storage.write(key, true); + final had = _storage.hasData(_lastDeviceKey) || pairedDevices.isNotEmpty; + _storage.remove(_lastDeviceKey); + _storage.remove(_pairedListKey); + if (had) { + Logger.w(_tag, '广播名白名单收紧,已清除旧的支架配对记录,请重新扫描配对'); + } + } + /// 记住最近一次成功连接的设备,供开机/断连后自动回连, /// 同时写入本地已绑定列表(设备管理页会合并展示)。 void _rememberDevice(String name, String address) { @@ -165,7 +209,21 @@ class HolderDeviceService extends GetxService { return; } - final name = (last?['name'] ?? 'Echomeet') as String; + // ⚠️ 两个名字要分开: + // rawName —— 固件实际广播的名字(`Echomeet`)。iOS 上它会被当成 + // `deviceNamePattern` 传给 startDeviceAssociation 去**匹配广播**, + // 传归一后的 `EaiRec` 就匹配不上,直接连不上且不报错。 + // name —— 对外的产品名(`EaiRec`)。binddevice 上报、设备产品图、 + // 固件升级的产品匹配都用它。 + final name = (last?['name'] ?? '') as String; + // ⚠️ 记住过的设备也要校验:名字白名单收紧之后,本地可能还留着旧固件那台 + // (刷名字之前连过)。不挡的话它会继续被自动回连上来,准入校验等于没做。 + if (!isAllowedHolderName(name)) { + Logger.w(_tag, '记住的设备名「$name」不在允许列表 $kHolderAllowedNames,不再自动回连,已清除记录'); + _autoAttempt = _maxAutoAttempts; + removePaired(address); + return; + } connectedNameRx.value = name; Logger.i(_tag, '自动回连注册: $name $address (第 ${_autoAttempt + 1} 次)'); connectedAddressRx.value = address; @@ -336,6 +394,8 @@ class HolderDeviceService extends GetxService { @override void onInit() { super.onInit(); + // 必须排在 ensureBound / 任何自动回连之前:清的就是会被回连拉起来的那份记录 + _purgeUntrustedPairsOnce(); _sdk.ensureBound(); _scanSub = _sdk.scanResults.listen((list) { if (list.isEmpty) return; // 空批次不清空已发现列表 @@ -352,12 +412,13 @@ class HolderDeviceService extends GetxService { if (s == EchoMeetConnectionState.connected) { _autoConnectTimer?.cancel(); _autoAttempt = 0; - // 名字用本次实际连上的那台。原来这里写死 'Echomeet',换成 EaiRec 之后 - // 记下来的名字会是错的,设备页据 lastDeviceName 选主图就会选错。 + // ⚠️ 这里存的必须是**固件实际广播的名字**(手动连接时来自扫描结果, + // 自动回连时是把存储值原样写回,幂等)。 + // 千万别在写入前对名字做任何"归一/美化"——存储是自动回连路径下**唯一** + // 的名字来源,一旦被改写成合规名,isAllowedHolderName 校验的就是我们 + // 自己编的名字,准入等于没做。2026-09-15 真机上就这么被绕过一次。 _rememberDevice( - // 兜底名要跟当前固件的广播名一致,否则设备页按名字取产品图会落空。 - // 切换设备名见 EchoMeetDeviceSdk.defaultNameFilters。 - connectedNameRx.value.isNotEmpty ? connectedNameRx.value : 'Echomeet', + connectedNameRx.value, connectedAddressRx.value, ); // 协议 §8.1 第 7 步:连上后主动查一次电量。 @@ -465,6 +526,12 @@ class HolderDeviceService extends GetxService { } Future connect(EchoMeetScannedDevice device) async { + // 扫描侧已按名字前缀筛过一道,这里是连接入口的兜底:手动传入、 + // 或将来 filters 被放宽时,仍然不放不合规的固件进来。 + if (!isAllowedHolderName(device.name)) { + Logger.w(_tag, '设备名「${device.name}」不在允许列表 $kHolderAllowedNames,拒绝连接'); + return false; + } await stopScan(); Logger.i(_tag, '连接设备: ${device.name} ${device.address}'); connectedAddressRx.value = device.address; @@ -496,40 +563,20 @@ class HolderDeviceService extends GetxService { Future bindConnectedToServer() => _bindMac(connectedNameRx.value, connectedAddressRx.value); - /// 本进程内已登记成功的 MAC,避免每次回连都打一次接口。 - /// **只记成功的**:失败(如后台还没补录这台 MAC)下次连上要能重试。 - final Set _bound = {}; - + /// 登记「这台支架现在属于当前账号」。 + /// + /// ⚠️ 去重键按 **uid|MAC** 走(在 [DeviceBindRegistry] 里),不能只按 MAC: + /// 只按 MAC 记会把「换了个账号」这件事一起吞掉 —— 服务端还认为设备属于上一个 + /// 账号,新账号的设备管理页永远空着。服务端 user_binddevice 本来就支持接管。 Future _bindMac(String name, String mac) async { if (mac.isEmpty) return; if (!User.isLoggedIn()) { - Logger.w(_tag, '未登录,跳过设备绑定登记'); + // 先连设备后登录是常见顺序(设备被动回连常快过登录态恢复)。 + // 登录完成后由 DeviceAuth.bindLoginRetry 补报,这里不算失败。 + Logger.w(_tag, '未登录,跳过设备绑定登记(登录后会自动补报)'); return; } - final key = mac.toUpperCase(); - if (_bound.contains(key)) return; - try { - final resp = await Api.binddevice({ - 'code': '', - 'devicename': name, - 'devicemac': mac, - }); - Logger.i(_tag, '绑定接口返回: $resp'); - final deviceJson = resp?['device']; - if (deviceJson is Map) { - _bound.add(key); - User.instance.addUserDevice(UserDevice.fromJson(deviceJson)); - Logger.i(_tag, '支架已登记到已绑定设备列表'); - } else { - // resp == null 是服务端明确拒绝(AuthInterceptor 在业务码 != 0 时 - // 把 data 置 null 后 resolve,不抛)——最常见的是这台 MAC 还没导入 - // 后台的 device_mac 表,返回 `{"code":2005,"msg":"设备未登记"}`。 - Logger.w(_tag, '绑定接口未返回 device 字段(resp=$resp),设备管理页看不到这台'); - } - } catch (e) { - // 绑定失败不影响本次连接和录音,只是设备管理页没有记录 - Logger.e(_tag, '支架绑定登记失败: $e'); - } + await DeviceBindRegistry.ensureBound(mac: mac, name: name); } /// 等待连接真正建立。 diff --git a/apps/client/lib/devices/smartcar/smartcar_device_auth.dart b/apps/client/lib/devices/smartcar/smartcar_device_auth.dart index 9a7b3dbf..d3b67019 100644 --- a/apps/client/lib/devices/smartcar/smartcar_device_auth.dart +++ b/apps/client/lib/devices/smartcar/smartcar_device_auth.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; import '../../data/models/user_Info.dart'; import '../../data/services/network/api.dart'; import '../device_auth.dart'; +import '../device_bind_registry.dart'; /// 「账号 ↔ Smartcar」的确权 + 绑定登记,走服务端 `user_binddevice`。 /// @@ -103,7 +106,11 @@ class SmartcarDeviceAuth { final m = mac.toUpperCase(); // ① 本地白名单——第一次之后走这条,不打接口、离线也能连 - if (isAuthorizedLocally(m)) return DeviceAuthResult.authorized; + if (isAuthorizedLocally(m)) { + // 放行的只是确权;登记是账号属性,换账号后必须重报一次,理由见 DeviceBindRegistry + unawaited(DeviceBindRegistry.ensureBound(mac: m, name: name)); + return DeviceAuthResult.authorized; + } if (_inflight.contains(m)) return DeviceAuthResult.unknown; _inflight.add(m); diff --git a/apps/client/lib/devices/smartcar/smartcar_device_plugin.dart b/apps/client/lib/devices/smartcar/smartcar_device_plugin.dart index c62fd66e..0e965084 100644 --- a/apps/client/lib/devices/smartcar/smartcar_device_plugin.dart +++ b/apps/client/lib/devices/smartcar/smartcar_device_plugin.dart @@ -18,7 +18,7 @@ import 'smartcar_link.dart'; import 'smartcar_ota_port.dart'; import 'smartcar_protocol.dart'; -/// Smartcar(车载香薰)厂商插件。 +/// EaiCar(车载香薰,旧名 Smartcar)厂商插件。 /// /// 把 [SmartcarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约, /// 业务层看到的只是「一台能读电量、能报 MAC、能发私有命令的设备」。 @@ -59,7 +59,7 @@ class SmartcarDevicePlugin implements DevicePlugin { String get vendorKey => DeviceVendors.smartcar; @override - String get displayName => 'Smartcar (车载香薰)'; + String get displayName => 'EaiCar (车载香薰)'; @override Set get capabilities => SmartcarSession.caps; @@ -154,7 +154,7 @@ class SmartcarDevicePlugin implements DevicePlugin { deviceId: r.id, discovered: DiscoveredDevice( id: r.id, - name: r.name.isEmpty ? 'Smartcar' : r.name, + name: r.name.isEmpty ? 'EaiCar' : r.name, rssi: r.rssi, vendor: vendorKey, // ⚠️ MAC 与 License 只在广播里有,连上之后就拿不到了。 @@ -284,7 +284,7 @@ class SmartcarDevicePlugin implements DevicePlugin { List get rememberedDevices => _remembered .map((m) => DiscoveredDevice( id: '${m['id']}', - name: '${m['name'] ?? 'Smartcar'}', + name: '${m['name'] ?? 'EaiCar'}', vendor: vendorKey, metadata: {'mac': m['mac']}, )) @@ -432,7 +432,7 @@ class SmartcarSession implements DeviceSession { Future _handshake() async { _info = DeviceInfo( id: deviceId, - name: 'Smartcar', + name: 'EaiCar', vendor: vendor, macAddress: _mac, ); diff --git a/apps/client/lib/devices/smartcar/smartcar_link.dart b/apps/client/lib/devices/smartcar/smartcar_link.dart index 2227a575..ca8c8500 100644 --- a/apps/client/lib/devices/smartcar/smartcar_link.dart +++ b/apps/client/lib/devices/smartcar/smartcar_link.dart @@ -24,6 +24,25 @@ import 'smartcar_protocol.dart'; /// /// ⚠️ 一部手机对同一个 peripheral **只能有一条 GATT 连接**。阶段 6 的 Opus 上行 /// 要在原生插件里复用这条连接订阅音频特征,**不能另起一条**。 +/// 我们规定的车载香薰广播名(准入白名单)。 +/// +/// ⚠️ **名字对不上就不放行**,这是准入校验不是容错:那样的固件不是正式产品, +/// 服务端的 MAC 校验同样过不了(device_mac 里没有它),连上也没有意义。 +/// 口径与支架的 [isAllowedHolderName] 一致。 +/// +/// ⚠️ 旧广播名 `Smartcar` 已于 2026-09-15 移除,刷着旧名字的样机从此扫不到。 +/// 别"顺手加回来"——加回去准入就整个失效了。 +const List kSmartcarAllowedNames = ['EaiCar']; + +bool isAllowedSmartcarName(String raw) { + final n = raw.trim().toUpperCase(); + if (n.isEmpty) return false; + for (final allowed in kSmartcarAllowedNames) { + if (n.startsWith(allowed.toUpperCase())) return true; + } + return false; +} + class SmartcarLink { static const String _tag = 'SmartcarLink'; @@ -236,14 +255,14 @@ class SmartcarLink { adv = SmartcarProtocol.parseManufacturerData( [payload.length + 3, 0xFF, 0x05, 0x85, ...payload]); } - final byName = name.toLowerCase().contains('smartcar'); + final nameOk = isAllowedSmartcarName(name); if (!_diag.isClosed) { _diag.add({ 'name': name, 'id': id, 'rssi': rssi, - 'matched': adv != null || byName, + 'matched': nameOk, 'mfg': companyId == null ? '(无厂商数据)' : '0x${companyId.toRadixString(16).toUpperCase()}[${payload.length}]=' @@ -253,9 +272,13 @@ class SmartcarLink { }); } - if (adv == null && !byName) return; + // ⚠️ 准入只认**名字**。厂商 ID(0x8505) 不再单独作为放行条件: + // 它只说明"这是云衍方案的设备",说明不了"这是我们的正式产品", + // 而名字不对的固件服务端那关同样过不了。名字对不上一律当没扫到。 + if (!nameOk) return; if (adv == null) { - Logger.w(_tag, '按名字命中 "$name" 但厂商广播段解析失败,MAC 取不到;companyId=$companyId len=${payload.length}'); + Logger.w(_tag, + '"$name" 名字命中但厂商广播段解析失败,MAC 取不到(绑定会失败);companyId=$companyId len=${payload.length}'); } final c = _scanCtrl; if (c != null && !c.isClosed) { diff --git a/apps/client/lib/modules/smartcar/module.dart b/apps/client/lib/modules/smartcar/module.dart index 9e044cbc..b7c163bf 100644 --- a/apps/client/lib/modules/smartcar/module.dart +++ b/apps/client/lib/modules/smartcar/module.dart @@ -50,77 +50,77 @@ final AgentModuleDescriptor smartcarModule = AgentModuleDescriptor( // 所以至少要保证 zh_CN / en_US 有(module_registry_test 守着这两个)。 translations: const { 'zh_CN': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': '您的车载助手', 'smartcarWelcome': '欢迎回来', 'smartcarA2dpTitle': '需要连接蓝牙音频', 'smartcarA2dpHint': 'AI 回复和音乐要经典蓝牙才能从设备里放出来。请到「设置 → 蓝牙」里配对 @name。', }, 'zh_TW': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': '您的車載助手', 'smartcarWelcome': '歡迎回來', 'smartcarA2dpTitle': '需要連接藍牙音訊', 'smartcarA2dpHint': 'AI 回覆和音樂要經典藍牙才能從裝置裡播出來。請到「設定 → 藍牙」配對 @name。', }, 'zh_HK': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': '您的車載助手', 'smartcarWelcome': '歡迎回來', 'smartcarA2dpTitle': '需要連接藍牙音訊', 'smartcarA2dpHint': 'AI 回覆和音樂要經典藍牙先可以喺裝置度播出嚟。請去「設定 → 藍牙」配對 @name。', }, 'en_US': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': 'Your in-car assistant', 'smartcarWelcome': 'Welcome back', 'smartcarA2dpTitle': 'Bluetooth audio not connected', 'smartcarA2dpHint': 'Pair @name under Settings → Bluetooth so replies and music play from the device.', }, 'ja_JP': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': '車載アシスタント', 'smartcarWelcome': 'おかえりなさい', 'smartcarA2dpTitle': 'Bluetoothオーディオ未接続', 'smartcarA2dpHint': '「設定 → Bluetooth」で @name をペアリングすると、応答と音楽が本体から再生されます。', }, 'ko_KR': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': '차량용 어시스턴트', 'smartcarWelcome': '다시 오신 것을 환영합니다', 'smartcarA2dpTitle': '블루투스 오디오 미연결', 'smartcarA2dpHint': '설정 → 블루투스에서 @name 을(를) 페어링하면 응답과 음악이 기기에서 재생됩니다。', }, 'de_DE': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': 'Ihr Auto-Assistent', 'smartcarWelcome': 'Willkommen zurück', 'smartcarA2dpTitle': 'Bluetooth-Audio nicht verbunden', 'smartcarA2dpHint': 'Koppeln Sie @name unter Einstellungen → Bluetooth, damit Antworten und Musik über das Gerät laufen.', }, 'fr_FR': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': 'Votre assistant automobile', 'smartcarWelcome': 'Bon retour', 'smartcarA2dpTitle': 'Audio Bluetooth non connecté', 'smartcarA2dpHint': 'Associez @name dans Réglages → Bluetooth pour que les réponses et la musique sortent de l\'appareil.', }, 'es_ES': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': 'Tu asistente para el coche', 'smartcarWelcome': 'Bienvenido de nuevo', 'smartcarA2dpTitle': 'Audio Bluetooth no conectado', 'smartcarA2dpHint': 'Empareja @name en Ajustes → Bluetooth para que las respuestas y la música suenen en el dispositivo.', }, 'pt_PT': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': 'O seu assistente para o carro', 'smartcarWelcome': 'Bem-vindo de volta', 'smartcarA2dpTitle': 'Áudio Bluetooth não ligado', 'smartcarA2dpHint': 'Emparelhe @name em Definições → Bluetooth para que as respostas e a música saiam do dispositivo.', }, 'ru_RU': { - 'agentSmartcar': 'Smartcar', + 'agentSmartcar': 'EaiCar', 'agentSmartcarDesc': 'Ваш автомобильный помощник', 'smartcarWelcome': 'С возвращением', 'smartcarA2dpTitle': 'Bluetooth-аудио не подключено', diff --git a/apps/client/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift b/apps/client/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift index 477eb78c..2c82287f 100644 --- a/apps/client/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift +++ b/apps/client/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift @@ -474,14 +474,30 @@ class StsAgent: NSObject { private func startRecording() { guard !recordingActive else { return } + // ⚠️ 会话类别必须先于读取输入格式:类别还是 playback(或会话未激活)时 + // inputNode.outputFormat(forBus:0) 会给出 0Hz / 0 声道的格式,拿它去 installTap + // 触发的是 AVAudioEngine 的 ObjC 断言(IsFormatSampleRateAndChannelsValid), + // Swift 捕获不到,进程直接崩。原先的顺序是先 installTap、后 setCategory。 + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + os_log("录音启动失败(音频会话): %{public}@", log: logger, type: .error, error.localizedDescription) + return + } let engine = AVAudioEngine() + let node = engine.inputNode + let hwFmt = node.outputFormat(forBus: 0) + guard hwFmt.sampleRate > 0, hwFmt.channelCount > 0, + let converterFmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true) else { + os_log("录音启动失败:输入格式无效 sampleRate=%{public}f ch=%{public}d(可能处于路由/类别切换中)", + log: logger, type: .error, hwFmt.sampleRate, hwFmt.channelCount) + return + } audioEngine = engine - inputNode = engine.inputNode - let fmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)! - let hwFmt = inputNode!.outputFormat(forBus: 0) - let converterFmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)! + inputNode = node - inputNode!.installTap(onBus: 0, bufferSize: 4096, format: hwFmt) { [weak self] buffer, _ in + node.installTap(onBus: 0, bufferSize: 4096, format: hwFmt) { [weak self] buffer, _ in guard let self = self, self.recordingActive else { return } // 转换为 16kHz PCM16 guard let cvt = AVAudioConverter(from: hwFmt, to: converterFmt) else { return } @@ -500,12 +516,13 @@ class StsAgent: NSObject { } do { - try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) - try AVAudioSession.sharedInstance().setActive(true) try engine.start() recordingActive = true } catch { os_log("录音启动失败: %{public}@", log: logger, type: .error, error.localizedDescription) + node.removeTap(onBus: 0) + audioEngine = nil + inputNode = nil } } diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift index be99cf88..4c173a65 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift @@ -55,7 +55,11 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate { // 记录最后播放的文本 private var lastSpokenText: String? // 播放相关:队列与播放器 - private var playbackQueue: [Data] = [] + // ⚠️ 必须是加锁队列:append 来自 Azure SDK 的合成回调线程、removeFirst 来自主线程的 + // AVAudioPlayerDelegate 回调、removeAll 来自 Flutter 线程的 stop()。三个线程直接改 + // 同一个 Swift Array,release(-O)下会以 EXC_BAD_ACCESS 崩在 Array 的写时复制上, + // debug 包几乎不复现。 + private let playbackQueue = LockedDataQueue() private var audioPlayer: AVAudioPlayer? private var isPlaying = false private var currentSynthesisBuffer = Data() @@ -1123,8 +1127,7 @@ private func checkStreamPlaybackCompletedIfNeeded() { isPlaying = false audioPlayer = nil } - guard !isPlaying, !playbackQueue.isEmpty else { return } - let next = playbackQueue.first! + guard !isPlaying, let next = playbackQueue.first else { return } let playableData = ensureWavDataForPlayback(next) do { // 先尝试直接用内存数据播放,失败再降级为临时文件播放(兼容性更好) @@ -1990,3 +1993,25 @@ public func dispose() { } } + +// MARK: - 线程安全的播放队列 + +/// 见 AzureTtsHelper.playbackQueue 的说明。接口刻意与 Array 的那几个调用点同名,调用方零改动。 +final class LockedDataQueue { + private var items: [Data] = [] + private let lock = NSLock() + + var isEmpty: Bool { lock.lock(); defer { lock.unlock() }; return items.isEmpty } + var count: Int { lock.lock(); defer { lock.unlock() }; return items.count } + var first: Data? { lock.lock(); defer { lock.unlock() }; return items.first } + + func append(_ data: Data) { lock.lock(); defer { lock.unlock() }; items.append(data) } + func removeAll() { lock.lock(); defer { lock.unlock() }; items.removeAll() } + + /// 空队列时返回 nil 而不是像 Array 那样直接 trap + @discardableResult + func removeFirst() -> Data? { + lock.lock(); defer { lock.unlock() } + return items.isEmpty ? nil : items.removeFirst() + } +} diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift index ec20fdcb..7c2bc1f6 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift @@ -210,6 +210,15 @@ public class MicrophoneCapture: NSObject { case .denied: permission = .denied case .undetermined: + // ⚠️ 主线程上不能用信号量等系统权限弹窗:requestRecordPermission 的回调派发回 + // 主队列,而主线程正被下面的 wait 卡着,回调永远执行不到 —— 界面冻住 60 秒, + // 用户这时切后台就会被看门狗按「无响应」杀掉(TestFlight 上就表现为崩溃)。 + // 主线程改成异步申请、本次直接报错让上层重试。Dart 侧的正常入口本来就先经 + // permission_handler 申请过,走到这里的只会是漏了预检的入口。 + if Thread.isMainThread { + audioSession.requestRecordPermission { _ in } + throw NSError(domain: "麦克风权限尚未授权,请授权后重试", code: 3) + } let semaphore = DispatchSemaphore(value: 0) var grantedResult = false audioSession.requestRecordPermission { granted in diff --git a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/tool/SimpleAudioReceiver.swift b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/tool/SimpleAudioReceiver.swift index c68a6683..dfc7f979 100644 --- a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/tool/SimpleAudioReceiver.swift +++ b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/tool/SimpleAudioReceiver.swift @@ -233,6 +233,15 @@ public class SimpleAudioReceiver: NSObject { // 记录系统采样率(用于调试) let systemSampleRate = recordingFormat.sampleRate os_log("🎤 系统麦克风实际采样率: %f Hz", log: logger, type: .info, systemSampleRate) + + // 0Hz / 0 声道(会话未激活、类别不含录音、路由切换瞬间都会出现)拿去 installTap + // 是 ObjC 断言,Swift 接不住,进程直接崩。这里退出比崩掉好:上层只是这次录不上。 + guard recordingFormat.sampleRate > 0, recordingFormat.channelCount > 0 else { + os_log("音频引擎初始化失败:输入格式无效 sampleRate=%f ch=%d", log: logger, type: .error, + recordingFormat.sampleRate, recordingFormat.channelCount) + self.audioEngine = nil + return + } inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(bufferSize), format: recordingFormat) { [weak self] (buffer, time) in guard let self = self else { return } @@ -280,9 +289,17 @@ public class SimpleAudioReceiver: NSObject { // 记录系统采样率(用于调试) let systemSampleRate = audioFormat?.sampleRate ?? 48000 os_log("🎤 恢复录音 - 系统麦克风实际采样率: %f Hz", log: logger, type: .info, systemSampleRate) + + // 同 initializeAudioEngine:无效格式不能 installTap(ObjC 断言,必崩) + guard let tapFormat = audioFormat, tapFormat.sampleRate > 0, tapFormat.channelCount > 0 else { + os_log("恢复录音失败:输入格式无效", log: logger, type: .error) + isWriting = false + audioEngine = nil + return + } // 安装音频tap - inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(bufferSize), format: audioFormat) { [weak self] buffer, _ in + inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(bufferSize), format: tapFormat) { [weak self] buffer, _ in guard let self = self, self.isWriting else { return } let audioData = self.convertAudioBufferToData(buffer) diff --git a/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart b/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart index 7ef3c0ce..a9213de9 100644 --- a/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart +++ b/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart @@ -51,15 +51,18 @@ class EchoMeetDeviceSdk { /// ★ **这是切换设备名的唯一入口**(协议 §2.1:App 不做 Service UUID 过滤, /// 只按设备名前缀筛)。名字对不上的表现是**扫描页一台设备都不出现,且不报任何错**。 /// - /// 2026-09-10 现状:**在用的支架固件广播名是 `Echomeet`**(用户实测)。 - /// `EaiRec` 是产品改名后的目标名,固件还没刷,先都留着: - /// - 设备刷成 `EaiRec` 之后,这里不用改也能扫到(两个前缀都在); - /// - 真要只认新名,把 `Echomeet` 从列表里删掉即可。 + /// ⚠️ **这是准入白名单,不是兼容性列表**(2026-09-15 定):广播名不在表里的 + /// 固件一律扫不到、连不上,这是**预期行为**而不是 bug。理由——名字对不上的固件 + /// 不是我们的正式产品,服务端那关同样过不了(它的 MAC 不会在 device_mac 里), + /// 连上也没有任何意义,反而让用户以为设备可用。所以在最前面就挡掉。 + /// + /// ⚠️ 旧固件广播名 `Echomeet` **已于 2026-09-15 移除**。刷着旧名字的样机从此 + /// 扫不到也不会自动回连 —— 要继续用必须把固件刷成 `EaiRec`。 + /// 这条如果被人"顺手加回来",准入校验就整个失效了。 static const List defaultNameFilters = [ - 'Echomeet', // ← 当前在用的固件广播名 - 'EaiRec', // ← 改名后的目标名 + 'EaiRec', // 录音支架 'EAIMAR', - 'Pad Note', + 'Pad Note', // 另一条产品线,UI 名 AI Pencil ]; List _nameFilters = List.from(defaultNameFilters); diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index 5137a906..e3194779 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -1,7 +1,7 @@ name: eaimar description: "Eaimar - AI Voice Assistant." publish_to: "none" -version: 1.0.1+7 +version: 1.0.1+10 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/apps/client/test/devices/holder_name_test.dart b/apps/client/test/devices/holder_name_test.dart new file mode 100644 index 00000000..eb921e2e --- /dev/null +++ b/apps/client/test/devices/holder_name_test.dart @@ -0,0 +1,41 @@ +import 'package:eaimar/devices/holder/holder_device_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isAllowedHolderName —— 广播名准入校验', () { + // 名字对不上的固件不是正式产品,服务端的 MAC 校验同样过不了, + // 连上只会让用户以为设备可用、然后在设备管理页找不到它。 + test('规定的名字放行', () { + expect(isAllowedHolderName('EaiRec'), isTrue); + expect(isAllowedHolderName('EAIMAR'), isTrue); + expect(isAllowedHolderName('Pad Note'), isTrue); + }); + + test('大小写与空白不影响判定(固件写法不一定规范)', () { + expect(isAllowedHolderName(' eairec '), isTrue); + expect(isAllowedHolderName('EAIREC'), isTrue); + }); + + test('带批次后缀的广播名放行', () { + expect(isAllowedHolderName('EaiRec-3F2A'), isTrue); + }); + + // ⚠️ 这条是本次改动的重点:旧固件名不再被接纳,也不做归一映射。 + // 归一等于接纳不合规固件、把失败推到服务端再发生一次。 + test('旧固件名 Echomeet 一律拒绝', () { + expect(isAllowedHolderName('Echomeet'), isFalse); + expect(isAllowedHolderName('echomeet'), isFalse); + expect(isAllowedHolderName('Echomeet-3F2A'), isFalse); + }); + + test('空名字拒绝,不要放行', () { + expect(isAllowedHolderName(''), isFalse); + expect(isAllowedHolderName(' '), isFalse); + }); + + test('无关设备拒绝', () { + expect(isAllowedHolderName('Echo-one'), isFalse); + expect(isAllowedHolderName('Smartcar'), isFalse); + }); + }); +} diff --git a/apps/client/test/devices/smartcar_name_test.dart b/apps/client/test/devices/smartcar_name_test.dart new file mode 100644 index 00000000..2d78ec65 --- /dev/null +++ b/apps/client/test/devices/smartcar_name_test.dart @@ -0,0 +1,30 @@ +import 'package:eaimar/devices/smartcar/smartcar_link.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isAllowedSmartcarName —— 车载香薰广播名准入', () { + test('规定的名字放行', () { + expect(isAllowedSmartcarName('EaiCar'), isTrue); + expect(isAllowedSmartcarName(' eaicar '), isTrue); + expect(isAllowedSmartcarName('EAICAR-3F2A'), isTrue); + }); + + // ⚠️ 旧广播名不再接纳。厂商 ID(0x8505) 也不再单独放行——它只说明 + // "这是云衍方案的设备",说明不了"这是我们的正式产品"。 + test('旧名 Smartcar 一律拒绝', () { + expect(isAllowedSmartcarName('Smartcar'), isFalse); + expect(isAllowedSmartcarName('smartcar'), isFalse); + expect(isAllowedSmartcarName('SmartcarX'), isFalse); + }); + + test('空名字拒绝', () { + expect(isAllowedSmartcarName(''), isFalse); + expect(isAllowedSmartcarName(' '), isFalse); + }); + + test('别家设备拒绝', () { + expect(isAllowedSmartcarName('EaiRec'), isFalse); + expect(isAllowedSmartcarName('Echo-one'), isFalse); + }); + }); +} diff --git a/docs/AI助手中枢-设计与落地文档.md b/docs/AI助手中枢-设计与落地文档.md new file mode 100644 index 00000000..0e93956b --- /dev/null +++ b/docs/AI助手中枢-设计与落地文档.md @@ -0,0 +1,602 @@ +# AI 助手中枢:翻译入纪要 · 纪要入拾忆 · EMAI 综合入口 —— 设计与落地文档 + +> 版本:v0.2  日期:2026-09-15(v0.1 同日草案;v0.2 按现有服务器架构复核:五进程同容器、mcp 无 Redis、RPC 转发写法、模板按应用隔离、部署只发模板,见 §2.1,并据此改了 §3.4/§3.5/§4.2a/§5.4/§5.5/§6.4) +> 范围:`apps/services`(后端 home/mcp)、`apps/client`(Flutter)、百炼控制台配置 +> 状态:**设计稿,未动代码**。三条需求的现状全部按当前 `along` 分支逐文件核过,引用带路径与行号。 +> 前置文档:[记忆中心-设计与开发文档](记忆中心-设计与开发文档.md)(v0.3,代码已落地);本文在它之上演进,与它冲突的地方在 §5.2 明说。 + +--- + +## 零、一句话 + +三条需求不是三个功能,是**一条数据链的三段**: + +``` + 翻译会话(同传/面对面/通话/音视频) ──①──▶ 语音纪要(echomeet_record) ──②──▶ 拾忆时间线(memory_item) + 现场录音 / 通话录音 / 支架 TF 卡 ──已通──▶ │ │ + │ 待办抽取(已通) │ + ▼ ▼ + ③ EMAI 综合入口:MCP 查(纪要/事项/统计/报告) + 工具写(待办/闹钟/花销) + 端侧指令(启动功能/设备控制) + ▲ + 耳机唤醒 / 支架 / 车载香薰 / App 对话页 —— 同一个会话持有者、同一套 MCP 身份 +``` + +- ① 翻译转写**已经在客户端手里**(`TranslationItem` 列表),差的只是把它送进纪要并「只总结、不转写」。服务端这条通道**已经存在但从没被调用过**(§3.1)。 +- ② 纪要目前只把「待办」抽进拾忆,**会议本身不在时间线上**;翻译会话更是连服务端都没到过。做法是给拾忆加一个 `meeting` 分类的**索引项**,正文不复制(§4)。 +- ③ EMAI 已有 MCP 读工具与 JWT 身份链路,缺的是**写工具**、指令清单、与多设备/多 agent 的统一口径(§5)。最大的一处改口径:**记忆类写操作从「端侧指令」改走「MCP 服务端工具」**,理由见 §5.2。 + +--- + +## 一、现状盘点(三段的地基) + +### 1.1 段 ①:翻译会话在客户端的形态 + +| 项 | 现状 | 位置 | +|---|---|---| +| 模式 | 四个裸字符串 `simultaneous` / `faceToFace` / `audioVideo` / `call`,无枚举 | [translation_controller.dart:160](../apps/client/lib/modules/translation/controllers/translation_controller.dart#L160) | +| 转写条目模型 | `TranslationItem{sourceText, translatedText, sourceLanguageCode, targetLanguageCode, timestamp, sessionId, isFirstInSession, isIntermediate, utteranceId, serviceId('A'/'B'), …}` | [translation_models.dart:6](../apps/client/lib/modules/translation/models/translation_models.dart#L6) | +| 持久化 | **GetStorage**,键 `translation_history_`,每模式一份,上限 1000 条,无 TTL,**无服务端副本** | [translation_history_controller.dart:14-24](../apps/client/lib/modules/translation/controllers/translation_history_controller.dart#L14) | +| 会话概念 | 只有 `sessionId`(毫秒时间戳串)+ `isFirstInSession`;没有会话对象、没有起止时间 | [translation_controller.dart:1419](../apps/client/lib/modules/translation/controllers/translation_controller.dart#L1419) | +| 说话人 | 通话模式 `serviceId` A=己方 / B=对方(**不落盘**);面对面 `activeSpeaker` 1/2 只存在于控制器内存;同传单说话人 | `:1754`、`:2215` | +| 音频 | 只有 `call` / `simultaneous` 在用户点了录音键时录 WAV 到 `TranslatAudio/`(16k 双声道,左=本地麦,右=对端 TTS);停止时 `RecordingArchive.archive()` **只把音频**送进纪要 | [translation_controller.dart:1150-1192](../apps/client/lib/modules/translation/controllers/translation_controller.dart#L1150),`_shouldArchiveRecording` 明确排除 faceToFace/audioVideo | +| 转写 ↔ 音频 | **零关联**:`sessionId` 与 WAV 文件名是两个独立时间戳,条目上没有音频偏移 | — | +| 已有出口 | 历史列表只有「复制 / 删除」;详情页有 `Share.share` 纯文本;没有「生成纪要」 | [translation_long_press_menu.dart:68](../apps/client/lib/modules/translation/views/dialogs/translation_long_press_menu.dart#L68)、[translation_detail_view.dart:745](../apps/client/lib/modules/translation/views/translation_detail_view.dart#L745) | +| 服务端 | `user_translate` 无状态不落库;`user_usages` 只报秒数;**没有任何翻译文本表** | [api_translate.go:25](../apps/services/modules/user/api_translate.go#L25)、[api_usages.go:52](../apps/services/modules/user/api_usages.go#L52) | + +### 1.2 段 ②:语音纪要流水线(服务端 echomeet) + +| 项 | 现状 | 位置 | +|---|---|---| +| 记录表 | `DBEchoMeetRecord`:`original` / `translate` 两列都是 **`[]ContextStruct{Meetingid, Content, Starttime, Endtime, Speaker}` 的 JSON 串**,`overview` 短总结,`summary` 模板纪要,`asr/mt/llm_svc_id` 三路选路结果 | [echomeet_db.proto:38-78](../apps/proto/echomeet/echomeet_db.proto#L38) | +| 状态机 | `Unknow(0)→AwaitTranscribing(1)→Transcribing(2)→AwaitSummarizing(3)→Summarizing(4)→Completed(5)→Readed(6)`;失败 `10001/10002` **数值大于 Completed** | 同上 `:7-18` | +| 建记录 | `echomeet_addrecord`:**`original` 字段已存在**(`repeated ContextStruct`),传了就写 `Original` 并镜像到 `Translate`,状态留 `Unknow` | [api_addrecord.go:49-59](../apps/services/modules/echomeet/api_addrecord.go#L49) | +| 起任务 | `echomeet_starttask`:VIP 闸门 → 按 `Seconds` 计算力 → 解析模板 → 选路;**`rtype=="VOICETRANSLAT"` 分支跳过 ASR**,直接 `TranslateProcess` + `SubmitAITask`,且不要求 `audiourl` | [api_starttask.go:135-152](../apps/services/modules/echomeet/api_starttask.go#L135) | +| 总结输入 | `AIProcess` 取 `translate`(空则 `original`)拼成 `[speaker]:content` 行,加 ``,两路并发出 `summary`/`overview` | [tasks.go:481-622](../apps/services/modules/echomeet/tasks.go#L481) | +| 总结后 | 起 goroutine 调 `IMemory.ExtractMeetingTodos`(待办抽取,第三路) | [tasks.go:621-644](../apps/services/modules/echomeet/tasks.go#L621) | +| 默认模板 | **服务端没有「默认模板」概念**;客户端取 GetStorage `meeting_last_template_Data`,没有就列表第 0 项 | [generate_bottom_sheet.dart:70-77](../apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart#L70) | +| 会议时间 | 服务端 `Creationtime = time.Now()`,**忽略客户端上报**;客户端 `recording_time.dart` 已按「服务端将来接受」的形状送了 | [api_addrecord.go:31](../apps/services/modules/echomeet/api_addrecord.go#L31) | +| 客户端归档入口 | `RecordingArchive.archive({path, seconds, tag, notify, createdAt})`:**只收音频**,标题=文件名,没有转写、没有场景参数 | [recording_archive.dart:38](../apps/client/lib/data/services/recording_archive.dart#L38) | + +**结论:「已转写 → 只总结」的服务端通道已经存在,客户端一次都没用过**(全仓 `VOICETRANSLAT` 只出现在 `api_starttask.go:135` 一处,没有任何写入方)。 + +### 1.3 段 ②→③:纪要有没有「给到拾忆」 + +只给了一半: + +| 内容 | 是否进拾忆 | 方式 | +|---|---|---| +| 纪要里的待办 | ✅ | `meeting_extract.go` 抽成 `category=todo, source=meeting, source_id=记录id` | +| 会议本身(时间、标题、时长、概览) | ❌ | 不在 `memory_item`,拾忆时间线上看不到「今天开了什么会」 | +| 翻译会话 | ❌ | 连服务端都没到 | +| 现场录音 / 通话录音 / 支架 TF 卡 | 进纪要 ✅,进拾忆 ❌ | 同会议本身 | + +`memory_item` 的分类白名单是 `alarm/todo/idea/expense`([comm/memory.go:23-28](../apps/services/comm/memory.go#L23)),`source` 已有 `meeting`。 + +### 1.4 段 ③:EMAI 现状 + +| 项 | 现状 | 位置 | +|---|---|---| +| 链路 | 客户端 **直连**百炼 `multimodal-dialog` WebSocket,服务端不在对话链路上 | [bailian_multimodal_service.dart:113](../apps/client/lib/data/services/bailian_multimodal_service.dart#L113) | +| 身份 | `client_info.user_id = uid`(游客回退 `eaimar-guest`);MCP 身份靠 `user_getmcptoken` 签的 2h 令牌,**按工具名嵌套**塞进 `biz_params.user_defined_params` | `:277-301`、[api_getmcptoken.go](../apps/services/modules/user/api_getmcptoken.go) | +| 需要令牌的工具清单 | 客户端**写死** `_mcpAuthTools`(7 个),服务端加工具必须同步改 | `:583-591` | +| 跨会话记忆 | `dialog_id` 由 Started 事件给、只在本连接内回传,**不持久化**;每次重连是新对话。聊天记录本地 200 条 | `:399,474`、[emai_chat_store.dart](../apps/client/lib/data/services/emai_chat_store.dart) | +| 端侧指令 | 只有 `SET_clock` 落地(双写本地 + `memory_add`);`SEND_message`/`MAKE_A_PHONE_CALL` 在 `_declined`;其余 `unsupported`。**模块注册表的 `directives` 机制接好了但没有任何模块声明** | [assistant_directive_service.dart:62-134](../apps/client/lib/data/services/assistant_directive_service.dart#L62)、[module_registry.dart:88](../apps/client/lib/modules/module_registry.dart#L88) | +| MCP 读工具(已启用) | GLOBAL:`get_memory_items` / `get_memory_stats` / `get_memory_report` / `search_meeting_notes` / `hefeng_weather` / `get_user_tasks` / `cancel_user_task`;CHINA/OVERSEAS:地图 + 联网搜索 | [modules/mcp/](../apps/services/modules/mcp/) | +| MCP 鉴权 | 已落地:header JWT 或 `auth_token`(aud=mcp)解 uid,参数 uid 不一致直接拒,无密钥则全拒 | [auth.go:92-162](../apps/services/modules/mcp/auth.go#L92) | +| MCP 写工具 | **一个都没有**(`add_user_task` 代码里根本没 `AddTool`,是死的) | [tool_allhelp_task.go:31-35](../apps/services/modules/mcp/tool_allhelp_task.go#L31) | +| 知识库 | **没有**。纪要检索是自家 MySQL ngram 全文(词面匹配),换向量方案的触发条件写在工具头注释里 | [tool_meeting.go:21-53](../apps/services/modules/mcp/tool_meeting.go#L21) | +| 多 agent | EMAI 与 Smartcar 车载助手是**两个百炼应用**(`bailianAppId` 在 data 层 `AgentModule`),设备唤醒按厂商品类选;共用 `EmaiController`/`BailianMultimodalService`/`AssistantDirectiveService` | [agent_module.dart:52](../apps/client/lib/data/models/agent_module.dart#L52)、[device_ai_session_service.dart:52-72](../apps/client/lib/data/services/device_ai_session_service.dart#L52) | +| 后台 | `agent_config` 表两台机都是 0 行,页面 9-12 已删;**百炼 app_id/工具绑定当前不受后台管**,在百炼控制台 + 客户端常量里 | [menus.ts:68-71](../apps/admin/app/utils/menus.ts#L68) | + +#### ⚠️ MCP 工具注册的四处名字对不上(顺手要修,否则新工具也会同样静默失效) + +`AddTool` 只登记 `Groups` 里点了名的工具([module.go:163-171](../apps/services/modules/mcp/module.go#L163)),名字对不上就**不报错、直接不暴露**: + +| YAML 里写的 | 代码里的真名 | 后果 | +|---|---|---| +| `add_user_task` | 代码 `Start()` 没调 `AddTool` | 永远不暴露;客户端 `_mcpAuthTools` 里还列着它 | +| `gaode_map_route_navigation` / `google_map_route_navigation` | `maps_route_navigation` / `maps_google_route_navigation` | 导航工具从未启用 | +| `music_search` / `music_play` | `music_search_play`(CHINA)/ `migu_music_search`(CHINA)/ `spotify_music_play`(未装配) | 音乐工具从未启用 | +| (缺)`gaode_map_weather` / `finance_query` | 代码归 CHINA 组 | 从未启用 | + +([deploy/app/confs/mcp.yaml.example:50-73](../../deploy/app/confs/mcp.yaml.example#L50)) + +--- + +## 二、架构约束(决定实现方式的硬事实)与设计原则 + +### 2.1 现有架构里与本文直接相关的 9 条 + +| # | 事实 | 对本设计的约束 | +|---|---|---| +| A1 | gateway / home / api / mcp / timer **五个进程打在一个镜像、一个容器**,entrypoint 见任一 pid 退出就杀全部([entrypoint.voitrans.sh:46-48](../apps/services/entrypoint.voitrans.sh#L46)) | 新增模块方法的 `Init` **不能返回 error**;存量回填放 `Start` 里起 goroutine;任何建表/回填失败只记日志 | +| A2 | echomeet 与 memory **同在 home 进程**,互相通过 `comm.IEchomeet` / `comm.IMemory` 接口调用,不 import 对方([comm/module.go:143](../apps/services/comm/module.go#L143)) | 需求 ② 的四个钩子是进程内接口调用,**不走 RPC、不走队列** | +| A3 | mcp 是**独立进程**(rpcx 集群服务,端口 7300 HTTP / 7004 RPCX),**只配了 MySQL,没有 Redis、没有 Postgres**([mcp.yaml.example](../../deploy/app/confs/mcp.yaml.example)) | MCP 侧不能做基于 Redis 的幂等/限流;**写操作不在 mcp 进程里实现** | +| A4 | gateway 转发业务请求的方式是 `RpcCall(ctx, "<服务名>", Rpc_GatewayHttpRoute, {MsgName, Meta{userid,ip}, Message})`,home 侧 `SCompHttpRoute.Rpc_GatewayHttpRoute` 按 `MsgName` 反射分发、从 `Meta.userid` 建会话、**过一遍拦截器**([wservice_comp.go:231](../apps/services/modules/gateway/wservice_comp.go#L231)、[comp_httproute.go:103-135](../apps/services/services/comp_httproute.go#L103)) | mcp 同为 rpcx 服务,**可以用完全相同的调用当「内部网关」**调 home 的 `memory_add/update/complete/del`——写逻辑零复制(§5.4) | +| A5 | 服务间靠 ETCD 发现,`CLUSTER_TAG` 按应用隔离;每个应用一套容器、一套业务 MySQL、一个 Redis 前缀、一个对外 MCP 域名(测试 `mcp-dev.ymaikj.com`、正式 `mcp.ymaikj.com`,正式反代 9-12 已建) | 百炼应用 ↔ 部署一一对应;`MCP_ADDR` 已是外网域名,三期不需要新的网络工作 | +| A6 | `dev-deploy.sh` / `prod-deploy.sh` **只下发 `*.example` 模板**,服务器上的真实 `confs/*.yaml` 与 `.env` 要手工改([deploy/app/README.md](../../deploy/app/README.md)) | `mcp.yaml` 的 `Groups` 加工具名、`home.yaml` 的 memory 段,**两台机各改一次并重启容器**;本文不新增 `.env` 变量(复用 `MCP_TOKEN_KEY`/`GATEWAY_TOKEN_KEY`) | +| A7 | 算力系数存**业务库 `config` 表**,`comm.LoadComputeRates` 30s 缓存;console 「会员与算力」页用**应用连接**读写([api_compute.go:29-73](../apps/services/modules/console/api_compute.go#L29),注释明写 console 不能用包级 mysql) | 新系数 `COMPUTE_RATE_SUMMARY` 三处同改:`comm/compute.go` 常量与 `ComputeRates`、`api_compute.go` 的 key 列表、`compute.vue` 一行 | +| A8 | 会议公共模板在 **Postgres 公共库、按 `app_name` 隔离**([echomeet/model.go:101-121](../apps/services/modules/echomeet/model.go#L101)),新应用要先在后台「从其它应用复制」 | 默认模板解析必须带 `comm.AppName()`;查不到时自动链路**只建记录不起任务**并记一条日志,不能让翻译结束流程报错 | +| A9 | 客户端登录时 `Synchrodata` 用 `echomeet_getallrecords` **拉全量整行**(含 `original/translate/summary`),服务端有 `getrecordbriefforuid` 但没接线([model.go:232](../apps/services/modules/echomeet/model.go#L232)) | 翻译会话自动进纪要会让记录数上一个量级;二期要把 `getallrecords` 换成 brief + 按需拉详情(§4.5) | + +### 2.2 设计原则(三段共用) + +1. **不新造第二套「记录」**。翻译会话进纪要就是一条 `echomeet_record`,进拾忆就是一条 `memory_item`;不建 `translation_session` 表、不建 `timeline` 表。 +2. **服务端为真相源,客户端本地是缓存**。翻译历史现在只活在 GetStorage,重装即丢;进纪要之后自然有了服务端副本,这是副产品不是目标。 +3. **拾忆存索引不存正文**。`memory_item.category=meeting` 只放标题/时间/时长/概览摘要 + 指回记录的 `source_id`,正文永远在 `echomeet_record`。两处正文必然漂移。 +4. **能让模型看到结果的写操作走 MCP,看不到结果的设备动作走端侧指令**(§5.2)。 +5. **失败隔离不变**:任何附赠链路(进拾忆、抽待办、入索引)失败只记日志,不改主记录状态。 +6. **凡是「名字对不上就静默失效」的地方补一道启动自检**(MCP 工具组、令牌工具清单)。 + +--- + +## 三、需求 ①:翻译会话 → 语音纪要(只总结) + +### 3.1 复用隐藏通道,把它做成正式契约 + +现有两步已经能跑: + +``` +echomeet_addrecord {rtype:"VOICETRANSLAT", original:[ContextStruct…], seconds, title} + → 记录落库,Original 写入、Translate 镜像,state=Unknow +echomeet_starttask {id, formlanguage, tolanguage, tid|templateid} + → VOICETRANSLAT 分支:跳过 ASR → TranslateProcess(同语种直接跳)→ SubmitAITask → summary/overview +``` + +**不新增接口族**,只把这条路补齐四处缺口(每处都是现在会踩的坑): + +| # | 缺口 | 改法 | +|---|---|---| +| a | `addrecord` 只收 `original`;翻译会话**两种语言的文本都现成**,再让服务端 MT 一遍是白花钱 | `EchomeetAddRecordReq` 加 `translate repeated ContextStruct`。传了就写 `Translate`,不再镜像 `Original` | +| b | `TranslateProcess` 的同语种短路 `rec.Translate = rec.Original`([tasks.go:420-424](../apps/services/modules/echomeet/tasks.go#L420))会把 a 传上来的 `Translate` **抹掉** | VOICETRANSLAT 分支改成:`Translate` 非空且 ≠ `Original` → **跳过** `TranslateProcess`;否则按原逻辑 | +| c | 没有幂等键。客户端会话结束时网络抖动重试就插两条 | `EchomeetAddRecordReq` 加 `client_key`(= 翻译 `sessionId`),`DBEchoMeetRecord` 加列 + `(uid, client_key)` 唯一索引;命中返回已存在记录而非报错。**空串要现生成**(同 `memory_item.client_key` 那条教训) | +| d | `Creationtime` 服务端写死 `time.Now()`;`rtype` 只有 4 个值,列表里分不清「同传」还是「通话」 | 加 `meet_time int64` + `tz string`(客户端上报,服务端只校验 2020 < t ≤ now+1d,否则退 now)+ `scene string`(`simultaneous/faceToFace/call/audioVideo/fieldRecording/callRecording/holder/import`)。`rtype` 语义收窄为「服务端怎么处理」,`scene` 负责「从哪来」 | + +`EchomeetStartTaskReq` **不改**。默认模板见 §3.6。 + +### 3.2 客户端:会话结束 → 组转写 → 建记录 → 触发总结 + +挂点:`stopRecognition()`([translation_controller.dart:2004](../apps/client/lib/modules/translation/controllers/translation_controller.dart#L2004))与 `_stopFaceToFaceRecognition()`(`:587`)——这两处是 `currentSessionId` 置空的地方,**四种模式**都经过,不再区分 `_shouldArchiveRecording`。 + +``` +会话结束 + ├─ 取本会话条目:_historyManager 里 sessionId == currentSessionId 且 !isIntermediate + ├─ 门槛:条目 ≥ 3 且 有效时长 ≥ 30s,否则不建记录(避免 10 秒试麦也进列表) + ├─ 组 original / translate(规则见 3.3) + ├─ 新的 TranscriptArchive.archive({scene, sessionId, items, audioPath?, seconds, meetTime, tz}) + │ ├─ echomeet_addrecord {rtype:VOICETRANSLAT, scene, client_key:sessionId, original, translate, seconds, title, meet_time, tz} + │ ├─ SqfliteApi.insertMeeting + meetingspeaker(本地立刻可见,不等 Synchrodata) + │ ├─ 有 WAV → MeetingUploadService.addUpload(id, path, 'TranslatAudio')(scene 白名单里已有) + │ └─ 「翻译结束后自动生成纪要」开关开 → MeetingTaskService.submitTask(id, formlanguage, tolanguage, isSpeaker, templateid:0, tid:'') + └─ 提示条「已存入语音纪要」,点击进详情页(复用 RecordingArchive 的 notify 样式) +``` + +- `TranscriptArchive` 与现有 `RecordingArchive` 并列放在 `lib/data/services/`,**不改 `RecordingArchive` 的签名**——它有 4 个音频调用方,加转写参数会把它变成两个函数揉一个。 +- 有 WAV 时 `audiourl` 由上传服务事后 `uprecord` 补上,与现有链路一致;**总结不等音频**(VOICETRANSLAT 分支不查 `audiourl`)。 +- `MeetingTaskService.addTask` 现有逻辑「`audiourl` 为空就先挂起、等上传完再提交」([meeting_task_service.dart:57-100](../apps/client/lib/data/services/meeting/meeting_task_service.dart#L57))对这条链路是**反的**:转写记录不需要音频。要么绕过 `addTask` 直接 `submitTask`,要么给 `addTask` 加 `requiresAudio:false`。选后者,一个布尔参数,默认 true 不影响老调用方。 + +### 3.3 说话人 / 语言 / 时间戳的归一规则 + +| 字段 | 规则 | +|---|---| +| `Speaker` | 通话:`A`→`Speaker_1`(本人)、`B`→`Speaker_2`(对方);面对面:`activeSpeaker` 1/2 → `Speaker_1/2`;同传/音视频:全部 `Speaker_1`。**统一下划线**(服务端异步转写路径就是 `Speaker_`+id,[tasks.go:675](../apps/services/modules/echomeet/tasks.go#L675);同步路径的 `Speaker %s` 带空格是既有不一致,顺手统一成下划线) | +| `original[i].Content` | `sourceText`(允许逐条语种不同——面对面就是交替的) | +| `translate[i].Content` | 归一到**一种语言** `tolanguage`(= 用户在翻译页设定的目标语,通话/面对面取「本人语种」):`sourceLanguageCode == tolanguage` 用 `sourceText`,`targetLanguageCode == tolanguage` 用 `translatedText`,两者都不是(理论上不会)退 `translatedText` | +| `Starttime/Endtime` | 毫秒偏移。有 WAV:相对录音开始时刻(需在 `startRecording` 时记下 `_recordingStartedAt`);无 WAV:相对首条 `timestamp`。`Endtime` = 下一条的 `Starttime`,末条 +3000 | +| `Meetingid` | 服务端回填,客户端填 0 | +| `formlanguage/tolanguage`(starttask) | 都传 `tolanguage`。这样服务端同语种判断为真,且 b 处改法保证不清空 `Translate` | +| `title` | `<模式名> `,41 语言用现有模式标题 key;不再用文件名当标题 | +| `seconds` | 有 WAV 用录音秒数;无 WAV 用 `末条 timestamp − 首条 timestamp`,**再取与 VAD 有效时长(`_recordAsrUsageStats` 那套)的较小值**——计费依据,不能虚高 | + +⚠️ `serviceId`、`activeSpeaker` 现在**不落盘**(`toJson` 丢弃),所以这套归一**只能在会话结束当场做**,不能事后从历史里补做。要支持「历史会话补生成纪要」得先把 `speaker` 字段加进 `TranslationItem.toJson`——这是一个小改动,建议一并做,否则「历史→纪要」入口只能全标 `Speaker_1`。 + +### 3.4 计费与闸门 + +`starttask` 按 `Seconds × ForMeeting` 计算力([api_starttask.go:60-99](../apps/services/modules/echomeet/api_starttask.go#L60))。翻译会话在 `user_usages` 里**已经按秒计过一次翻译算力**,再按会议全价算一次是重复计费,且这次没有 ASR 成本。 + +**决策:新增用量类型 `ComputeUsageSummary`,系数键 `COMPUTE_RATE_SUMMARY`(算力/分钟),后台「会员与算力」页多一行**。`starttask` 在 `rtype==VOICETRANSLAT` 时用它,其余不变。默认值建议 = 会议系数的 1/2,产品可调。 + +落点按 A7:`comm/compute.go` 加常量 + `ComputeRates.Summary` + `ForSummary()`(与 `ForMeeting` 同形:向上取整到分钟、最低 1);`console/api_compute.go` 的 key 列表与 `computeRatesView` 各加一项;`admin/app/pages/compute.vue` 加一行。`config` 表没这一行时**回默认值**(与其它系数同口径,计费不能因配置表缺行而中断)。 + +- 流水与埋点**不加新维度**:`DBUserUseLog.Addmeetsecond` 照记(它就是「纪要秒数」台账),`analyze` 仍报 `StatEventMeeting`——后台看板的「会议」列语义变成「纪要生成」,比改 `writeEvent` 的固定 switch + console 看板列划算。写进看板说明即可。 +- VIP 闸门(`VipRequired 5302`)**照旧**:非 VIP 只得到「记录 + 转写」,不生成总结;详情页的「生成纪要」按钮照常引导续费。 +- ⚠️ 顺带发现:`echomeet_summary`(重新总结)**完全不过 VIP 和算力闸门**([api_summary.go](../apps/services/modules/echomeet/api_summary.go) 里没有 `ResolveUserVip`/`ApplyComputeUsage`)。这是既有漏洞,本次不修但要记:自动化之后「重新总结」的调用量会上来。 + +### 3.5 默认模板 + +服务端没有默认模板概念(§1.2)。自动链路没有 UI 让用户挑,需要一个确定的兜底: + +**`starttask` / `summary` 在 `templateid==0 && tid==""` 时,服务端按 `tolanguage` 解析默认模板**:优先本应用(`app_name = comm.AppName()`,A8)`echomeet_template` 里 `source='public' && language 匹配 && sort 最大` 的那条(= 客户端列表的第 0 项,两边口径一致),语言退化沿用 `model_cache.go` 的 base-lang 规则。客户端自动链路 `templateid:0, tid:''`;手动链路照旧传用户挑的。 + +- 解析走 `cache` 组件的内存快照(模板缓存本就存在,NATS `Rpc_ModifyEchomeetTemplate` 热更),不加查库。 +- **本应用一条公共模板都没有时**(新部署没复制模板):`starttask` 返回 `TemplateNotFound`,客户端自动链路把它当成「记录已建、总结未起」静默处理并记日志,详情页「生成纪要」照常可手动重试。翻译结束流程**不能因此弹错**。 +- 以后要在后台加「默认模板」勾选,只改这个解析函数,客户端不用动。 + +### 3.6 客户端展示与老包兼容 + +- 详情页「转写」Tab 现在 `tasktype >= 3` 才解锁([meeting_details_view.dart:354-393](../apps/client/lib/modules/meeting/views/meeting_details_view.dart#L354)),转写记录建好时 state 还是 `Unknow(0)`,转写看不到。改成 `tasktype >= 3 || textList.isNotEmpty`。 +- 列表按 `scene` 显示来源图标(同传/面对面/通话/音视频/录音/支架/导入),`rtype` 不再参与展示。 +- 无音频的记录:播放器区域隐藏,不要放一个点了报错的播放条。`Synchrodata` 已能处理 `audiourl` 空串。 +- **老包**:多出来的 `scene/meet_time/tz/client_key` 字段被手写 `fromJson` 忽略,无害;老包看到新记录 `type=VOICETRANSLAT`,上传桶选择 `type == 'LOCAL' ? … : 'ExternalAudio'` 落到 ExternalAudio,但老包不会给这类记录上传音频,无影响。 +- 翻译历史页保留,但每个会话卡片加「查看纪要」(本地 sqflite 里按 `client_key` 反查记录 id)。历史列表长按菜单加「生成纪要」(对 3.3 的 ⚠️ 有依赖)。 + +### 3.7 设置项 + +「翻译结束后自动生成纪要」:默认**开**;关闭时只建记录不起任务。存 GetStorage,不需要服务端。 + +--- + +## 四、需求 ②:纪要 → 拾忆时间线 + +### 4.1 设计:`category=meeting` 的索引项 + +在 `memory_item` 加分类 `meeting`([comm/memory.go](../apps/services/comm/memory.go) 白名单加一行,不改表): + +| 字段 | 取值 | +|---|---| +| `category` | `meeting` | +| `source` / `source_id` | `meeting` / 记录 id(与待办抽取同一对键,拾忆里「来自同一场会」能关联) | +| `client_key` | `meeting:<记录id>`(服务端生成,天然幂等) | +| `title` | 记录标题 | +| `detail` | `overview`(短总结,≤ 500 字截断;`summary` 全文**不放**) | +| `happen_date` / `happen_time` / `tz` | 由 `meet_time` + `tz` 折算(§3.1 d;没有 `meet_time` 的存量记录用 `creationtime` + `Asia/Shanghai`,与待办抽取现状同口径) | +| `state` | 0;**不参与「完成」语义**,客户端不显示勾选框 | +| `remind_ahead` | 0(不提醒) | +| `extra` | `{"scene":"call","seconds":1830,"rec_state":5,"has_audio":true}` | +| `user_edited` | 恒 false;这类项**只读**(编辑入口跳会议详情页) | + +**为什么不让拾忆页直接查 `echomeet_record`**:`memory_list` / `memory_today` / `memory_stats` / 周报月报 / `get_memory_items` 全部只读一张表;再加一个数据源意味着日期过滤、时区、分页、MCP 工具各写两份。索引项一行 200 字节,代价可忽略。 + +### 4.2 同步钩子(都在 home 进程内,走 `comm.IMemory`,不跨服务) + +`IMemory` 加两个方法([comm/module.go:143](../apps/services/comm/module.go#L143)): + +```go +// UpsertMeetingIndex 会议索引项写入/更新;幂等键 meeting:。失败只记日志。 +UpsertMeetingIndex(ctx, uid, recordID, title, overview, meetTime int64, tz, scene string, seconds int64, recState int32) +// RemoveBySource 删除来源为 source_id 的索引项(不删待办:那些已经是用户自己的任务)。 +RemoveBySource(ctx, uid, source, sourceID string) +``` + +| 时机 | 位置 | 动作 | +|---|---|---| +| 建记录 | `api_addrecord.go` 末尾 | Upsert(此时 overview 空、rec_state=0) | +| 总结完成 | `tasks.go AIProcess` 置 `Completed` 之后,与 `extractMemoryTodos` 并排 | Upsert(带 overview、rec_state=5) | +| 改标题 | `api_modifyrecord.go` | Upsert 标题 | +| 删记录 | `api_delrecords.go` | `RemoveBySource`;**抽出的待办保留**,只把它们的 `source_id` 留着(拾忆里显示「来源会议已删除」) | +| 转写/总结失败 | `tasks.go` 置 `TranscribeFail/SummarizFail` | Upsert `rec_state`,拾忆卡片显示「生成失败」而不是永远「生成中」 | + +存量记录:memory 模块 `Start` 里起 goroutine 跑一次幂等回填(照 `migrate_task.go` 的写法,按 uid 分批、每批 200 条、批间 sleep 100ms;`client_key` 唯一索引保证重跑不重插;失败只记日志——A1,绝不能让 home 起不来)。回填只读 `echomeet_record` 的 id/title/overview/creationtime/state/seconds 六列,**不要 `SELECT *`**(`original/translate/summary` 三列每行可达几十 KB)。 + +### 4.2a 性能与列表接口(A9) + +翻译会话自动进纪要后记录数会翻几倍。`Synchrodata` 现在每次登录 `echomeet_getallrecords` 拉全量整行(含三个大文本列)。二期一并做: + +- `echomeet_getallrecords` 加 `brief:true` 参数 → 走已有但未接线的 `getrecordbriefforuid`(不返回 `original/translate/summary`); +- 客户端 `Synchrodata` 改成 brief 同步 + 打开详情时 `echomeet_getrecord` 补拉;老包不传 `brief` 行为不变。 +- 拾忆页读的是 `memory_item` 索引项,本来就不碰大文本列,不受影响。 + +### 4.3 客户端拾忆页 + +- `CalendarEventCategory` 加 `meeting`;卡片:来源图标 + 标题 + 时长 + 状态 chip(转写中 / 已生成 / 失败)+ overview 前两行;点击 → `Routes.meetingDetails`(记录 id 从 `source_id`)。 +- 筛选条加「会议」。 +- **未知分类兜底已在**(记忆中心 §3.3),所以老包收到 `meeting` 分类不会崩,只是按 todo 卡片显示——可接受。 +- 每日弹窗/播报:`memory_today` 把 `meeting` 项**排除在播报文案之外**(播「今天有一场 30 分钟的通话翻译」没有意义),列表里照常显示。 + +### 4.4 报告与 MCP + +- `stat_json` 加 `meeting_count` / `meeting_seconds`;报告 prompt 的「依次覆盖」加一句「会议与录音场次」。 +- `get_memory_items` 的 `category` 枚举加 `meeting`;EMAI 问「我这周开了几次会」直接从 `get_memory_stats` 答;问内容仍走 `search_meeting_notes`。 +- 新增 `get_meeting_detail(record_id)`:拿到 overview 之后追问「第二点具体说了什么」用,返回 `summary` 全文(截断 4000 字)。 + +--- + +## 五、需求 ③:EMAI 综合入口 + +### 5.1 中枢模型 + +``` + ┌───────────────── 客户端(会话持有者)──────────────────┐ + App 对话页 ─────┤ EmaiController │ + 耳机按键唤醒 ───┤ DeviceAiSessionService ──▶ BailianMultimodalService ─┼──WS──▶ 百炼应用(EMAI / Smartcar) + 支架 / 香薰 ────┤ │ │ RespondingContent │ │ 工具调用(带 auth_token) + │ │ ▼ │ ▼ + │ │ AssistantDirectiveService │ 我们的 MCP(7300) + │ │ ├─ 设备/功能类指令 → 端侧执行 │ ├─ 读:纪要/事项/统计/报告/天气/地图/联网 + │ │ └─ tool_infos → 触发本地刷新 │ └─ 写:待办/闹钟/灵感/花销/完成/取消 + └────────┴────────────────────────────────────────────────┘ │ 直查业务库(uid 来自令牌) + ▼ + memory_item / echomeet_record +``` + +四条链路(App、耳机、支架、香薰)**已经**共用同一个会话持有者与指令分发器(§1.4),本节不动这个结构,只补三件事:写路径、指令清单、身份清单下发。 + +### 5.2 决策:记忆类写操作从端侧指令改走 MCP 工具 + +记忆中心文档 §8.3 定的是「记录走端侧、查询走 MCP」,理由是延迟低、离线能先落本地。**本文推翻这条**,理由: + +1. **模型看不到端侧指令的执行结果**。`tool_calls` 下发即结束,模型在同一帧里已经说了「已经帮你定好了」——CLAUDE.md 记录的「取消闹钟 / 记待办不下发指令却回已取消」就是这种结构的必然产物。MCP 工具的返回会进入模型的下一句话,「记好了,明天下午三点」是真的。 +2. **「离线先落本地」不成立**。指令只在百炼会话建立时才收得到,会话本身就要联网;不存在「离线拿到指令」这回事。 +3. **耳机在后台唤醒**这个场景两种方式一样:会话都跑在手机进程里。 +4. 写路径在服务端,幂等、限流、注销清表全在一处;端侧指令那套 `client_key` 双写、`syncPending` 补传、本地 `reminders` 迁移期双写全部可以退役。 + +**保留端侧的**:所有「结果在设备上」的动作——启动/停止某个功能、音量、地图跳转、播放控制。这些 MCP 做不了。 + +**过渡**:`SET_clock` 指令处理器保留一个版本(百炼控制台摘掉工具前老应用仍会下发),新老同时存在时按 `client_key` 幂等不会重。 + +### 5.3 端侧指令清单(保留 / 新增 / 摘除) + +| 指令 | 处置 | 落点 | +|---|---|---| +| `SET_clock` | 保留一版作过渡,之后由 MCP `add_memory_item(category=alarm)` 取代 | 现有 | +| `SEND_message` / `MAKE_A_PHONE_CALL_phone_call` | **百炼控制台摘除**(`_declined` 保留兜底) | 外部操作 | +| `ROUTE_map`(endLoc_city/endLoc_poi) | 实现:拼高德/Google 地图 URL scheme 打开外部应用;国内外按 region | 新增 | +| `INCREASE_DEFAULT_volume` / `DECREASE_…` | 实现:系统媒体音量 ±(现有 `volume_controller` 类插件或原生 MethodChannel) | 新增 | +| `START_feature(module_id, params)` | **新增,走模块注册表 `directives`**:`simultaneous/faceToFace/call/audioVideo/fieldRecording/callRecording/meeting` 各自在 `module.dart` 声明,参数含 `from/to` 语种;这是 `AgentModuleDescriptor.directives` 第一次真正被用 | 各模块 | +| `STOP_feature` | 同上 | 各模块 | +| `MUSIC_control(play/pause/next)` | 车载香薰已有 `commands` 形状(`intent_info`),归到同一分发器 | smartcar | + +`AssistantDirectiveService._dispatch` 的顺序不变:declined → 模块 handler → 内置 → unsupported。`unsupported` 继续记流水,这是发现百炼新下发了什么的唯一渠道。 + +⚠️ `START_feature` 的能力校验:模块声明了 `requiredCapabilities`(通话翻译要 `callAudioTap`),指令到达时 `DeviceHub.to.has(cap)` 不满足要把「缺设备」作为结果**告诉用户**(本地 TTS 一句「需要连接耳机」),而不是静默 unsupported——这条指令的用户正对着设备说话。 + +### 5.4 MCP 工具清单 + +**新增写工具**(GLOBAL,全部要 `auth_token`): + +| 工具 | 参数 | 落点 | 返回 | +|---|---|---|---| +| `add_memory_item` | `category`(todo/alarm/idea/expense)、`title`、`date`(YYYY-MM-DD,缺省今天)、`time`(HH:mm)、`repeat`、`amount`(元,expense)、`detail` | `memory_item`,`source=assistant` | `{id, title, happen_date, happen_time}` | +| `complete_memory_item` | `id` 或 `title_keyword` | `state=1` | 完成的那条 | +| `cancel_memory_item` | 同上 | `state=2` | 取消的那条;**这就是「取消闹钟」的落点** | +| `update_memory_item` | `id` + 改动字段 | `user_edited=true` | 改后的那条 | +| `get_meeting_detail` | `record_id` | 只读 | overview + summary(截断) | + +**实现方式:mcp 进程当「内部网关」,写逻辑留在 home(A3 + A4)** + +``` +百炼 → mcp:7300 add_memory_item(args, auth_token) + │ ResolveUIDWithToken → uid(现有 auth.go) + │ 组 pb.DBMemoryItem(分类/日期/时间/金额归一,见下) + ▼ + this.module.Service().RpcCall(ctx, "home", comm.Rpc_GatewayHttpRoute, + &pb.Rpc_GatewayHttpRouteReq{MsgName:"memory_add", + Meta:{userid: uid, ip: "mcp", servicetag: <本集群>}, + Message: json(MemoryAddReq)}, &reply) + ▼ + home SCompHttpRoute.Rpc_GatewayHttpRoute → memory.apiComp.Add(校验、幂等、recalcRemind、落库) + ▼ + reply.Body(业务 JSON)→ 转成可朗读短句回给模型 +``` + +- 这是 gateway 转发业务请求的**原样写法**([wservice_comp.go:231](../apps/services/modules/gateway/wservice_comp.go#L231)),只是调用方从 gateway 换成 mcp;mcp 本就是 rpcx 集群服务,同 `CLUSTER_TAG` 下 ETCD 能发现 home。**mcp 模块里从没 `RpcCall` 过**,这是第一次,先用 `get_memory_items` 之外的一个只读工具(`get_meeting_detail`)走通再上写工具。 +- 分类白名单、日期校验、`recalcRemind`、`client_key` 幂等、`user_edited` 语义**全部复用 home 的 `memory_add/update/complete/del`**,mcp 侧零业务逻辑——记忆中心 §8.2 担心的「第二份实现漂移」不存在。 +- home 侧拦截器(实名/VIP 类)对 RPC 进来的请求同样生效([comp_httproute.go:135](../apps/services/services/comp_httproute.go#L135)),EMAI 已过实名闸门所以不会被拦;万一被拦,返回给模型的是同一句业务提示。 +- mcp 没有 Redis(A3):写工具的**限流**用 mcp 进程内的按 uid 令牌桶(每应用单进程,够用,重启即清零可接受);**幂等**不需要 Redis——`client_key` 由 mcp 生成:`mcp:`,home 的 `(uid, client_key)` 唯一索引天然去重,命中返回 `Duplicated:true`([api_add.go](../apps/services/modules/memory/api_add.go) 已有这个返回)。 +- RPC 超时 5s;超时/失败回给模型「没记上,请再说一次」,**不要**回「已记好」。 + +- **今天是几号由服务端给**:工具入参日期允许省略,mcp 按 `user_getmcptoken` 时上报并签进令牌 claims 的 `tz` 折算「今天」(令牌 2h 内时区不会变;不查库)。不能靠模型自己算日期——它不知道今天几号,`SET_clock` 那次 `03:30` 的坑就是这么来的。 +- **幂等**:见上,靠 `client_key` 唯一索引;同一句话被模型重试两次只会落一条。 +- **写工具的 `title_keyword` 匹配命中多条时不动手**,返回候选让模型追问——宁可多说一句,不能删错。 +- 写工具的错误码走 `pb.ErrorCode` 5201 段(记忆中心已开),返回给模型的是可朗读的中文短句,不是 code。 + +**修正现有**:§1.4 表里四处名字对齐;`add_user_task` / `get_user_tasks` / `cancel_user_task` 三个 allhelp 旧工具**下线**(`DBTask` 已迁到 `memory_item`,记忆中心 §9.1),从 YAML 与 `_mcpAuthTools` 一并删。 + +**启动自检**:`mcp` 模块 `Start` 结束时,对每个 `AddTool` 过的名字若不在任何组 → `safeLogErrorf` 一行;YAML 里点名但没有实现的 → 同样一行。名字对不上不再是「安静地没有」。 + +### 5.5 身份链路:令牌工具清单改由服务端下发 + +现在 `_mcpAuthTools` 写死在客户端(§1.4),服务端每加一个要身份的工具就要发版。改: + +- `UserGetMcpTokenResp` 加 `auth_tools repeated string`。清单的**唯一来源是 `comm.McpAuthTools`**(一个字符串切片常量):mcp 模块用它判定哪些工具必须解身份,user 模块用它下发。user 在 home 进程、读不到 `mcp.yaml`,而五个服务出自同一个镜像(A1),放 `comm` 就是同一份代码、同一次构建,不会漂移;`comm/mcpauth_test.go` 断言每个 `tool_*.go` 里调了 `ResolveUID*` 的工具名都在这个切片里。 +- 令牌 claims 加 `tz`(客户端 `user_getmcptoken` 请求带 IANA 时区),供 §5.4 的「今天」折算。 +- 客户端 `BailianMultimodalService` 按下发清单嵌套 `user_defined_params`,本地清单退为兜底。 +- 百炼**只透传给被调用的那个工具**,多嵌几个没有成本。 + +⚠️ 记忆文件里的实测:**只有按工具名嵌套这一种形状能收到**,平铺 / 按 MCP 服务名嵌套 / `mcp` 子节点三种都静默丢弃。别改形状。 + +### 5.6 会话记忆与上下文注入(两条都要先验证) + +| 目标 | 方案 | 验证方法 | +|---|---|---| +| 跨会话记忆(重开 App 还记得上一轮) | Start 的 `input` 里回传上次的 `dialog_id`(现在只在 continue-task 里回传,Start 不传,[bailian_multimodal_service.dart:399](../apps/client/lib/data/services/bailian_multimodal_service.dart#L399))。持久化到 GetStorage,按 uid 分,超过 24h 作废 | **必须做对照组**:一组传真 dialog_id,一组传乱写的 id,一组传虚构字段名。三组表现一致 = 字段被静默丢弃,方案不成立(这是 `rag_options` 那次实验的教训) | +| 模型知道「今天几号 / 用户在哪个时区 / 接着哪个设备 / 今天有什么事」 | `biz_params.user_prompt_params`(协议里**确实存在**的字段)注入 `{"user_context": "..."}`,百炼应用提示词里放 `${user_context}` 占位。内容 ≤ 300 字:日期、时区、设备、未来 3 条事项标题 | 控制台提示词里加占位 → 真机问「今天几号」;同样加对照组 | + +两条都不成立时的退路:跨会话记忆靠服务端把最近 N 轮对话摘要(`allhelp` 的 `DBChatSummary` 机制已有)作为 `user_context` 的一部分注入——这条只依赖 `user_prompt_params`,不依赖百炼的会话保持。 + +### 5.7 联网与知识库 + +- **联网**:百炼应用自带 `enable_web_search`(客户端已按开关发 `extra_config`),与我们 MCP 的 `bocha/tavily` 是两条路。**定一条**:国内用百炼内置(省一次工具往返),海外用 `tavily`(内置搜索对海外站点覆盖未验证)。 +- **知识库**:**不建**。纪要检索继续用 `search_meeting_notes`(MySQL ngram 全文),换向量/百炼知识库的三个触发条件已写在 [tool_meeting.go:42-53](../apps/services/modules/mcp/tool_meeting.go#L42),到了再评估。原因复述一遍免得再议:对话协议里没有入口告诉知识库「只检索这个用户的文件」,隔离只能落在我们自己这一侧;MCP 工具刚好就是这一侧。 +- 翻译会话进纪要之后自动进入同一个检索范围(`echomeet_record.summary` 有全文索引),不用另做。灵感/待办的关键词检索由 `get_memory_items` 加 `keyword` 参数覆盖(`title/detail` LIKE,量小够用)。 + +### 5.8 多设备 / 多 agent 统一 + +- EMAI 与 Smartcar 是两个百炼应用。**约束:两个应用绑定同一个 MCP 服务、同一份工具勾选、同一份提示词变量**,否则「耳机能记待办、香薰不能」。做成一份控制台配置清单(§5.9),每次加工具按清单过两遍。 +- 设备唤醒选哪个应用:现有 `AgentDefaults.categoryForVendor → defaultsForCategory → bailianAppId` 不动。 +- 游客:`client_info.user_id` 回退 `eaimar-guest` 会让多设备串记忆;MCP 侧无令牌一律拒,所以游客只能闲聊——这是对的。EMAI 已过实名闸门,游客本来进不来。 +- 车载香薰的 `commands` 形状(`intent_info`)与 `tool_calls` 形状都进 `AssistantDirective.parseFromOutput`,分发器不分设备。 + +### 5.9 百炼控制台配置清单(外部操作,每个应用各做一遍) + +| # | 操作 | 应用 | +|---|---|---| +| C1 | MCP 服务「emai-mcp」:技能 → MCP 服务 → **逐个勾选**新工具(`add/complete/cancel/update_memory_item`、`get_meeting_detail`),点发布 | EMAI、Smartcar | +| C2 | 摘掉 `SEND_message`、`MAKE_A_PHONE_CALL_phone_call`、`SET_clock`(写工具上线一版之后) | 两者 | +| C3 | 端侧函数加 `START_feature` / `STOP_feature` / 音量 / `ROUTE_map`(参数按 §5.3) | 两者 | +| C4 | 提示词加 `${user_context}` 占位(§5.6 验证通过后) | 两者 | +| C5 | 开 `enable_web_search`(国内应用) | EMAI | +| C6 | 后台 `svc_config` 的 `sts_bailian.app_id / workspace_id` 填实值,别再靠客户端硬编码兜底 | 后台 | + +⚠️ 控制台「语音交互体验」里测不出身份链路(没有客户端就没有 `user_defined_params`),验证只能真机或脚本直连 WS。 + +--- + +## 六、跨需求的公共改动汇总 + +### 6.1 proto + +| 文件 | 改动 | +|---|---| +| `echomeet_msg.proto` `EchomeetAddRecordReq` | + `translate`(9, repeated ContextStruct) `client_key`(10) `meet_time`(11) `tz`(12) `scene`(13) | +| `echomeet_db.proto` `DBEchoMeetRecord` | + `scene`(33) `meet_time`(34) `tz`(35) `client_key`(36, `uniqueIndex:idx_echo_uid_ck` 与 uid 联合) | +| `memory_msg.proto` `MemoryStatsResp` | + `meeting_count` `meeting_seconds` | +| `memory_msg.proto` `MemoryListReq` | + `keyword` | +| `user_msg.proto` `UserGetMcpTokenReq/Resp` | Req + `tz`;Resp + `auth_tools` | +| `errorcode.proto` | 5201 段续编:`MemoryItemAmbiguous`(写工具命中多条)、`EchomeetTranscriptEmpty` | + +生成后 `git diff --stat -- apps/services/pb/` 必须只动这几个文件(CLAUDE.md 那条 protoc-gen-go 版本坑)。 + +### 6.2 服务端 + +| 模块 | 改动 | +|---|---| +| `comm` | `MemoryCatMeeting`;`ComputeUsageSummary` + `COMPUTE_RATE_SUMMARY`;`IMemory` 加 `UpsertMeetingIndex/RemoveBySource`;`meet_time` 校验函数 | +| `echomeet` | addrecord 收新字段 + 幂等;starttask VOICETRANSLAT 分支不清空 Translate、走 Summary 计费、默认模板解析;四个钩子调 IMemory;`Speaker` 前缀统一 | +| `memory` | 分类白名单;两个新方法;stats 加会议;list 加 keyword;启动回填存量记录索引 | +| `mcp` | 5 个新工具(写工具经 RPCX 调 home,`client_key` 由 mcp 生成);名字对齐;下线 allhelp 三工具;启动自检;进程内按 uid 限流 | +| `user` | `user_getmcptoken` 下发 `comm.McpAuthTools`、收 `tz` 并签进 claims | +| `echomeet` | `getallrecords` 加 `brief` 参数接上 `getrecordbriefforuid` | +| `console` | 「会员与算力」页加 `COMPUTE_RATE_SUMMARY` 一行(`api_compute.go` + `compute.vue`) | +| 注销 | `cancel()` 表清单**不用改**(`echomeet_record`、`memory_item` 都在) | + +### 6.3 客户端 + +| 位置 | 改动 | +|---|---| +| `data/services/transcript_archive.dart` | 新:组转写 → addrecord → 本地库 → 上传 → 起任务 | +| `translation_controller.dart` | 两处结束挂点;`_recordingStartedAt`;`TranslationItem` 加 `speaker` 并落盘 | +| `meeting_task_service.dart` | `addTask(requiresAudio:)` | +| `meeting_details_view.dart` | 转写 Tab 解锁条件;无音频隐藏播放器 | +| 拾忆页 | `meeting` 分类卡片 + 筛选 + 跳转 | +| `assistant_directive_service.dart` | `tool_infos` 解析 → 命中写工具名则 `MemoryService.refresh()` + `MemoryReminderScheduler.reschedule()`;`ROUTE_map`/音量处理器 | +| 各 `module.dart` | 声明 `directives: {START_feature…}` | +| `bailian_multimodal_service.dart` | 令牌清单改读服务端;Start 回传 `dialog_id`(验证通过后);`user_prompt_params` | +| `mcp_token_service.dart` | 缓存 `auth_tools`,上报 `tz` | +| 设置页 | 「翻译结束后自动生成纪要」 | + +⚠️ `tool_infos` 的精确形状(工具名字段叫什么、结果在哪一层)**要先抓一帧**再写解析,记忆中心那次只确认了「它是服务端已执行的结果」。 + +--- + +### 6.4 落地清单(按环境;A5/A6 决定了这些都是手工步骤) + +服务端改动全部在 `starpivot-app` 一个镜像里,走既有 `dev-deploy.sh along` / `prod-build.sh + prod-deploy.sh ym along`。**代码之外**要动的: + +| 步骤 | 测试机 8.133.166.29 `/home/work/starpivot/app` | 正式机 47.116.104.181 `/home/work/ym-a11` | 哪一期 | +|---|---|---|---| +| `confs/home.yaml` | `memory` 段无新键(回填与钩子无需配置) | 同 | 二期 | +| `confs/mcp.yaml` `Groups.GLOBAL` | 加 `add/complete/cancel/update_memory_item`、`get_meeting_detail`;删 `add/get/cancel_user_task`、`music_search`、`music_play`;导航项改成代码真名 | 同 | 三期 | +| `.env` | **不新增**(复用 `MCP_TOKEN_KEY` / `GATEWAY_TOKEN_KEY`,正式机 9-11 已配) | 同 | — | +| 业务库 `config` 表 | `COMPUTE_RATE_SUMMARY` 由后台「会员与算力」页写入;不写走默认 | 同 | 一期 | +| `echomeet_template` | 确认本应用(`app_name=EAIMAR`)有公共模板,否则默认模板解析落空 | 同 | 一期 | +| 重启 | 改 `confs/*.yaml` 后 `docker compose up -d --force-recreate`(Groups 只在启动时读) | 同 | 三期 | +| NPM 反代 | `mcp-dev.ymaikj.com` 已有 | `mcp.ymaikj.com` 9-12 已建(SSE 关缓冲) | 无需动 | +| 百炼控制台 | §5.9 C1~C6,EMAI 与 Smartcar 各一遍 | 同一套应用 | 三期 | +| 客户端 | 一期/二期改动**不依赖**服务端先上(addrecord 多传的字段老服务端忽略,只是转写不落库);三期客户端依赖 `auth_tools` 下发,**服务端先上** | — | — | + +⚠️ 顺序:一期服务端先于客户端上(否则翻译结束建出的记录 `translate` 会被同语种短路抹掉,用户看到的总结是原文语种)。二期无顺序要求。三期服务端 → 百炼控制台 → 客户端。 + +## 七、分期实施 + +### 一期:翻译 → 纪要(两周内可交付,服务端改动最小) + +| # | 任务 | 端 | +|---|---|---| +| 1.1 | proto:`AddRecordReq` 5 字段 + `DBEchoMeetRecord` 4 列 | 后端 | +| 1.2 | addrecord 幂等 + `translate` 直写 + `meet_time/tz/scene`;starttask VOICETRANSLAT 分支修 Translate 清空、默认模板解析 | 后端 | +| 1.3 | `ComputeUsageSummary` + 后台系数行 | 后端/后台 | +| 1.4 | `TranslationItem.speaker` 落盘;`TranscriptArchive`;两处结束挂点;`addTask(requiresAudio)`;自动生成开关 | 客户端 | +| 1.5 | 详情页转写 Tab 解锁、无音频播放器隐藏、列表 scene 图标 | 客户端 | +| 1.6 | 翻译历史会话卡「查看纪要」 | 客户端 | + +**验收**:四种模式各跑一段 ≥ 30s 的会话 → 结束 3s 内语音纪要列表出现记录、转写 Tab 可看、说话人分行正确;VIP 用户自动出总结;非 VIP 只有转写;`user_getcompute` 里多出的算力 = 时长 × Summary 系数;同一会话重复触发不插第二条。 + +### 二期:纪要 → 拾忆 + +| # | 任务 | 端 | +|---|---|---| +| 2.1 | `MemoryCatMeeting`、`IMemory` 两方法、echomeet 四钩子、存量回填 | 后端 | +| 2.2 | `memory_stats` 会议字段、`memory_today` 播报排除、报告 prompt | 后端 | +| 2.3 | 拾忆页 meeting 卡片/筛选/跳转 | 客户端 | +| 2.4 | `get_memory_items` 分类枚举加 meeting;`get_meeting_detail`(**经 RPCX 调 home,作为三期写工具的链路预演**) | 后端 MCP | +| 2.5 | `echomeet_getallrecords` 加 `brief`;客户端 `Synchrodata` 改 brief + 详情按需拉 | 两端 | + +**验收**:一期产生的记录在拾忆当天格子里出现,状态 chip 随转写/总结推进;删记录后索引项消失、抽出的待办还在;周报里有「本周 N 场会议共 M 分钟」。 + +### 三期:EMAI 综合入口 + +| # | 任务 | 端 | +|---|---|---| +| 3.1 | MCP 名字对齐 + 启动自检 + 下线 allhelp 三工具 | 后端 | +| 3.2 | 四个写工具:mcp 组 item → RPCX 调 home `memory_*`;`client_key` 由 mcp 生成;进程内限流;按令牌 `tz` 折算「今天」 | 后端 | +| 3.3 | `comm.McpAuthTools` 单一来源 + 守卫测试;`user_getmcptoken` 下发 `auth_tools`、签 `tz`;客户端改读 | 两端 | +| 3.8 | 两台机 `confs/mcp.yaml` Groups 手改 + 重启(§6.4) | 运维 | +| 3.4 | `tool_infos` 抓帧 → 解析 → 本地刷新与重排通知 | 客户端 | +| 3.5 | `START/STOP_feature` 走模块注册表;`ROUTE_map`;音量 | 客户端 | +| 3.6 | 百炼控制台 C1~C6 | 外部 | +| 3.7 | `dialog_id` 回传与 `user_prompt_params` 两个对照实验;通过则接入 | 客户端 | + +**验收**:对耳机说「明天下午三点提醒我开会」→ 模型回复里带服务端返回的日期时间 → 拾忆页 2s 内出现该项 → 本地通知已排;说「取消它」→ `state=2`;说「开始通话翻译,中译英」→ 翻译页打开且模式/语种正确,未连耳机时 TTS 提示;香薰上同一套说法结果一致。 + +--- + +## 八、风险与待确认 + +### 风险 + +| 级别 | 风险 | 应对 | +|---|---|---| +| 高 | **`TranslateProcess` 同语种短路会抹掉客户端传的 `Translate`**,表现为总结用的是原文语种、且不报错 | §3.1 b,一期必改;加单测:VOICETRANSLAT + 非空 Translate → AIProcess 输入等于 Translate | +| 高 | **MCP 工具名对不上就静默不暴露**,新写工具会重蹈 `add_user_task` 覆辙 | §5.4 启动自检;新工具上线用真机验一次 | +| 高 | **`_mcpAuthTools` 与服务端脱节** → 新工具永远「身份校验失败」 | §5.5 改服务端下发;过渡期两边都列 | +| 高 | 写工具的模型幻觉方向反转:以前是「没做说做了」,MCP 之后可能「做了两遍」(模型重试) | 120s 幂等窗口 + 结果里 `duplicated` 标记 | +| 高 | `dialog_id` / `user_prompt_params` 两条百炼能力**未验证**,且失败是静默的 | 对照组实验先于编码(§5.6) | +| 高 | **mcp → home 的 RPCX 调用是这个模块第一次跨服务调用**,ETCD 发现/`CLUSTER_TAG`/超时任何一环不通都表现为「工具超时」 | 先用只读的 `get_meeting_detail` 走通链路再上写工具;RPC 超时 5s 回「没记上」 | +| 高 | 新应用没复制公共模板 → 自动总结 `TemplateNotFound` | 客户端静默降级为「只建记录」;上线清单里核对模板(§6.4) | +| 中 | `getallrecords` 全量整行同步随记录数膨胀 | 二期 `brief` 参数 + 详情按需拉(§4.2a) | +| 中 | 五进程同容器:memory 回填或新钩子 panic 会拖垮全站 | 回填在 goroutine + recover;钩子沿用 `ExtractMeetingTodos` 的 recover 写法 | +| 中 | 翻译会话自动进纪要 → 列表被短会话刷屏 | 3 条 / 30s 门槛 + 设置开关 | +| 中 | 重复计费:翻译秒 + 纪要秒 | 独立 Summary 系数(§3.4) | +| 中 | `echomeet_summary` 不过闸门,自动化后调用量上升 | 记为既有漏洞,单独排期 | +| 中 | 面对面/通话的说话人信息现在不落盘,历史会话补生成纪要全是 Speaker_1 | `TranslationItem.speaker` 一期就加 | +| 中 | 拾忆 `meeting` 项进每日播报会念出无意义内容 | `memory_today` 播报排除该分类 | +| 中 | 存量记录回填索引时 `meet_time` 空,全部按上传日期 | 已知偏差,与待办抽取一致,写进说明 | +| 中 | EMAI 与 Smartcar 两个百炼应用工具配置漂移 | 控制台清单 §5.9,加工具按清单过两遍 | +| 低 | 老包收到 `category=meeting` | 兜底已发(记忆中心一期) | + +### 待确认(产品/运营) + +1. **翻译会话默认自动生成纪要还是默认只存转写**?默认自动会持续消耗算力与 VIP 用户的耐心(每次通话结束都多一条纪要);默认关则功能等于没做。建议**默认开 + 30s 门槛**,两周后看 `user_getcompute` 的 Summary 用量再定。 +2. **`COMPUTE_RATE_SUMMARY` 的初值**。建议会议系数的 1/2。 +3. **记忆写操作改走 MCP**(§5.2)是否接受——它推翻记忆中心文档 §8.3,且意味着 `SET_clock` 那套本地双写在一个版本后退役。 +4. **`START_feature` 要不要让香薰能拉起手机上的翻译页**:技术上是同一条链,但用户在车里对着香薰说「开始通话翻译」、手机自己跳页,体验是否合理。 +5. 拾忆里「来源会议已删除」的待办要不要跟着删。建议不删(已经是用户的任务)。 +6. 联网搜索国内走百炼内置、海外走 tavily——需要确认百炼内置搜索在海外应用上的表现,没验证过。 + +--- + +## 附录 A:关键文件索引 + +| 用途 | 路径 | +|---|---| +| 隐藏的只总结通道 | `apps/services/modules/echomeet/api_starttask.go:135`、`api_addrecord.go:49` | +| 同语种短路(要改) | `apps/services/modules/echomeet/tasks.go:420` | +| 总结 + 待办抽取挂点 | `apps/services/modules/echomeet/tasks.go:481-644` | +| 翻译条目模型 / 历史存储 | `apps/client/lib/modules/translation/models/translation_models.dart`、`controllers/translation_history_controller.dart` | +| 会话结束挂点 | `apps/client/lib/modules/translation/controllers/translation_controller.dart:587,2004` | +| 音频归档(不改签名) | `apps/client/lib/data/services/recording_archive.dart` | +| 任务提交 / 轮询 | `apps/client/lib/data/services/meeting/meeting_task_service.dart` | +| 记忆中心跨模块接口 | `apps/services/comm/module.go:143`、`comm/memory.go` | +| MCP 鉴权 / 工具组 / 会议工具 / 记忆工具 | `apps/services/modules/mcp/auth.go`、`module.go:163`、`tool_meeting.go`、`tool_memory.go` | +| MCP 工具组配置 | `deploy/app/confs/mcp.yaml.example:50-73` | +| MCP 令牌签发 / 客户端缓存 | `apps/services/modules/user/api_getmcptoken.go`、`apps/client/lib/data/services/mcp_token_service.dart` | +| gateway → home 的 RPC 转发写法(mcp 照抄) | `apps/services/modules/gateway/wservice_comp.go:231`、`apps/services/services/comp_httproute.go:103` | +| 五进程守护 | `apps/services/entrypoint.voitrans.sh` | +| 算力系数三处落点 | `apps/services/comm/compute.go:37`、`modules/console/api_compute.go:29`、`apps/admin/app/pages/compute.vue` | +| 部署方式(只发模板) | `deploy/app/README.md`、`deploy/app/confs/mcp.yaml.example` | +| 百炼会话 / 令牌嵌套 / dialog_id | `apps/client/lib/data/services/bailian_multimodal_service.dart:277-301,399,474,583` | +| 指令解析 / 分发 | `apps/client/lib/data/models/assistant_directive.dart`、`data/services/assistant_directive_service.dart` | +| 模块注册表(directives 未用) | `apps/client/lib/modules/module_registry.dart:88`、`module_descriptor.dart:55` | +| 设备唤醒选 agent | `apps/client/lib/data/services/device_ai_session_service.dart:52-72` | +| 算力用量类型 | `apps/services/comm/compute.go:473,543` |